mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(offline): local-bytes browse when server is unreachable (#1017)
* feat(offline): local-bytes browse for artists and albums Make Artists, All Albums, and artist/album detail pages work offline from the library index limited to on-disk library and favorite-auto tracks. Add a DEV header toggle to simulate offline browse for testing. * feat(offline): reactive DEV offline toggle with full disconnect simulation Subscribe nav and browse/detail hooks to useOfflineBrowseActive so UI refreshes on toggle. DEV force-offline now blocks server probes, reports disconnected status, and gates Subsonic like real offline for player parity. * feat(offline): bytes-first favorites when offline browse is active Load Favorites from local playback bytes, filter starred tracks client-side, and restrict album-level star queries to local album ids. Drop interim perf attempts (lean SQL, progressive load, connection singleton, prefetch UX). * feat(offline): tracks, help, player stats; suspend library picker offline - Offline browse for Tracks hub from local bytes; sidebar nav for tracks/help/statistics - Statistics redirects to player-stats offline; server/Last.fm tabs skip network fetches - Hide music-library picker offline; save filter and restore on reconnect (all libraries while disconnected) - Unified isOfflineSidebarNavAllowed for library + system entries * feat(offline): fork disconnect navigation by offline browse capability When the server drops: stay on the page if nothing is browsable offline; reload in place on offline-capable routes; otherwise redirect to All Albums instead of the old /offline or /favorites bounce. * feat(offline): browse cached playlists when the server is down List and open manually pinned regular playlists from local library-tier bytes offline, with sidebar/nav routing and read-only playlist UI. * feat(offline): read-only artist detail and local play-all paths Hide favorites and discography offline actions when browse is offline; load Play All, Shuffle, and top-track continuation from local album bytes. * feat(offline): read-only album detail and enqueue from local bytes Hide favorites, download, and cache-offline actions on album pages when offline browse is active. Favorites album cards enqueue via the same resolveAlbumForServer path as play, including local playback bytes. * chore: remove unused import in AlbumCard after enqueue refactor * feat(offline): unify browse integration contract across the app Add useOfflineBrowseContext, offlineMediaResolve, and offlineActionPolicy; wire shell nav to a single capability source; migrate play/enqueue and context-menu paths off raw getAlbum; replace readOnly with action policy on detail surfaces. Tests updated for the media-resolve facade. * feat(offline): close browse contract gaps and fix offline Home feed Split offline browse modules, align favorites capability across servers, wire action policy on context menus, migrate hooks to useOfflineBrowseContext, and preserve stale Home feed cache when offline so the UI does not empty. * fix(offline): block playbar stars, close audit gaps, trim dead exports Hide star rating and favorite in PlayerBar when offline browse is active via offlineActionPolicy playerBar surface. Wire stay-reload token into browse hooks, migrate hooks to context.active, guard rating prefetch network calls, and route playlist load through resolvePlaylist. * docs: add CHANGELOG and credits for offline browse PR #1017 * fix(offline): stop DEV connection probe regression in tests React to devForceOffline transitions only in useConnectionStatus so mount does not double-fire check() or ignore disableBackgroundPolling. Add pingWithCredentials to PlayerBar test mock and DEV-toggle unit tests.
This commit is contained in:
@@ -67,6 +67,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Offline — local-bytes browse when the server is down
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1017](https://github.com/Psychotoxical/psysonic/pull/1017)**
|
||||
|
||||
* When the active server is unreachable, browse and detail pages read from **local playback bytes** and the **library index** instead of Subsonic — albums, artists, tracks, cached playlists, and cross-server favorites.
|
||||
* Single integration contract: `offlineBrowseContext`, `offlineActionPolicy`, and `resolveAlbum` / `resolveArtist` / `resolvePlaylist` resolvers; context menus and detail toolbars block server mutations offline.
|
||||
* Disconnect navigation forks by offline capability (stay on page, stay-reload, or redirect); Home reuses the last cached feed snapshot; DEV offline toggle simulates full disconnect for testing.
|
||||
* PlayerBar hides star rating and favorite controls while offline browse is active.
|
||||
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Dependencies — npm and Rust refresh
|
||||
|
||||
@@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./subsonicArtists', () => ({ getArtist: vi.fn() }));
|
||||
vi.mock('./subsonicLibrary', () => ({ getAlbum: vi.fn() }));
|
||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForActiveServer: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
import { getArtist } from './subsonicArtists';
|
||||
import { invalidateEntityUserRatingCaches, prefetchArtistUserRatings } from './subsonicRatings';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getArtist } from './subsonicArtists';
|
||||
import { getAlbum } from './subsonicLibrary';
|
||||
import { shouldAttemptSubsonicForActiveServer } from '../utils/network/subsonicNetworkGuard';
|
||||
|
||||
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
||||
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
|
||||
@@ -55,6 +56,7 @@ export async function prefetchArtistUserRatings(
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
@@ -93,6 +95,7 @@ export async function prefetchAlbumUserRatings(
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
|
||||
+12
-21
@@ -11,6 +11,7 @@ import PlayerBar from '../components/PlayerBar';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import LiveSearch from '../components/LiveSearch';
|
||||
import DevNetworkModeToggle from '../components/DevNetworkModeToggle';
|
||||
import NowPlayingDropdown from '../components/NowPlayingDropdown';
|
||||
import QueuePanel from '../components/QueuePanel';
|
||||
import AppRoutes from './AppRoutes';
|
||||
@@ -39,11 +40,8 @@ import { useOrbitHost } from '../hooks/useOrbitHost';
|
||||
import { useOrbitGuest } from '../hooks/useOrbitGuest';
|
||||
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
|
||||
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
|
||||
import {
|
||||
hasOfflineBrowsingContent,
|
||||
} from '../utils/offline/favoritesOfflineBrowse';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
|
||||
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
|
||||
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
|
||||
@@ -56,11 +54,11 @@ import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority'
|
||||
import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope';
|
||||
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
|
||||
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
|
||||
import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilterSuspend';
|
||||
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
|
||||
import { IS_LINUX } from '../utils/platform';
|
||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import '../store/previewPlayerVolumeSync';
|
||||
import '../store/queueResolverBridge';
|
||||
@@ -110,13 +108,10 @@ export function AppShell() {
|
||||
useLiveSearchRouteScope();
|
||||
useNowPlayingPrewarm();
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(activeServerId));
|
||||
const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled;
|
||||
const hasManualOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const hasOfflineContent = hasOfflineBrowsingContent(offlineAlbums);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const hasOfflineContent = offlineCtx.hasBrowsingContent;
|
||||
const hasOfflineBrowse = offlineCtx.hasBrowseCapability;
|
||||
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
|
||||
@@ -144,13 +139,8 @@ export function AppShell() {
|
||||
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
|
||||
}, [location.pathname, location.state]);
|
||||
|
||||
useOfflineAutoNav(
|
||||
connStatus,
|
||||
hasManualOfflineContent,
|
||||
favoritesOfflineBrowse,
|
||||
location.pathname,
|
||||
navigate,
|
||||
);
|
||||
useOfflineAutoNav(connStatus, offlineNav, location, navigate);
|
||||
useOfflineLibraryFilterSuspend();
|
||||
|
||||
useEffect(() => {
|
||||
initializeFromServerQueue();
|
||||
@@ -264,6 +254,7 @@ export function AppShell() {
|
||||
<div className="main-content-zoom">
|
||||
<header className="content-header">
|
||||
<LiveSearch />
|
||||
{import.meta.env.DEV && <DevNetworkModeToggle />}
|
||||
<div className="spacer" />
|
||||
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
||||
<LastfmIndicator />
|
||||
@@ -282,7 +273,7 @@ export function AppShell() {
|
||||
</header>
|
||||
<OrbitSessionBar />
|
||||
{connStatus === 'disconnected' && (
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
|
||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineBrowse} serverName={serverName} />
|
||||
)}
|
||||
<div className="content-body app-shell-route-host">
|
||||
<OverlayScrollArea
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||
@@ -18,7 +16,7 @@ import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
import { resolveCoverDisplayTier } from '../cover/tiers';
|
||||
import { acquireUrl } from '../utils/imageCache/urlPool';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
||||
import { fetchAlbumTracks, playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
||||
import { useLongPressAction } from '../hooks/useLongPressAction';
|
||||
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
@@ -192,10 +190,13 @@ function AlbumCard({
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const data = await getAlbum(album.id);
|
||||
enqueue(data.songs.map(songToTrack));
|
||||
const tracks = await fetchAlbumTracks(
|
||||
album.id,
|
||||
offlineServerId || undefined,
|
||||
);
|
||||
if (tracks.length > 0) enqueue(tracks);
|
||||
} catch {
|
||||
// Network failure — silent (toast would be too noisy for a hover action)
|
||||
// Unavailable offline or network failure — silent on hover action
|
||||
}
|
||||
}}
|
||||
aria-label={t('contextMenu.enqueueAlbum')}
|
||||
|
||||
+128
-111
@@ -19,6 +19,7 @@ import { formatMb } from '../utils/format/formatBytes';
|
||||
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
|
||||
/** True when the album artist label means "no single artist" — `getArtistInfo`
|
||||
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
|
||||
@@ -91,6 +92,8 @@ interface AlbumHeaderProps {
|
||||
onEntityRatingChange: (rating: number) => void;
|
||||
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
|
||||
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
|
||||
/** Offline browse action gates (favorites, download, cache, bio, ratings). */
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
export default function AlbumHeader({
|
||||
@@ -117,7 +120,9 @@ export default function AlbumHeader({
|
||||
entityRatingValue,
|
||||
onEntityRatingChange,
|
||||
entityRatingSupport,
|
||||
actionPolicy,
|
||||
}: AlbumHeaderProps) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const goBack = useAlbumDetailBack();
|
||||
@@ -222,7 +227,7 @@ export default function AlbumHeader({
|
||||
<StarRating
|
||||
value={entityRatingValue}
|
||||
onChange={onEntityRatingChange}
|
||||
disabled={entityRatingSupport === 'track_only'}
|
||||
disabled={!policy.canRate || entityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.albumAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
@@ -250,14 +255,16 @@ export default function AlbumHeader({
|
||||
|
||||
{/* Row 2 — Secondary actions */}
|
||||
<div className="album-actions-row album-actions-row--secondary">
|
||||
<button
|
||||
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
{policy.canFavorite && (
|
||||
<button
|
||||
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
@@ -269,7 +276,7 @@ export default function AlbumHeader({
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
|
||||
{showBioButton && (
|
||||
{showBioButton && policy.canShowBio && (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onBio}
|
||||
@@ -280,53 +287,57 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Download size={14} />
|
||||
<span className="album-icon-btn-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onDownload}
|
||||
aria-label={t('albumDetail.download')}
|
||||
data-tooltip={t('albumDetail.download')}
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
{policy.canDownload && (
|
||||
downloadProgress !== null ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Download size={14} />
|
||||
<span className="album-icon-btn-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onDownload}
|
||||
aria-label={t('albumDetail.download')}
|
||||
data-tooltip={t('albumDetail.download')}
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{offlineStatus === 'downloading' ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
</div>
|
||||
) : offlineStatus === 'queued' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.offlineQueued')}
|
||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onRemoveOffline}
|
||||
aria-label={t('albumDetail.offlineCached')}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.cacheOffline')}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
{policy.canPinOffline && (
|
||||
offlineStatus === 'downloading' ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
</div>
|
||||
) : offlineStatus === 'queued' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.offlineQueued')}
|
||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onRemoveOffline}
|
||||
aria-label={t('albumDetail.offlineCached')}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.cacheOffline')}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -357,13 +368,15 @@ export default function AlbumHeader({
|
||||
>
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
{policy.canFavorite && (
|
||||
<button
|
||||
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface"
|
||||
@@ -375,7 +388,7 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showBioButton && (
|
||||
{showBioButton && policy.canShowBio && (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-bio-btn"
|
||||
@@ -386,56 +399,60 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
{policy.canDownload && (
|
||||
downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-download-btn"
|
||||
onClick={onDownload}
|
||||
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
|
||||
>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-download-btn"
|
||||
onClick={onDownload}
|
||||
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
|
||||
>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{offlineStatus === 'downloading' && offlineProgress ? (
|
||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||
</div>
|
||||
) : offlineStatus === 'queued' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineQueued')}
|
||||
</button>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
{policy.canPinOffline && (
|
||||
offlineStatus === 'downloading' && offlineProgress ? (
|
||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||
</div>
|
||||
) : offlineStatus === 'queued' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineQueued')}
|
||||
</button>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useTracklistColumns } from '../utils/useTracklistColumns';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -15,6 +15,7 @@ import { TrackRow } from './albumTrackList/TrackRow';
|
||||
import { AlbumTrackListMobile } from './albumTrackList/AlbumTrackListMobile';
|
||||
import { TracklistColumnPicker } from './albumTrackList/TracklistColumnPicker';
|
||||
import { TracklistHeaderRow } from './albumTrackList/TracklistHeaderRow';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
|
||||
export type { SortKey } from '../utils/componentHelpers/albumTrackListHelpers';
|
||||
|
||||
@@ -38,6 +39,7 @@ interface AlbumTrackListProps {
|
||||
sortKey?: SortKey;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
onSort?: (key: SortKey) => void;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
// ── AlbumTrackList ────────────────────────────────────────────────────────────
|
||||
@@ -60,7 +62,9 @@ export default function AlbumTrackList({
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSort,
|
||||
actionPolicy,
|
||||
}: AlbumTrackListProps) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
@@ -98,6 +102,10 @@ export default function AlbumTrackList({
|
||||
);
|
||||
|
||||
const currentTrackId = currentTrack?.id ?? null;
|
||||
const displayCols = useMemo(
|
||||
() => (policy.canFavorite ? visibleCols : visibleCols.filter(c => c.key !== 'favorite')),
|
||||
[policy.canFavorite, visibleCols],
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -139,7 +147,7 @@ export default function AlbumTrackList({
|
||||
>
|
||||
|
||||
<TracklistHeaderRow
|
||||
visibleCols={visibleCols}
|
||||
visibleCols={displayCols}
|
||||
gridStyle={gridStyle}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
@@ -170,7 +178,7 @@ export default function AlbumTrackList({
|
||||
key={song.id}
|
||||
song={song}
|
||||
globalIdx={globalIdx}
|
||||
visibleCols={visibleCols}
|
||||
visibleCols={displayCols}
|
||||
gridStyle={gridStyle}
|
||||
currentTrackId={currentTrackId}
|
||||
isPlaying={isPlaying}
|
||||
@@ -186,6 +194,7 @@ export default function AlbumTrackList({
|
||||
onToggleSelect={onToggleSelect}
|
||||
onDragStart={onDragStart}
|
||||
setContextMenuSongId={setContextMenuSongId}
|
||||
actionPolicy={policy}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
|
||||
import { filterAlbumsToActiveLibrary, getAlbum } from '../api/subsonicLibrary';
|
||||
import { filterAlbumsToActiveLibrary } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||
@@ -596,7 +597,10 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
|
||||
const handleEnqueue = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const data = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const data = await resolveAlbum(serverId, album.id);
|
||||
if (!data) return;
|
||||
enqueue(data.songs.map(songToTrack));
|
||||
} catch {
|
||||
/* silent — toast would be too noisy for a hover action */
|
||||
|
||||
@@ -43,6 +43,23 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||
orbitBulkGuard: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useOfflineBrowseContext', () => ({
|
||||
useOfflineBrowseContext: () => ({
|
||||
active: false,
|
||||
serverId: 'srv-1',
|
||||
capabilities: {
|
||||
localLibrary: false,
|
||||
favorites: false,
|
||||
playlists: false,
|
||||
manualPins: false,
|
||||
playerStats: false,
|
||||
},
|
||||
hasBrowseCapability: false,
|
||||
hasBrowsingContent: false,
|
||||
connStatus: 'connected' as const,
|
||||
}),
|
||||
}));
|
||||
|
||||
import ContextMenu from './ContextMenu';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
|
||||
@@ -17,8 +17,29 @@ import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav';
|
||||
import { useContextMenuRating } from '../hooks/useContextMenuRating';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import {
|
||||
offlineActionPolicy,
|
||||
type OfflineSurface,
|
||||
} from '../utils/offline/offlineActionPolicy';
|
||||
import ContextMenuItems from './contextMenu/ContextMenuItems';
|
||||
|
||||
function contextMenuSurfaceForType(type: string | null): OfflineSurface {
|
||||
switch (type) {
|
||||
case 'album':
|
||||
case 'multi-album':
|
||||
return 'contextMenuAlbum';
|
||||
case 'artist':
|
||||
case 'multi-artist':
|
||||
return 'contextMenuArtist';
|
||||
case 'playlist':
|
||||
case 'multi-playlist':
|
||||
return 'contextMenuPlaylist';
|
||||
default:
|
||||
return 'contextMenuSong';
|
||||
}
|
||||
}
|
||||
|
||||
export { AddToPlaylistSubmenu };
|
||||
|
||||
|
||||
@@ -180,6 +201,12 @@ export default function ContextMenu() {
|
||||
|
||||
const downloadAlbum = downloadAlbumAction;
|
||||
|
||||
const { active: offlineBrowseActive } = useOfflineBrowseContext();
|
||||
const offlinePolicy = offlineActionPolicy(
|
||||
contextMenuSurfaceForType(type),
|
||||
offlineBrowseActive,
|
||||
);
|
||||
|
||||
if (!contextMenu.isOpen || !contextMenu.item) return null;
|
||||
|
||||
return (
|
||||
@@ -233,6 +260,7 @@ export default function ContextMenu() {
|
||||
isStarred={isStarred}
|
||||
pinToPlaybackServer={pinToPlaybackServer}
|
||||
navigateLibrary={navigateLibrary}
|
||||
offlinePolicy={offlinePolicy}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Cloud, CloudOff } from 'lucide-react';
|
||||
import { useDevOfflineBrowseStore } from '../store/devOfflineBrowseStore';
|
||||
|
||||
/** DEV-only: simulate full offline (disconnect UI, block Subsonic, local playback only). */
|
||||
export default function DevNetworkModeToggle() {
|
||||
const forceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
|
||||
const toggle = useDevOfflineBrowseStore(s => s.toggleForceOffline);
|
||||
|
||||
if (!import.meta.env.DEV) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`dev-network-mode-toggle${forceOffline ? ' dev-network-mode-toggle--offline' : ''}`}
|
||||
onClick={toggle}
|
||||
title={forceOffline ? 'DEV: forced offline (click for online)' : 'DEV: online (click to simulate offline)'}
|
||||
aria-pressed={forceOffline}
|
||||
>
|
||||
{forceOffline ? <CloudOff size={16} aria-hidden /> : <Cloud size={16} aria-hidden />}
|
||||
<span>{forceOffline ? 'Offline' : 'Online'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+17
-4
@@ -1,4 +1,5 @@
|
||||
import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
|
||||
import { getRandomAlbums } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
@@ -259,7 +260,13 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
if (!album || albumFormats[album.id] !== undefined) return;
|
||||
getAlbum(album.id).then(data => {
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
resolveAlbum(serverId, album.id).then(data => {
|
||||
if (!data) {
|
||||
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
|
||||
return;
|
||||
}
|
||||
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
|
||||
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
|
||||
}).catch(() => {
|
||||
@@ -339,7 +346,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
|
||||
} catch (_) {}
|
||||
}}
|
||||
@@ -369,7 +379,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
} catch (_) {}
|
||||
|
||||
@@ -4,13 +4,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Settings, HardDriveDownload } from 'lucide-react';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||
import { isOfflineSidebarLibraryNavAllowed } from '../utils/offline/favoritesOfflineBrowse';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||
|
||||
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
|
||||
|
||||
@@ -18,14 +16,10 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
|
||||
const { t } = useTranslation();
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
||||
const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled;
|
||||
const { status: connStatus } = useConnectionStatus();
|
||||
const isServerOffline = connStatus === 'disconnected';
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const isServerOffline = offlineCtx.active;
|
||||
const hasOfflineContent = offlineCtx.capabilities.manualPins;
|
||||
const luckyMixBase = useLuckyMixAvailable();
|
||||
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
||||
|
||||
@@ -38,7 +32,13 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||
if (isServerOffline && !isOfflineSidebarLibraryNavAllowed(cfg.id, favoritesOfflineBrowse)) {
|
||||
if (isServerOffline && !isOfflineSidebarNavAllowed(
|
||||
cfg.id,
|
||||
offlineNav.favoritesOfflineBrowse,
|
||||
offlineNav.localLibraryBrowse,
|
||||
offlineNav.playerStatsBrowse,
|
||||
offlineNav.playlistsOfflineBrowse,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -10,6 +10,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
vi.mock('@/api/subsonic', () => ({
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||
pingWithCredentials: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: 'navidrome',
|
||||
serverVersion: '0.55.0',
|
||||
openSubsonic: true,
|
||||
})),
|
||||
scheduleInstantMixProbeForServer: vi.fn(),
|
||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
import StarRating from './StarRating';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Play } from 'lucide-react';
|
||||
import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists';
|
||||
import { updatePlaylist } from '../api/subsonicPlaylists';
|
||||
import { resolvePlaylist, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
@@ -398,7 +399,10 @@ function QueuePanelHostOrSolo() {
|
||||
onClose={() => setLoadModalOpen(false)}
|
||||
onLoad={async (id, name, mode) => {
|
||||
try {
|
||||
const data = await getPlaylist(id);
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return;
|
||||
const data = await resolvePlaylist(serverId, id);
|
||||
if (!data) return;
|
||||
const tracks: Track[] = data.songs.map(songToTrack);
|
||||
if (tracks.length > 0) {
|
||||
if (mode === 'append') {
|
||||
|
||||
+31
-17
@@ -1,7 +1,6 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { clearOfflinePinTasks } from '../utils/offline/offlinePinQueue';
|
||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||
@@ -24,10 +23,9 @@ import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnrea
|
||||
import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd';
|
||||
import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown';
|
||||
import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible';
|
||||
import { isOfflineSidebarLibraryNavAllowed } from '../utils/offline/favoritesOfflineBrowse';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||
import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
|
||||
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
|
||||
import SidebarNavBody from './sidebar/SidebarNavBody';
|
||||
@@ -61,7 +59,8 @@ export default function Sidebar({
|
||||
const syncJobFail = useDeviceSyncJobStore(s => s.failed);
|
||||
const syncJobTotal = useDeviceSyncJobStore(s => s.total);
|
||||
const isSyncing = syncJobStatus === 'running';
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const musicFolders = useAuthStore(s => s.musicFolders);
|
||||
@@ -73,12 +72,8 @@ export default function Sidebar({
|
||||
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
|
||||
const loggingMode = useAuthStore(s => s.loggingMode);
|
||||
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
|
||||
const { status: connStatus } = useConnectionStatus();
|
||||
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
||||
const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled;
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const isServerOffline = connStatus === 'disconnected';
|
||||
const hasOfflineContent = offlineCtx.capabilities.manualPins;
|
||||
const isServerOffline = offlineCtx.active;
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const setSidebarItems = useSidebarStore(s => s.setItems);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
@@ -99,7 +94,7 @@ export default function Sidebar({
|
||||
}, [playlistsRaw]);
|
||||
const [sidebarViewportEl, setSidebarViewportEl] = useState<HTMLDivElement | null>(null);
|
||||
const isSidebarScrolling = useSidebarScrollVisible(sidebarViewportEl);
|
||||
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
|
||||
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1 && !isServerOffline;
|
||||
|
||||
const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all';
|
||||
const selectedFolderName =
|
||||
@@ -118,20 +113,34 @@ export default function Sidebar({
|
||||
libraryItemsForReorder.filter(c => {
|
||||
if (!c.visible) return false;
|
||||
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||
if (isServerOffline && !isOfflineSidebarLibraryNavAllowed(c.id, favoritesOfflineBrowse)) {
|
||||
if (isServerOffline && !isOfflineSidebarNavAllowed(
|
||||
c.id,
|
||||
offlineNav.favoritesOfflineBrowse,
|
||||
offlineNav.localLibraryBrowse,
|
||||
offlineNav.playerStatsBrowse,
|
||||
offlineNav.playlistsOfflineBrowse,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, favoritesOfflineBrowse],
|
||||
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, offlineNav],
|
||||
);
|
||||
const visibleSystemConfigs = useMemo(
|
||||
() => systemItemsForReorder.filter(c => {
|
||||
if (!c.visible) return false;
|
||||
if (isServerOffline) return false;
|
||||
if (isServerOffline && !isOfflineSidebarNavAllowed(
|
||||
c.id,
|
||||
offlineNav.favoritesOfflineBrowse,
|
||||
offlineNav.localLibraryBrowse,
|
||||
offlineNav.playerStatsBrowse,
|
||||
offlineNav.playlistsOfflineBrowse,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[systemItemsForReorder, isServerOffline],
|
||||
[systemItemsForReorder, isServerOffline, offlineNav],
|
||||
);
|
||||
|
||||
const sidebarItemsRef = useRef(sidebarItems);
|
||||
@@ -164,10 +173,15 @@ export default function Sidebar({
|
||||
|
||||
|
||||
const pickLibrary = (id: 'all' | string) => {
|
||||
if (isServerOffline) return;
|
||||
setMusicLibraryFilter(id);
|
||||
setLibraryDropdownOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isServerOffline) setLibraryDropdownOpen(false);
|
||||
}, [isServerOffline, setLibraryDropdownOpen]);
|
||||
|
||||
// Fetch playlists when expanded
|
||||
useEffect(() => {
|
||||
if (!playlistsExpanded || !isLoggedIn) return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ListPlus, Search, X } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useSelectionStore } from '../../store/selectionStore';
|
||||
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
interface Props {
|
||||
filterText: string;
|
||||
@@ -12,6 +13,7 @@ interface Props {
|
||||
showPlPicker: boolean;
|
||||
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
t: TFunction;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +33,9 @@ export function AlbumDetailToolbar({
|
||||
showPlPicker,
|
||||
setShowPlPicker,
|
||||
t,
|
||||
actionPolicy,
|
||||
}: Props) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
|
||||
return (
|
||||
<div className="album-track-toolbar">
|
||||
<div className="album-track-toolbar-filter">
|
||||
@@ -59,22 +63,24 @@ export function AlbumDetailToolbar({
|
||||
<span className="bulk-action-count">
|
||||
{t('common.bulkSelected', { count: selectedCount })}
|
||||
</span>
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...useSelectionStore.getState().selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{policy.canAddToPlaylist && (
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...useSelectionStore.getState().selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => useSelectionStore.getState().clearAll()}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrack
|
||||
import { formatLongDuration } from '../../utils/format/formatDuration';
|
||||
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
|
||||
import i18n from '../../i18n';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
type ContextMenuFn = (
|
||||
x: number,
|
||||
@@ -41,6 +42,7 @@ interface TrackRowProps {
|
||||
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
|
||||
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
|
||||
setContextMenuSongId: (id: string | null) => void;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +69,9 @@ export const TrackRow = React.memo(function TrackRow({
|
||||
onToggleSelect,
|
||||
onDragStart,
|
||||
setContextMenuSongId,
|
||||
actionPolicy,
|
||||
}: TrackRowProps) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const showBitrate = useThemeStore(s => s.showBitrate);
|
||||
@@ -171,6 +175,7 @@ export const TrackRow = React.memo(function TrackRow({
|
||||
key="rating"
|
||||
value={ratingValue}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
disabled={!policy.canRate}
|
||||
/>
|
||||
);
|
||||
case 'duration':
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { CoverArtRef } from '../../cover/types';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import StarRating from '../StarRating';
|
||||
import { tooltipAttrs } from '../tooltipAttrs';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -41,6 +42,7 @@ interface Props {
|
||||
coverRevision: number;
|
||||
headerCoverFailed: boolean;
|
||||
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
export default function ArtistDetailHero({
|
||||
@@ -49,7 +51,9 @@ export default function ArtistDetailHero({
|
||||
handleImageUpload, playAllLoading, radioLoading, uploading,
|
||||
openedLink, openLink,
|
||||
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||
actionPolicy,
|
||||
}: Props) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('artistDetail', false);
|
||||
const { t } = useTranslation();
|
||||
const goBack = useAlbumDetailBack();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -139,7 +143,7 @@ export default function ArtistDetailHero({
|
||||
<StarRating
|
||||
value={artistEntityRating}
|
||||
onChange={handleArtistEntityRating}
|
||||
disabled={artistEntityRatingSupport === 'track_only'}
|
||||
disabled={!policy.canRate || artistEntityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
@@ -168,15 +172,17 @@ export default function ArtistDetailHero({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('artistDetail.favorite')}
|
||||
</button>
|
||||
{policy.canFavorite && (
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('artistDetail.favorite')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
@@ -222,7 +228,7 @@ export default function ArtistDetailHero({
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
{albums.length > 0 && (
|
||||
{policy.canCacheDiscography && albums.length > 0 && (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
disabled={
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { getArtist } from '../../api/subsonicArtists';
|
||||
import { resolveAlbum, resolveArtist, resolveMediaServerId } from '../../utils/offline/offlineMediaResolve';
|
||||
import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu';
|
||||
|
||||
interface AlbumProps {
|
||||
@@ -13,8 +12,13 @@ export function AlbumToPlaylistSubmenu({ albumId, onDone, triggerId }: AlbumProp
|
||||
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAlbum(albumId).then((data) => {
|
||||
setResolvedIds(data.songs.map((s) => s.id));
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) {
|
||||
setResolvedIds([]);
|
||||
return;
|
||||
}
|
||||
resolveAlbum(serverId, albumId).then((data) => {
|
||||
setResolvedIds(data ? data.songs.map((s) => s.id) : []);
|
||||
}).catch(() => setResolvedIds([]));
|
||||
}, [albumId]);
|
||||
|
||||
@@ -40,8 +44,19 @@ export function ArtistToPlaylistSubmenu({ artistId, onDone, triggerId }: ArtistP
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { albums } = await getArtist(artistId);
|
||||
const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs)));
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) {
|
||||
setResolvedIds([]);
|
||||
return;
|
||||
}
|
||||
const artistData = await resolveArtist(serverId, artistId);
|
||||
if (!artistData) {
|
||||
setResolvedIds([]);
|
||||
return;
|
||||
}
|
||||
const albumSongs = await Promise.all(
|
||||
artistData.albums.map(a => resolveAlbum(serverId, a.id).then(r => r?.songs ?? [])),
|
||||
);
|
||||
setResolvedIds(albumSongs.flat().map(s => s.id));
|
||||
})().catch(() => setResolvedIds([]));
|
||||
}, [artistId]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, ListPlus, Heart, Download, ChevronRight, ChevronsRight, User, ListMusic, Star, Share2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../../utils/offline/offlineMediaResolve';
|
||||
import { star, unstar } from '../../api/subsonicStarRating';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
@@ -22,7 +22,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||
applySongRating, applyAlbumRating, applyArtistRating,
|
||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||
pinToPlaybackServer, navigateLibrary,
|
||||
pinToPlaybackServer, navigateLibrary, offlinePolicy,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
@@ -40,7 +40,10 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
<Play size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
if (tracks.length === 0) return;
|
||||
playNext(tracks);
|
||||
@@ -48,7 +51,10 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
enqueue(albumData.songs.map(songToTrack));
|
||||
})}>
|
||||
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
|
||||
@@ -57,58 +63,66 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => goLibrary(`/artist/${album.artistId}`))}>
|
||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(album.id, album.starred);
|
||||
setStarredOverride(album.id, !starred);
|
||||
const meta = {
|
||||
serverId: album.serverId,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
artistId: album.artistId,
|
||||
coverArtId: album.coverArt,
|
||||
year: album.year,
|
||||
};
|
||||
return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="album"
|
||||
data-rating-id={album.id}
|
||||
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'album' && keyboardRating.id === album.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[album.id] ?? album.userRating ?? 0}
|
||||
disabled={albumRatingDisabled}
|
||||
labelKey="entityRating.albumAriaLabel"
|
||||
onChange={r => { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canFavorite && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(album.id, album.starred);
|
||||
setStarredOverride(album.id, !starred);
|
||||
const meta = {
|
||||
serverId: album.serverId,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
artistId: album.artistId,
|
||||
coverArtId: album.coverArt,
|
||||
year: album.year,
|
||||
};
|
||||
return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="album"
|
||||
data-rating-id={album.id}
|
||||
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'album' && keyboardRating.id === album.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[album.id] ?? album.userRating ?? 0}
|
||||
disabled={albumRatingDisabled}
|
||||
labelKey="entityRating.albumAriaLabel"
|
||||
onChange={r => { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('album', album.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||
<Download size={14} /> {t('contextMenu.download')}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`album:${album.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
|
||||
<AlbumToPlaylistSubmenu albumId={album.id} triggerId={`album:${album.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canDownload && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||
<Download size={14} /> {t('contextMenu.download')}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`album:${album.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
|
||||
<AlbumToPlaylistSubmenu albumId={album.id} triggerId={`album:${album.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -131,47 +145,56 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
// Parallel — Navidrome handles concurrent getAlbum requests fine.
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const allTracks = results.flatMap(r => r.songs.map(songToTrack));
|
||||
const results = await Promise.all(albums.map(async a => {
|
||||
const serverId = resolveMediaServerId(a.serverId);
|
||||
if (!serverId) return null;
|
||||
return resolveAlbum(serverId, a.id);
|
||||
}));
|
||||
const allTracks = results
|
||||
.filter((r): r is NonNullable<typeof r> => r != null)
|
||||
.flatMap(r => r.songs.map(songToTrack));
|
||||
enqueue(allTracks);
|
||||
})}>
|
||||
<ListPlus size={14} /> {t('contextMenu.enqueueAlbums', { count: albums.length })}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
|
||||
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="album"
|
||||
data-rating-id={multiAlbumRatingId}
|
||||
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={
|
||||
keyboardRating?.kind === 'album' && keyboardRating.id === multiAlbumRatingId
|
||||
? keyboardRating.value
|
||||
: unifiedAlbumRating
|
||||
}
|
||||
disabled={albumRatingDisabled}
|
||||
ariaLabel={t('entityRating.selectedAlbumsRatingAriaLabel', { count: albums.length })}
|
||||
onChange={r => {
|
||||
setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
|
||||
for (const a of albums) applyAlbumRating(a, r);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
|
||||
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="album"
|
||||
data-rating-id={multiAlbumRatingId}
|
||||
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={
|
||||
keyboardRating?.kind === 'album' && keyboardRating.id === multiAlbumRatingId
|
||||
? keyboardRating.value
|
||||
: unifiedAlbumRating
|
||||
}
|
||||
disabled={albumRatingDisabled}
|
||||
ariaLabel={t('entityRating.selectedAlbumsRatingAriaLabel', { count: albums.length })}
|
||||
onChange={r => {
|
||||
setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
|
||||
for (const a of albums) applyAlbumRating(a, r);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
|
||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||
applySongRating, applyAlbumRating, applyArtistRating,
|
||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||
offlinePolicy,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
@@ -35,54 +36,64 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`artist:${artist.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
|
||||
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`artist:${artist.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
|
||||
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(artist.id, artist.starred);
|
||||
setStarredOverride(artist.id, !starred);
|
||||
const meta = {
|
||||
serverId: artist.serverId,
|
||||
name: artist.name,
|
||||
albumCount: artist.albumCount,
|
||||
};
|
||||
return starred
|
||||
? unstar(artist.id, 'artist', meta)
|
||||
: star(artist.id, 'artist', meta);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="artist"
|
||||
data-rating-id={artist.id}
|
||||
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'artist' && keyboardRating.id === artist.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[artist.id] ?? artist.userRating ?? 0}
|
||||
disabled={artistRatingDisabled}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
onChange={r => { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }}
|
||||
/>
|
||||
</div>
|
||||
{(offlinePolicy.canFavorite || offlinePolicy.canRate) && (
|
||||
<>
|
||||
<div className="context-menu-divider" />
|
||||
{offlinePolicy.canFavorite && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(artist.id, artist.starred);
|
||||
setStarredOverride(artist.id, !starred);
|
||||
const meta = {
|
||||
serverId: artist.serverId,
|
||||
name: artist.name,
|
||||
albumCount: artist.albumCount,
|
||||
};
|
||||
return starred
|
||||
? unstar(artist.id, 'artist', meta)
|
||||
: star(artist.id, 'artist', meta);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="artist"
|
||||
data-rating-id={artist.id}
|
||||
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'artist' && keyboardRating.id === artist.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[artist.id] ?? artist.userRating ?? 0}
|
||||
disabled={artistRatingDisabled}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
onChange={r => { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -104,40 +115,44 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
|
||||
{t('contextMenu.selectedArtists', { count: artists.length })}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-artist:${artistIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
|
||||
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="artist"
|
||||
data-rating-id={multiArtistRatingId}
|
||||
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={
|
||||
keyboardRating?.kind === 'artist' && keyboardRating.id === multiArtistRatingId
|
||||
? keyboardRating.value
|
||||
: unifiedArtistRating
|
||||
}
|
||||
disabled={artistRatingDisabled}
|
||||
ariaLabel={t('entityRating.selectedArtistsRatingAriaLabel', { count: artists.length })}
|
||||
onChange={r => {
|
||||
setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
|
||||
for (const a of artists) applyArtistRating(a, r);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-artist:${artistIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
|
||||
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="artist"
|
||||
data-rating-id={multiArtistRatingId}
|
||||
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={
|
||||
keyboardRating?.kind === 'artist' && keyboardRating.id === multiArtistRatingId
|
||||
? keyboardRating.value
|
||||
: unifiedArtistRating
|
||||
}
|
||||
disabled={artistRatingDisabled}
|
||||
ariaLabel={t('entityRating.selectedArtistsRatingAriaLabel', { count: artists.length })}
|
||||
onChange={r => {
|
||||
setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
|
||||
for (const a of artists) applyArtistRating(a, r);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ListMusic, Plus } from 'lucide-react';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
|
||||
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||
import { usePlaylistStore } from '../../store/playlistStore';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
@@ -26,7 +26,10 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
|
||||
setTotalAlbums(albumIds.length);
|
||||
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
|
||||
(async () => {
|
||||
const albumSongs = await Promise.all(albumIds.map(id => getAlbum(id).then(r => r.songs).catch(() => [])));
|
||||
const serverId = resolveMediaServerId();
|
||||
const albumSongs = serverId
|
||||
? await Promise.all(albumIds.map(id => resolveAlbum(serverId, id).then(r => r?.songs ?? []).catch(() => [])))
|
||||
: [];
|
||||
const allSongs = albumSongs.flat();
|
||||
setResolvedIds(allSongs.map(s => s.id));
|
||||
})().catch(() => setResolvedIds([]));
|
||||
@@ -34,11 +37,15 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
|
||||
}, [albumIds]);
|
||||
|
||||
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const { updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
|
||||
try {
|
||||
const { songs: existingSongs } = await getPlaylist(pl.id);
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return;
|
||||
const resolved = await resolvePlaylist(serverId, pl.id);
|
||||
if (!resolved) return;
|
||||
const { songs: existingSongs } = resolved;
|
||||
const existingIds = new Set(existingSongs.map((s) => s.id));
|
||||
|
||||
const newIds: string[] = [];
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ListMusic, Plus } from 'lucide-react';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { getArtist } from '../../api/subsonicArtists';
|
||||
import { resolveAlbum, resolveArtist, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
|
||||
import { getPlaylists } from '../../api/subsonicPlaylists';
|
||||
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||
import { usePlaylistStore } from '../../store/playlistStore';
|
||||
@@ -29,10 +28,18 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
|
||||
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
|
||||
(async () => {
|
||||
const allSongs: string[] = [];
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) {
|
||||
setResolvedIds([]);
|
||||
return;
|
||||
}
|
||||
for (const artistId of artistIds) {
|
||||
try {
|
||||
const { albums } = await getArtist(artistId);
|
||||
const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs).catch(() => [])));
|
||||
const artistData = await resolveArtist(serverId, artistId);
|
||||
if (!artistData) continue;
|
||||
const albumSongs = await Promise.all(
|
||||
artistData.albums.map(a => resolveAlbum(serverId, a.id).then(r => r?.songs ?? []).catch(() => [])),
|
||||
);
|
||||
allSongs.push(...albumSongs.flat().map(s => s.id));
|
||||
} catch {
|
||||
// Skip failed artists
|
||||
@@ -44,11 +51,15 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
|
||||
}, [artistIds]);
|
||||
|
||||
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const { updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
|
||||
try {
|
||||
const { songs: existingSongs } = await getPlaylist(pl.id);
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return;
|
||||
const resolved = await resolvePlaylist(serverId, pl.id);
|
||||
if (!resolved) return;
|
||||
const { songs: existingSongs } = resolved;
|
||||
const existingIds = new Set(existingSongs.map((s) => s.id));
|
||||
|
||||
const newIds: string[] = [];
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||
applySongRating, applyAlbumRating, applyArtistRating,
|
||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||
offlinePolicy,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
@@ -33,18 +34,22 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
<Play size={14} /> {t('contextMenu.playNow')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`playlist:${playlist.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
|
||||
<SinglePlaylistToPlaylistSubmenu playlist={playlist} triggerId={`playlist:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`playlist:${playlist.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
|
||||
<SinglePlaylistToPlaylistSubmenu playlist={playlist} triggerId={`playlist:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canEditPlaylist && (
|
||||
<>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||
const { showToast } = await import('../../utils/ui/toast');
|
||||
@@ -64,6 +69,8 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
})}>
|
||||
<Trash2 size={14} /> {t('playlists.deletePlaylist')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -77,18 +84,21 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
{t('contextMenu.selectedPlaylists', { count: selectedPlaylists.length })}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-playlist:${playlistIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
|
||||
<MultiPlaylistToPlaylistSubmenu playlists={selectedPlaylists} triggerId={`multi-playlist:${playlistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-playlist:${playlistIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
|
||||
<MultiPlaylistToPlaylistSubmenu playlists={selectedPlaylists} triggerId={`multi-playlist:${playlistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canEditPlaylist && (
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||
const { showToast } = await import('../../utils/ui/toast');
|
||||
const { deletePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
@@ -113,6 +123,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
})}>
|
||||
<Trash2 size={14} /> {t('playlists.deleteSelected')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Play, ListPlus, Radio, Heart, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
|
||||
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
|
||||
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
|
||||
import { queueSongStar } from '../../store/pendingStarSync';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
@@ -27,6 +27,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||
applySongRating, applyAlbumRating, applyArtistRating,
|
||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||
offlinePolicy,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
@@ -80,21 +81,26 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{type === 'album-song' && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(song.albumId);
|
||||
const serverId = resolveMediaServerId(song.serverId);
|
||||
if (!serverId || !song.albumId) return;
|
||||
const albumData = await resolveAlbum(serverId, song.albumId);
|
||||
if (!albumData) return;
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
enqueue(tracks);
|
||||
})}>
|
||||
@@ -120,12 +126,14 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
{offlinePolicy.canFavorite && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
)}
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
const loveKey = `${song.title}::${song.artist}`;
|
||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||
@@ -141,22 +149,24 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="song"
|
||||
data-rating-id={song.id}
|
||||
data-rating-disabled="false"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="song"
|
||||
data-rating-id={song.id}
|
||||
data-rating-disabled="false"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
@@ -164,13 +174,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
{playlistId && playlistSongIndex !== undefined && (
|
||||
{offlinePolicy.canEditPlaylist && playlistId && playlistSongIndex !== undefined && (
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const { updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const { showToast } = await import('../../utils/ui/toast');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
try {
|
||||
const { songs } = await getPlaylist(playlistId);
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return;
|
||||
const resolved = await resolvePlaylist(serverId, playlistId);
|
||||
if (!resolved) return;
|
||||
const { songs } = resolved;
|
||||
const prevCount = songs.length;
|
||||
const updatedIds = songs.filter((_, i) => i !== playlistSongIndex).map(s => s.id);
|
||||
await updatePlaylist(playlistId, updatedIds, prevCount);
|
||||
@@ -232,18 +246,20 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
{song.albumId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!))}>
|
||||
@@ -278,22 +294,24 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="song"
|
||||
data-rating-id={song.id}
|
||||
data-rating-disabled="false"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="song"
|
||||
data-rating-id={song.id}
|
||||
data-rating-disabled="false"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
@@ -301,12 +319,16 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, false, song.serverId);
|
||||
})}>
|
||||
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
|
||||
</div>
|
||||
{offlinePolicy.canFavorite && (
|
||||
<>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, false, song.serverId);
|
||||
})}>
|
||||
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type React from 'react';
|
||||
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
|
||||
import type { EntityShareKind } from '../../utils/share/shareLink';
|
||||
import type { OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
export type RatingKind = 'song' | 'album' | 'artist';
|
||||
|
||||
@@ -56,4 +57,5 @@ export interface ContextMenuItemsProps {
|
||||
/** When true, album/artist links switch to the queue server before routing. */
|
||||
pinToPlaybackServer: boolean;
|
||||
navigateLibrary: (path: string) => void | Promise<void>;
|
||||
offlinePolicy: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
usePlayerBarLayoutStore,
|
||||
type PlayerBarLayoutItemId,
|
||||
} from '../../store/playerBarLayoutStore';
|
||||
import { useOfflineBrowseContext } from '../../hooks/useOfflineBrowseContext';
|
||||
import { offlineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
interface Props {
|
||||
currentTrack: Track | null;
|
||||
@@ -67,6 +69,8 @@ export function PlayerTrackInfo({
|
||||
const layoutItems = usePlayerBarLayoutStore(s => s.items);
|
||||
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
||||
layoutItems.find(i => i.id === id)?.visible !== false;
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const playerPolicy = offlineActionPolicy('playerBar', offlineBrowseActive);
|
||||
|
||||
return (
|
||||
<div className="player-track-info">
|
||||
@@ -168,7 +172,7 @@ export function PlayerTrackInfo({
|
||||
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
/>
|
||||
)}
|
||||
{currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && (
|
||||
{currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && playerPolicy.canRate && (
|
||||
<StarRating
|
||||
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
||||
onChange={r => queueSongRating(currentTrack.id, r)}
|
||||
@@ -182,7 +186,7 @@ export function PlayerTrackInfo({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{currentTrack && !isRadio && isLayoutVisible('favorite') && (
|
||||
{currentTrack && !isRadio && isLayoutVisible('favorite') && playerPolicy.canFavorite && (
|
||||
<button
|
||||
className={`player-btn player-btn-sm player-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={toggleStar}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
|
||||
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
|
||||
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
|
||||
import type { OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
interface Props {
|
||||
playlist: SubsonicPlaylist;
|
||||
@@ -34,6 +35,7 @@ interface Props {
|
||||
offlineStatus: AlbumOfflineStatus;
|
||||
offlineProgress: { done: number; total: number } | null;
|
||||
activeServerId: string;
|
||||
actionPolicy: OfflineActionPolicy;
|
||||
setEditingMeta: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||
@@ -53,7 +55,7 @@ export default function PlaylistHero({
|
||||
playlist, songs, id,
|
||||
customCoverId, coverQuadIds,
|
||||
resolvedBgUrl, saving, searchOpen, csvImporting, activeZip,
|
||||
offlineStatus, offlineProgress, activeServerId,
|
||||
offlineStatus, offlineProgress, activeServerId, actionPolicy,
|
||||
setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults,
|
||||
setSelectedSearchIds, setSearchPlPickerOpen,
|
||||
handlePlayAll, handleShuffleAll, handleEnqueueAll, handleImportCsv, handleDownload,
|
||||
@@ -86,7 +88,7 @@ export default function PlaylistHero({
|
||||
{enablePlaylistCoverPhoto && (
|
||||
<div
|
||||
className="playlist-hero-cover"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
onClick={() => { if (actionPolicy.canEditPlaylist) setEditingMeta(true); }}
|
||||
>
|
||||
{customCoverId ? (
|
||||
<AlbumCoverArtImage
|
||||
@@ -120,14 +122,16 @@ export default function PlaylistHero({
|
||||
{isSmartPlaylistName(playlist.name) && <Sparkles size={16} style={{ color: 'var(--text-muted)' }} />}
|
||||
<span>{displayPlaylistName(playlist.name)}</span>
|
||||
</h1>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
data-tooltip={t('playlists.editMeta')}
|
||||
style={{ padding: '4px 6px', opacity: 0.7, flexShrink: 0 }}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
{actionPolicy.canEditPlaylist && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
data-tooltip={t('playlists.editMeta')}
|
||||
style={{ padding: '4px 6px', opacity: 0.7, flexShrink: 0 }}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{playlist.comment && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 2 }}>{playlist.comment}</div>
|
||||
@@ -172,7 +176,7 @@ export default function PlaylistHero({
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{isLayoutVisible('addSongs') && (
|
||||
{actionPolicy.canEditPlaylist && isLayoutVisible('addSongs') && (
|
||||
<button
|
||||
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
|
||||
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
|
||||
@@ -181,7 +185,7 @@ export default function PlaylistHero({
|
||||
<Search size={16} /> {t('playlists.addSongs')}
|
||||
</button>
|
||||
)}
|
||||
{isLayoutVisible('importCsv') && (
|
||||
{actionPolicy.canEditPlaylist && isLayoutVisible('importCsv') && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={handleImportCsv}
|
||||
@@ -192,7 +196,7 @@ export default function PlaylistHero({
|
||||
{t('playlists.importCSV')}
|
||||
</button>
|
||||
)}
|
||||
{isLayoutVisible('downloadZip') && songs.length > 0 && (
|
||||
{actionPolicy.canDownload && isLayoutVisible('downloadZip') && songs.length > 0 && (
|
||||
activeZip && !activeZip.done && !activeZip.error ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
@@ -207,7 +211,7 @@ export default function PlaylistHero({
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{isLayoutVisible('offlineCache') && songs.length > 0 && id
|
||||
{actionPolicy.canPinOffline && isLayoutVisible('offlineCache') && songs.length > 0 && id
|
||||
&& (!isSmartPlaylistName(playlist.name) || offlineStatus !== 'none') && (
|
||||
<button
|
||||
className={`btn btn-ghost${offlineStatus === 'cached' ? ' btn-danger' : ''}${offlineStatus === 'queued' ? ' offline-cache-btn--queued' : ''}`}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||
import {
|
||||
defaultSmartFilters, type SmartFilters,
|
||||
} from '../../utils/playlist/playlistsSmart';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
interface Props {
|
||||
selectionMode: boolean;
|
||||
@@ -24,6 +25,7 @@ interface Props {
|
||||
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
|
||||
setGenreQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
export default function PlaylistsHeader({
|
||||
@@ -32,8 +34,10 @@ export default function PlaylistsHeader({
|
||||
creating, setCreating, setCreatingSmart,
|
||||
newName, setNewName, nameInputRef, handleCreate,
|
||||
isNavidromeServer, setEditingSmartId, setSmartFilters, setGenreQuery,
|
||||
actionPolicy,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const policy = actionPolicy ?? offlineActionPolicy('playlistsHeader', false);
|
||||
|
||||
return (
|
||||
<div className="playlists-header">
|
||||
@@ -43,7 +47,7 @@ export default function PlaylistsHeader({
|
||||
: t('playlists.title')}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{!(selectionMode && selectedIds.size > 0) && (<>
|
||||
{policy.canEditPlaylist && !(selectionMode && selectedIds.size > 0) && (<>
|
||||
{creating ? (
|
||||
<>
|
||||
<input
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useOfflineBrowseContext } from '../../hooks/useOfflineBrowseContext';
|
||||
import { usePlayerStatsRecordingEnabled } from '../../hooks/usePlayerStatsRecordingEnabled';
|
||||
|
||||
export default function StatisticsTabBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const playerStatsEnabled = usePlayerStatsRecordingEnabled();
|
||||
|
||||
const isPlayer = location.pathname === '/player-stats';
|
||||
const showServerTab = !offlineBrowseActive;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${!isPlayer ? 'btn-primary' : 'btn-surface'}`}
|
||||
onClick={() => navigate('/statistics')}
|
||||
>
|
||||
{t('statistics.tabServer')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${isPlayer ? 'btn-primary' : 'btn-surface'}`}
|
||||
onClick={() => navigate('/player-stats')}
|
||||
>
|
||||
{t('statistics.tabPlayer')}
|
||||
</button>
|
||||
{showServerTab && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${!isPlayer ? 'btn-primary' : 'btn-surface'}`}
|
||||
onClick={() => navigate('/statistics')}
|
||||
>
|
||||
{t('statistics.tabServer')}
|
||||
</button>
|
||||
)}
|
||||
{playerStatsEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${isPlayer || offlineBrowseActive ? 'btn-primary' : 'btn-surface'}`}
|
||||
onClick={() => navigate('/player-stats')}
|
||||
>
|
||||
{t('statistics.tabPlayer')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Cover backfill: follow the smart local/public endpoint switch so off-LAN clients stop fetching covers from the unreachable local address (PR #952)',
|
||||
'Audio: Symphonia 0.6 migration with libopus adapter 0.3; ranged-stream start latency fix (probe seek-gate) and a probe timeout so a stalled stream no longer hangs playback start (PR #999)',
|
||||
'Offline experience — unified media layout (cache/library/favorites), localPlaybackStore, library-index Offline Library, favorites auto-sync, cached album/playlist/artist pin reconcile, mixed-server offline queue, and single mediaDir setting (PR #1008)',
|
||||
'Offline browse — local-bytes catalog when server is down, integration contract (context/policy/resolvers), disconnect nav fork, Home stale-cache feed, read-only context menus, PlayerBar rating/favorite guard (PR #1017)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -31,6 +31,13 @@ import {
|
||||
ALBUM_YEAR_FILTER_DEBOUNCE_MS,
|
||||
resolveAlbumYearBounds,
|
||||
} from '../utils/library/albumYearFilter';
|
||||
import { loadOfflineAlbumBrowseInitial } from '../utils/offline/offlineAlbumBrowseCatalog';
|
||||
import { useOfflineBrowseReloadToken } from './useOfflineBrowseReloadToken';
|
||||
import {
|
||||
fetchAlbumBrowseCatalogChunk,
|
||||
mergeAlbumCatalogChunk,
|
||||
} from '../utils/library/albumBrowseCatalogChunk';
|
||||
import { useOfflineBrowseContext } from './useOfflineBrowseContext';
|
||||
import { useClientSliceInfiniteScroll } from './useClientSliceInfiniteScroll';
|
||||
import { useDebouncedValue } from './useDebouncedValue';
|
||||
import { useInpageScrollSentinel } from './useInpageScrollSentinel';
|
||||
@@ -91,6 +98,8 @@ export function useAlbumBrowseData({
|
||||
scrollRootEl,
|
||||
restoreDisplayCount,
|
||||
}: UseAlbumBrowseDataArgs) {
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
@@ -232,18 +241,19 @@ export function useAlbumBrowseData({
|
||||
catalogLoadingRef.current = true;
|
||||
setCatalogLoadingMore(true);
|
||||
try {
|
||||
const chunk = await fetchLocalAlbumCatalogChunk(serverId, query, offset, CATALOG_CHUNK_SIZE);
|
||||
const chunk = await fetchAlbumBrowseCatalogChunk(
|
||||
serverId,
|
||||
query,
|
||||
offset,
|
||||
CATALOG_CHUNK_SIZE,
|
||||
starredOverrides,
|
||||
);
|
||||
if (generation !== loadGenerationRef.current || chunk == null) return;
|
||||
if (append) {
|
||||
setAlbums(prev => {
|
||||
const merged = dedupeById([...prev, ...chunk.albums]);
|
||||
catalogOffsetRef.current = merged.length;
|
||||
return merged;
|
||||
});
|
||||
} else {
|
||||
setAlbums(chunk.albums);
|
||||
catalogOffsetRef.current = chunk.albums.length;
|
||||
}
|
||||
setAlbums(prev => {
|
||||
const { albums: next, offset: nextOffset } = mergeAlbumCatalogChunk(prev, chunk, append);
|
||||
catalogOffsetRef.current = nextOffset;
|
||||
return next;
|
||||
});
|
||||
setCatalogHasMore(chunk.hasMore);
|
||||
} finally {
|
||||
catalogLoadingRef.current = false;
|
||||
@@ -251,7 +261,7 @@ export function useAlbumBrowseData({
|
||||
setCatalogLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [serverId]);
|
||||
}, [offlineBrowseActive, serverId, starredOverrides]);
|
||||
|
||||
const loadBrowse = useCallback(async (
|
||||
query: AlbumBrowseQuery,
|
||||
@@ -321,6 +331,28 @@ export function useAlbumBrowseData({
|
||||
setLoading(true);
|
||||
|
||||
void (async () => {
|
||||
if (offlineBrowseActive) {
|
||||
const generation = ++loadGenerationRef.current;
|
||||
if (cancelled || generation !== loadGenerationRef.current) return;
|
||||
setBrowseMode('slice');
|
||||
try {
|
||||
const first = await loadOfflineAlbumBrowseInitial(
|
||||
serverId,
|
||||
browseQuery,
|
||||
CATALOG_CHUNK_SIZE,
|
||||
starredOverrides,
|
||||
);
|
||||
if (cancelled || generation !== loadGenerationRef.current) return;
|
||||
setAlbums(first.albums);
|
||||
catalogOffsetRef.current = first.albums.length;
|
||||
setCatalogHasMore(first.hasMore);
|
||||
} catch {
|
||||
setAlbums([]);
|
||||
setCatalogHasMore(false);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (indexEnabled && serverId) {
|
||||
const generation = ++loadGenerationRef.current;
|
||||
coverTrafficBeginGridPagination();
|
||||
@@ -353,7 +385,7 @@ export function useAlbumBrowseData({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [browseQuery, indexEnabled, serverId, loadBrowse, musicLibraryFilterVersion]);
|
||||
}, [browseQuery, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, loadBrowse, musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!genreCatalogActive) {
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { getAlbum, getAlbumForServer } from '../api/subsonicLibrary';
|
||||
import { getArtist, getArtistForServer } from '../api/subsonicArtists';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useConnectionStatus } from './useConnectionStatus';
|
||||
import {
|
||||
loadAlbumFromLibraryIndex,
|
||||
loadArtistFromLibraryIndex,
|
||||
resolveAlbumForServer,
|
||||
} from '../utils/offline/favoritesOfflineBrowse';
|
||||
} from '../utils/offline/offlineLibraryIndexLoad';
|
||||
import {
|
||||
resolveAlbum,
|
||||
resolveArtist,
|
||||
type ResolvedAlbum,
|
||||
} from '../utils/offline/offlineMediaResolve';
|
||||
import { useOfflineBrowseContext } from './useOfflineBrowseContext';
|
||||
import {
|
||||
loadArtistFromLocalPlayback,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from '../utils/offline/offlineLocalBrowse';
|
||||
import { readDetailServerId } from '../utils/navigation/detailServerScope';
|
||||
import {
|
||||
shouldAttemptSubsonicForActiveServer,
|
||||
shouldAttemptSubsonicForServer,
|
||||
} from '../utils/network/subsonicNetworkGuard';
|
||||
|
||||
type AlbumPayload = Awaited<ReturnType<typeof getAlbum>>;
|
||||
type AlbumPayload = ResolvedAlbum;
|
||||
|
||||
interface UseAlbumDetailDataResult {
|
||||
album: AlbumPayload | null;
|
||||
@@ -44,7 +50,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const [searchParams] = useSearchParams();
|
||||
const detailServerId = readDetailServerId(searchParams, activeServerId);
|
||||
const { status: connStatus } = useConnectionStatus();
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active && !!detailServerId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -64,22 +70,25 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
||||
serverId: string | null,
|
||||
artistId: string | undefined,
|
||||
useLocalArtist: boolean,
|
||||
localBytesOnly: boolean,
|
||||
) => {
|
||||
if (!artistId) return;
|
||||
try {
|
||||
if (useLocalArtist && serverId) {
|
||||
const artistLocal = await loadArtistFromLibraryIndex(serverId, artistId);
|
||||
const artistLocal = localBytesOnly
|
||||
? await loadArtistFromLocalPlayback(serverId, artistId)
|
||||
: await loadArtistFromLibraryIndex(serverId, artistId);
|
||||
if (artistLocal) {
|
||||
setRelatedAlbums(artistLocal.albums.filter(a => a.id !== id));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const relatedServerId = serverId ?? detailServerId ?? activeServerId;
|
||||
if (!relatedServerId || !shouldAttemptSubsonicForServer(relatedServerId)) return;
|
||||
const artistData = detailServerId
|
||||
? await getArtistForServer(detailServerId, artistId)
|
||||
: await getArtist(artistId);
|
||||
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
|
||||
if (!relatedServerId) return;
|
||||
const artistData = await resolveArtist(relatedServerId, artistId);
|
||||
if (artistData) {
|
||||
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch related albums', e);
|
||||
}
|
||||
@@ -88,12 +97,28 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
||||
const libraryFirst = favoritesOfflineEnabled && !!detailServerId;
|
||||
|
||||
void (async () => {
|
||||
if (offlineBrowseActive && detailServerId) {
|
||||
const local = await resolveAlbum(detailServerId, id);
|
||||
if (local) {
|
||||
applyAlbumPayload(local);
|
||||
await loadRelatedAlbums(
|
||||
detailServerId,
|
||||
local.album.artistId,
|
||||
true,
|
||||
offlineLocalBrowseEnabled(detailServerId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (libraryFirst && detailServerId) {
|
||||
try {
|
||||
const local = await resolveAlbumForServer(detailServerId, id);
|
||||
const local = await resolveAlbum(detailServerId, id);
|
||||
if (local) {
|
||||
applyAlbumPayload(local);
|
||||
await loadRelatedAlbums(detailServerId, local.album.artistId, true);
|
||||
await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
|
||||
return;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
@@ -106,10 +131,10 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
||||
if (!detailNetworkAllowed) {
|
||||
if (favoritesOfflineEnabled && detailServerId) {
|
||||
try {
|
||||
const local = await loadAlbumFromLibraryIndex(detailServerId, id);
|
||||
const local = await resolveAlbum(detailServerId, id);
|
||||
if (local) {
|
||||
applyAlbumPayload(local);
|
||||
await loadRelatedAlbums(detailServerId, local.album.artistId, true);
|
||||
await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
@@ -119,18 +144,25 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
||||
}
|
||||
|
||||
try {
|
||||
const data = detailServerId
|
||||
? await getAlbumForServer(detailServerId, id)
|
||||
: await getAlbum(id);
|
||||
const sid = detailServerId ?? activeServerId;
|
||||
if (!sid) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const data = await resolveAlbum(sid, id);
|
||||
if (!data) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
applyAlbumPayload(data);
|
||||
await loadRelatedAlbums(detailServerId, data.album.artistId, false);
|
||||
await loadRelatedAlbums(detailServerId, data.album.artistId, false, false);
|
||||
} catch {
|
||||
if (favoritesOfflineEnabled && detailServerId) {
|
||||
try {
|
||||
const local = await loadAlbumFromLibraryIndex(detailServerId, id);
|
||||
if (local) {
|
||||
applyAlbumPayload(local);
|
||||
await loadRelatedAlbums(detailServerId, local.album.artistId, true);
|
||||
await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
@@ -138,7 +170,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [id, connStatus, favoritesOfflineEnabled, detailServerId, searchParams]);
|
||||
}, [activeServerId, detailServerId, favoritesOfflineEnabled, id, offlineBrowseActive, searchParams]);
|
||||
|
||||
return { album, setAlbum, relatedAlbums, loading, isStarred, setIsStarred, starredSongs, setStarredSongs };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import type {
|
||||
} from '../api/subsonicTypes';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useConnectionStatus } from './useConnectionStatus';
|
||||
import { loadArtistFromLibraryIndex } from '../utils/offline/favoritesOfflineBrowse';
|
||||
import { loadArtistFromLibraryIndex } from '../utils/offline/offlineLibraryIndexLoad';
|
||||
import { useOfflineBrowseContext } from './useOfflineBrowseContext';
|
||||
import { loadArtistFromLocalPlayback, offlineLocalBrowseEnabled } from '../utils/offline/offlineLocalBrowse';
|
||||
import { readDetailServerId } from '../utils/navigation/detailServerScope';
|
||||
import { runLocalArtistLosslessBrowse } from '../utils/library/browseTextSearch';
|
||||
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
||||
@@ -58,9 +60,10 @@ export function useArtistDetailData(
|
||||
s => !!(serverId && s.audiomuseNavidromeByServer[serverId]),
|
||||
);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const preferLocalArtist = connStatus === 'disconnected'
|
||||
&& favoritesOfflineEnabled
|
||||
&& !!serverId;
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active && !!serverId;
|
||||
const preferLocalBytesOnly = offlineBrowseActive && offlineLocalBrowseEnabled(serverId);
|
||||
const preferLocalArtist = preferLocalBytesOnly
|
||||
|| (connStatus === 'disconnected' && favoritesOfflineEnabled && !!serverId);
|
||||
|
||||
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -82,8 +85,14 @@ export function useArtistDetailData(
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (offlineBrowseActive && !preferLocalBytesOnly) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (preferLocalArtist && serverId && id) {
|
||||
const local = await loadArtistFromLibraryIndex(serverId, id);
|
||||
const local = preferLocalBytesOnly
|
||||
? await loadArtistFromLocalPlayback(serverId, id)
|
||||
: await loadArtistFromLibraryIndex(serverId, id);
|
||||
if (cancelled) return;
|
||||
if (local) {
|
||||
setArtist(local.artist);
|
||||
@@ -93,6 +102,10 @@ export function useArtistDetailData(
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (preferLocalBytesOnly) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (losslessOnly && serverId) {
|
||||
@@ -135,7 +148,9 @@ export function useArtistDetailData(
|
||||
if (!cancelled) {
|
||||
if (preferLocalArtist && serverId && id) {
|
||||
try {
|
||||
const local = await loadArtistFromLibraryIndex(serverId, id);
|
||||
const local = preferLocalBytesOnly
|
||||
? await loadArtistFromLocalPlayback(serverId, id)
|
||||
: await loadArtistFromLibraryIndex(serverId, id);
|
||||
if (cancelled) return;
|
||||
if (local) {
|
||||
setArtist(local.artist);
|
||||
@@ -154,7 +169,7 @@ export function useArtistDetailData(
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [id, losslessOnly, serverId, preferLocalArtist, searchParams]);
|
||||
}, [id, losslessOnly, serverId, offlineBrowseActive, preferLocalArtist, preferLocalBytesOnly, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || preferLocalArtist) return;
|
||||
|
||||
@@ -6,6 +6,13 @@ import {
|
||||
fetchLocalArtistCatalogChunk,
|
||||
fetchNetworkStarredArtists,
|
||||
} from '../utils/library/browseTextSearch';
|
||||
import { useOfflineBrowseContext } from './useOfflineBrowseContext';
|
||||
import { useOfflineBrowseReloadToken } from './useOfflineBrowseReloadToken';
|
||||
import {
|
||||
fetchOfflineLocalArtistCatalogChunk,
|
||||
fetchOfflineLocalStarredArtists,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from '../utils/offline/offlineLocalBrowse';
|
||||
|
||||
/** Local-index artist catalog buffer grows by this many rows per background SQL chunk. */
|
||||
export const ARTIST_CATALOG_CHUNK_SIZE = 200;
|
||||
@@ -25,6 +32,8 @@ export function useArtistsBrowseCatalog({
|
||||
starredOnly,
|
||||
musicLibraryFilterVersion,
|
||||
}: UseArtistsBrowseCatalogArgs) {
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
|
||||
const [catalogArtists, setCatalogArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [catalogHasMore, setCatalogHasMore] = useState(false);
|
||||
@@ -41,6 +50,27 @@ export function useArtistsBrowseCatalog({
|
||||
catalogLoadingRef.current = true;
|
||||
setCatalogLoadingMore(true);
|
||||
try {
|
||||
if (offlineBrowseActive) {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return;
|
||||
const chunk = await fetchOfflineLocalArtistCatalogChunk(
|
||||
serverId,
|
||||
catalogOffsetRef.current,
|
||||
ARTIST_CATALOG_CHUNK_SIZE,
|
||||
);
|
||||
if (generation !== loadGenerationRef.current || chunk == null) return;
|
||||
if (append) {
|
||||
setCatalogArtists(prev => {
|
||||
const merged = dedupeById([...prev, ...chunk.artists]);
|
||||
catalogOffsetRef.current = merged.length;
|
||||
return merged;
|
||||
});
|
||||
} else {
|
||||
setCatalogArtists(chunk.artists);
|
||||
catalogOffsetRef.current = chunk.artists.length;
|
||||
}
|
||||
setCatalogHasMore(chunk.hasMore);
|
||||
return;
|
||||
}
|
||||
const chunk = await fetchLocalArtistCatalogChunk(
|
||||
serverId,
|
||||
catalogOffsetRef.current,
|
||||
@@ -65,7 +95,7 @@ export function useArtistsBrowseCatalog({
|
||||
setCatalogLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [serverId]);
|
||||
}, [offlineBrowseActive, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -80,6 +110,27 @@ export function useArtistsBrowseCatalog({
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (offlineBrowseActive) {
|
||||
if (!cancelled && generation === loadGenerationRef.current) {
|
||||
if (serverId && starredOnly && offlineLocalBrowseEnabled(serverId)) {
|
||||
setCatalogArtists((await fetchOfflineLocalStarredArtists(serverId)) ?? []);
|
||||
} else if (serverId && !starredOnly && offlineLocalBrowseEnabled(serverId)) {
|
||||
const first = await fetchOfflineLocalArtistCatalogChunk(
|
||||
serverId,
|
||||
0,
|
||||
ARTIST_CATALOG_CHUNK_SIZE,
|
||||
);
|
||||
setCatalogArtists(first?.artists ?? []);
|
||||
catalogOffsetRef.current = first?.artists.length ?? 0;
|
||||
setCatalogHasMore(first?.hasMore ?? false);
|
||||
} else {
|
||||
setCatalogArtists([]);
|
||||
setCatalogHasMore(false);
|
||||
}
|
||||
setBrowseMode('slice');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (starredOnly) {
|
||||
if (!cancelled && generation === loadGenerationRef.current) {
|
||||
setCatalogArtists(await fetchNetworkStarredArtists());
|
||||
@@ -116,7 +167,7 @@ export function useArtistsBrowseCatalog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [musicLibraryFilterVersion, indexEnabled, serverId, starredOnly]);
|
||||
}, [musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]);
|
||||
|
||||
return {
|
||||
catalogArtists,
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
runLocalBrowseAlbums,
|
||||
runNetworkBrowseAlbums,
|
||||
} from '../utils/library/browseTextSearch';
|
||||
import { useOfflineBrowseContext } from './useOfflineBrowseContext';
|
||||
import { offlineLocalBrowseEnabled, searchOfflineLocalAlbums } from '../utils/offline/offlineLocalBrowse';
|
||||
|
||||
/**
|
||||
* Debounced album title search with local-vs-network race when the
|
||||
@@ -19,6 +21,7 @@ export function useBrowseAlbumTextSearch(
|
||||
serverId: string | null | undefined,
|
||||
losslessOnly = false,
|
||||
) {
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const [debouncedFilter, setDebouncedFilter] = useState('');
|
||||
const [textSearchAlbums, setTextSearchAlbums] = useState<SubsonicAlbum[] | null>(null);
|
||||
const [textSearchLoading, setTextSearchLoading] = useState(false);
|
||||
@@ -43,6 +46,15 @@ export function useBrowseAlbumTextSearch(
|
||||
setTextSearchLoading(true);
|
||||
|
||||
void (async () => {
|
||||
if (offlineBrowseActive) {
|
||||
const albums = offlineLocalBrowseEnabled(serverId)
|
||||
? await searchOfflineLocalAlbums(serverId, q, losslessOnly)
|
||||
: [];
|
||||
if (isStale()) return;
|
||||
setTextSearchAlbums(albums);
|
||||
setTextSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!indexEnabled) {
|
||||
const albums = await runNetworkBrowseAlbums(q);
|
||||
if (isStale()) return;
|
||||
@@ -66,7 +78,7 @@ export function useBrowseAlbumTextSearch(
|
||||
setTextSearchAlbums(outcome?.result ?? null);
|
||||
setTextSearchLoading(false);
|
||||
})();
|
||||
}, [debouncedFilter, indexEnabled, serverId, losslessOnly]);
|
||||
}, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, losslessOnly]);
|
||||
|
||||
const effectiveFilter = textSearchAlbums != null ? '' : filter;
|
||||
return { textSearchAlbums, textSearchLoading, effectiveFilter };
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
runNetworkBrowseArtists,
|
||||
type LibrarySearchSurface,
|
||||
} from '../utils/library/browseTextSearch';
|
||||
import { useOfflineBrowseContext } from './useOfflineBrowseContext';
|
||||
import { offlineLocalBrowseEnabled, searchOfflineLocalArtists } from '../utils/offline/offlineLocalBrowse';
|
||||
|
||||
/**
|
||||
* Debounced artist/composer name search with local-vs-network race when the
|
||||
@@ -22,6 +24,7 @@ export function useBrowseArtistTextSearch(
|
||||
serverId: string | null | undefined,
|
||||
surface: LibrarySearchSurface = 'artists_browse',
|
||||
) {
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const [debouncedFilter, setDebouncedFilter] = useState('');
|
||||
const [textSearchArtists, setTextSearchArtists] = useState<SubsonicArtist[] | null>(null);
|
||||
const [textSearchLoading, setTextSearchLoading] = useState(false);
|
||||
@@ -46,6 +49,15 @@ export function useBrowseArtistTextSearch(
|
||||
setTextSearchLoading(true);
|
||||
|
||||
void (async () => {
|
||||
if (offlineBrowseActive) {
|
||||
const artists = offlineLocalBrowseEnabled(serverId)
|
||||
? await searchOfflineLocalArtists(serverId, q)
|
||||
: [];
|
||||
if (isStale()) return;
|
||||
setTextSearchArtists(artists);
|
||||
setTextSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
const outcome = await raceBrowseWithLocalFallback(
|
||||
isStale,
|
||||
() => runLocalBrowseArtists(serverId, q),
|
||||
@@ -61,7 +73,7 @@ export function useBrowseArtistTextSearch(
|
||||
setTextSearchArtists(outcome?.result ?? null);
|
||||
setTextSearchLoading(false);
|
||||
})();
|
||||
}, [debouncedFilter, indexEnabled, serverId, surface]);
|
||||
}, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, surface]);
|
||||
|
||||
const effectiveFilter = textSearchArtists != null ? '' : filter;
|
||||
return { textSearchArtists, textSearchLoading, effectiveFilter };
|
||||
|
||||
@@ -17,11 +17,13 @@ vi.mock('@/utils/perf/perfFlags', () => ({
|
||||
}));
|
||||
|
||||
import { pingWithCredentials } from '@/api/subsonic';
|
||||
import { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore';
|
||||
import { useConnectionStatus } from './useConnectionStatus';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
invalidateReachableEndpointCache();
|
||||
useDevOfflineBrowseStore.getState().setForceOffline(false);
|
||||
vi.mocked(pingWithCredentials).mockReset();
|
||||
});
|
||||
|
||||
@@ -133,3 +135,39 @@ describe('useConnectionStatus online event', () => {
|
||||
expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useConnectionStatus DEV offline toggle', () => {
|
||||
it('does not probe again on mount beyond the polling effect', async () => {
|
||||
seedDualAddressServer();
|
||||
vi.mocked(pingWithCredentials).mockResolvedValue({
|
||||
ok: true,
|
||||
type: 'navidrome',
|
||||
serverVersion: '0.55.0',
|
||||
openSubsonic: true,
|
||||
});
|
||||
|
||||
renderHook(() => useConnectionStatus());
|
||||
await waitFor(() => expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(1));
|
||||
const callsAfterMount = vi.mocked(pingWithCredentials).mock.calls.length;
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
expect(vi.mocked(pingWithCredentials).mock.calls.length).toBe(callsAfterMount);
|
||||
});
|
||||
|
||||
it('disconnects on force-offline toggle without an extra probe', async () => {
|
||||
seedDualAddressServer();
|
||||
vi.mocked(pingWithCredentials).mockResolvedValue({
|
||||
ok: true,
|
||||
type: 'navidrome',
|
||||
serverVersion: '0.55.0',
|
||||
openSubsonic: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useConnectionStatus());
|
||||
await waitFor(() => expect(result.current.status).toBe('connected'));
|
||||
const callsBeforeToggle = vi.mocked(pingWithCredentials).mock.calls.length;
|
||||
|
||||
act(() => useDevOfflineBrowseStore.getState().setForceOffline(true));
|
||||
await waitFor(() => expect(result.current.status).toBe('disconnected'));
|
||||
expect(vi.mocked(pingWithCredentials).mock.calls.length).toBe(callsBeforeToggle);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
} from '../utils/server/serverEndpoint';
|
||||
import { setActiveServerReachable } from '../utils/network/activeServerReachability';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import {
|
||||
isDevOfflineBrowseForced,
|
||||
useDevOfflineBrowseStore,
|
||||
} from '../store/devOfflineBrowseStore';
|
||||
|
||||
// Backward-compatible re-export for call sites that still import from the hook.
|
||||
export { isLanUrl };
|
||||
@@ -18,6 +22,7 @@ export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
||||
|
||||
export function useConnectionStatus() {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
|
||||
const [status, setStatus] = useState<ConnectionStatus>('checking');
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
// Tracks the kind of endpoint the last successful probe answered on so the
|
||||
@@ -26,8 +31,15 @@ export function useConnectionStatus() {
|
||||
// public alternate must read as 'public', not 'local'.
|
||||
const [activeEndpointKind, setActiveEndpointKind] = useState<ServerEndpointKind | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const prevDevForceOfflineRef = useRef<boolean | null>(null);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
if (isDevOfflineBrowseForced()) {
|
||||
setActiveServerReachable(false);
|
||||
setStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
const server = useAuthStore.getState().getActiveServer();
|
||||
if (!server) {
|
||||
setActiveServerReachable(false);
|
||||
@@ -75,14 +87,47 @@ export function useConnectionStatus() {
|
||||
setIsRetrying(false);
|
||||
}, [check]);
|
||||
|
||||
// DEV offline toggle: react to transitions only — the polling effect already
|
||||
// probes on mount; an unconditional check() here doubled probes and ignored
|
||||
// disableBackgroundPolling (PlayerBar tests, perf-flagged runs).
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
|
||||
if (prevDevForceOfflineRef.current === null) {
|
||||
prevDevForceOfflineRef.current = devForceOffline;
|
||||
if (devForceOffline) {
|
||||
setActiveServerReachable(false);
|
||||
setStatus('disconnected');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (prevDevForceOfflineRef.current === devForceOffline) return;
|
||||
prevDevForceOfflineRef.current = devForceOffline;
|
||||
|
||||
if (devForceOffline) {
|
||||
setActiveServerReachable(false);
|
||||
setStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
if (!perfFlags.disableBackgroundPolling) {
|
||||
void check();
|
||||
}
|
||||
}, [devForceOffline, check, perfFlags.disableBackgroundPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (perfFlags.disableBackgroundPolling) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
setActiveServerReachable(true);
|
||||
setStatus('connected');
|
||||
if (isDevOfflineBrowseForced()) {
|
||||
setActiveServerReachable(false);
|
||||
setStatus('disconnected');
|
||||
} else {
|
||||
setActiveServerReachable(true);
|
||||
setStatus('connected');
|
||||
}
|
||||
return;
|
||||
}
|
||||
check();
|
||||
@@ -108,7 +153,7 @@ export function useConnectionStatus() {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, [check, perfFlags.disableBackgroundPolling]);
|
||||
}, [check, devForceOffline, perfFlags.disableBackgroundPolling]);
|
||||
|
||||
const server = useAuthStore(s => s.getActiveServer());
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
|
||||
@@ -9,10 +9,12 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import type { TopFavoriteArtist } from '../components/favorites/TopFavoriteArtists';
|
||||
import { useConnectionStatus } from './useConnectionStatus';
|
||||
import { isActiveServerReachable } from '../utils/network/activeServerReachability';
|
||||
import { useOfflineBrowseContext } from './useOfflineBrowseContext';
|
||||
import { useOfflineBrowseReloadToken } from './useOfflineBrowseReloadToken';
|
||||
import {
|
||||
loadStarredFromAllLibraryIndexes,
|
||||
loadStarredFromAllServersOnline,
|
||||
} from '../utils/offline/favoritesOfflineBrowse';
|
||||
} from '../utils/offline/offlineStarredLoad';
|
||||
|
||||
export interface FavoritesDataResult {
|
||||
albums: SubsonicAlbum[];
|
||||
@@ -43,6 +45,8 @@ export function useFavoritesData(): FavoritesDataResult {
|
||||
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const { status: connStatus } = useConnectionStatus();
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,7 +80,7 @@ export function useFavoritesData(): FavoritesDataResult {
|
||||
|
||||
if (favoritesOfflineEnabled) {
|
||||
try {
|
||||
applyStarred(await loadStarredFromAllLibraryIndexes());
|
||||
applyStarred(await loadStarredFromAllLibraryIndexes(offlineBrowseActive));
|
||||
} catch { /* ignore */ }
|
||||
if (!cancelled) setLoading(false);
|
||||
|
||||
@@ -100,9 +104,8 @@ export function useFavoritesData(): FavoritesDataResult {
|
||||
|
||||
void loadAll();
|
||||
return () => { cancelled = true; };
|
||||
}, [musicLibraryFilterVersion, connStatus, favoritesOfflineEnabled, servers]);
|
||||
}, [musicLibraryFilterVersion, connStatus, favoritesOfflineEnabled, offlineBrowseActive, offlineBrowseReloadTs, servers]);
|
||||
|
||||
// ── Top Favorite Artists aggregated from favorited songs ─────────────
|
||||
const topFavoriteArtists = useMemo<TopFavoriteArtist[]>(() => {
|
||||
const counts = new Map<string, TopFavoriteArtist>();
|
||||
for (const s of songs) {
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import type { Location, NavigateFunction } from 'react-router-dom';
|
||||
import { resolveOfflineDisconnectNavAction } from '../utils/offline/offlineBrowseRouting';
|
||||
|
||||
type ConnStatus = 'connected' | 'disconnected' | 'connecting' | 'unknown';
|
||||
|
||||
type OfflineAutoNavContext = {
|
||||
favoritesOfflineBrowse: boolean;
|
||||
localLibraryBrowse: boolean;
|
||||
playerStatsBrowse: boolean;
|
||||
playlistsOfflineBrowse: boolean;
|
||||
hasManualOfflineContent: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-route the user between offline-capable pages and main pages based on
|
||||
* connection status:
|
||||
* - Disconnect with manual offline pins → push `/offline`.
|
||||
* - Disconnect with favorites offline browse enabled → push `/favorites`.
|
||||
* - Reconnect while sitting on `/offline` or `/favorites` → push back to `/`.
|
||||
* On disconnect:
|
||||
* - No offline browse content → stay on the current page (banner only).
|
||||
* - Offline-capable route → stay and bump location state so data hooks reload.
|
||||
* - Otherwise → redirect to All Albums.
|
||||
*
|
||||
* Only fires on transitions (not on every render). Reconnect-bounce is
|
||||
* gated on `prev === 'disconnected'` so a user who navigates to `/offline`
|
||||
* manually while online stays there.
|
||||
* Only runs on connection transitions, not every render.
|
||||
*/
|
||||
export function useOfflineAutoNav(
|
||||
connStatus: ConnStatus | string,
|
||||
hasManualOfflineContent: boolean,
|
||||
favoritesOfflineBrowse: boolean,
|
||||
pathname: string,
|
||||
ctx: OfflineAutoNavContext,
|
||||
location: Pick<Location, 'pathname' | 'search' | 'state'>,
|
||||
navigate: NavigateFunction,
|
||||
): void {
|
||||
const prevConnStatus = useRef(connStatus);
|
||||
@@ -26,25 +31,46 @@ export function useOfflineAutoNav(
|
||||
const prev = prevConnStatus.current;
|
||||
prevConnStatus.current = connStatus;
|
||||
|
||||
if (connStatus === 'disconnected' && prev !== 'disconnected') {
|
||||
if (hasManualOfflineContent) {
|
||||
navigate('/offline', { replace: true });
|
||||
} else if (favoritesOfflineBrowse) {
|
||||
navigate('/favorites', { replace: true });
|
||||
}
|
||||
}
|
||||
if (
|
||||
connStatus === 'connected'
|
||||
&& prev === 'disconnected'
|
||||
&& (pathname === '/offline' || pathname === '/favorites')
|
||||
) {
|
||||
navigate('/', { replace: true });
|
||||
if (connStatus !== 'disconnected' || prev === 'disconnected') return;
|
||||
|
||||
const action = resolveOfflineDisconnectNavAction(
|
||||
location.pathname,
|
||||
ctx.favoritesOfflineBrowse,
|
||||
ctx.localLibraryBrowse,
|
||||
ctx.playerStatsBrowse,
|
||||
ctx.playlistsOfflineBrowse,
|
||||
ctx.hasManualOfflineContent,
|
||||
);
|
||||
|
||||
if (action.kind === 'stay') return;
|
||||
|
||||
if (action.kind === 'stay-reload') {
|
||||
navigate(
|
||||
{ pathname: location.pathname, search: location.search },
|
||||
{
|
||||
replace: true,
|
||||
state: {
|
||||
...(typeof location.state === 'object' && location.state != null
|
||||
? location.state as Record<string, unknown>
|
||||
: {}),
|
||||
offlineBrowseReloadTs: Date.now(),
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(action.to, { replace: true });
|
||||
}, [
|
||||
connStatus,
|
||||
hasManualOfflineContent,
|
||||
favoritesOfflineBrowse,
|
||||
pathname,
|
||||
ctx.favoritesOfflineBrowse,
|
||||
ctx.localLibraryBrowse,
|
||||
ctx.playerStatsBrowse,
|
||||
ctx.playlistsOfflineBrowse,
|
||||
ctx.hasManualOfflineContent,
|
||||
location.pathname,
|
||||
location.search,
|
||||
location.state,
|
||||
navigate,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useConnectionStatus } from './useConnectionStatus';
|
||||
import { usePlayerStatsRecordingEnabled } from './usePlayerStatsRecordingEnabled';
|
||||
import { hasOfflineBrowsingContent } from '../utils/offline/favoritesOfflineBrowse';
|
||||
import { useOfflineBrowseActive } from '../utils/offline/offlineBrowseMode';
|
||||
import {
|
||||
buildOfflineBrowseContext,
|
||||
computeOfflineBrowseCapabilities,
|
||||
type OfflineBrowseContext,
|
||||
} from '../utils/offline/offlineBrowseContext';
|
||||
|
||||
/** Single subscription for shell and pages: offline browse mode + capabilities. */
|
||||
export function useOfflineBrowseContext(): OfflineBrowseContext {
|
||||
const active = useOfflineBrowseActive();
|
||||
const serverId = useAuthStore(s => s.activeServerId);
|
||||
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const playerStats = usePlayerStatsRecordingEnabled();
|
||||
const { status: connStatus } = useConnectionStatus();
|
||||
|
||||
const capabilities = computeOfflineBrowseCapabilities({
|
||||
activeServerId: serverId,
|
||||
favoritesOfflineEnabled,
|
||||
offlineAlbums,
|
||||
playerStats,
|
||||
});
|
||||
|
||||
return buildOfflineBrowseContext({
|
||||
active,
|
||||
serverId,
|
||||
capabilities,
|
||||
connStatus,
|
||||
hasBrowsingContent: hasOfflineBrowsingContent(offlineAlbums),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
/** Bumps when disconnect fork chooses stay-reload ({@link useOfflineAutoNav}). */
|
||||
export function useOfflineBrowseReloadToken(): number | undefined {
|
||||
const location = useLocation();
|
||||
const state = location.state as { offlineBrowseReloadTs?: number } | null;
|
||||
return state?.offlineBrowseReloadTs;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useOfflineBrowseContext } from './useOfflineBrowseContext';
|
||||
import {
|
||||
restoreMusicLibraryFiltersAfterOffline,
|
||||
suspendMusicLibraryFiltersForOffline,
|
||||
} from '../utils/offline/offlineLibraryFilterSuspend';
|
||||
|
||||
/** Disable scoped library browse offline; restore the picker value when back online. */
|
||||
export function useOfflineLibraryFilterSuspend(): void {
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const prevOfflineRef = useRef<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevOfflineRef.current;
|
||||
prevOfflineRef.current = offlineBrowseActive;
|
||||
|
||||
if (prev === null) {
|
||||
if (offlineBrowseActive) suspendMusicLibraryFiltersForOffline();
|
||||
return;
|
||||
}
|
||||
if (offlineBrowseActive && !prev) {
|
||||
suspendMusicLibraryFiltersForOffline();
|
||||
} else if (!offlineBrowseActive && prev) {
|
||||
restoreMusicLibraryFiltersAfterOffline();
|
||||
}
|
||||
}, [offlineBrowseActive]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { filterSongsToActiveLibrary } from '../api/subsonicLibrary';
|
||||
import { getPlaylist } from '../api/subsonicPlaylists';
|
||||
import type { SubsonicPlaylist } from '../api/subsonicTypes';
|
||||
import { useOfflineBrowseContext } from './useOfflineBrowseContext';
|
||||
|
||||
export interface PlaylistsLibraryScopeCountsResult {
|
||||
filteredSongCountByPlaylist: Record<string, number>;
|
||||
@@ -20,6 +21,7 @@ export function usePlaylistsLibraryScopeCounts(
|
||||
): PlaylistsLibraryScopeCountsResult {
|
||||
const [filteredSongCountByPlaylist, setFilteredSongCountByPlaylist] = useState<Record<string, number>>({});
|
||||
const [filteredDurationByPlaylist, setFilteredDurationByPlaylist] = useState<Record<string, number>>({});
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -31,6 +33,19 @@ export function usePlaylistsLibraryScopeCounts(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (offlineBrowseActive) {
|
||||
const next: Record<string, number> = {};
|
||||
const nextDuration: Record<string, number> = {};
|
||||
for (const pl of playlists) {
|
||||
next[pl.id] = pl.songCount;
|
||||
nextDuration[pl.id] = pl.duration;
|
||||
}
|
||||
if (!cancelled) {
|
||||
setFilteredSongCountByPlaylist(next);
|
||||
setFilteredDurationByPlaylist(nextDuration);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const ids = playlists.map((pl) => pl.id);
|
||||
const next: Record<string, number> = {};
|
||||
const nextDuration: Record<string, number> = {};
|
||||
@@ -60,7 +75,7 @@ export function usePlaylistsLibraryScopeCounts(
|
||||
};
|
||||
run();
|
||||
return () => { cancelled = true; };
|
||||
}, [playlists, musicLibraryFilterVersion]);
|
||||
}, [playlists, musicLibraryFilterVersion, offlineBrowseActive]);
|
||||
|
||||
return { filteredSongCountByPlaylist, filteredDurationByPlaylist };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useDragDrop, registerQueueDragHitTest } from '../contexts/DragDropContext';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
@@ -77,13 +78,11 @@ export function useQueuePanelDrag({
|
||||
} else if (parsedData.type === 'songs') {
|
||||
enqueueAt(parsedData.tracks as Track[], insertIdx);
|
||||
} else if (parsedData.type === 'album') {
|
||||
const albumData = await getAlbum(parsedData.id);
|
||||
const tracks: Track[] = albumData.songs.map((s: any) => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
|
||||
}));
|
||||
enqueueAt(tracks, insertIdx);
|
||||
const serverId = resolveMediaServerId(parsedData.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, parsedData.id);
|
||||
if (!albumData) return;
|
||||
enqueueAt(albumData.songs.map(songToTrack), insertIdx);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ vi.mock('../utils/library/advancedSearchLocal', () => ({
|
||||
runLocalSongBrowse: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('./useOfflineBrowseReloadToken', () => ({
|
||||
useOfflineBrowseReloadToken: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../utils/library/browseTextSearch', () => ({
|
||||
BROWSE_TEXT_DEBOUNCE_NETWORK_MS: 10,
|
||||
BROWSE_TEXT_DEBOUNCE_RACE_MS: 10,
|
||||
|
||||
@@ -14,6 +14,13 @@ import {
|
||||
} from '../utils/library/browseTextSearch';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { useOfflineBrowseContext } from './useOfflineBrowseContext';
|
||||
import { useOfflineBrowseReloadToken } from './useOfflineBrowseReloadToken';
|
||||
import {
|
||||
fetchOfflineLocalBrowsableSongPage,
|
||||
offlineLocalBrowseEnabled,
|
||||
searchOfflineLocalBrowsableSongs,
|
||||
} from '../utils/offline/offlineLocalBrowse';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
@@ -50,7 +57,10 @@ type UseSongBrowseListArgs = {
|
||||
/** Tracks hub song browse — all-library paging or filtered text search. */
|
||||
export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseSongBrowseListArgs) {
|
||||
const serverId = useAuthStore(s => s.activeServerId);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
|
||||
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(
|
||||
() => initialRestore?.query.trim() ?? searchQuery.trim(),
|
||||
@@ -73,7 +83,6 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
|
||||
const restoreQueryHoldRef = useRef(
|
||||
initialRestore?.query.trim() ? initialRestore.query.trim() : null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const incoming = searchQuery.trim();
|
||||
@@ -88,6 +97,15 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
|
||||
|
||||
const fetchSongPage = useCallback(
|
||||
async (q: string, pageOffset: number, isStale: () => boolean): Promise<SubsonicSong[]> => {
|
||||
if (offlineBrowseActive && serverId && offlineLocalBrowseEnabled(serverId)) {
|
||||
localSearchModeRef.current = true;
|
||||
if (q === '') {
|
||||
const page = await fetchOfflineLocalBrowsableSongPage(serverId, pageOffset, PAGE_SIZE);
|
||||
return page?.songs ?? [];
|
||||
}
|
||||
return (await searchOfflineLocalBrowsableSongs(serverId, q, pageOffset, PAGE_SIZE)) ?? [];
|
||||
}
|
||||
|
||||
if (q === '') {
|
||||
return fetchBrowseAllPage(serverId, pageOffset);
|
||||
}
|
||||
@@ -123,7 +141,7 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
|
||||
|
||||
return (await runNetworkBrowseSongPage(q, pageOffset, PAGE_SIZE)) ?? [];
|
||||
},
|
||||
[indexEnabled, serverId],
|
||||
[indexEnabled, musicLibraryFilterVersion, offlineBrowseActive, serverId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -171,7 +189,7 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debouncedQuery, searchQuery, fetchSongPage, enabled]);
|
||||
}, [debouncedQuery, searchQuery, fetchSongPage, enabled, musicLibraryFilterVersion, offlineBrowseReloadTs]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!enabled || loading || !hasMore) return;
|
||||
|
||||
@@ -46,6 +46,8 @@ import LosslessModeBanner from '../components/LosslessModeBanner';
|
||||
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
||||
import { isLosslessMode } from '../utils/library/losslessMode';
|
||||
import { readDetailServerId } from '../utils/navigation/detailServerScope';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
|
||||
export default function AlbumDetail() {
|
||||
const { t } = useTranslation();
|
||||
@@ -76,6 +78,8 @@ export default function AlbumDetail() {
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const albumActionPolicy = offlineActionPolicy('albumDetail', offlineCtx.active);
|
||||
|
||||
const [albumEntityRating, setAlbumEntityRating] = useState(0);
|
||||
const [filterText, setFilterText] = useState('');
|
||||
@@ -369,6 +373,7 @@ const handleShuffleAll = () => {
|
||||
entityRatingValue={albumEntityRating}
|
||||
onEntityRatingChange={handleAlbumEntityRating}
|
||||
entityRatingSupport={albumEntityRatingSupport}
|
||||
actionPolicy={albumActionPolicy}
|
||||
/>
|
||||
{losslessOnly && <LosslessModeBanner />}
|
||||
|
||||
@@ -381,6 +386,7 @@ const handleShuffleAll = () => {
|
||||
showPlPicker={showPlPicker}
|
||||
setShowPlPicker={setShowPlPicker}
|
||||
t={t}
|
||||
actionPolicy={albumActionPolicy}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -402,6 +408,7 @@ const handleShuffleAll = () => {
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
actionPolicy={albumActionPolicy}
|
||||
/>
|
||||
|
||||
{relatedAlbums.length > 0 && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
@@ -233,8 +233,10 @@ export default function Albums() {
|
||||
const handleEnqueueSelected = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
try {
|
||||
// Parallel — Navidrome handles concurrent getAlbum requests fine.
|
||||
const results = await Promise.all(selectedAlbums.map(a => getAlbum(a.id).catch(() => null)));
|
||||
// Parallel album resolves — Navidrome handles concurrent requests fine.
|
||||
const results = await Promise.all(
|
||||
selectedAlbums.map(a => resolveAlbum(serverId, a.id).catch(() => null)),
|
||||
);
|
||||
const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []);
|
||||
if (tracks.length > 0) {
|
||||
enqueue(tracks);
|
||||
@@ -277,7 +279,8 @@ export default function Albums() {
|
||||
let queued = 0;
|
||||
for (const album of selectedAlbums) {
|
||||
try {
|
||||
const detail = await getAlbum(album.id);
|
||||
const detail = await resolveAlbum(serverId, album.id);
|
||||
if (!detail) throw new Error('album unavailable');
|
||||
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||
queued++;
|
||||
} catch {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { uploadArtistImage } from '../api/subsonicPlaylists';
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
||||
@@ -31,8 +30,11 @@ import {
|
||||
import { useArtistDetailData } from '../hooks/useArtistDetailData';
|
||||
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
|
||||
import {
|
||||
fetchArtistDetailTracks,
|
||||
runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio,
|
||||
} from '../utils/componentHelpers/runArtistDetailPlay';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
import {
|
||||
runArtistEntityRating, runArtistToggleStar, runArtistShare, runArtistImageUpload,
|
||||
} from '../utils/componentHelpers/runArtistDetailActions';
|
||||
@@ -101,6 +103,8 @@ export default function ArtistDetail() {
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const artistActionPolicy = offlineActionPolicy('artistDetail', offlineCtx.active);
|
||||
|
||||
const [artistEntityRating, setArtistEntityRating] = useState(0);
|
||||
|
||||
@@ -122,8 +126,12 @@ export default function ArtistDetail() {
|
||||
|
||||
const toggleStar = () => runArtistToggleStar({ artist, isStarred, setIsStarred });
|
||||
|
||||
const handlePlayAll = () => runArtistDetailPlayAll({ albums, setPlayAllLoading, playTrack });
|
||||
const handleShuffle = () => runArtistDetailShuffle({ albums, setPlayAllLoading, playTrack });
|
||||
const handlePlayAll = () => runArtistDetailPlayAll({
|
||||
albums, serverId: activeServerId, setPlayAllLoading, playTrack,
|
||||
});
|
||||
const handleShuffle = () => runArtistDetailShuffle({
|
||||
albums, serverId: activeServerId, setPlayAllLoading, playTrack,
|
||||
});
|
||||
const handleStartRadio = () => {
|
||||
if (!artist) return;
|
||||
return runArtistDetailStartRadio({ artist, t, setRadioLoading, playTrack, enqueue });
|
||||
@@ -139,9 +147,7 @@ export default function ArtistDetail() {
|
||||
setPlayAllLoading(true);
|
||||
try {
|
||||
// Get all artist tracks ordered by album and track number
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
const allTracks = sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
|
||||
const allTracks = await fetchArtistDetailTracks(albums, activeServerId);
|
||||
|
||||
// Top songs from clicked index onward
|
||||
const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack);
|
||||
@@ -314,6 +320,7 @@ export default function ArtistDetail() {
|
||||
coverRevision={coverRevision}
|
||||
headerCoverFailed={headerCoverFailed}
|
||||
setHeaderCoverFailed={setHeaderCoverFailed}
|
||||
actionPolicy={artistActionPolicy}
|
||||
/>
|
||||
|
||||
{losslessOnly && <LosslessModeBanner />}
|
||||
|
||||
+46
-13
@@ -22,10 +22,16 @@ import { useLibraryCoverPrefetch } from '../cover/useLibraryCoverPrefetch';
|
||||
import { primeAlbumCoversForDisplay, warmHomeMainstageCovers } from '../cover/warmDiskPeek';
|
||||
import { readBecauseYouLikeCache } from '../store/becauseYouLikeCache';
|
||||
import {
|
||||
isHomeFeedSnapshotEmpty,
|
||||
readHomeFeedCache,
|
||||
readHomeFeedCacheStale,
|
||||
writeHomeFeedCache,
|
||||
type HomeFeedSnapshot,
|
||||
} from '../store/homeFeedCache';
|
||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { useOfflineBrowseReloadToken } from '../hooks/useOfflineBrowseReloadToken';
|
||||
import { useDevOfflineBrowseStore } from '../store/devOfflineBrowseStore';
|
||||
|
||||
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
|
||||
const HOME_RANDOM_FETCH = 100;
|
||||
@@ -52,7 +58,8 @@ const HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED = 8;
|
||||
function getInitialHomeFeed(): HomeFeedSnapshot | null {
|
||||
const { activeServerId, musicLibraryFilterVersion } = useAuthStore.getState();
|
||||
if (!activeServerId) return null;
|
||||
return readHomeFeedCache(activeServerId, musicLibraryFilterVersion);
|
||||
return readHomeFeedCache(activeServerId, musicLibraryFilterVersion)
|
||||
?? readHomeFeedCacheStale(activeServerId);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
@@ -63,6 +70,10 @@ export default function Home() {
|
||||
const homeSections = useHomeStore(s => s.sections);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const connStatus = useConnectionStatus().status;
|
||||
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
|
||||
// Mix-rating deps intentionally NOT subscribed here — they change during Zustand
|
||||
// rehydration and would trigger a second useEffect fire right after the first,
|
||||
// showing the cached home feed briefly and then replacing it (~500 ms later)
|
||||
@@ -119,7 +130,6 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
if (!activeServerId) return;
|
||||
let cancelled = false;
|
||||
|
||||
const fetchFreshHomeFeed = async (): Promise<HomeFeedSnapshot | null> => {
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const albumMix =
|
||||
@@ -154,7 +164,8 @@ export default function Home() {
|
||||
};
|
||||
};
|
||||
|
||||
const cached = readHomeFeedCache(activeServerId, musicLibraryFilterVersion);
|
||||
const cached = readHomeFeedCache(activeServerId, musicLibraryFilterVersion)
|
||||
?? (offlineBrowseActive ? readHomeFeedCacheStale(activeServerId) : null);
|
||||
if (cached) {
|
||||
// When lazy initializers already pre-populated state from this same
|
||||
// snapshot, re-applying it would only create new array references and
|
||||
@@ -168,27 +179,37 @@ export default function Home() {
|
||||
});
|
||||
// Keep the current visit visually stable, but prepare fresh data so the
|
||||
// next re-enter opens with a newer snapshot immediately.
|
||||
void (async () => {
|
||||
try {
|
||||
const fresh = await fetchFreshHomeFeed();
|
||||
if (!fresh || cancelled) return;
|
||||
writeHomeFeedCache(fresh);
|
||||
void warmHomeMainstageCovers(fresh);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
if (!offlineBrowseActive) {
|
||||
void (async () => {
|
||||
try {
|
||||
const fresh = await fetchFreshHomeFeed();
|
||||
if (!fresh || cancelled || isHomeFeedSnapshotEmpty(fresh)) return;
|
||||
writeHomeFeedCache(fresh);
|
||||
void warmHomeMainstageCovers(fresh);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const stale = offlineBrowseActive ? readHomeFeedCacheStale(activeServerId) : null;
|
||||
if (stale) {
|
||||
applyFeedSnapshot(stale);
|
||||
setLoading(false);
|
||||
return () => { cancelled = true; };
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const snap = await fetchFreshHomeFeed();
|
||||
if (!snap) return;
|
||||
if (cancelled) return;
|
||||
if (offlineBrowseActive && isHomeFeedSnapshotEmpty(snap)) return;
|
||||
writeHomeFeedCache(snap);
|
||||
applyFeedSnapshot(snap);
|
||||
if (!cancelled) setLoading(false);
|
||||
@@ -208,8 +229,20 @@ export default function Home() {
|
||||
activeServerId,
|
||||
musicLibraryFilterVersion,
|
||||
homeSections,
|
||||
offlineBrowseActive,
|
||||
offlineBrowseReloadTs,
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/** When offline toggles without a library-filter bump, re-apply stale cache if the feed was cleared. */
|
||||
useEffect(() => {
|
||||
if (!activeServerId || !offlineBrowseActive) return;
|
||||
const stale = readHomeFeedCacheStale(activeServerId);
|
||||
if (!stale || isHomeFeedSnapshotEmpty(stale)) return;
|
||||
if (recent.length > 0 || random.length > 0 || heroAlbums.length > 0) return;
|
||||
applyFeedSnapshot(stale);
|
||||
setLoading(false);
|
||||
}, [activeServerId, connStatus, devForceOffline, offlineBrowseActive]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadMore = async (
|
||||
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
|
||||
currentList: SubsonicAlbum[],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -232,7 +232,9 @@ export default function LosslessAlbums() {
|
||||
const handleEnqueueSelected = async () => {
|
||||
if (selectedAlbums.length === 0) return;
|
||||
try {
|
||||
const results = await Promise.all(selectedAlbums.map(a => getAlbum(a.id).catch(() => null)));
|
||||
const results = await Promise.all(
|
||||
selectedAlbums.map(a => resolveAlbum(serverId, a.id).catch(() => null)),
|
||||
);
|
||||
const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []);
|
||||
if (tracks.length > 0) {
|
||||
enqueue(tracks);
|
||||
@@ -248,7 +250,8 @@ export default function LosslessAlbums() {
|
||||
let queued = 0;
|
||||
for (const album of selectedAlbums) {
|
||||
try {
|
||||
const detail = await getAlbum(album.id);
|
||||
const detail = await resolveAlbum(serverId, album.id);
|
||||
if (!detail) throw new Error('album unavailable');
|
||||
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||
queued++;
|
||||
} catch {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
||||
import { getAlbumList } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
@@ -87,17 +88,20 @@ export default function MostPlayed() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
|
||||
const handleEnqueueAlbum = useCallback(async (albumId: string) => {
|
||||
if (!activeServerId) return;
|
||||
try {
|
||||
const data = await getAlbum(albumId);
|
||||
const data = await resolveAlbum(activeServerId, albumId);
|
||||
if (!data) return;
|
||||
enqueue(data.songs.map(songToTrack));
|
||||
} catch {
|
||||
// Network failure — silent (toast would be too noisy for a hover action).
|
||||
}
|
||||
}, [enqueue]);
|
||||
}, [activeServerId, enqueue]);
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
||||
import { getAlbumsByGenre } from '../api/subsonicGenres';
|
||||
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
||||
import { getAlbumList } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { dedupeById } from '../utils/dedupeById';
|
||||
import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
@@ -157,7 +158,8 @@ export default function NewReleases() {
|
||||
let queued = 0;
|
||||
for (const album of selectedAlbums) {
|
||||
try {
|
||||
const detail = await getAlbum(album.id);
|
||||
const detail = await resolveAlbum(serverId, album.id);
|
||||
if (!detail) throw new Error('album unavailable');
|
||||
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||
queued++;
|
||||
} catch {
|
||||
|
||||
@@ -54,6 +54,8 @@ import { usePlaylistDerived } from '../hooks/usePlaylistDerived';
|
||||
import { usePlaylistRouteEffects } from '../hooks/usePlaylistRouteEffects';
|
||||
import { useBulkPlPickerOutsideClick } from '../hooks/useBulkPlPickerOutsideClick';
|
||||
import { usePlaylistDnDReorder } from '../hooks/usePlaylistDnDReorder';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
|
||||
// ── Column configuration ──────────────────────────────────────────────────────
|
||||
const PL_COLUMNS: readonly ColDef[] = [
|
||||
@@ -179,13 +181,15 @@ export default function PlaylistDetail() {
|
||||
|
||||
// ── Load ─────────────────────────────────────────────────────
|
||||
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
|
||||
const { active: offlineBrowseActive } = useOfflineBrowseContext();
|
||||
const actionPolicy = offlineActionPolicy('playlistDetail', offlineBrowseActive);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
runPlaylistLoad({
|
||||
id, setLoading, setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs,
|
||||
});
|
||||
}, [id, lastModified]);
|
||||
}, [id, lastModified, offlineBrowseActive]);
|
||||
|
||||
// ── Meta edit ─────────────────────────────────────────────────
|
||||
const handleSaveMeta = async (opts: {
|
||||
@@ -280,6 +284,7 @@ export default function PlaylistDetail() {
|
||||
offlineStatus={resolvedOfflineStatus}
|
||||
offlineProgress={offlineProgress}
|
||||
activeServerId={activeServerId}
|
||||
actionPolicy={actionPolicy}
|
||||
setEditingMeta={setEditingMeta}
|
||||
setSearchOpen={setSearchOpen}
|
||||
setSearchQuery={setSearchQuery}
|
||||
|
||||
+19
-6
@@ -1,4 +1,4 @@
|
||||
import { getPlaylist } from '../api/subsonicPlaylists';
|
||||
import { resolveMediaServerId, resolvePlaylist } from '../utils/offline/offlineMediaResolve';
|
||||
import { getGenres } from '../api/subsonicGenres';
|
||||
import { filterSongsToActiveLibrary } from '../api/subsonicLibrary';
|
||||
import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes';
|
||||
@@ -29,6 +29,8 @@ import PlaylistsHeader from '../components/playlists/PlaylistsHeader';
|
||||
import PlaylistCard from '../components/playlists/PlaylistCard';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
return formatHumanHoursMinutes(seconds);
|
||||
@@ -49,6 +51,9 @@ export default function Playlists() {
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const subsonicIdentityByServer = useAuthStore(s => s.subsonicServerIdentityByServer);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineBrowseActive = offlineCtx.active;
|
||||
const playlistsActionPolicy = offlineActionPolicy('playlistsHeader', offlineCtx.active);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
@@ -94,8 +99,10 @@ export default function Playlists() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylists().finally(() => setLoading(false));
|
||||
getGenres().then(setGenres).catch(() => {});
|
||||
}, [fetchPlaylists]);
|
||||
if (!offlineBrowseActive) {
|
||||
getGenres().then(setGenres).catch(() => {});
|
||||
}
|
||||
}, [fetchPlaylists, offlineBrowseActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (creating) nameInputRef.current?.focus();
|
||||
@@ -138,9 +145,14 @@ export default function Playlists() {
|
||||
if (playingId === pl.id) return;
|
||||
setPlayingId(pl.id);
|
||||
try {
|
||||
const data = await getPlaylist(pl.id);
|
||||
const filteredSongs = await filterSongsToActiveLibrary(data.songs);
|
||||
const tracks = filteredSongs.map(songToTrack);
|
||||
const serverId = resolveMediaServerId(activeServerId);
|
||||
if (!serverId) return;
|
||||
const data = await resolvePlaylist(serverId, pl.id);
|
||||
if (!data) return;
|
||||
const songs = offlineBrowseActive
|
||||
? data.songs
|
||||
: await filterSongsToActiveLibrary(data.songs);
|
||||
const tracks = songs.map(songToTrack);
|
||||
if (tracks.length > 0) {
|
||||
touchPlaylist(pl.id);
|
||||
playTrack(tracks[0], tracks);
|
||||
@@ -233,6 +245,7 @@ export default function Playlists() {
|
||||
setEditingSmartId={setEditingSmartId}
|
||||
setSmartFilters={setSmartFilters}
|
||||
setGenreQuery={setGenreQuery}
|
||||
actionPolicy={playlistsActionPolicy}
|
||||
/>
|
||||
|
||||
{creatingSmart && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
||||
import { getAlbumsByGenre } from '../api/subsonicGenres';
|
||||
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
||||
import { getAlbumList } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { dedupeById } from '../utils/dedupeById';
|
||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||
@@ -199,7 +200,8 @@ export default function RandomAlbums() {
|
||||
let queued = 0;
|
||||
for (const album of selectedAlbums) {
|
||||
try {
|
||||
const detail = await getAlbum(album.id);
|
||||
const detail = await resolveAlbum(serverId, album.id);
|
||||
if (!detail) throw new Error('album unavailable');
|
||||
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||
queued++;
|
||||
} catch {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { fetchStatisticsFormatSample, fetchStatisticsLibraryAggregates, fetchSta
|
||||
import { getAlbumList } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum, SubsonicGenre } from '../api/subsonicTypes';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Share2 } from 'lucide-react';
|
||||
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
@@ -12,6 +13,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { usePlayerStatsRecordingEnabled } from '../hooks/usePlayerStatsRecordingEnabled';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function relativeTime(timestamp: number, t: (key: string, opts?: any) => string): string {
|
||||
@@ -34,7 +37,10 @@ const PERIODS: { key: LastfmPeriod; label: string }[] = [
|
||||
export default function Statistics() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isPlayerStats = location.pathname === '/player-stats';
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const playerStatsEnabled = usePlayerStatsRecordingEnabled();
|
||||
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -62,6 +68,16 @@ export default function Statistics() {
|
||||
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (offlineBrowseActive && playerStatsEnabled && !isPlayerStats) {
|
||||
navigate('/player-stats', { replace: true });
|
||||
}
|
||||
}, [offlineBrowseActive, playerStatsEnabled, isPlayerStats, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (offlineBrowseActive || isPlayerStats) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
fetchStatisticsOverview()
|
||||
.then(d => {
|
||||
setRecent(d.recent);
|
||||
@@ -71,10 +87,11 @@ export default function Statistics() {
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, [musicLibraryFilterVersion]);
|
||||
}, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
|
||||
|
||||
// Background: playtime, album/song counts, genre insights (cached per server+library like rating prefetch)
|
||||
useEffect(() => {
|
||||
if (offlineBrowseActive || isPlayerStats) return;
|
||||
let cancelled = false;
|
||||
setTotalPlaytime(null);
|
||||
setTotalAlbums(null);
|
||||
@@ -101,10 +118,11 @@ export default function Statistics() {
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [musicLibraryFilterVersion]);
|
||||
}, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
|
||||
|
||||
// Background: format distribution (cached random sample, same TTL as other Statistics fetches)
|
||||
useEffect(() => {
|
||||
if (offlineBrowseActive || isPlayerStats) return;
|
||||
let cancelled = false;
|
||||
setFormatData(null);
|
||||
setFormatSampleSize(0);
|
||||
@@ -116,17 +134,19 @@ export default function Statistics() {
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [musicLibraryFilterVersion]);
|
||||
}, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (offlineBrowseActive || isPlayerStats) return;
|
||||
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
|
||||
setLfmRecentLoading(true);
|
||||
lastfmGetRecentTracks(lastfmUsername, lastfmSessionKey, 20)
|
||||
.then(tracks => { setLfmRecentTracks(tracks); setLfmRecentLoading(false); })
|
||||
.catch(() => setLfmRecentLoading(false));
|
||||
}, [lastfmSessionKey, lastfmUsername]);
|
||||
}, [lastfmSessionKey, lastfmUsername, offlineBrowseActive, isPlayerStats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (offlineBrowseActive || isPlayerStats) return;
|
||||
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
|
||||
setLfmLoading(true);
|
||||
Promise.all([
|
||||
@@ -139,7 +159,7 @@ export default function Statistics() {
|
||||
setLfmTopTracks(tracks);
|
||||
setLfmLoading(false);
|
||||
}).catch(() => setLfmLoading(false));
|
||||
}, [lfmPeriod, lastfmSessionKey, lastfmUsername]);
|
||||
}, [lfmPeriod, lastfmSessionKey, lastfmUsername, offlineBrowseActive, isPlayerStats]);
|
||||
|
||||
const loadMore = async (
|
||||
type: 'frequent' | 'highest',
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/** DEV-only: simulate full offline (no server probes, no Subsonic, local playback only). */
|
||||
interface DevOfflineBrowseState {
|
||||
forceOffline: boolean;
|
||||
setForceOffline: (v: boolean) => void;
|
||||
toggleForceOffline: () => void;
|
||||
}
|
||||
|
||||
export const useDevOfflineBrowseStore = create<DevOfflineBrowseState>()((set, get) => ({
|
||||
forceOffline: false,
|
||||
setForceOffline: (v) => set({ forceOffline: v }),
|
||||
toggleForceOffline: () => set({ forceOffline: !get().forceOffline }),
|
||||
}));
|
||||
|
||||
/** True when DEV mode forces disconnected server + offline player behavior. */
|
||||
export function isDevOfflineBrowseForced(): boolean {
|
||||
return import.meta.env.DEV && useDevOfflineBrowseStore.getState().forceOffline;
|
||||
}
|
||||
@@ -28,6 +28,27 @@ export function readHomeFeedCache(
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Last good snapshot for this server when filter version changed (e.g. offline filter suspend). */
|
||||
export function readHomeFeedCacheStale(
|
||||
serverId: string | null | undefined,
|
||||
): HomeFeedSnapshot | null {
|
||||
if (!serverId || !snapshot) return null;
|
||||
if (snapshot.serverId !== serverId) return null;
|
||||
if (Date.now() - snapshot.savedAt > TTL_MS) return null;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function isHomeFeedSnapshotEmpty(snap: HomeFeedSnapshot): boolean {
|
||||
return snap.heroAlbums.length === 0
|
||||
&& snap.recent.length === 0
|
||||
&& snap.random.length === 0
|
||||
&& snap.starred.length === 0
|
||||
&& snap.mostPlayed.length === 0
|
||||
&& snap.recentlyPlayed.length === 0
|
||||
&& snap.discoverSongs.length === 0
|
||||
&& snap.randomArtists.length === 0;
|
||||
}
|
||||
|
||||
export function writeHomeFeedCache(data: Omit<HomeFeedSnapshot, 'savedAt'>): void {
|
||||
snapshot = { ...data, savedAt: Date.now() };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { SubsonicPlaylist } from '../api/subsonicTypes';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { createPlaylist as apiCreatePlaylist } from '../api/subsonicPlaylists';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { isOfflineBrowseActive } from '../utils/offline/offlineBrowseMode';
|
||||
import { fetchOfflineBrowsablePlaylists } from '../utils/offline/offlinePlaylistBrowse';
|
||||
interface PlaylistStore {
|
||||
recentIds: string[];
|
||||
playlists: SubsonicPlaylist[];
|
||||
@@ -32,6 +35,12 @@ export const usePlaylistStore = create<PlaylistStore>()(
|
||||
fetchPlaylists: async () => {
|
||||
set({ playlistsLoading: true });
|
||||
try {
|
||||
const serverId = useAuthStore.getState().activeServerId;
|
||||
if (isOfflineBrowseActive() && serverId) {
|
||||
const playlists = await fetchOfflineBrowsablePlaylists(serverId);
|
||||
set({ playlists, playlistsLoading: false });
|
||||
return;
|
||||
}
|
||||
const playlists = await getPlaylists();
|
||||
set({ playlists, playlistsLoading: false });
|
||||
} catch {
|
||||
|
||||
@@ -61,3 +61,24 @@ html[data-dev-build] .app-shell[data-mobile] .dev-build-badge {
|
||||
html[data-dev-build] .app-shell[data-mobile][data-titlebar] .dev-build-badge {
|
||||
top: var(--titlebar-height);
|
||||
}
|
||||
|
||||
html[data-dev-build] .dev-network-mode-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
html[data-dev-build] .dev-network-mode-toggle--offline {
|
||||
color: var(--warning, #e6a700);
|
||||
border-color: color-mix(in srgb, var(--warning, #e6a700) 45%, var(--border));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import * as offlineMediaResolve from '../offline/offlineMediaResolve';
|
||||
import { fetchArtistDetailTracks } from './runArtistDetailPlay';
|
||||
|
||||
vi.mock('../offline/offlineMediaResolve', () => ({
|
||||
resolveAlbum: vi.fn(),
|
||||
resolveMediaServerId: vi.fn((id?: string | null) => id ?? 'srv-1'),
|
||||
}));
|
||||
|
||||
const resolveAlbumMock = vi.mocked(offlineMediaResolve.resolveAlbum);
|
||||
|
||||
const albums: SubsonicAlbum[] = [
|
||||
{ id: 'al-2', name: 'B', artist: 'A', artistId: 'ar-1', songCount: 1, duration: 100, year: 2001 },
|
||||
{ id: 'al-1', name: 'A', artist: 'A', artistId: 'ar-1', songCount: 1, duration: 100, year: 2000 },
|
||||
];
|
||||
|
||||
describe('fetchArtistDetailTracks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads albums via resolveAlbum when serverId is set', async () => {
|
||||
resolveAlbumMock
|
||||
.mockResolvedValueOnce({
|
||||
album: albums[1],
|
||||
songs: [{ id: 't1', title: 'One', artist: 'A', album: 'A', albumId: 'al-1', duration: 100, track: 2 }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
album: albums[0],
|
||||
songs: [{ id: 't2', title: 'Two', artist: 'A', album: 'B', albumId: 'al-2', duration: 100, track: 1 }],
|
||||
});
|
||||
|
||||
const tracks = await fetchArtistDetailTracks(albums, 'srv-1');
|
||||
expect(tracks.map(t => t.id)).toEqual(['t1', 't2']);
|
||||
expect(resolveAlbumMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns empty when no server scope', async () => {
|
||||
vi.mocked(offlineMediaResolve.resolveMediaServerId).mockReturnValueOnce(null);
|
||||
|
||||
const tracks = await fetchArtistDetailTracks(albums, null);
|
||||
expect(tracks).toEqual([]);
|
||||
expect(resolveAlbumMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,53 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
|
||||
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { songToTrack } from '../playback/songToTrack';
|
||||
import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../offline/offlineMediaResolve';
|
||||
|
||||
async function fetchAllTracks(albums: SubsonicAlbum[]): Promise<Track[]> {
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
|
||||
/** Ordered artist discography tracks for play-all / shuffle (network or local bytes). */
|
||||
export async function fetchArtistDetailTracks(
|
||||
albums: SubsonicAlbum[],
|
||||
serverId?: string | null,
|
||||
): Promise<Track[]> {
|
||||
const sid = resolveMediaServerId(serverId ?? albums[0]?.serverId);
|
||||
if (!sid) return [];
|
||||
|
||||
const loaded = await Promise.all(albums.map(a => resolveAlbum(sid, a.id)));
|
||||
const sorted = loaded
|
||||
.filter((r): r is NonNullable<typeof r> => r != null)
|
||||
.sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
return sorted.flatMap(r =>
|
||||
[...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0)).map(songToTrack),
|
||||
);
|
||||
}
|
||||
|
||||
export interface RunArtistDetailPlayDeps {
|
||||
albums: SubsonicAlbum[];
|
||||
serverId?: string | null;
|
||||
setPlayAllLoading: (v: boolean) => void;
|
||||
playTrack: (track: Track, queue: Track[]) => void;
|
||||
}
|
||||
|
||||
export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||
const { albums, serverId, setPlayAllLoading, playTrack } = deps;
|
||||
if (albums.length === 0) return;
|
||||
await runBulkPlayAll({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack });
|
||||
await runBulkPlayAll({
|
||||
fetchTracks: () => fetchArtistDetailTracks(albums, serverId),
|
||||
setLoading: setPlayAllLoading,
|
||||
playTrack,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||
const { albums, serverId, setPlayAllLoading, playTrack } = deps;
|
||||
if (albums.length === 0) return;
|
||||
await runBulkShuffle({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack });
|
||||
await runBulkShuffle({
|
||||
fetchTracks: () => fetchArtistDetailTracks(albums, serverId),
|
||||
setLoading: setPlayAllLoading,
|
||||
playTrack,
|
||||
});
|
||||
}
|
||||
|
||||
export interface RunArtistDetailStartRadioDeps {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import { dedupeById } from '../dedupeById';
|
||||
import { isOfflineBrowseActive } from '../offline/offlineBrowseMode';
|
||||
import { loadOfflineAlbumCatalogChunk } from '../offline/offlineAlbumBrowseCatalog';
|
||||
import type { AlbumBrowseQuery } from './albumBrowseTypes';
|
||||
import { fetchLocalAlbumCatalogChunk } from './albumBrowseLoad';
|
||||
|
||||
export type AlbumCatalogChunk = {
|
||||
albums: SubsonicAlbum[];
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
export function mergeAlbumCatalogChunk(
|
||||
prev: SubsonicAlbum[],
|
||||
chunk: AlbumCatalogChunk,
|
||||
append: boolean,
|
||||
): { albums: SubsonicAlbum[]; offset: number } {
|
||||
if (!append) {
|
||||
return { albums: chunk.albums, offset: chunk.albums.length };
|
||||
}
|
||||
const merged = dedupeById([...prev, ...chunk.albums]);
|
||||
return { albums: merged, offset: merged.length };
|
||||
}
|
||||
|
||||
/** Local-index or offline-bytes catalog chunk for the albums grid. */
|
||||
export async function fetchAlbumBrowseCatalogChunk(
|
||||
serverId: string,
|
||||
query: AlbumBrowseQuery,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
starredOverrides: Record<string, boolean>,
|
||||
): Promise<AlbumCatalogChunk | null> {
|
||||
if (isOfflineBrowseActive()) {
|
||||
return loadOfflineAlbumCatalogChunk(
|
||||
serverId,
|
||||
query,
|
||||
offset,
|
||||
chunkSize,
|
||||
starredOverrides,
|
||||
);
|
||||
}
|
||||
return fetchLocalAlbumCatalogChunk(serverId, query, offset, chunkSize);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTopSongs } from '../../api/subsonicArtists';
|
||||
import { filterSongsToActiveLibrary, getAlbum, getAlbumList, getRandomSongs } from '../../api/subsonicLibrary';
|
||||
import { filterSongsToActiveLibrary, getAlbumList, getRandomSongs } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbumForActiveServer } from '../offline/offlineMediaResolve';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import {
|
||||
filterSongsForLuckyMixRatings,
|
||||
@@ -98,7 +99,7 @@ export async function pickSongsForAlbum(
|
||||
need: number,
|
||||
mixRatings: MixMinRatingsConfig,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const full = await getAlbum(albumId).catch(() => null);
|
||||
const full = await resolveAlbumForActiveServer(albumId).catch(() => null);
|
||||
if (!full?.songs?.length) return [];
|
||||
const scopedSongs = await filterSongsToActiveLibrary(full.songs);
|
||||
const unique = uniqueBySongId(scopedSongs);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useDevOfflineBrowseStore } from '../../store/devOfflineBrowseStore';
|
||||
import {
|
||||
getActiveServerReachable,
|
||||
isActiveServerReachable,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
|
||||
describe('activeServerReachability', () => {
|
||||
beforeEach(() => {
|
||||
useDevOfflineBrowseStore.setState({ forceOffline: false });
|
||||
setActiveServerReachable(null);
|
||||
});
|
||||
|
||||
@@ -24,6 +26,13 @@ describe('activeServerReachability', () => {
|
||||
expect(getActiveServerReachable()).toBe(true);
|
||||
});
|
||||
|
||||
it('isActiveServerReachable is false when DEV force-offline is enabled', () => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
setActiveServerReachable(true);
|
||||
useDevOfflineBrowseStore.setState({ forceOffline: true });
|
||||
expect(isActiveServerReachable()).toBe(false);
|
||||
});
|
||||
|
||||
it('onActiveServerBecameReachable fires only on false/null → true', () => {
|
||||
const listener = vi.fn();
|
||||
onActiveServerBecameReachable(listener);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isDevOfflineBrowseForced } from '../../store/devOfflineBrowseStore';
|
||||
|
||||
/**
|
||||
* Active-server reachability snapshot maintained by `useConnectionStatus`.
|
||||
* Non-hook code (queue sync, favorites refresh) uses this to avoid noisy
|
||||
@@ -27,6 +29,7 @@ export function getActiveServerReachable(): boolean | null {
|
||||
|
||||
/** True only when the browser is online and the last active-server probe succeeded. */
|
||||
export function isActiveServerReachable(): boolean {
|
||||
if (isDevOfflineBrowseForced()) return false;
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return false;
|
||||
return activeServerReachable === true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { resolvePlaybackUrl } from '../playback/resolvePlaybackUrl';
|
||||
import { isDevOfflineBrowseForced } from '../../store/devOfflineBrowseStore';
|
||||
import { isActiveServerReachable } from './activeServerReachability';
|
||||
|
||||
/**
|
||||
@@ -9,6 +10,7 @@ import { isActiveServerReachable } from './activeServerReachability';
|
||||
*/
|
||||
export function shouldAttemptSubsonicForServer(serverId: string, trackId?: string): boolean {
|
||||
if (!serverId) return false;
|
||||
if (isDevOfflineBrowseForced()) return false;
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return false;
|
||||
if (trackId) {
|
||||
const url = resolvePlaybackUrl(trackId, serverId);
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import {
|
||||
favoritesOfflineBrowseEnabled,
|
||||
hasOfflineBrowsingContent,
|
||||
} from './favoritesOfflineBrowse';
|
||||
import {
|
||||
isOfflineSidebarLibraryNavAllowed,
|
||||
isOfflineSidebarNavAllowed,
|
||||
isOfflineSidebarSystemNavAllowed,
|
||||
} from './offlineNavPolicy';
|
||||
import {
|
||||
loadStarredFromLibraryIndex,
|
||||
mergeStarredFromServers,
|
||||
resolveAlbumForServer,
|
||||
} from './favoritesOfflineBrowse';
|
||||
} from './offlineStarredLoad';
|
||||
import { resolveAlbumForServer } from './offlineMediaResolve';
|
||||
|
||||
const isActiveServerReachableMock = vi.fn(() => true);
|
||||
const shouldAttemptSubsonicForServerMock = vi.fn((_serverId: string, _trackId?: string) => true);
|
||||
@@ -23,6 +30,7 @@ vi.mock('../network/subsonicNetworkGuard', () => ({
|
||||
const getAlbumForServerMock = vi.fn();
|
||||
const libraryAdvancedSearchMock = vi.fn();
|
||||
const libraryGetTracksByAlbumMock = vi.fn();
|
||||
const libraryGetTracksBatchChunkedMock = vi.fn();
|
||||
|
||||
vi.mock('../../api/subsonicLibrary', () => ({
|
||||
getAlbumForServer: (...args: unknown[]) => getAlbumForServerMock(...args),
|
||||
@@ -31,6 +39,7 @@ vi.mock('../../api/subsonicLibrary', () => ({
|
||||
vi.mock('../../api/library', () => ({
|
||||
libraryAdvancedSearch: (...args: unknown[]) => libraryAdvancedSearchMock(...args),
|
||||
libraryGetTracksByAlbum: (...args: unknown[]) => libraryGetTracksByAlbumMock(...args),
|
||||
libraryGetTracksBatchChunked: (...args: unknown[]) => libraryGetTracksBatchChunkedMock(...args),
|
||||
}));
|
||||
|
||||
describe('favoritesOfflineBrowse', () => {
|
||||
@@ -40,6 +49,8 @@ describe('favoritesOfflineBrowse', () => {
|
||||
getAlbumForServerMock.mockReset();
|
||||
libraryGetTracksByAlbumMock.mockReset();
|
||||
libraryAdvancedSearchMock.mockReset();
|
||||
libraryGetTracksBatchChunkedMock.mockReset();
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
useAuthStore.setState({
|
||||
favoritesOfflineEnabled: false,
|
||||
activeServerId: 'srv-1',
|
||||
@@ -87,28 +98,121 @@ describe('favoritesOfflineBrowse', () => {
|
||||
expect(merged.songs.map(s => s.serverId)).toEqual(['srv-1', 'srv-2']);
|
||||
});
|
||||
|
||||
it('isOfflineSidebarLibraryNavAllowed keeps only favorites when offline', () => {
|
||||
it('isOfflineSidebarLibraryNavAllowed gates offline sidebar entries', () => {
|
||||
expect(isOfflineSidebarLibraryNavAllowed('favorites', true)).toBe(true);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('favorites', false)).toBe(false);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('albums', true)).toBe(false);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('artists', false, true)).toBe(true);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('allAlbums', false, true)).toBe(true);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('tracks', false, true)).toBe(true);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('tracks', false, false)).toBe(false);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('allAlbums', false, false)).toBe(false);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('offline', false, false)).toBe(true);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('playlists', false, false, true)).toBe(true);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('playlists', false, false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('loadStarredFromLibraryIndex omits artist entity (no artist.starred_at in index)', async () => {
|
||||
it('isOfflineSidebarSystemNavAllowed keeps help and player stats offline', () => {
|
||||
expect(isOfflineSidebarSystemNavAllowed('help', false)).toBe(true);
|
||||
expect(isOfflineSidebarSystemNavAllowed('statistics', true)).toBe(true);
|
||||
expect(isOfflineSidebarSystemNavAllowed('statistics', false)).toBe(false);
|
||||
expect(isOfflineSidebarNavAllowed('help', false, false, false)).toBe(true);
|
||||
expect(isOfflineSidebarNavAllowed('statistics', false, false, true)).toBe(true);
|
||||
expect(isOfflineSidebarNavAllowed('tracks', false, true, false)).toBe(true);
|
||||
expect(isOfflineSidebarNavAllowed('playlists', false, false, false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('loadStarredFromLibraryIndex uses starred advanced search when not offline-bytes', async () => {
|
||||
libraryAdvancedSearchMock.mockResolvedValue({
|
||||
albums: [{ id: 'alb-1', name: 'A', artist: 'X', artistId: 'art-1', serverId: 'srv-1' }],
|
||||
artists: [{ id: 'art-99', name: 'Not A Favorite', serverId: 'srv-1' }],
|
||||
tracks: [{ id: 't-1', title: 'S', artist: 'X', album: 'A', albumId: 'alb-1', durationSec: 1, serverId: 'srv-1' }],
|
||||
artists: [],
|
||||
});
|
||||
|
||||
const starred = await loadStarredFromLibraryIndex('srv-1');
|
||||
expect(libraryAdvancedSearchMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
serverId: 'srv-1',
|
||||
entityTypes: ['album', 'track'],
|
||||
starredOnly: true,
|
||||
}));
|
||||
expect(libraryGetTracksBatchChunkedMock).not.toHaveBeenCalled();
|
||||
expect(starred.artists).toEqual([]);
|
||||
expect(starred.songs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('loadStarredFromLibraryIndex prefers local bytes then starred filter when offline', async () => {
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/media/library/a.test/a/al/t1.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'favorite-auto',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
'a.test:t2': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't2',
|
||||
localPath: '/media/library/a.test/a/al/t2.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'favorite-auto',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
},
|
||||
});
|
||||
libraryGetTracksBatchChunkedMock.mockResolvedValue([
|
||||
{
|
||||
id: 't1',
|
||||
title: 'Starred',
|
||||
artist: 'X',
|
||||
album: 'A',
|
||||
albumId: 'alb-1',
|
||||
durationSec: 1,
|
||||
starredAt: 1,
|
||||
serverId: 'srv-1',
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
title: 'Not starred',
|
||||
artist: 'X',
|
||||
album: 'A',
|
||||
albumId: 'alb-1',
|
||||
durationSec: 1,
|
||||
serverId: 'srv-1',
|
||||
},
|
||||
]);
|
||||
libraryAdvancedSearchMock.mockResolvedValue({
|
||||
albums: [{
|
||||
id: 'alb-2',
|
||||
name: 'Album star only',
|
||||
artist: 'Y',
|
||||
artistId: 'art-2',
|
||||
starredAt: 1,
|
||||
serverId: 'srv-1',
|
||||
}],
|
||||
artists: [],
|
||||
tracks: [],
|
||||
});
|
||||
|
||||
const starred = await loadStarredFromLibraryIndex('srv-1', true);
|
||||
|
||||
expect(libraryGetTracksBatchChunkedMock).toHaveBeenCalled();
|
||||
expect(libraryAdvancedSearchMock).toHaveBeenCalled();
|
||||
expect(libraryAdvancedSearchMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
serverId: 'srv-1',
|
||||
entityTypes: ['album'],
|
||||
starredOnly: true,
|
||||
restrictAlbumIds: ['alb-1'],
|
||||
}));
|
||||
expect(starred.songs).toHaveLength(1);
|
||||
expect(starred.songs[0]?.id).toBe('t1');
|
||||
expect(starred.albums.map(a => a.id).sort()).toEqual(['alb-1', 'alb-2']);
|
||||
});
|
||||
|
||||
it('resolveAlbumForServer uses library index when network fails', async () => {
|
||||
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
||||
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
import { getStarredForServer } from '../../api/subsonicStarRating';
|
||||
import { isActiveServerReachable } from '../network/activeServerReachability';
|
||||
import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard';
|
||||
import { getAlbumForServer } from '../../api/subsonicLibrary';
|
||||
import { libraryAdvancedSearch, libraryGetTracksByAlbum } from '../../api/library';
|
||||
import type {
|
||||
StarredResults,
|
||||
SubsonicAlbum,
|
||||
SubsonicArtist,
|
||||
SubsonicSong,
|
||||
} from '../../api/subsonicTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import type { OfflineAlbumMeta } from '../../store/offlineStore';
|
||||
import {
|
||||
albumToAlbum,
|
||||
artistToArtist,
|
||||
trackToSong,
|
||||
} from '../library/advancedSearchLocal';
|
||||
import { dedupeById } from '../dedupeById';
|
||||
import { countFavoriteAutoTracks, hasAnyOfflineAlbums } from './offlineLibraryHelpers';
|
||||
|
||||
/** Saved servers with a local library index (cross-server favorites scope). */
|
||||
@@ -34,14 +17,6 @@ export function favoritesOfflineBrowseEnabled(): boolean {
|
||||
return favoritesServerIds().length > 0;
|
||||
}
|
||||
|
||||
export function isOfflineSidebarLibraryNavAllowed(
|
||||
navId: string,
|
||||
favoritesOfflineBrowse: boolean,
|
||||
): boolean {
|
||||
if (navId === 'favorites') return favoritesOfflineBrowse;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Any offline browsing surface: manual pins and/or saved favorite-auto bytes. */
|
||||
export function hasOfflineBrowsingContent(
|
||||
offlineAlbums: Record<string, OfflineAlbumMeta>,
|
||||
@@ -50,203 +25,3 @@ export function hasOfflineBrowsingContent(
|
||||
if (favoritesOfflineBrowseEnabled() && countFavoriteAutoTracks() > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function tagStarredWithServer(starred: StarredResults, serverId: string): StarredResults {
|
||||
const withServer = <T extends { id: string }>(items: T[]): (T & { serverId: string })[] =>
|
||||
items.map(item => ({ ...item, serverId }));
|
||||
|
||||
return {
|
||||
artists: withServer(starred.artists),
|
||||
albums: withServer(starred.albums),
|
||||
songs: withServer(starred.songs),
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge starred lists from multiple servers; dedupe by `serverId:id`. */
|
||||
export function mergeStarredFromServers(
|
||||
entries: { serverId: string; starred: StarredResults }[],
|
||||
): StarredResults {
|
||||
const artists: SubsonicArtist[] = [];
|
||||
const albums: SubsonicAlbum[] = [];
|
||||
const songs: SubsonicSong[] = [];
|
||||
for (const { serverId, starred } of entries) {
|
||||
const tagged = tagStarredWithServer(starred, serverId);
|
||||
artists.push(...tagged.artists);
|
||||
albums.push(...tagged.albums);
|
||||
songs.push(...tagged.songs);
|
||||
}
|
||||
return {
|
||||
artists: dedupeById(artists),
|
||||
albums: dedupeById(albums),
|
||||
songs: dedupeById(songs),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadStarredFromLibraryIndex(serverId: string): Promise<StarredResults> {
|
||||
// Artist-level favorites are network-only today (`artist` has no `starred_at`;
|
||||
// `starredOnly` on artists would return the whole artist table). Songs/albums
|
||||
// use track/album stars in the index.
|
||||
const response = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album', 'track'],
|
||||
starredOnly: true,
|
||||
limit: 10_000,
|
||||
});
|
||||
return {
|
||||
artists: [],
|
||||
albums: response.albums.map(albumToAlbum),
|
||||
songs: response.tracks.map(trackToSong),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadStarredFromAllLibraryIndexes(): Promise<StarredResults> {
|
||||
const serverIds = favoritesServerIds();
|
||||
const entries = await Promise.all(
|
||||
serverIds.map(async serverId => {
|
||||
try {
|
||||
const starred = await loadStarredFromLibraryIndex(serverId);
|
||||
return { serverId, starred };
|
||||
} catch {
|
||||
return { serverId, starred: { artists: [], albums: [], songs: [] } satisfies StarredResults };
|
||||
}
|
||||
}),
|
||||
);
|
||||
return mergeStarredFromServers(entries);
|
||||
}
|
||||
|
||||
/** Online starred merge with per-server local index fallback. */
|
||||
export async function loadStarredFromAllServersOnline(): Promise<StarredResults> {
|
||||
if (!isActiveServerReachable()) {
|
||||
return loadStarredFromAllLibraryIndexes();
|
||||
}
|
||||
const serverIds = favoritesServerIds();
|
||||
const entries = await Promise.all(
|
||||
serverIds.map(async serverId => {
|
||||
try {
|
||||
const starred = await getStarredForServer(serverId);
|
||||
return { serverId, starred };
|
||||
} catch {
|
||||
try {
|
||||
const starred = await loadStarredFromLibraryIndex(serverId);
|
||||
return { serverId, starred };
|
||||
} catch {
|
||||
return { serverId, starred: { artists: [], albums: [], songs: [] } satisfies StarredResults };
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
return mergeStarredFromServers(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Album detail / play / offline pin: use the network album when reachable so the
|
||||
* track list is complete. The library index may only contain a subset (e.g.
|
||||
* starred tracks or a partial sync) — never prefer that over `getAlbum` online.
|
||||
* When the server is unreachable, fall back to the index when favorites-offline
|
||||
* browsing is enabled.
|
||||
*/
|
||||
export async function resolveAlbumForServer(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> {
|
||||
const favoritesOffline = useAuthStore.getState().favoritesOfflineEnabled;
|
||||
const networkAllowed = shouldAttemptSubsonicForServer(serverId);
|
||||
|
||||
if (networkAllowed) {
|
||||
try {
|
||||
const data = await getAlbumForServer(serverId, albumId);
|
||||
return { album: data.album, songs: data.songs };
|
||||
} catch {
|
||||
/* fall through to library index */
|
||||
}
|
||||
} else if (!favoritesOffline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await loadAlbumFromLibraryIndex(serverId, albumId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAlbumFromLibraryIndex(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> {
|
||||
const tracks = await libraryGetTracksByAlbum(serverId, albumId);
|
||||
if (tracks.length === 0) return null;
|
||||
|
||||
const songs = tracks.map(trackToSong);
|
||||
const albumSearch = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album'],
|
||||
restrictAlbumIds: [albumId],
|
||||
limit: 1,
|
||||
});
|
||||
const albumDto = albumSearch.albums[0];
|
||||
if (albumDto) {
|
||||
const album = albumToAlbum(albumDto);
|
||||
return {
|
||||
album: {
|
||||
...album,
|
||||
serverId,
|
||||
songCount: songs.length,
|
||||
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
|
||||
},
|
||||
songs: songs.map(s => ({ ...s, serverId })),
|
||||
};
|
||||
}
|
||||
|
||||
const first = tracks[0];
|
||||
return {
|
||||
album: {
|
||||
id: albumId,
|
||||
name: first.album ?? albumId,
|
||||
artist: first.artist ?? '',
|
||||
artistId: first.artistId ?? '',
|
||||
songCount: songs.length,
|
||||
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
|
||||
coverArt: first.coverArtId ?? albumId,
|
||||
year: first.year ?? undefined,
|
||||
genre: first.genre ?? undefined,
|
||||
starred: first.starredAt != null ? new Date(first.starredAt).toISOString() : undefined,
|
||||
serverId,
|
||||
},
|
||||
songs: songs.map(s => ({ ...s, serverId })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadArtistFromLibraryIndex(
|
||||
serverId: string,
|
||||
artistId: string,
|
||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> {
|
||||
const response = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album', 'artist'],
|
||||
limit: 10_000,
|
||||
});
|
||||
const albums = response.albums
|
||||
.filter(a => a.artistId === artistId)
|
||||
.map(albumToAlbum)
|
||||
.map(a => ({ ...a, serverId }));
|
||||
const artistDto = response.artists.find(a => a.id === artistId);
|
||||
if (!artistDto && albums.length === 0) return null;
|
||||
|
||||
const artist = artistDto
|
||||
? { ...artistToArtist(artistDto), serverId }
|
||||
: {
|
||||
id: artistId,
|
||||
name: albums[0]?.artist ?? artistId,
|
||||
albumCount: albums.length,
|
||||
serverId,
|
||||
};
|
||||
|
||||
return {
|
||||
artist: {
|
||||
...artist,
|
||||
albumCount: albums.length,
|
||||
},
|
||||
albums,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import { getMediaDir } from '../media/mediaDir';
|
||||
import { resolveIndexKey, serverIndexKeyForProfile } from '../server/serverIndexKey';
|
||||
import { FAVORITES_OFFLINE_JOB_ID } from './favoritesOfflineConstants';
|
||||
import { isActiveServerReachable } from '../network/activeServerReachability';
|
||||
import { favoritesServerIds, loadAlbumFromLibraryIndex } from './favoritesOfflineBrowse';
|
||||
import { favoritesServerIds } from './favoritesOfflineBrowse';
|
||||
import { loadAlbumFromLibraryIndex } from './offlineLibraryIndexLoad';
|
||||
import {
|
||||
entryBelongsToServer,
|
||||
hasLocalLibraryBytes,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { offlineActionPolicy } from './offlineActionPolicy';
|
||||
|
||||
describe('offlineActionPolicy', () => {
|
||||
it('allows all mutations when offline browse is inactive', () => {
|
||||
const p = offlineActionPolicy('albumDetail', false);
|
||||
expect(p.canFavorite).toBe(true);
|
||||
expect(p.canDownload).toBe(true);
|
||||
expect(p.canPinOffline).toBe(true);
|
||||
expect(p.canAddToPlaylist).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks server mutations when offline browse is active', () => {
|
||||
const p = offlineActionPolicy('albumDetail', true);
|
||||
expect(p.canFavorite).toBe(false);
|
||||
expect(p.canRate).toBe(false);
|
||||
expect(p.canDownload).toBe(false);
|
||||
expect(p.canPinOffline).toBe(false);
|
||||
expect(p.canAddToPlaylist).toBe(false);
|
||||
expect(p.canShowBio).toBe(false);
|
||||
});
|
||||
|
||||
it('applies same read-only policy to context menu surfaces', () => {
|
||||
expect(offlineActionPolicy('contextMenuAlbum', true).canFavorite).toBe(false);
|
||||
expect(offlineActionPolicy('contextMenuSong', true).canAddToPlaylist).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks rating and favorite in player bar when offline browse is active', () => {
|
||||
const p = offlineActionPolicy('playerBar', true);
|
||||
expect(p.canRate).toBe(false);
|
||||
expect(p.canFavorite).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
export type OfflineSurface =
|
||||
| 'albumDetail'
|
||||
| 'artistDetail'
|
||||
| 'albumCard'
|
||||
| 'trackRow'
|
||||
| 'playlistDetail'
|
||||
| 'playlistsHeader'
|
||||
| 'contextMenuAlbum'
|
||||
| 'contextMenuSong'
|
||||
| 'contextMenuArtist'
|
||||
| 'contextMenuPlaylist'
|
||||
| 'hero'
|
||||
| 'statistics'
|
||||
| 'playerBar';
|
||||
|
||||
export type OfflineActionPolicy = {
|
||||
canFavorite: boolean;
|
||||
canRate: boolean;
|
||||
canDownload: boolean;
|
||||
canPinOffline: boolean;
|
||||
canCacheDiscography: boolean;
|
||||
canAddToPlaylist: boolean;
|
||||
canEditPlaylist: boolean;
|
||||
canShowBio: boolean;
|
||||
canScrobble: boolean;
|
||||
};
|
||||
|
||||
const ALLOW_ALL: OfflineActionPolicy = {
|
||||
canFavorite: true,
|
||||
canRate: true,
|
||||
canDownload: true,
|
||||
canPinOffline: true,
|
||||
canCacheDiscography: true,
|
||||
canAddToPlaylist: true,
|
||||
canEditPlaylist: true,
|
||||
canShowBio: true,
|
||||
canScrobble: true,
|
||||
};
|
||||
|
||||
const READ_ONLY_MUTATIONS: OfflineActionPolicy = {
|
||||
canFavorite: false,
|
||||
canRate: false,
|
||||
canDownload: false,
|
||||
canPinOffline: false,
|
||||
canCacheDiscography: false,
|
||||
canAddToPlaylist: false,
|
||||
canEditPlaylist: false,
|
||||
canShowBio: false,
|
||||
canScrobble: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* What server-mutating actions are allowed on a UI surface while offline browse is active.
|
||||
* `surface` is reserved for per-surface divergence; today all surfaces share read-only policy.
|
||||
*/
|
||||
export function offlineActionPolicy(_surface: OfflineSurface, active: boolean): OfflineActionPolicy {
|
||||
if (!active) return ALLOW_ALL;
|
||||
return READ_ONLY_MUTATIONS;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import type { AlbumBrowseQuery } from '../library/albumBrowseTypes';
|
||||
import { isOfflineBrowseActive } from './offlineBrowseMode';
|
||||
import {
|
||||
fetchOfflineLocalAlbumCatalogChunk,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from './offlineLocalBrowse';
|
||||
|
||||
type OfflineAlbumCatalogChunk = {
|
||||
albums: SubsonicAlbum[];
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
/** Offline album grid catalog chunk; null when offline browse or local bytes are unavailable. */
|
||||
export async function loadOfflineAlbumCatalogChunk(
|
||||
serverId: string,
|
||||
browseQuery: AlbumBrowseQuery,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
starredOverrides: Record<string, boolean>,
|
||||
): Promise<OfflineAlbumCatalogChunk | null> {
|
||||
if (!isOfflineBrowseActive() || !offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const chunk = await fetchOfflineLocalAlbumCatalogChunk(
|
||||
serverId,
|
||||
browseQuery,
|
||||
offset,
|
||||
chunkSize,
|
||||
starredOverrides,
|
||||
);
|
||||
if (chunk == null) return null;
|
||||
return { albums: chunk.albums, hasMore: chunk.hasMore };
|
||||
}
|
||||
|
||||
/** Initial offline album browse load for the albums grid. */
|
||||
export async function loadOfflineAlbumBrowseInitial(
|
||||
serverId: string,
|
||||
browseQuery: AlbumBrowseQuery,
|
||||
chunkSize: number,
|
||||
starredOverrides: Record<string, boolean>,
|
||||
): Promise<OfflineAlbumCatalogChunk> {
|
||||
const first = await loadOfflineAlbumCatalogChunk(
|
||||
serverId,
|
||||
browseQuery,
|
||||
0,
|
||||
chunkSize,
|
||||
starredOverrides,
|
||||
);
|
||||
return first ?? { albums: [], hasMore: false };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import {
|
||||
buildOfflineBrowseContext,
|
||||
computeOfflineBrowseCapabilities,
|
||||
offlineBrowseNavFlags,
|
||||
} from './offlineBrowseContext';
|
||||
|
||||
vi.mock('./offlineLocalBrowse', () => ({
|
||||
offlineLocalBrowseEnabled: vi.fn(() => false),
|
||||
countLocalBrowsableTracks: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock('./offlinePlaylistBrowse', () => ({
|
||||
playlistsOfflineBrowseEnabled: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
import { offlineLocalBrowseEnabled } from './offlineLocalBrowse';
|
||||
import { playlistsOfflineBrowseEnabled } from './offlinePlaylistBrowse';
|
||||
|
||||
describe('offlineBrowseContext', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
favoritesOfflineEnabled: false,
|
||||
activeServerId: 'srv-1',
|
||||
} as Partial<ReturnType<typeof useAuthStore.getState>>);
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
useLocalPlaybackStore.setState({ entries: {} });
|
||||
vi.mocked(offlineLocalBrowseEnabled).mockReturnValue(false);
|
||||
vi.mocked(playlistsOfflineBrowseEnabled).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('computeOfflineBrowseCapabilities returns all false when nothing enabled', () => {
|
||||
const caps = computeOfflineBrowseCapabilities({
|
||||
activeServerId: 'srv-1',
|
||||
favoritesOfflineEnabled: false,
|
||||
offlineAlbums: {},
|
||||
playerStats: false,
|
||||
});
|
||||
expect(caps).toEqual({
|
||||
localLibrary: false,
|
||||
favorites: false,
|
||||
playlists: false,
|
||||
manualPins: false,
|
||||
playerStats: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('favorites capability uses cross-server index when setting is on', () => {
|
||||
useAuthStore.setState({
|
||||
favoritesOfflineEnabled: true,
|
||||
servers: [{ id: 'srv-2', name: 'B', url: 'https://b.test', username: 'u', password: 'p' }],
|
||||
activeServerId: null,
|
||||
});
|
||||
const caps = computeOfflineBrowseCapabilities({
|
||||
activeServerId: null,
|
||||
favoritesOfflineEnabled: true,
|
||||
offlineAlbums: {},
|
||||
playerStats: false,
|
||||
});
|
||||
expect(caps.favorites).toBe(true);
|
||||
});
|
||||
|
||||
it('buildOfflineBrowseContext sets hasBrowseCapability from capabilities', () => {
|
||||
vi.mocked(offlineLocalBrowseEnabled).mockReturnValue(true);
|
||||
const caps = computeOfflineBrowseCapabilities({
|
||||
activeServerId: 'srv-1',
|
||||
favoritesOfflineEnabled: false,
|
||||
offlineAlbums: {},
|
||||
playerStats: true,
|
||||
});
|
||||
const ctx = buildOfflineBrowseContext({
|
||||
active: true,
|
||||
serverId: 'srv-1',
|
||||
capabilities: caps,
|
||||
connStatus: 'disconnected',
|
||||
hasBrowsingContent: true,
|
||||
});
|
||||
expect(ctx.hasBrowseCapability).toBe(true);
|
||||
expect(ctx.capabilities.playerStats).toBe(true);
|
||||
});
|
||||
|
||||
it('offlineBrowseNavFlags maps capability fields for sidebar', () => {
|
||||
const flags = offlineBrowseNavFlags({
|
||||
localLibrary: true,
|
||||
favorites: false,
|
||||
playlists: true,
|
||||
manualPins: true,
|
||||
playerStats: false,
|
||||
});
|
||||
expect(flags).toEqual({
|
||||
favoritesOfflineBrowse: false,
|
||||
localLibraryBrowse: true,
|
||||
playlistsOfflineBrowse: true,
|
||||
playerStatsBrowse: false,
|
||||
hasManualOfflineContent: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||
import type { OfflineAlbumMeta } from '../../store/offlineStore';
|
||||
import { favoritesOfflineBrowseEnabled } from './favoritesOfflineBrowse';
|
||||
import { hasOfflineBrowseCapability } from './offlineBrowseRouting';
|
||||
import { offlineLocalBrowseEnabled } from './offlineLocalBrowse';
|
||||
import { playlistsOfflineBrowseEnabled } from './offlinePlaylistBrowse';
|
||||
import { hasAnyOfflineAlbums } from './offlineLibraryHelpers';
|
||||
|
||||
export type OfflineBrowseCapabilities = {
|
||||
localLibrary: boolean;
|
||||
favorites: boolean;
|
||||
playlists: boolean;
|
||||
manualPins: boolean;
|
||||
playerStats: boolean;
|
||||
};
|
||||
|
||||
export type OfflineBrowseContext = {
|
||||
active: boolean;
|
||||
serverId: string | null;
|
||||
capabilities: OfflineBrowseCapabilities;
|
||||
/** Disconnect fork / banner: local library, favorites, or manual pins. */
|
||||
hasBrowseCapability: boolean;
|
||||
/** Any offline bytes to show (includes favorite-auto without browse). */
|
||||
hasBrowsingContent: boolean;
|
||||
connStatus: ConnectionStatus;
|
||||
};
|
||||
|
||||
type ComputeOfflineBrowseCapabilitiesInput = {
|
||||
activeServerId: string | null;
|
||||
favoritesOfflineEnabled: boolean;
|
||||
offlineAlbums: Record<string, OfflineAlbumMeta>;
|
||||
playerStats: boolean;
|
||||
};
|
||||
|
||||
/** Pure capability snapshot for tests and non-React callers. */
|
||||
export function computeOfflineBrowseCapabilities(
|
||||
input: ComputeOfflineBrowseCapabilitiesInput,
|
||||
): OfflineBrowseCapabilities {
|
||||
const { activeServerId, favoritesOfflineEnabled, offlineAlbums, playerStats } = input;
|
||||
|
||||
return {
|
||||
localLibrary: offlineLocalBrowseEnabled(activeServerId),
|
||||
favorites: favoritesBrowseCapabilityAnyServer(favoritesOfflineEnabled),
|
||||
playlists: playlistsOfflineBrowseEnabled(activeServerId),
|
||||
manualPins: hasAnyOfflineAlbums(offlineAlbums),
|
||||
playerStats,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOfflineBrowseContext(input: {
|
||||
active: boolean;
|
||||
serverId: string | null;
|
||||
capabilities: OfflineBrowseCapabilities;
|
||||
connStatus: ConnectionStatus;
|
||||
hasBrowsingContent: boolean;
|
||||
}): OfflineBrowseContext {
|
||||
const { capabilities, hasBrowsingContent, ...rest } = input;
|
||||
return {
|
||||
...rest,
|
||||
capabilities,
|
||||
hasBrowseCapability: hasOfflineBrowseCapability(
|
||||
capabilities.localLibrary,
|
||||
capabilities.favorites,
|
||||
capabilities.manualPins,
|
||||
),
|
||||
hasBrowsingContent,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sidebar / disconnect helpers — maps capability snapshot to nav gate flags. */
|
||||
export function offlineBrowseNavFlags(capabilities: OfflineBrowseCapabilities): {
|
||||
favoritesOfflineBrowse: boolean;
|
||||
localLibraryBrowse: boolean;
|
||||
playlistsOfflineBrowse: boolean;
|
||||
playerStatsBrowse: boolean;
|
||||
hasManualOfflineContent: boolean;
|
||||
} {
|
||||
return {
|
||||
favoritesOfflineBrowse: capabilities.favorites,
|
||||
localLibraryBrowse: capabilities.localLibrary,
|
||||
playlistsOfflineBrowse: capabilities.playlists,
|
||||
playerStatsBrowse: capabilities.playerStats,
|
||||
hasManualOfflineContent: capabilities.manualPins,
|
||||
};
|
||||
}
|
||||
|
||||
/** Cross-server favorites scope (setting + any indexed server). */
|
||||
function favoritesBrowseCapabilityAnyServer(favoritesOfflineEnabled: boolean): boolean {
|
||||
if (!favoritesOfflineEnabled) return false;
|
||||
return favoritesOfflineBrowseEnabled();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { useDevOfflineBrowseStore } from '../../store/devOfflineBrowseStore';
|
||||
import { useOfflineBrowseActive } from './offlineBrowseMode';
|
||||
|
||||
describe('useOfflineBrowseActive', () => {
|
||||
beforeEach(() => {
|
||||
useDevOfflineBrowseStore.setState({ forceOffline: false });
|
||||
});
|
||||
|
||||
it('enables offline browse when DEV force-offline is set', () => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
|
||||
act(() => {
|
||||
useDevOfflineBrowseStore.getState().setForceOffline(true);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOfflineBrowseActive());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
isDevOfflineBrowseForced,
|
||||
useDevOfflineBrowseStore,
|
||||
} from '../../store/devOfflineBrowseStore';
|
||||
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||
import { isActiveServerReachable } from '../network/activeServerReachability';
|
||||
|
||||
/** True when browse/detail pages should use local-bytes-only data sources. */
|
||||
export function isOfflineBrowseActive(): boolean {
|
||||
if (isDevOfflineBrowseForced()) return true;
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return true;
|
||||
return !isActiveServerReachable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive offline-browse flag for React trees. Re-renders when the DEV toggle,
|
||||
* browser online state, or active-server connection status changes.
|
||||
*/
|
||||
export function useOfflineBrowseActive(): boolean {
|
||||
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
|
||||
const { status: connStatus } = useConnectionStatus();
|
||||
|
||||
if (import.meta.env.DEV && devForceOffline) return true;
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return true;
|
||||
if (connStatus === 'disconnected') return true;
|
||||
if (connStatus === 'connected') return false;
|
||||
return !isActiveServerReachable();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
hasOfflineBrowseCapability,
|
||||
isPathOfflineBrowsable,
|
||||
resolveOfflineDisconnectNavAction,
|
||||
} from './offlineBrowseRouting';
|
||||
|
||||
describe('offlineBrowseRouting', () => {
|
||||
it('hasOfflineBrowseCapability is true when any offline surface exists', () => {
|
||||
expect(hasOfflineBrowseCapability(false, false, false)).toBe(false);
|
||||
expect(hasOfflineBrowseCapability(true, false, false)).toBe(true);
|
||||
expect(hasOfflineBrowseCapability(false, true, false)).toBe(true);
|
||||
expect(hasOfflineBrowseCapability(false, false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('isPathOfflineBrowsable covers library, detail, and local-only routes', () => {
|
||||
const ctx = [true, true, true, true] as const;
|
||||
expect(isPathOfflineBrowsable('/albums', ...ctx)).toBe(true);
|
||||
expect(isPathOfflineBrowsable('/album/abc', ...ctx)).toBe(true);
|
||||
expect(isPathOfflineBrowsable('/artist/abc', ...ctx)).toBe(true);
|
||||
expect(isPathOfflineBrowsable('/playlists', ...ctx)).toBe(true);
|
||||
expect(isPathOfflineBrowsable('/playlists/pl-1', ...ctx)).toBe(true);
|
||||
expect(isPathOfflineBrowsable('/now-playing', ...ctx)).toBe(true);
|
||||
expect(isPathOfflineBrowsable('/', ...ctx)).toBe(false);
|
||||
expect(isPathOfflineBrowsable('/playlists', true, true, true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('resolveOfflineDisconnectNavAction stays when nothing offline', () => {
|
||||
expect(resolveOfflineDisconnectNavAction('/playlists', false, false, false, false, false))
|
||||
.toEqual({ kind: 'stay' });
|
||||
expect(resolveOfflineDisconnectNavAction('/albums', false, false, false, false, false))
|
||||
.toEqual({ kind: 'stay' });
|
||||
});
|
||||
|
||||
it('resolveOfflineDisconnectNavAction reloads allowed pages', () => {
|
||||
expect(resolveOfflineDisconnectNavAction('/artists', false, true, false, false, false))
|
||||
.toEqual({ kind: 'stay-reload' });
|
||||
expect(resolveOfflineDisconnectNavAction('/playlists', false, false, false, true, true))
|
||||
.toEqual({ kind: 'stay-reload' });
|
||||
});
|
||||
|
||||
it('resolveOfflineDisconnectNavAction redirects disallowed pages to all albums', () => {
|
||||
expect(resolveOfflineDisconnectNavAction('/playlists', false, true, false, false, false))
|
||||
.toEqual({ kind: 'redirect', to: '/albums' });
|
||||
expect(resolveOfflineDisconnectNavAction('/', false, true, false, false, false))
|
||||
.toEqual({ kind: 'redirect', to: '/albums' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { isOfflineSidebarNavAllowed } from './offlineNavPolicy';
|
||||
|
||||
/** Any offline browse surface the disconnect fork may use. */
|
||||
export function hasOfflineBrowseCapability(
|
||||
localLibraryBrowse: boolean,
|
||||
favoritesOfflineBrowse: boolean,
|
||||
hasManualOfflineContent: boolean,
|
||||
): boolean {
|
||||
return localLibraryBrowse || favoritesOfflineBrowse || hasManualOfflineContent;
|
||||
}
|
||||
|
||||
/** Map a route to a sidebar nav id for offline-allow checks (detail pages included). */
|
||||
function offlineNavIdForPathname(pathname: string): string | null {
|
||||
if (pathname === '/albums') return 'allAlbums';
|
||||
if (pathname === '/artists' || pathname.startsWith('/artist/')) return 'artists';
|
||||
if (pathname === '/playlists' || pathname.startsWith('/playlists/')) return 'playlists';
|
||||
if (pathname === '/tracks') return 'tracks';
|
||||
if (pathname === '/favorites') return 'favorites';
|
||||
if (pathname === '/offline') return 'offline';
|
||||
if (pathname === '/help') return 'help';
|
||||
if (pathname === '/statistics' || pathname === '/player-stats') return 'statistics';
|
||||
if (pathname.startsWith('/album/')) return 'allAlbums';
|
||||
return null;
|
||||
}
|
||||
|
||||
const OFFLINE_ALWAYS_STAY_PATHS = new Set([
|
||||
'/now-playing',
|
||||
'/settings',
|
||||
]);
|
||||
|
||||
export function isPathOfflineBrowsable(
|
||||
pathname: string,
|
||||
favoritesOfflineBrowse: boolean,
|
||||
localLibraryBrowse: boolean,
|
||||
playerStatsBrowse: boolean,
|
||||
playlistsOfflineBrowse: boolean,
|
||||
): boolean {
|
||||
if (OFFLINE_ALWAYS_STAY_PATHS.has(pathname)) return true;
|
||||
const navId = offlineNavIdForPathname(pathname);
|
||||
if (!navId) return false;
|
||||
return isOfflineSidebarNavAllowed(
|
||||
navId,
|
||||
favoritesOfflineBrowse,
|
||||
localLibraryBrowse,
|
||||
playerStatsBrowse,
|
||||
playlistsOfflineBrowse,
|
||||
);
|
||||
}
|
||||
|
||||
type OfflineDisconnectNavAction =
|
||||
| { kind: 'stay' }
|
||||
| { kind: 'stay-reload' }
|
||||
| { kind: 'redirect'; to: '/albums' };
|
||||
|
||||
/** Decide what to do when the active server just became unreachable. */
|
||||
export function resolveOfflineDisconnectNavAction(
|
||||
pathname: string,
|
||||
favoritesOfflineBrowse: boolean,
|
||||
localLibraryBrowse: boolean,
|
||||
playerStatsBrowse: boolean,
|
||||
playlistsOfflineBrowse: boolean,
|
||||
hasManualOfflineContent: boolean,
|
||||
): OfflineDisconnectNavAction {
|
||||
if (!hasOfflineBrowseCapability(localLibraryBrowse, favoritesOfflineBrowse, hasManualOfflineContent)) {
|
||||
return { kind: 'stay' };
|
||||
}
|
||||
if (isPathOfflineBrowsable(
|
||||
pathname,
|
||||
favoritesOfflineBrowse,
|
||||
localLibraryBrowse,
|
||||
playerStatsBrowse,
|
||||
playlistsOfflineBrowse,
|
||||
)) {
|
||||
return { kind: 'stay-reload' };
|
||||
}
|
||||
return { kind: 'redirect', to: '/albums' };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
resetOfflineLibraryFilterSuspendState,
|
||||
restoreMusicLibraryFiltersAfterOffline,
|
||||
suspendMusicLibraryFiltersForOffline,
|
||||
} from './offlineLibraryFilterSuspend';
|
||||
|
||||
describe('offlineLibraryFilterSuspend', () => {
|
||||
beforeEach(() => {
|
||||
resetOfflineLibraryFilterSuspendState();
|
||||
useAuthStore.setState({
|
||||
activeServerId: 'srv-a',
|
||||
musicLibraryFilterByServer: { 'srv-a': 'lib-1' },
|
||||
musicLibraryFilterVersion: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('suspend saves scoped filter and resets active server to all', () => {
|
||||
suspendMusicLibraryFiltersForOffline();
|
||||
expect(useAuthStore.getState().musicLibraryFilterByServer['srv-a']).toBe('all');
|
||||
expect(useAuthStore.getState().musicLibraryFilterVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('restore brings back the saved filter after reconnect', () => {
|
||||
suspendMusicLibraryFiltersForOffline();
|
||||
restoreMusicLibraryFiltersAfterOffline();
|
||||
expect(useAuthStore.getState().musicLibraryFilterByServer['srv-a']).toBe('lib-1');
|
||||
expect(useAuthStore.getState().musicLibraryFilterVersion).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
let savedFilterByServer: Record<string, 'all' | string> | null = null;
|
||||
|
||||
/** Remember sidebar library filters and browse all libraries while offline. */
|
||||
export function suspendMusicLibraryFiltersForOffline(): void {
|
||||
if (savedFilterByServer != null) return;
|
||||
const auth = useAuthStore.getState();
|
||||
savedFilterByServer = { ...auth.musicLibraryFilterByServer };
|
||||
const serverId = auth.activeServerId;
|
||||
if (!serverId) return;
|
||||
const current = auth.musicLibraryFilterByServer[serverId] ?? 'all';
|
||||
if (current !== 'all') {
|
||||
auth.setMusicLibraryFilter('all');
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore the pre-offline library filter for the active server. */
|
||||
export function restoreMusicLibraryFiltersAfterOffline(): void {
|
||||
if (!savedFilterByServer) return;
|
||||
const snapshot = savedFilterByServer;
|
||||
savedFilterByServer = null;
|
||||
const auth = useAuthStore.getState();
|
||||
const serverId = auth.activeServerId;
|
||||
if (!serverId) return;
|
||||
const saved = snapshot[serverId] ?? 'all';
|
||||
const current = auth.musicLibraryFilterByServer[serverId] ?? 'all';
|
||||
if (saved !== current) {
|
||||
auth.setMusicLibraryFilter(saved);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test helper — drop suspended snapshot without restoring. */
|
||||
export function resetOfflineLibraryFilterSuspendState(): void {
|
||||
savedFilterByServer = null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { libraryAdvancedSearch, libraryGetTracksByAlbum } from '../../api/library';
|
||||
import type {
|
||||
SubsonicAlbum,
|
||||
SubsonicArtist,
|
||||
SubsonicSong,
|
||||
} from '../../api/subsonicTypes';
|
||||
import {
|
||||
albumToAlbum,
|
||||
artistToArtist,
|
||||
trackToSong,
|
||||
} from '../library/advancedSearchLocal';
|
||||
|
||||
export async function loadAlbumFromLibraryIndex(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> {
|
||||
const tracks = await libraryGetTracksByAlbum(serverId, albumId);
|
||||
if (tracks.length === 0) return null;
|
||||
|
||||
const songs = tracks.map(trackToSong);
|
||||
const albumSearch = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album'],
|
||||
restrictAlbumIds: [albumId],
|
||||
limit: 1,
|
||||
});
|
||||
const albumDto = albumSearch.albums[0];
|
||||
if (albumDto) {
|
||||
const album = albumToAlbum(albumDto);
|
||||
return {
|
||||
album: {
|
||||
...album,
|
||||
serverId,
|
||||
songCount: songs.length,
|
||||
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
|
||||
},
|
||||
songs: songs.map(s => ({ ...s, serverId })),
|
||||
};
|
||||
}
|
||||
|
||||
const first = tracks[0];
|
||||
return {
|
||||
album: {
|
||||
id: albumId,
|
||||
name: first.album ?? albumId,
|
||||
artist: first.artist ?? '',
|
||||
artistId: first.artistId ?? '',
|
||||
songCount: songs.length,
|
||||
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
|
||||
coverArt: first.coverArtId ?? albumId,
|
||||
year: first.year ?? undefined,
|
||||
genre: first.genre ?? undefined,
|
||||
starred: first.starredAt != null ? new Date(first.starredAt).toISOString() : undefined,
|
||||
serverId,
|
||||
},
|
||||
songs: songs.map(s => ({ ...s, serverId })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadArtistFromLibraryIndex(
|
||||
serverId: string,
|
||||
artistId: string,
|
||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> {
|
||||
const response = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album', 'artist'],
|
||||
limit: 10_000,
|
||||
});
|
||||
const albums = response.albums
|
||||
.filter(a => a.artistId === artistId)
|
||||
.map(albumToAlbum)
|
||||
.map(a => ({ ...a, serverId }));
|
||||
const artistDto = response.artists.find(a => a.id === artistId);
|
||||
if (!artistDto && albums.length === 0) return null;
|
||||
|
||||
const artist = artistDto
|
||||
? { ...artistToArtist(artistDto), serverId }
|
||||
: {
|
||||
id: artistId,
|
||||
name: albums[0]?.artist ?? artistId,
|
||||
albumCount: albums.length,
|
||||
serverId,
|
||||
};
|
||||
|
||||
return {
|
||||
artist: {
|
||||
...artist,
|
||||
albumCount: albums.length,
|
||||
},
|
||||
albums,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { LibraryTrackDto } from '../../api/library';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import {
|
||||
countLocalBrowsableTracks,
|
||||
fetchOfflineLocalBrowsableSongPage,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from './offlineLocalBrowse';
|
||||
|
||||
const { libraryGetTracksBatchChunkedMock } = vi.hoisted(() => ({
|
||||
libraryGetTracksBatchChunkedMock: vi.fn(async (): Promise<LibraryTrackDto[]> => []),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/library', () => ({
|
||||
libraryGetTracksBatchChunked: libraryGetTracksBatchChunkedMock,
|
||||
libraryGetTracksByAlbum: vi.fn(async () => []),
|
||||
libraryAdvancedSearch: vi.fn(async () => ({ albums: [], artists: [], tracks: [] })),
|
||||
}));
|
||||
|
||||
describe('offlineLocalBrowse', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
activeServerId: 'srv-a',
|
||||
servers: [{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }],
|
||||
});
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
useLocalPlaybackStore.setState({ entries: {} });
|
||||
libraryGetTracksBatchChunkedMock.mockReset();
|
||||
libraryGetTracksBatchChunkedMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('offlineLocalBrowseEnabled requires index and local bytes', () => {
|
||||
expect(offlineLocalBrowseEnabled('srv-a')).toBe(false);
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/media/library/a.test/a/al/t1.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(countLocalBrowsableTracks('srv-a')).toBe(1);
|
||||
expect(offlineLocalBrowseEnabled('srv-a')).toBe(true);
|
||||
});
|
||||
|
||||
it('fetchOfflineLocalBrowsableSongPage pages local bytes alphabetically', async () => {
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/media/library/a.test/a/al/t1.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
'a.test:t2': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't2',
|
||||
localPath: '/media/library/a.test/a/al/t2.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
},
|
||||
});
|
||||
libraryGetTracksBatchChunkedMock.mockResolvedValue([
|
||||
{
|
||||
id: 't2', title: 'Beta', artist: 'A', album: 'Al', albumId: 'al-1',
|
||||
durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {},
|
||||
},
|
||||
{
|
||||
id: 't1', title: 'Alpha', artist: 'A', album: 'Al', albumId: 'al-1',
|
||||
durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const page = await fetchOfflineLocalBrowsableSongPage('srv-a', 0, 1);
|
||||
expect(page?.songs.map(s => s.id)).toEqual(['t1']);
|
||||
expect(page?.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import type { LibraryTrackDto } from '../../api/library';
|
||||
import { libraryAdvancedSearch, libraryGetTracksBatchChunked, libraryGetTracksByAlbum } from '../../api/library';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import type { LocalPlaybackEntry } from '../../store/localPlaybackStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import {
|
||||
albumToAlbum,
|
||||
artistToArtist,
|
||||
resolveTrackCoverArtId,
|
||||
trackToSong,
|
||||
} from '../library/advancedSearchLocal';
|
||||
import {
|
||||
filterAlbumsByCompilation,
|
||||
filterAlbumsByGenres,
|
||||
filterAlbumsByStarred,
|
||||
filterAlbumsByYearBounds,
|
||||
} from '../library/albumBrowseFilters';
|
||||
import type { AlbumBrowseQuery } from '../library/albumBrowseTypes';
|
||||
import { sortSubsonicAlbums } from '../library/albumBrowseSort';
|
||||
import { isLosslessSuffix } from '../library/losslessFormats';
|
||||
import { entryBelongsToServer } from './offlineLibraryHelpers';
|
||||
|
||||
function sortBrowsableSongs(songs: SubsonicSong[]): SubsonicSong[] {
|
||||
return [...songs].sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
function listBrowsableEntries(serverId: string): LocalPlaybackEntry[] {
|
||||
return Object.values(useLocalPlaybackStore.getState().entries).filter(
|
||||
e => (e.tier === 'library' || e.tier === 'favorite-auto')
|
||||
&& !!e.localPath
|
||||
&& entryBelongsToServer(e, serverId),
|
||||
);
|
||||
}
|
||||
|
||||
export function countLocalBrowsableTracks(serverId: string): number {
|
||||
return listBrowsableEntries(serverId).length;
|
||||
}
|
||||
|
||||
/** Local library index + at least one on-disk library/favorites track for this server. */
|
||||
export function offlineLocalBrowseEnabled(serverId: string | null | undefined): boolean {
|
||||
if (!serverId) return false;
|
||||
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
|
||||
return countLocalBrowsableTracks(serverId) > 0;
|
||||
}
|
||||
|
||||
/** Track DTOs for every library/favorite-auto entry with on-disk bytes for this server. */
|
||||
export async function fetchBrowsableLocalTrackDtos(serverId: string): Promise<LibraryTrackDto[]> {
|
||||
const entries = listBrowsableEntries(serverId);
|
||||
if (entries.length === 0) return [];
|
||||
const refs = entries.map(e => ({ serverId, trackId: e.trackId }));
|
||||
return libraryGetTracksBatchChunked(refs);
|
||||
}
|
||||
|
||||
export function buildAlbumFromTracks(
|
||||
albumId: string,
|
||||
tracks: LibraryTrackDto[],
|
||||
serverId: string,
|
||||
): SubsonicAlbum {
|
||||
const songs = tracks.map(trackToSong).map(s => ({ ...s, serverId }));
|
||||
const first = tracks[0];
|
||||
const starred = tracks.some(t => t.starredAt != null);
|
||||
return {
|
||||
id: albumId,
|
||||
name: first.album ?? albumId,
|
||||
artist: first.artist ?? first.albumArtist ?? '',
|
||||
artistId: first.artistId ?? '',
|
||||
coverArt: resolveTrackCoverArtId(first) ?? albumId,
|
||||
year: first.year ?? undefined,
|
||||
genre: first.genre ?? undefined,
|
||||
songCount: songs.length,
|
||||
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
|
||||
starred: starred ? new Date().toISOString() : undefined,
|
||||
serverId,
|
||||
};
|
||||
}
|
||||
|
||||
function aggregateAlbumsFromTracks(
|
||||
tracks: LibraryTrackDto[],
|
||||
serverId: string,
|
||||
): SubsonicAlbum[] {
|
||||
const byAlbum = new Map<string, LibraryTrackDto[]>();
|
||||
for (const track of tracks) {
|
||||
const albumId = track.albumId;
|
||||
if (!albumId) continue;
|
||||
const list = byAlbum.get(albumId) ?? [];
|
||||
list.push(track);
|
||||
byAlbum.set(albumId, list);
|
||||
}
|
||||
return [...byAlbum.entries()].map(([albumId, albumTracks]) =>
|
||||
buildAlbumFromTracks(albumId, albumTracks, serverId),
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateArtistsFromTracks(
|
||||
tracks: LibraryTrackDto[],
|
||||
serverId: string,
|
||||
): SubsonicArtist[] {
|
||||
const albumIdsByArtist = new Map<string, Set<string>>();
|
||||
const names = new Map<string, string>();
|
||||
for (const track of tracks) {
|
||||
const artistId = track.artistId;
|
||||
if (!artistId) continue;
|
||||
names.set(artistId, track.artist ?? track.albumArtist ?? artistId);
|
||||
const set = albumIdsByArtist.get(artistId) ?? new Set<string>();
|
||||
if (track.albumId) set.add(track.albumId);
|
||||
albumIdsByArtist.set(artistId, set);
|
||||
}
|
||||
return [...names.entries()]
|
||||
.map(([id, name]) => ({
|
||||
id,
|
||||
name,
|
||||
albumCount: albumIdsByArtist.get(id)?.size ?? 0,
|
||||
serverId,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function applyAlbumBrowseQuery(
|
||||
albums: SubsonicAlbum[],
|
||||
query: AlbumBrowseQuery,
|
||||
starredOverrides: Record<string, boolean>,
|
||||
): SubsonicAlbum[] {
|
||||
let out = albums;
|
||||
if (query.genres.length > 0) {
|
||||
out = filterAlbumsByGenres(out, query.genres);
|
||||
}
|
||||
if (query.year) {
|
||||
out = filterAlbumsByYearBounds(out, query.year);
|
||||
}
|
||||
if (query.starredOnly) {
|
||||
out = filterAlbumsByStarred(out, starredOverrides);
|
||||
}
|
||||
if (query.compFilter !== 'all') {
|
||||
out = filterAlbumsByCompilation(out, query.compFilter);
|
||||
}
|
||||
return sortSubsonicAlbums(out, query.sort);
|
||||
}
|
||||
|
||||
export async function fetchOfflineLocalBrowsableSongPage(
|
||||
serverId: string,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
): Promise<{ songs: SubsonicSong[]; hasMore: boolean } | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const tracks = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
const songs = sortBrowsableSongs(
|
||||
tracks.map(trackToSong).map(s => ({ ...s, serverId })),
|
||||
);
|
||||
const slice = songs.slice(offset, offset + chunkSize);
|
||||
return { songs: slice, hasMore: offset + chunkSize < songs.length };
|
||||
}
|
||||
|
||||
export async function searchOfflineLocalBrowsableSongs(
|
||||
serverId: string,
|
||||
query: string,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
): Promise<SubsonicSong[] | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
const tracks = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
const matched = tracks
|
||||
.filter(t =>
|
||||
(t.title?.toLowerCase().includes(q))
|
||||
|| (t.artist?.toLowerCase().includes(q))
|
||||
|| (t.album?.toLowerCase().includes(q)),
|
||||
)
|
||||
.map(trackToSong)
|
||||
.map(s => ({ ...s, serverId }));
|
||||
return sortBrowsableSongs(matched).slice(offset, offset + chunkSize);
|
||||
}
|
||||
|
||||
export async function fetchOfflineLocalStarredArtists(serverId: string): Promise<SubsonicArtist[] | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const tracks = (await fetchBrowsableLocalTrackDtos(serverId)).filter(t => t.starredAt != null);
|
||||
return aggregateArtistsFromTracks(tracks, serverId);
|
||||
}
|
||||
|
||||
export async function fetchOfflineLocalArtistCatalogChunk(
|
||||
serverId: string,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
): Promise<{ artists: SubsonicArtist[]; hasMore: boolean } | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const tracks = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
const artists = aggregateArtistsFromTracks(tracks, serverId);
|
||||
const slice = artists.slice(offset, offset + chunkSize);
|
||||
return {
|
||||
artists: slice,
|
||||
hasMore: offset + chunkSize < artists.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchOfflineLocalArtists(
|
||||
serverId: string,
|
||||
query: string,
|
||||
): Promise<SubsonicArtist[] | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const tracks = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
return aggregateArtistsFromTracks(tracks, serverId)
|
||||
.filter(a => a.name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
export async function fetchOfflineLocalAlbumCatalogChunk(
|
||||
serverId: string,
|
||||
query: AlbumBrowseQuery,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
starredOverrides: Record<string, boolean> = {},
|
||||
): Promise<{ albums: SubsonicAlbum[]; hasMore: boolean } | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
let tracks = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
if (query.losslessOnly) {
|
||||
tracks = tracks.filter(t => isLosslessSuffix(t.suffix ?? undefined));
|
||||
}
|
||||
let albums = aggregateAlbumsFromTracks(tracks, serverId);
|
||||
albums = applyAlbumBrowseQuery(albums, query, starredOverrides);
|
||||
const slice = albums.slice(offset, offset + chunkSize);
|
||||
return {
|
||||
albums: slice,
|
||||
hasMore: offset + chunkSize < albums.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchOfflineLocalAlbums(
|
||||
serverId: string,
|
||||
query: string,
|
||||
losslessOnly = false,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
let tracks = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
if (losslessOnly) {
|
||||
tracks = tracks.filter(t => isLosslessSuffix(t.suffix ?? undefined));
|
||||
}
|
||||
return aggregateAlbumsFromTracks(tracks, serverId)
|
||||
.filter(a => a.name.toLowerCase().includes(q) || a.artist.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
export async function loadAlbumFromLocalPlayback(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const localIds = new Set(listBrowsableEntries(serverId).map(e => e.trackId));
|
||||
const tracks = await libraryGetTracksByAlbum(serverId, albumId);
|
||||
const localTracks = tracks.filter(t => localIds.has(t.id));
|
||||
if (localTracks.length === 0) return null;
|
||||
|
||||
const songs = localTracks.map(trackToSong).map(s => ({ ...s, serverId }));
|
||||
const albumSearch = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album'],
|
||||
restrictAlbumIds: [albumId],
|
||||
limit: 1,
|
||||
}).catch(() => null);
|
||||
const albumDto = albumSearch?.albums[0];
|
||||
const album = albumDto
|
||||
? { ...albumToAlbum(albumDto), serverId, songCount: songs.length }
|
||||
: buildAlbumFromTracks(albumId, localTracks, serverId);
|
||||
|
||||
return {
|
||||
album: {
|
||||
...album,
|
||||
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
|
||||
},
|
||||
songs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadArtistFromLocalPlayback(
|
||||
serverId: string,
|
||||
artistId: string,
|
||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const localIds = new Set(listBrowsableEntries(serverId).map(e => e.trackId));
|
||||
const tracks = (await fetchBrowsableLocalTrackDtos(serverId)).filter(
|
||||
t => t.artistId === artistId && localIds.has(t.id),
|
||||
);
|
||||
if (tracks.length === 0) return null;
|
||||
|
||||
const albums = aggregateAlbumsFromTracks(tracks, serverId)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const artistDto = tracks[0];
|
||||
const artistSearch = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['artist'],
|
||||
limit: 10_000,
|
||||
}).catch(() => null);
|
||||
const match = artistSearch?.artists.find(a => a.id === artistId);
|
||||
|
||||
const artist = match
|
||||
? { ...artistToArtist(match), serverId, albumCount: albums.length }
|
||||
: {
|
||||
id: artistId,
|
||||
name: artistDto.artist ?? artistDto.albumArtist ?? artistId,
|
||||
albumCount: albums.length,
|
||||
serverId,
|
||||
};
|
||||
|
||||
return { artist, albums };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
resolveAlbum,
|
||||
resolveAlbumForActiveServer,
|
||||
resolveArtist,
|
||||
resolvePlaylist,
|
||||
} from './offlineMediaResolve';
|
||||
|
||||
const isOfflineBrowseActiveMock = vi.fn(() => false);
|
||||
const offlineLocalBrowseEnabledMock = vi.fn((_serverId: string) => false);
|
||||
const playlistsOfflineBrowseEnabledMock = vi.fn((_serverId: string) => false);
|
||||
const loadAlbumFromLocalPlaybackMock = vi.fn();
|
||||
const loadArtistFromLocalPlaybackMock = vi.fn();
|
||||
const loadAlbumFromLibraryIndexMock = vi.fn();
|
||||
const loadArtistFromLibraryIndexMock = vi.fn();
|
||||
const loadOfflineBrowsablePlaylistMock = vi.fn();
|
||||
const shouldAttemptSubsonicForServerMock = vi.fn((_serverId: string, _trackId?: string) => true);
|
||||
const getAlbumForServerMock = vi.fn((_serverId: string, _albumId: string) => ({}));
|
||||
const getArtistForServerMock = vi.fn((_serverId: string, _artistId: string) => ({}));
|
||||
const getPlaylistForServerMock = vi.fn((_serverId: string, _playlistId: string) => ({}));
|
||||
|
||||
vi.mock('./offlineBrowseMode', () => ({
|
||||
isOfflineBrowseActive: () => isOfflineBrowseActiveMock(),
|
||||
}));
|
||||
|
||||
vi.mock('./offlineLocalBrowse', () => ({
|
||||
offlineLocalBrowseEnabled: (id: string) => offlineLocalBrowseEnabledMock(id),
|
||||
loadAlbumFromLocalPlayback: (serverId: string, albumId: string) =>
|
||||
loadAlbumFromLocalPlaybackMock(serverId, albumId),
|
||||
loadArtistFromLocalPlayback: (serverId: string, artistId: string) =>
|
||||
loadArtistFromLocalPlaybackMock(serverId, artistId),
|
||||
}));
|
||||
|
||||
vi.mock('./offlineLibraryIndexLoad', () => ({
|
||||
loadAlbumFromLibraryIndex: (...args: unknown[]) => loadAlbumFromLibraryIndexMock(...args),
|
||||
loadArtistFromLibraryIndex: (...args: unknown[]) => loadArtistFromLibraryIndexMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./offlinePlaylistBrowse', () => ({
|
||||
playlistsOfflineBrowseEnabled: (id: string) => playlistsOfflineBrowseEnabledMock(id),
|
||||
loadOfflineBrowsablePlaylist: (playlistId: string, serverId: string) =>
|
||||
loadOfflineBrowsablePlaylistMock(playlistId, serverId),
|
||||
}));
|
||||
|
||||
vi.mock('../network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForServer: (serverId: string, trackId?: string) =>
|
||||
shouldAttemptSubsonicForServerMock(serverId, trackId),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/subsonicLibrary', () => ({
|
||||
getAlbumForServer: (serverId: string, albumId: string) => getAlbumForServerMock(serverId, albumId),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/subsonicArtists', () => ({
|
||||
getArtistForServer: (serverId: string, artistId: string) => getArtistForServerMock(serverId, artistId),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/subsonicPlaylists', () => ({
|
||||
getPlaylistForServer: (serverId: string, playlistId: string) =>
|
||||
getPlaylistForServerMock(serverId, playlistId),
|
||||
}));
|
||||
|
||||
describe('offlineMediaResolve', () => {
|
||||
beforeEach(() => {
|
||||
isOfflineBrowseActiveMock.mockReturnValue(false);
|
||||
offlineLocalBrowseEnabledMock.mockReturnValue(false);
|
||||
playlistsOfflineBrowseEnabledMock.mockReturnValue(false);
|
||||
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
|
||||
loadAlbumFromLocalPlaybackMock.mockReset();
|
||||
loadArtistFromLocalPlaybackMock.mockReset();
|
||||
loadAlbumFromLibraryIndexMock.mockReset();
|
||||
loadArtistFromLibraryIndexMock.mockReset();
|
||||
loadOfflineBrowsablePlaylistMock.mockReset();
|
||||
getAlbumForServerMock.mockReset();
|
||||
getArtistForServerMock.mockReset();
|
||||
getPlaylistForServerMock.mockReset();
|
||||
useAuthStore.setState({ favoritesOfflineEnabled: true, activeServerId: 'srv-1' } as Partial<
|
||||
ReturnType<typeof useAuthStore.getState>
|
||||
>);
|
||||
});
|
||||
|
||||
it('resolveAlbum prefers local bytes when offline browse and local library enabled', async () => {
|
||||
isOfflineBrowseActiveMock.mockReturnValue(true);
|
||||
offlineLocalBrowseEnabledMock.mockReturnValue(true);
|
||||
loadAlbumFromLocalPlaybackMock.mockResolvedValue({
|
||||
album: { id: 'alb-1', name: 'Local' },
|
||||
songs: [{ id: 't1', title: 'One' }],
|
||||
});
|
||||
const result = await resolveAlbum('srv-1', 'alb-1');
|
||||
expect(loadAlbumFromLocalPlaybackMock).toHaveBeenCalledWith('srv-1', 'alb-1');
|
||||
expect(result?.songs).toHaveLength(1);
|
||||
expect(getAlbumForServerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolveAlbum uses network when allowed', async () => {
|
||||
getAlbumForServerMock.mockResolvedValue({
|
||||
album: { id: 'alb-1', name: 'Net' },
|
||||
songs: [{ id: 't1' }, { id: 't2' }],
|
||||
});
|
||||
const result = await resolveAlbum('srv-1', 'alb-1');
|
||||
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-1');
|
||||
expect(result?.songs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('resolveAlbum falls back to library index when network blocked', async () => {
|
||||
shouldAttemptSubsonicForServerMock.mockReturnValue(false);
|
||||
loadAlbumFromLibraryIndexMock.mockResolvedValue({
|
||||
album: { id: 'alb-1', name: 'Idx' },
|
||||
songs: [{ id: 't1' }],
|
||||
});
|
||||
const result = await resolveAlbum('srv-1', 'alb-1');
|
||||
expect(loadAlbumFromLibraryIndexMock).toHaveBeenCalledWith('srv-1', 'alb-1');
|
||||
expect(result?.album.name).toBe('Idx');
|
||||
});
|
||||
|
||||
it('resolveAlbumForActiveServer uses active server id', async () => {
|
||||
getAlbumForServerMock.mockResolvedValue({
|
||||
album: { id: 'alb-2' },
|
||||
songs: [],
|
||||
});
|
||||
await resolveAlbumForActiveServer('alb-2');
|
||||
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-2');
|
||||
});
|
||||
|
||||
it('resolveArtist prefers local bytes when offline browse and local library enabled', async () => {
|
||||
isOfflineBrowseActiveMock.mockReturnValue(true);
|
||||
offlineLocalBrowseEnabledMock.mockReturnValue(true);
|
||||
loadArtistFromLocalPlaybackMock.mockResolvedValue({
|
||||
artist: { id: 'art-1', name: 'Local Artist' },
|
||||
albums: [{ id: 'alb-1' }],
|
||||
});
|
||||
const result = await resolveArtist('srv-1', 'art-1');
|
||||
expect(loadArtistFromLocalPlaybackMock).toHaveBeenCalledWith('srv-1', 'art-1');
|
||||
expect(result?.albums).toHaveLength(1);
|
||||
expect(getArtistForServerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolvePlaylist uses offline browse cache when enabled', async () => {
|
||||
isOfflineBrowseActiveMock.mockReturnValue(true);
|
||||
playlistsOfflineBrowseEnabledMock.mockReturnValue(true);
|
||||
loadOfflineBrowsablePlaylistMock.mockResolvedValue({
|
||||
playlist: { id: 'pl-1', name: 'Offline' },
|
||||
songs: [{ id: 't1' }],
|
||||
});
|
||||
const result = await resolvePlaylist('srv-1', 'pl-1');
|
||||
expect(loadOfflineBrowsablePlaylistMock).toHaveBeenCalledWith('pl-1', 'srv-1');
|
||||
expect(result?.songs).toHaveLength(1);
|
||||
expect(getPlaylistForServerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolvePlaylist uses network when allowed', async () => {
|
||||
getPlaylistForServerMock.mockResolvedValue({
|
||||
playlist: { id: 'pl-2', name: 'Net' },
|
||||
songs: [{ id: 't1' }, { id: 't2' }],
|
||||
});
|
||||
const result = await resolvePlaylist('srv-1', 'pl-2');
|
||||
expect(getPlaylistForServerMock).toHaveBeenCalledWith('srv-1', 'pl-2');
|
||||
expect(result?.songs).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { getAlbumForServer } from '../../api/subsonicLibrary';
|
||||
import { getArtistForServer } from '../../api/subsonicArtists';
|
||||
import { getPlaylistForServer } from '../../api/subsonicPlaylists';
|
||||
import type {
|
||||
SubsonicAlbum,
|
||||
SubsonicArtist,
|
||||
SubsonicPlaylist,
|
||||
SubsonicSong,
|
||||
} from '../../api/subsonicTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard';
|
||||
import { isOfflineBrowseActive } from './offlineBrowseMode';
|
||||
import {
|
||||
loadAlbumFromLibraryIndex,
|
||||
loadArtistFromLibraryIndex,
|
||||
} from './offlineLibraryIndexLoad';
|
||||
import {
|
||||
loadAlbumFromLocalPlayback,
|
||||
loadArtistFromLocalPlayback,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from './offlineLocalBrowse';
|
||||
import {
|
||||
loadOfflineBrowsablePlaylist,
|
||||
playlistsOfflineBrowseEnabled,
|
||||
} from './offlinePlaylistBrowse';
|
||||
|
||||
export type ResolvedAlbum = { album: SubsonicAlbum; songs: SubsonicSong[] };
|
||||
|
||||
/**
|
||||
* Album detail / play / enqueue: network album when reachable (complete track list);
|
||||
* local bytes or library index when offline browse is active.
|
||||
*/
|
||||
export async function resolveAlbum(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
): Promise<ResolvedAlbum | null> {
|
||||
if (isOfflineBrowseActive() && offlineLocalBrowseEnabled(serverId)) {
|
||||
return loadAlbumFromLocalPlayback(serverId, albumId);
|
||||
}
|
||||
const favoritesOffline = useAuthStore.getState().favoritesOfflineEnabled;
|
||||
const networkAllowed = shouldAttemptSubsonicForServer(serverId);
|
||||
|
||||
if (networkAllowed) {
|
||||
try {
|
||||
const data = await getAlbumForServer(serverId, albumId);
|
||||
return { album: data.album, songs: data.songs };
|
||||
} catch {
|
||||
/* fall through to library index */
|
||||
}
|
||||
} else if (!favoritesOffline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await loadAlbumFromLibraryIndex(serverId, albumId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link resolveAlbum}. */
|
||||
export const resolveAlbumForServer = resolveAlbum;
|
||||
|
||||
export async function resolveArtist(
|
||||
serverId: string,
|
||||
artistId: string,
|
||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> {
|
||||
if (isOfflineBrowseActive() && offlineLocalBrowseEnabled(serverId)) {
|
||||
return loadArtistFromLocalPlayback(serverId, artistId);
|
||||
}
|
||||
const favoritesOffline = useAuthStore.getState().favoritesOfflineEnabled;
|
||||
const networkAllowed = shouldAttemptSubsonicForServer(serverId);
|
||||
|
||||
if (networkAllowed) {
|
||||
try {
|
||||
return await getArtistForServer(serverId, artistId);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
} else if (!favoritesOffline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await loadArtistFromLibraryIndex(serverId, artistId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolvePlaylist(
|
||||
serverId: string,
|
||||
playlistId: string,
|
||||
): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] } | null> {
|
||||
if (isOfflineBrowseActive() && playlistsOfflineBrowseEnabled(serverId)) {
|
||||
const offline = await loadOfflineBrowsablePlaylist(playlistId, serverId);
|
||||
if (offline) return offline;
|
||||
}
|
||||
|
||||
if (!shouldAttemptSubsonicForServer(serverId)) return null;
|
||||
|
||||
try {
|
||||
return await getPlaylistForServer(serverId, playlistId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMediaServerId(explicit?: string | null): string | null {
|
||||
return explicit ?? useAuthStore.getState().activeServerId;
|
||||
}
|
||||
|
||||
/** Resolve album for active server when `serverId` omitted. */
|
||||
export async function resolveAlbumForActiveServer(
|
||||
albumId: string,
|
||||
serverId?: string,
|
||||
): Promise<ResolvedAlbum | null> {
|
||||
const sid = serverId ?? useAuthStore.getState().activeServerId;
|
||||
if (!sid) return null;
|
||||
return resolveAlbum(sid, albumId);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Sidebar / mobile-more navigation gates while offline browse is active. */
|
||||
|
||||
export function isOfflineSidebarLibraryNavAllowed(
|
||||
navId: string,
|
||||
favoritesOfflineBrowse: boolean,
|
||||
localLibraryBrowse = false,
|
||||
playlistsOfflineBrowse = false,
|
||||
): boolean {
|
||||
if (navId === 'favorites') return favoritesOfflineBrowse;
|
||||
if (navId === 'artists' || navId === 'allAlbums' || navId === 'tracks') return localLibraryBrowse;
|
||||
if (navId === 'playlists') return playlistsOfflineBrowse;
|
||||
if (navId === 'offline') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** System nav entries that stay available without a Subsonic connection. */
|
||||
export function isOfflineSidebarSystemNavAllowed(
|
||||
navId: string,
|
||||
playerStatsBrowse: boolean,
|
||||
): boolean {
|
||||
if (navId === 'help') return true;
|
||||
if (navId === 'statistics') return playerStatsBrowse;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Sidebar / mobile-more gate while offline browse is active. */
|
||||
export function isOfflineSidebarNavAllowed(
|
||||
navId: string,
|
||||
favoritesOfflineBrowse: boolean,
|
||||
localLibraryBrowse: boolean,
|
||||
playerStatsBrowse: boolean,
|
||||
playlistsOfflineBrowse = false,
|
||||
): boolean {
|
||||
if (isOfflineSidebarSystemNavAllowed(navId, playerStatsBrowse)) return true;
|
||||
return isOfflineSidebarLibraryNavAllowed(
|
||||
navId,
|
||||
favoritesOfflineBrowse,
|
||||
localLibraryBrowse,
|
||||
playlistsOfflineBrowse,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as libraryApi from '../../api/library';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import { useOfflineStore } from '../../store/offlineStore';
|
||||
import {
|
||||
fetchOfflineBrowsablePlaylists,
|
||||
loadOfflineBrowsablePlaylist,
|
||||
playlistsOfflineBrowseEnabled,
|
||||
} from './offlinePlaylistBrowse';
|
||||
|
||||
vi.mock('../../api/library', () => ({
|
||||
libraryGetTracksBatchChunked: vi.fn(),
|
||||
}));
|
||||
|
||||
const libraryGetTracksBatchChunkedMock = vi.mocked(libraryApi.libraryGetTracksBatchChunked);
|
||||
|
||||
function seedPlaylistPin(serverId = 'srv-1', indexKey = 'srv-1') {
|
||||
useAuthStore.setState({
|
||||
activeServerId: serverId,
|
||||
servers: [{
|
||||
id: serverId,
|
||||
name: 'Test',
|
||||
url: 'https://music.test',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
}],
|
||||
});
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
useOfflineStore.setState({
|
||||
albums: {
|
||||
[`${indexKey}:pl-1`]: {
|
||||
id: 'pl-1',
|
||||
serverId: indexKey,
|
||||
name: 'Road mix',
|
||||
artist: '',
|
||||
trackIds: ['t1', 't2'],
|
||||
type: 'playlist',
|
||||
},
|
||||
},
|
||||
});
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
[`${indexKey}:t1`]: {
|
||||
serverIndexKey: indexKey,
|
||||
trackId: 't1',
|
||||
localPath: '/media/library/t1.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1000,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'playlist', sourceId: 'pl-1', displayName: 'Road mix' },
|
||||
},
|
||||
[`${indexKey}:t2`]: {
|
||||
serverIndexKey: indexKey,
|
||||
trackId: 't2',
|
||||
localPath: '/media/library/t2.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1000,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'playlist', sourceId: 'pl-1', displayName: 'Road mix' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('offlinePlaylistBrowse', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAuthStore.setState({ activeServerId: null, servers: [] });
|
||||
useLibraryIndexStore.setState({ masterEnabled: false });
|
||||
useOfflineStore.setState({ albums: {} });
|
||||
useLocalPlaybackStore.setState({ entries: {} });
|
||||
});
|
||||
|
||||
it('playlistsOfflineBrowseEnabled is true when cached playlist bytes exist', () => {
|
||||
seedPlaylistPin();
|
||||
expect(playlistsOfflineBrowseEnabled('srv-1')).toBe(true);
|
||||
expect(playlistsOfflineBrowseEnabled(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('fetchOfflineBrowsablePlaylists returns cached regular playlists only', async () => {
|
||||
seedPlaylistPin();
|
||||
libraryGetTracksBatchChunkedMock.mockResolvedValueOnce([
|
||||
{
|
||||
serverId: 'srv-1',
|
||||
id: 't1',
|
||||
title: 'A',
|
||||
artist: 'Ar',
|
||||
album: 'Al',
|
||||
albumId: 'al-1',
|
||||
durationSec: 100,
|
||||
syncedAt: 1,
|
||||
rawJson: {},
|
||||
},
|
||||
{
|
||||
serverId: 'srv-1',
|
||||
id: 't2',
|
||||
title: 'B',
|
||||
artist: 'Br',
|
||||
album: 'Bl',
|
||||
albumId: 'al-2',
|
||||
durationSec: 200,
|
||||
syncedAt: 1,
|
||||
rawJson: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const playlists = await fetchOfflineBrowsablePlaylists('srv-1');
|
||||
expect(playlists).toHaveLength(1);
|
||||
expect(playlists[0]?.id).toBe('pl-1');
|
||||
expect(playlists[0]?.name).toBe('Road mix');
|
||||
expect(playlists[0]?.songCount).toBe(2);
|
||||
expect(playlists[0]?.duration).toBe(300);
|
||||
});
|
||||
|
||||
it('loadOfflineBrowsablePlaylist preserves playlist track order', async () => {
|
||||
seedPlaylistPin();
|
||||
libraryGetTracksBatchChunkedMock.mockResolvedValueOnce([
|
||||
{
|
||||
serverId: 'srv-1',
|
||||
id: 't1',
|
||||
title: 'A',
|
||||
artist: 'Ar',
|
||||
album: 'Al',
|
||||
albumId: 'al-1',
|
||||
durationSec: 100,
|
||||
syncedAt: 1,
|
||||
rawJson: {},
|
||||
},
|
||||
{
|
||||
serverId: 'srv-1',
|
||||
id: 't2',
|
||||
title: 'B',
|
||||
artist: 'Br',
|
||||
album: 'Bl',
|
||||
albumId: 'al-2',
|
||||
durationSec: 200,
|
||||
syncedAt: 1,
|
||||
rawJson: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const loaded = await loadOfflineBrowsablePlaylist('pl-1', 'srv-1');
|
||||
expect(loaded?.playlist.name).toBe('Road mix');
|
||||
expect(loaded?.songs.map(s => s.id)).toEqual(['t1', 't2']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { libraryGetTracksBatchChunked } from '../../api/library';
|
||||
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import type { PinnedGroup } from '../../store/localPlaybackStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import { trackToSong } from '../library/advancedSearchLocal';
|
||||
import { isManualOfflinePlaylist } from './pinnedOfflineSync';
|
||||
import {
|
||||
hasLocalLibraryBytes,
|
||||
indexKeyBelongsToServer,
|
||||
resolveOfflineAlbumMeta,
|
||||
} from './offlineLibraryHelpers';
|
||||
|
||||
function listPlaylistPinnedGroupsForServer(serverId: string): PinnedGroup[] {
|
||||
return useLocalPlaybackStore.getState()
|
||||
.listPinnedGroups()
|
||||
.filter(g => g.pinSource.kind === 'playlist' && indexKeyBelongsToServer(g.serverIndexKey, serverId));
|
||||
}
|
||||
|
||||
function orderedPlayableTrackIds(
|
||||
playlistId: string,
|
||||
serverId: string,
|
||||
group: PinnedGroup,
|
||||
): string[] {
|
||||
const meta = resolveOfflineAlbumMeta(playlistId, serverId);
|
||||
const ordered = meta?.trackIds?.length ? meta.trackIds : group.trackIds;
|
||||
return ordered.filter(tid => hasLocalLibraryBytes(tid, serverId));
|
||||
}
|
||||
|
||||
/** Cached regular playlists with on-disk bytes for the active server. */
|
||||
export function playlistsOfflineBrowseEnabled(serverId: string | null | undefined): boolean {
|
||||
if (!serverId) return false;
|
||||
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
|
||||
return listPlaylistPinnedGroupsForServer(serverId).some(g => {
|
||||
if (!isManualOfflinePlaylist(g.pinSource.sourceId, serverId, g.pinSource.displayName)) {
|
||||
return false;
|
||||
}
|
||||
return orderedPlayableTrackIds(g.pinSource.sourceId, serverId, g).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchOfflineBrowsablePlaylists(serverId: string): Promise<SubsonicPlaylist[]> {
|
||||
const groups = listPlaylistPinnedGroupsForServer(serverId)
|
||||
.filter(g => isManualOfflinePlaylist(g.pinSource.sourceId, serverId, g.pinSource.displayName));
|
||||
|
||||
const playlists: SubsonicPlaylist[] = [];
|
||||
for (const group of groups) {
|
||||
const playlistId = group.pinSource.sourceId;
|
||||
const trackIds = orderedPlayableTrackIds(playlistId, serverId, group);
|
||||
if (trackIds.length === 0) continue;
|
||||
|
||||
const meta = resolveOfflineAlbumMeta(playlistId, serverId);
|
||||
const refs = trackIds.map(trackId => ({ serverId, trackId }));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs);
|
||||
const byId = new Map(dtos.map(d => [d.id, d]));
|
||||
let duration = 0;
|
||||
for (const trackId of trackIds) {
|
||||
duration += byId.get(trackId)?.durationSec ?? 0;
|
||||
}
|
||||
|
||||
playlists.push({
|
||||
id: playlistId,
|
||||
name: group.pinSource.displayName ?? meta?.name ?? playlistId,
|
||||
songCount: trackIds.length,
|
||||
duration,
|
||||
created: '',
|
||||
changed: '',
|
||||
coverArt: meta?.coverArt,
|
||||
});
|
||||
}
|
||||
|
||||
return playlists.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export async function loadOfflineBrowsablePlaylist(
|
||||
playlistId: string,
|
||||
serverId: string,
|
||||
): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] } | null> {
|
||||
const group = listPlaylistPinnedGroupsForServer(serverId)
|
||||
.find(g => g.pinSource.sourceId === playlistId);
|
||||
if (!group) return null;
|
||||
if (!isManualOfflinePlaylist(playlistId, serverId, group.pinSource.displayName)) return null;
|
||||
|
||||
const trackIds = orderedPlayableTrackIds(playlistId, serverId, group);
|
||||
if (trackIds.length === 0) return null;
|
||||
|
||||
const meta = resolveOfflineAlbumMeta(playlistId, serverId);
|
||||
const refs = trackIds.map(trackId => ({ serverId, trackId }));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs);
|
||||
const byId = new Map(dtos.map(d => [d.id, d]));
|
||||
const songs = trackIds
|
||||
.map(id => byId.get(id))
|
||||
.filter((dto): dto is NonNullable<typeof dto> => !!dto)
|
||||
.map(dto => ({ ...trackToSong(dto), serverId }));
|
||||
|
||||
const duration = songs.reduce((sum, song) => sum + (song.duration ?? 0), 0);
|
||||
return {
|
||||
playlist: {
|
||||
id: playlistId,
|
||||
name: group.pinSource.displayName ?? meta?.name ?? playlistId,
|
||||
songCount: songs.length,
|
||||
duration,
|
||||
created: '',
|
||||
changed: '',
|
||||
coverArt: meta?.coverArt,
|
||||
},
|
||||
songs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { getStarredForServer } from '../../api/subsonicStarRating';
|
||||
import { libraryAdvancedSearch } from '../../api/library';
|
||||
import type {
|
||||
StarredResults,
|
||||
SubsonicAlbum,
|
||||
SubsonicArtist,
|
||||
SubsonicSong,
|
||||
} from '../../api/subsonicTypes';
|
||||
import { isActiveServerReachable } from '../network/activeServerReachability';
|
||||
import {
|
||||
albumToAlbum,
|
||||
trackToSong,
|
||||
} from '../library/advancedSearchLocal';
|
||||
import { dedupeById } from '../dedupeById';
|
||||
import { isOfflineBrowseActive } from './offlineBrowseMode';
|
||||
import { favoritesServerIds } from './favoritesOfflineBrowse';
|
||||
import {
|
||||
buildAlbumFromTracks,
|
||||
fetchBrowsableLocalTrackDtos,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from './offlineLocalBrowse';
|
||||
|
||||
function tagStarredWithServer(starred: StarredResults, serverId: string): StarredResults {
|
||||
const withServer = <T extends { id: string }>(items: T[]): (T & { serverId: string })[] =>
|
||||
items.map(item => ({ ...item, serverId }));
|
||||
|
||||
return {
|
||||
artists: withServer(starred.artists),
|
||||
albums: withServer(starred.albums),
|
||||
songs: withServer(starred.songs),
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge starred lists from multiple servers; dedupe by `serverId:id`. */
|
||||
export function mergeStarredFromServers(
|
||||
entries: { serverId: string; starred: StarredResults }[],
|
||||
): StarredResults {
|
||||
const artists: SubsonicArtist[] = [];
|
||||
const albums: SubsonicAlbum[] = [];
|
||||
const songs: SubsonicSong[] = [];
|
||||
for (const { serverId, starred } of entries) {
|
||||
const tagged = tagStarredWithServer(starred, serverId);
|
||||
artists.push(...tagged.artists);
|
||||
albums.push(...tagged.albums);
|
||||
songs.push(...tagged.songs);
|
||||
}
|
||||
return {
|
||||
artists: dedupeById(artists),
|
||||
albums: dedupeById(albums),
|
||||
songs: dedupeById(songs),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline favorites: start from on-disk bytes, then keep starred tracks/albums only.
|
||||
* Avoids scanning the full starred catalog in SQL when only a local subset is playable.
|
||||
*/
|
||||
async function loadStarredFromBrowsableLocalBytes(serverId: string): Promise<StarredResults> {
|
||||
const allLocal = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
if (allLocal.length === 0) {
|
||||
return { artists: [], albums: [], songs: [] };
|
||||
}
|
||||
|
||||
const starredTracks = allLocal.filter(t => t.starredAt != null);
|
||||
const songs = starredTracks
|
||||
.map(trackToSong)
|
||||
.map(s => ({ ...s, serverId }));
|
||||
|
||||
const albumsById = new Map<string, SubsonicAlbum>();
|
||||
const byStarredAlbum = new Map<string, typeof allLocal>();
|
||||
for (const track of starredTracks) {
|
||||
if (!track.albumId) continue;
|
||||
const list = byStarredAlbum.get(track.albumId) ?? [];
|
||||
list.push(track);
|
||||
byStarredAlbum.set(track.albumId, list);
|
||||
}
|
||||
for (const [albumId, albumTracks] of byStarredAlbum) {
|
||||
albumsById.set(albumId, buildAlbumFromTracks(albumId, albumTracks, serverId));
|
||||
}
|
||||
|
||||
const localAlbumIds = [...new Set(
|
||||
allLocal.map(t => t.albumId).filter((id): id is string => !!id),
|
||||
)];
|
||||
if (localAlbumIds.length > 0) {
|
||||
const albumSearch = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album'],
|
||||
starredOnly: true,
|
||||
restrictAlbumIds: localAlbumIds,
|
||||
limit: localAlbumIds.length,
|
||||
skipTotals: true,
|
||||
});
|
||||
for (const dto of albumSearch.albums) {
|
||||
albumsById.set(dto.id, { ...albumToAlbum(dto), serverId });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
artists: [],
|
||||
albums: [...albumsById.values()],
|
||||
songs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadStarredFromLibraryIndex(
|
||||
serverId: string,
|
||||
preferLocalBytes = false,
|
||||
): Promise<StarredResults> {
|
||||
if (preferLocalBytes && offlineLocalBrowseEnabled(serverId)) {
|
||||
return loadStarredFromBrowsableLocalBytes(serverId);
|
||||
}
|
||||
|
||||
// Artist-level favorites are network-only today (`artist` has no `starred_at`;
|
||||
// `starredOnly` on artists would return the whole artist table). Songs/albums
|
||||
// use track/album stars in the index.
|
||||
const response = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album', 'track'],
|
||||
starredOnly: true,
|
||||
limit: 10_000,
|
||||
});
|
||||
return {
|
||||
artists: [],
|
||||
albums: response.albums.map(albumToAlbum),
|
||||
songs: response.tracks.map(trackToSong),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadStarredFromAllLibraryIndexes(
|
||||
preferLocalBytes = isOfflineBrowseActive(),
|
||||
): Promise<StarredResults> {
|
||||
const serverIds = favoritesServerIds();
|
||||
const entries = await Promise.all(
|
||||
serverIds.map(async serverId => {
|
||||
try {
|
||||
const starred = await loadStarredFromLibraryIndex(serverId, preferLocalBytes);
|
||||
return { serverId, starred };
|
||||
} catch {
|
||||
return { serverId, starred: { artists: [], albums: [], songs: [] } satisfies StarredResults };
|
||||
}
|
||||
}),
|
||||
);
|
||||
return mergeStarredFromServers(entries);
|
||||
}
|
||||
|
||||
/** Online starred merge with per-server local index fallback. */
|
||||
export async function loadStarredFromAllServersOnline(): Promise<StarredResults> {
|
||||
if (!isActiveServerReachable()) {
|
||||
return loadStarredFromAllLibraryIndexes();
|
||||
}
|
||||
const serverIds = favoritesServerIds();
|
||||
const entries = await Promise.all(
|
||||
serverIds.map(async serverId => {
|
||||
try {
|
||||
const starred = await getStarredForServer(serverId);
|
||||
return { serverId, starred };
|
||||
} catch {
|
||||
try {
|
||||
const starred = await loadStarredFromLibraryIndex(serverId);
|
||||
return { serverId, starred };
|
||||
} catch {
|
||||
return { serverId, starred: { artists: [], albums: [], songs: [] } satisfies StarredResults };
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
return mergeStarredFromServers(entries);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ vi.mock('./offlineLibraryHelpers', () => ({
|
||||
isOfflinePinCompleteMock(albumId, serverId),
|
||||
}));
|
||||
|
||||
vi.mock('./favoritesOfflineBrowse', () => ({
|
||||
vi.mock('./offlineMediaResolve', () => ({
|
||||
resolveAlbumForServer: (serverId: string, albumId: string) =>
|
||||
resolveAlbumForServerMock(serverId, albumId),
|
||||
}));
|
||||
|
||||
@@ -9,7 +9,7 @@ import { isActiveServerReachable, onActiveServerBecameReachable } from '../netwo
|
||||
import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard';
|
||||
import { resolveServerIdForIndexKey } from '../server/serverLookup';
|
||||
import { isOfflinePinComplete } from './offlineLibraryHelpers';
|
||||
import { resolveAlbumForServer } from './favoritesOfflineBrowse';
|
||||
import { resolveAlbumForServer } from './offlineMediaResolve';
|
||||
|
||||
const DEBOUNCE_MS = 800;
|
||||
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { getArtist } from '../../api/subsonicArtists';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { getPlaylist } from '../../api/subsonicPlaylists';
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import type { DeviceSyncSource } from '../../store/deviceSyncStore';
|
||||
import {
|
||||
resolveAlbum,
|
||||
resolveArtist,
|
||||
resolveMediaServerId,
|
||||
resolvePlaylist,
|
||||
} from '../offline/offlineMediaResolve';
|
||||
|
||||
export async function fetchTracksForSource(source: DeviceSyncSource): Promise<SubsonicSong[]> {
|
||||
if (source.type === 'playlist') { const { songs } = await getPlaylist(source.id); return songs; }
|
||||
if (source.type === 'album') { const { songs } = await getAlbum(source.id); return songs; }
|
||||
const { albums } = await getArtist(source.id);
|
||||
// Parallel album fetches — Navidrome handles getAlbum requests in flight
|
||||
// without serialising. Sequential awaits here multiplied a 50-album artist
|
||||
// sync into 50 round-trips (~7 s blocking) before any device write started.
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return [];
|
||||
|
||||
if (source.type === 'playlist') {
|
||||
const result = await resolvePlaylist(serverId, source.id);
|
||||
return result?.songs ?? [];
|
||||
}
|
||||
if (source.type === 'album') {
|
||||
const result = await resolveAlbum(serverId, source.id);
|
||||
return result?.songs ?? [];
|
||||
}
|
||||
|
||||
const artistData = await resolveArtist(serverId, source.id);
|
||||
if (!artistData) return [];
|
||||
|
||||
const results = await Promise.all(
|
||||
albums.map(a => getAlbum(a.id).then(r => r.songs).catch(() => [] as SubsonicSong[])),
|
||||
artistData.albums.map(a =>
|
||||
resolveAlbum(serverId, a.id).then(r => r?.songs ?? []).catch(() => [] as SubsonicSong[]),
|
||||
),
|
||||
);
|
||||
return results.flat();
|
||||
}
|
||||
|
||||
@@ -4,20 +4,37 @@ import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { resetOrbitStore, resetPlayerStore } from '@/test/helpers/storeReset';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
|
||||
vi.mock('../../api/subsonicLibrary', () => ({
|
||||
getAlbum: vi.fn(),
|
||||
vi.mock('../offline/offlineMediaResolve', () => ({
|
||||
resolveAlbumForActiveServer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./fadeOut', () => ({
|
||||
fadeOut: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbumForActiveServer } from '../offline/offlineMediaResolve';
|
||||
import { useOrbitStore } from '../../store/orbitStore';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { playAlbum, playAlbumShuffled } from './playAlbum';
|
||||
import * as shuffleModule from './shuffleArray';
|
||||
|
||||
const albumPayload = {
|
||||
album: {
|
||||
id: 'al-1',
|
||||
name: 'Test Album',
|
||||
artist: 'Test Artist',
|
||||
artistId: 'artist-1',
|
||||
songCount: 3,
|
||||
duration: 540,
|
||||
genre: 'Rock',
|
||||
},
|
||||
songs: [
|
||||
makeSubsonicSong({ id: 't1', title: 'One' }),
|
||||
makeSubsonicSong({ id: 't2', title: 'Two' }),
|
||||
makeSubsonicSong({ id: 't3', title: 'Three' }),
|
||||
],
|
||||
};
|
||||
|
||||
function stubPlaybackActions() {
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
@@ -40,22 +57,7 @@ describe('playAlbum', () => {
|
||||
resetPlayerStore();
|
||||
resetOrbitStore();
|
||||
stubPlaybackActions();
|
||||
vi.mocked(getAlbum).mockResolvedValue({
|
||||
album: {
|
||||
id: 'al-1',
|
||||
name: 'Test Album',
|
||||
artist: 'Test Artist',
|
||||
artistId: 'artist-1',
|
||||
songCount: 3,
|
||||
duration: 540,
|
||||
genre: 'Rock',
|
||||
},
|
||||
songs: [
|
||||
makeSubsonicSong({ id: 't1', title: 'One' }),
|
||||
makeSubsonicSong({ id: 't2', title: 'Two' }),
|
||||
makeSubsonicSong({ id: 't3', title: 'Three' }),
|
||||
],
|
||||
});
|
||||
vi.mocked(resolveAlbumForActiveServer).mockResolvedValue(albumPayload);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -90,22 +92,7 @@ describe('playAlbumShuffled', () => {
|
||||
resetPlayerStore();
|
||||
resetOrbitStore();
|
||||
stubPlaybackActions();
|
||||
vi.mocked(getAlbum).mockResolvedValue({
|
||||
album: {
|
||||
id: 'al-1',
|
||||
name: 'Test Album',
|
||||
artist: 'Test Artist',
|
||||
artistId: 'artist-1',
|
||||
songCount: 3,
|
||||
duration: 540,
|
||||
genre: 'Rock',
|
||||
},
|
||||
songs: [
|
||||
makeSubsonicSong({ id: 't1', title: 'One' }),
|
||||
makeSubsonicSong({ id: 't2', title: 'Two' }),
|
||||
makeSubsonicSong({ id: 't3', title: 'Three' }),
|
||||
],
|
||||
});
|
||||
vi.mocked(resolveAlbumForActiveServer).mockResolvedValue(albumPayload);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -143,7 +130,7 @@ describe('playAlbumShuffled', () => {
|
||||
});
|
||||
|
||||
it('does not start playback for an empty album', async () => {
|
||||
vi.mocked(getAlbum).mockResolvedValue({
|
||||
vi.mocked(resolveAlbumForActiveServer).mockResolvedValue({
|
||||
album: {
|
||||
id: 'al-empty',
|
||||
name: 'Empty',
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { resolveAlbumForServer } from '../offline/favoritesOfflineBrowse';
|
||||
import { resolveAlbumForActiveServer } from '../offline/offlineMediaResolve';
|
||||
import { songToTrack } from './songToTrack';
|
||||
import { useOrbitStore } from '../../store/orbitStore';
|
||||
import { fadeOut } from './fadeOut';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { shuffleArray } from './shuffleArray';
|
||||
|
||||
async function fetchAlbumTracks(albumId: string, serverId?: string): Promise<Track[]> {
|
||||
const albumData = serverId
|
||||
? await resolveAlbumForServer(serverId, albumId)
|
||||
: await getAlbum(albumId).then(d => ({ album: d.album, songs: d.songs }));
|
||||
export async function fetchAlbumTracks(albumId: string, serverId?: string): Promise<Track[]> {
|
||||
const albumData = await resolveAlbumForActiveServer(albumId, serverId);
|
||||
if (!albumData) throw new Error(`Album ${albumId} not available`);
|
||||
const albumGenre = albumData.album.genre;
|
||||
const ownerServerId = serverId ?? albumData.album.serverId;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user