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
|
## Changed
|
||||||
|
|
||||||
### Dependencies — npm and Rust refresh
|
### 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('./subsonicArtists', () => ({ getArtist: vi.fn() }));
|
||||||
vi.mock('./subsonicLibrary', () => ({ getAlbum: vi.fn() }));
|
vi.mock('./subsonicLibrary', () => ({ getAlbum: vi.fn() }));
|
||||||
|
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||||
|
shouldAttemptSubsonicForActiveServer: vi.fn(() => true),
|
||||||
|
}));
|
||||||
|
|
||||||
import { getArtist } from './subsonicArtists';
|
import { getArtist } from './subsonicArtists';
|
||||||
import { invalidateEntityUserRatingCaches, prefetchArtistUserRatings } from './subsonicRatings';
|
import { invalidateEntityUserRatingCaches, prefetchArtistUserRatings } from './subsonicRatings';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getArtist } from './subsonicArtists';
|
import { getArtist } from './subsonicArtists';
|
||||||
import { getAlbum } from './subsonicLibrary';
|
import { getAlbum } from './subsonicLibrary';
|
||||||
|
import { shouldAttemptSubsonicForActiveServer } from '../utils/network/subsonicNetworkGuard';
|
||||||
|
|
||||||
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
||||||
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
|
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
|
||||||
@@ -55,6 +56,7 @@ export async function prefetchArtistUserRatings(
|
|||||||
else uncached.push(id);
|
else uncached.push(id);
|
||||||
}
|
}
|
||||||
if (!uncached.length) return out;
|
if (!uncached.length) return out;
|
||||||
|
if (!shouldAttemptSubsonicForActiveServer()) return out;
|
||||||
let next = 0;
|
let next = 0;
|
||||||
async function worker() {
|
async function worker() {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -93,6 +95,7 @@ export async function prefetchAlbumUserRatings(
|
|||||||
else uncached.push(id);
|
else uncached.push(id);
|
||||||
}
|
}
|
||||||
if (!uncached.length) return out;
|
if (!uncached.length) return out;
|
||||||
|
if (!shouldAttemptSubsonicForActiveServer()) return out;
|
||||||
let next = 0;
|
let next = 0;
|
||||||
async function worker() {
|
async function worker() {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
|
|||||||
+12
-21
@@ -11,6 +11,7 @@ import PlayerBar from '../components/PlayerBar';
|
|||||||
import BottomNav from '../components/BottomNav';
|
import BottomNav from '../components/BottomNav';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import LiveSearch from '../components/LiveSearch';
|
import LiveSearch from '../components/LiveSearch';
|
||||||
|
import DevNetworkModeToggle from '../components/DevNetworkModeToggle';
|
||||||
import NowPlayingDropdown from '../components/NowPlayingDropdown';
|
import NowPlayingDropdown from '../components/NowPlayingDropdown';
|
||||||
import QueuePanel from '../components/QueuePanel';
|
import QueuePanel from '../components/QueuePanel';
|
||||||
import AppRoutes from './AppRoutes';
|
import AppRoutes from './AppRoutes';
|
||||||
@@ -39,11 +40,8 @@ import { useOrbitHost } from '../hooks/useOrbitHost';
|
|||||||
import { useOrbitGuest } from '../hooks/useOrbitGuest';
|
import { useOrbitGuest } from '../hooks/useOrbitGuest';
|
||||||
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
|
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
|
||||||
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
|
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
|
||||||
import {
|
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||||
hasOfflineBrowsingContent,
|
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||||
} from '../utils/offline/favoritesOfflineBrowse';
|
|
||||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
|
||||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
|
||||||
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
|
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
|
||||||
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
|
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
|
||||||
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
|
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
|
||||||
@@ -56,11 +54,11 @@ import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority'
|
|||||||
import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope';
|
import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope';
|
||||||
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
|
import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm';
|
||||||
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
|
import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav';
|
||||||
|
import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilterSuspend';
|
||||||
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
|
import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam';
|
||||||
import { IS_LINUX } from '../utils/platform';
|
import { IS_LINUX } from '../utils/platform';
|
||||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import '../store/previewPlayerVolumeSync';
|
import '../store/previewPlayerVolumeSync';
|
||||||
import '../store/queueResolverBridge';
|
import '../store/queueResolverBridge';
|
||||||
@@ -110,13 +108,10 @@ export function AppShell() {
|
|||||||
useLiveSearchRouteScope();
|
useLiveSearchRouteScope();
|
||||||
useNowPlayingPrewarm();
|
useNowPlayingPrewarm();
|
||||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
const offlineCtx = useOfflineBrowseContext();
|
||||||
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const hasOfflineContent = offlineCtx.hasBrowsingContent;
|
||||||
const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(activeServerId));
|
const hasOfflineBrowse = offlineCtx.hasBrowseCapability;
|
||||||
const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled;
|
|
||||||
const hasManualOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
|
||||||
const hasOfflineContent = hasOfflineBrowsingContent(offlineAlbums);
|
|
||||||
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
||||||
const perfFlags = usePerfProbeFlags();
|
const perfFlags = usePerfProbeFlags();
|
||||||
|
|
||||||
@@ -144,13 +139,8 @@ export function AppShell() {
|
|||||||
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
|
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
|
||||||
}, [location.pathname, location.state]);
|
}, [location.pathname, location.state]);
|
||||||
|
|
||||||
useOfflineAutoNav(
|
useOfflineAutoNav(connStatus, offlineNav, location, navigate);
|
||||||
connStatus,
|
useOfflineLibraryFilterSuspend();
|
||||||
hasManualOfflineContent,
|
|
||||||
favoritesOfflineBrowse,
|
|
||||||
location.pathname,
|
|
||||||
navigate,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeFromServerQueue();
|
initializeFromServerQueue();
|
||||||
@@ -264,6 +254,7 @@ export function AppShell() {
|
|||||||
<div className="main-content-zoom">
|
<div className="main-content-zoom">
|
||||||
<header className="content-header">
|
<header className="content-header">
|
||||||
<LiveSearch />
|
<LiveSearch />
|
||||||
|
{import.meta.env.DEV && <DevNetworkModeToggle />}
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
||||||
<LastfmIndicator />
|
<LastfmIndicator />
|
||||||
@@ -282,7 +273,7 @@ export function AppShell() {
|
|||||||
</header>
|
</header>
|
||||||
<OrbitSessionBar />
|
<OrbitSessionBar />
|
||||||
{connStatus === 'disconnected' && (
|
{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">
|
<div className="content-body app-shell-route-host">
|
||||||
<OverlayScrollArea
|
<OverlayScrollArea
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { getAlbum } from '../api/subsonicLibrary';
|
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
|
||||||
import React, { memo, useMemo } from 'react';
|
import React, { memo, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
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 { resolveCoverDisplayTier } from '../cover/tiers';
|
||||||
import { acquireUrl } from '../utils/imageCache/urlPool';
|
import { acquireUrl } from '../utils/imageCache/urlPool';
|
||||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
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 { useLongPressAction } from '../hooks/useLongPressAction';
|
||||||
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
||||||
import { useDragDrop } from '../contexts/DragDropContext';
|
import { useDragDrop } from '../contexts/DragDropContext';
|
||||||
@@ -192,10 +190,13 @@ function AlbumCard({
|
|||||||
onClick={async e => {
|
onClick={async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
try {
|
||||||
const data = await getAlbum(album.id);
|
const tracks = await fetchAlbumTracks(
|
||||||
enqueue(data.songs.map(songToTrack));
|
album.id,
|
||||||
|
offlineServerId || undefined,
|
||||||
|
);
|
||||||
|
if (tracks.length > 0) enqueue(tracks);
|
||||||
} catch {
|
} 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')}
|
aria-label={t('contextMenu.enqueueAlbum')}
|
||||||
|
|||||||
+128
-111
@@ -19,6 +19,7 @@ import { formatMb } from '../utils/format/formatBytes';
|
|||||||
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||||
import { tooltipAttrs } from './tooltipAttrs';
|
import { tooltipAttrs } from './tooltipAttrs';
|
||||||
|
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||||
|
|
||||||
/** True when the album artist label means "no single artist" — `getArtistInfo`
|
/** 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.
|
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
|
||||||
@@ -91,6 +92,8 @@ interface AlbumHeaderProps {
|
|||||||
onEntityRatingChange: (rating: number) => void;
|
onEntityRatingChange: (rating: number) => void;
|
||||||
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
|
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
|
||||||
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
|
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
|
||||||
|
/** Offline browse action gates (favorites, download, cache, bio, ratings). */
|
||||||
|
actionPolicy?: OfflineActionPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlbumHeader({
|
export default function AlbumHeader({
|
||||||
@@ -117,7 +120,9 @@ export default function AlbumHeader({
|
|||||||
entityRatingValue,
|
entityRatingValue,
|
||||||
onEntityRatingChange,
|
onEntityRatingChange,
|
||||||
entityRatingSupport,
|
entityRatingSupport,
|
||||||
|
actionPolicy,
|
||||||
}: AlbumHeaderProps) {
|
}: AlbumHeaderProps) {
|
||||||
|
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const goBack = useAlbumDetailBack();
|
const goBack = useAlbumDetailBack();
|
||||||
@@ -222,7 +227,7 @@ export default function AlbumHeader({
|
|||||||
<StarRating
|
<StarRating
|
||||||
value={entityRatingValue}
|
value={entityRatingValue}
|
||||||
onChange={onEntityRatingChange}
|
onChange={onEntityRatingChange}
|
||||||
disabled={entityRatingSupport === 'track_only'}
|
disabled={!policy.canRate || entityRatingSupport === 'track_only'}
|
||||||
labelKey="entityRating.albumAriaLabel"
|
labelKey="entityRating.albumAriaLabel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,14 +255,16 @@ export default function AlbumHeader({
|
|||||||
|
|
||||||
{/* Row 2 — Secondary actions */}
|
{/* Row 2 — Secondary actions */}
|
||||||
<div className="album-actions-row album-actions-row--secondary">
|
<div className="album-actions-row album-actions-row--secondary">
|
||||||
<button
|
{policy.canFavorite && (
|
||||||
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
|
<button
|
||||||
onClick={onToggleStar}
|
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
|
||||||
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
onClick={onToggleStar}
|
||||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
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>
|
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="album-icon-btn album-icon-btn--sm"
|
className="album-icon-btn album-icon-btn--sm"
|
||||||
@@ -269,7 +276,7 @@ export default function AlbumHeader({
|
|||||||
<Share2 size={16} />
|
<Share2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showBioButton && (
|
{showBioButton && policy.canShowBio && (
|
||||||
<button
|
<button
|
||||||
className="album-icon-btn album-icon-btn--sm"
|
className="album-icon-btn album-icon-btn--sm"
|
||||||
onClick={onBio}
|
onClick={onBio}
|
||||||
@@ -280,53 +287,57 @@ export default function AlbumHeader({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{downloadProgress !== null ? (
|
{policy.canDownload && (
|
||||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
downloadProgress !== null ? (
|
||||||
<Download size={14} />
|
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||||
<span className="album-icon-btn-pct">{downloadProgress}%</span>
|
<Download size={14} />
|
||||||
</div>
|
<span className="album-icon-btn-pct">{downloadProgress}%</span>
|
||||||
) : (
|
</div>
|
||||||
<button
|
) : (
|
||||||
className="album-icon-btn album-icon-btn--sm"
|
<button
|
||||||
onClick={onDownload}
|
className="album-icon-btn album-icon-btn--sm"
|
||||||
aria-label={t('albumDetail.download')}
|
onClick={onDownload}
|
||||||
data-tooltip={t('albumDetail.download')}
|
aria-label={t('albumDetail.download')}
|
||||||
>
|
data-tooltip={t('albumDetail.download')}
|
||||||
<Download size={16} />
|
>
|
||||||
</button>
|
<Download size={16} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{offlineStatus === 'downloading' ? (
|
{policy.canPinOffline && (
|
||||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
offlineStatus === 'downloading' ? (
|
||||||
<Loader2 size={14} className="spin" />
|
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||||
</div>
|
<Loader2 size={14} className="spin" />
|
||||||
) : offlineStatus === 'queued' ? (
|
</div>
|
||||||
<button
|
) : offlineStatus === 'queued' ? (
|
||||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
<button
|
||||||
onClick={onCacheOffline}
|
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||||
aria-label={t('albumDetail.offlineQueued')}
|
onClick={onCacheOffline}
|
||||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
aria-label={t('albumDetail.offlineQueued')}
|
||||||
>
|
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||||
<HardDriveDownload size={16} />
|
>
|
||||||
</button>
|
<HardDriveDownload size={16} />
|
||||||
) : offlineStatus === 'cached' ? (
|
</button>
|
||||||
<button
|
) : offlineStatus === 'cached' ? (
|
||||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
<button
|
||||||
onClick={onRemoveOffline}
|
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||||
aria-label={t('albumDetail.offlineCached')}
|
onClick={onRemoveOffline}
|
||||||
data-tooltip={t('albumDetail.removeOffline')}
|
aria-label={t('albumDetail.offlineCached')}
|
||||||
>
|
data-tooltip={t('albumDetail.removeOffline')}
|
||||||
<HardDriveDownload size={16} />
|
>
|
||||||
</button>
|
<HardDriveDownload size={16} />
|
||||||
) : (
|
</button>
|
||||||
<button
|
) : (
|
||||||
className="album-icon-btn album-icon-btn--sm"
|
<button
|
||||||
onClick={onCacheOffline}
|
className="album-icon-btn album-icon-btn--sm"
|
||||||
aria-label={t('albumDetail.cacheOffline')}
|
onClick={onCacheOffline}
|
||||||
data-tooltip={t('albumDetail.cacheOffline')}
|
aria-label={t('albumDetail.cacheOffline')}
|
||||||
>
|
data-tooltip={t('albumDetail.cacheOffline')}
|
||||||
<HardDriveDownload size={16} />
|
>
|
||||||
</button>
|
<HardDriveDownload size={16} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,13 +368,15 @@ export default function AlbumHeader({
|
|||||||
>
|
>
|
||||||
<ListPlus size={16} />
|
<ListPlus size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{policy.canFavorite && (
|
||||||
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
|
<button
|
||||||
onClick={onToggleStar}
|
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
|
||||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
onClick={onToggleStar}
|
||||||
>
|
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
>
|
||||||
</button>
|
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-surface"
|
className="btn btn-surface"
|
||||||
@@ -375,7 +388,7 @@ export default function AlbumHeader({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showBioButton && (
|
{showBioButton && policy.canShowBio && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-surface"
|
className="btn btn-surface"
|
||||||
id="album-bio-btn"
|
id="album-bio-btn"
|
||||||
@@ -386,56 +399,60 @@ export default function AlbumHeader({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{downloadProgress !== null ? (
|
{policy.canDownload && (
|
||||||
<div className="download-progress-wrap">
|
downloadProgress !== null ? (
|
||||||
<Download size={14} />
|
<div className="download-progress-wrap">
|
||||||
<div className="download-progress-bar">
|
<Download size={14} />
|
||||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
<div className="download-progress-bar">
|
||||||
|
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
) : (
|
||||||
</div>
|
<button
|
||||||
) : (
|
className="btn btn-surface"
|
||||||
<button
|
id="album-download-btn"
|
||||||
className="btn btn-surface"
|
onClick={onDownload}
|
||||||
id="album-download-btn"
|
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
|
||||||
onClick={onDownload}
|
>
|
||||||
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
|
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
||||||
>
|
</button>
|
||||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
)
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
{offlineStatus === 'downloading' && offlineProgress ? (
|
{policy.canPinOffline && (
|
||||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
offlineStatus === 'downloading' && offlineProgress ? (
|
||||||
<Loader2 size={14} className="spin" />
|
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
<Loader2 size={14} className="spin" />
|
||||||
</div>
|
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||||
) : offlineStatus === 'queued' ? (
|
</div>
|
||||||
<button
|
) : offlineStatus === 'queued' ? (
|
||||||
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
|
<button
|
||||||
onClick={onCacheOffline}
|
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
|
||||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
onClick={onCacheOffline}
|
||||||
>
|
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||||
<HardDriveDownload size={16} />
|
>
|
||||||
{t('albumDetail.offlineQueued')}
|
<HardDriveDownload size={16} />
|
||||||
</button>
|
{t('albumDetail.offlineQueued')}
|
||||||
) : offlineStatus === 'cached' ? (
|
</button>
|
||||||
<button
|
) : offlineStatus === 'cached' ? (
|
||||||
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
<button
|
||||||
onClick={onRemoveOffline}
|
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
||||||
data-tooltip={t('albumDetail.removeOffline')}
|
onClick={onRemoveOffline}
|
||||||
>
|
data-tooltip={t('albumDetail.removeOffline')}
|
||||||
<HardDriveDownload size={16} />
|
>
|
||||||
{t('albumDetail.offlineCached')}
|
<HardDriveDownload size={16} />
|
||||||
</button>
|
{t('albumDetail.offlineCached')}
|
||||||
) : (
|
</button>
|
||||||
<button
|
) : (
|
||||||
className="btn btn-surface offline-cache-btn"
|
<button
|
||||||
onClick={onCacheOffline}
|
className="btn btn-surface offline-cache-btn"
|
||||||
data-tooltip={t('albumDetail.cacheOffline')}
|
onClick={onCacheOffline}
|
||||||
>
|
data-tooltip={t('albumDetail.cacheOffline')}
|
||||||
<HardDriveDownload size={16} />
|
>
|
||||||
{t('albumDetail.cacheOffline')}
|
<HardDriveDownload size={16} />
|
||||||
</button>
|
{t('albumDetail.cacheOffline')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
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 { useTracklistColumns } from '../utils/useTracklistColumns';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -15,6 +15,7 @@ import { TrackRow } from './albumTrackList/TrackRow';
|
|||||||
import { AlbumTrackListMobile } from './albumTrackList/AlbumTrackListMobile';
|
import { AlbumTrackListMobile } from './albumTrackList/AlbumTrackListMobile';
|
||||||
import { TracklistColumnPicker } from './albumTrackList/TracklistColumnPicker';
|
import { TracklistColumnPicker } from './albumTrackList/TracklistColumnPicker';
|
||||||
import { TracklistHeaderRow } from './albumTrackList/TracklistHeaderRow';
|
import { TracklistHeaderRow } from './albumTrackList/TracklistHeaderRow';
|
||||||
|
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||||
|
|
||||||
export type { SortKey } from '../utils/componentHelpers/albumTrackListHelpers';
|
export type { SortKey } from '../utils/componentHelpers/albumTrackListHelpers';
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ interface AlbumTrackListProps {
|
|||||||
sortKey?: SortKey;
|
sortKey?: SortKey;
|
||||||
sortDir?: 'asc' | 'desc';
|
sortDir?: 'asc' | 'desc';
|
||||||
onSort?: (key: SortKey) => void;
|
onSort?: (key: SortKey) => void;
|
||||||
|
actionPolicy?: OfflineActionPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AlbumTrackList ────────────────────────────────────────────────────────────
|
// ── AlbumTrackList ────────────────────────────────────────────────────────────
|
||||||
@@ -60,7 +62,9 @@ export default function AlbumTrackList({
|
|||||||
sortKey,
|
sortKey,
|
||||||
sortDir,
|
sortDir,
|
||||||
onSort,
|
onSort,
|
||||||
|
actionPolicy,
|
||||||
}: AlbumTrackListProps) {
|
}: AlbumTrackListProps) {
|
||||||
|
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||||
@@ -98,6 +102,10 @@ export default function AlbumTrackList({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const currentTrackId = currentTrack?.id ?? null;
|
const currentTrackId = currentTrack?.id ?? null;
|
||||||
|
const displayCols = useMemo(
|
||||||
|
() => (policy.canFavorite ? visibleCols : visibleCols.filter(c => c.key !== 'favorite')),
|
||||||
|
[policy.canFavorite, visibleCols],
|
||||||
|
);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
@@ -139,7 +147,7 @@ export default function AlbumTrackList({
|
|||||||
>
|
>
|
||||||
|
|
||||||
<TracklistHeaderRow
|
<TracklistHeaderRow
|
||||||
visibleCols={visibleCols}
|
visibleCols={displayCols}
|
||||||
gridStyle={gridStyle}
|
gridStyle={gridStyle}
|
||||||
sortKey={sortKey}
|
sortKey={sortKey}
|
||||||
sortDir={sortDir}
|
sortDir={sortDir}
|
||||||
@@ -170,7 +178,7 @@ export default function AlbumTrackList({
|
|||||||
key={song.id}
|
key={song.id}
|
||||||
song={song}
|
song={song}
|
||||||
globalIdx={globalIdx}
|
globalIdx={globalIdx}
|
||||||
visibleCols={visibleCols}
|
visibleCols={displayCols}
|
||||||
gridStyle={gridStyle}
|
gridStyle={gridStyle}
|
||||||
currentTrackId={currentTrackId}
|
currentTrackId={currentTrackId}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
@@ -186,6 +194,7 @@ export default function AlbumTrackList({
|
|||||||
onToggleSelect={onToggleSelect}
|
onToggleSelect={onToggleSelect}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
setContextMenuSongId={setContextMenuSongId}
|
setContextMenuSongId={setContextMenuSongId}
|
||||||
|
actionPolicy={policy}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
|
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 type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
import { songToTrack } from '../utils/playback/songToTrack';
|
||||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
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) => {
|
const handleEnqueue = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
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));
|
enqueue(data.songs.map(songToTrack));
|
||||||
} catch {
|
} catch {
|
||||||
/* silent — toast would be too noisy for a hover action */
|
/* silent — toast would be too noisy for a hover action */
|
||||||
|
|||||||
@@ -43,6 +43,23 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
|
|||||||
orbitBulkGuard: vi.fn(async () => true),
|
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 ContextMenu from './ContextMenu';
|
||||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||||
import { usePlayerStore } from '@/store/playerStore';
|
import { usePlayerStore } from '@/store/playerStore';
|
||||||
|
|||||||
@@ -17,8 +17,29 @@ import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav';
|
|||||||
import { useContextMenuRating } from '../hooks/useContextMenuRating';
|
import { useContextMenuRating } from '../hooks/useContextMenuRating';
|
||||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||||
|
import {
|
||||||
|
offlineActionPolicy,
|
||||||
|
type OfflineSurface,
|
||||||
|
} from '../utils/offline/offlineActionPolicy';
|
||||||
import ContextMenuItems from './contextMenu/ContextMenuItems';
|
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 };
|
export { AddToPlaylistSubmenu };
|
||||||
|
|
||||||
|
|
||||||
@@ -180,6 +201,12 @@ export default function ContextMenu() {
|
|||||||
|
|
||||||
const downloadAlbum = downloadAlbumAction;
|
const downloadAlbum = downloadAlbumAction;
|
||||||
|
|
||||||
|
const { active: offlineBrowseActive } = useOfflineBrowseContext();
|
||||||
|
const offlinePolicy = offlineActionPolicy(
|
||||||
|
contextMenuSurfaceForType(type),
|
||||||
|
offlineBrowseActive,
|
||||||
|
);
|
||||||
|
|
||||||
if (!contextMenu.isOpen || !contextMenu.item) return null;
|
if (!contextMenu.isOpen || !contextMenu.item) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -233,6 +260,7 @@ export default function ContextMenu() {
|
|||||||
isStarred={isStarred}
|
isStarred={isStarred}
|
||||||
pinToPlaybackServer={pinToPlaybackServer}
|
pinToPlaybackServer={pinToPlaybackServer}
|
||||||
navigateLibrary={navigateLibrary}
|
navigateLibrary={navigateLibrary}
|
||||||
|
offlinePolicy={offlinePolicy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
import { songToTrack } from '../utils/playback/songToTrack';
|
||||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
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>>({});
|
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!album || albumFormats[album.id] !== undefined) return;
|
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))];
|
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(' / ') }));
|
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
@@ -339,7 +346,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
onClick={async e => {
|
onClick={async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
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));
|
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}}
|
}}
|
||||||
@@ -369,7 +379,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
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);
|
const tracks = albumData.songs.map(songToTrack);
|
||||||
usePlayerStore.getState().enqueue(tracks);
|
usePlayerStore.getState().enqueue(tracks);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Settings, HardDriveDownload } from 'lucide-react';
|
import { Settings, HardDriveDownload } from 'lucide-react';
|
||||||
import { useSidebarStore } from '../store/sidebarStore';
|
import { useSidebarStore } from '../store/sidebarStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
|
||||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||||
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||||
import { isOfflineSidebarLibraryNavAllowed } from '../utils/offline/favoritesOfflineBrowse';
|
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
|
||||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
|
||||||
|
|
||||||
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
|
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
|
||||||
|
|
||||||
@@ -18,14 +16,10 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const sidebarItems = useSidebarStore(s => s.items);
|
const sidebarItems = useSidebarStore(s => s.items);
|
||||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
const offlineCtx = useOfflineBrowseContext();
|
||||||
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||||
const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
const isServerOffline = offlineCtx.active;
|
||||||
const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled;
|
const hasOfflineContent = offlineCtx.capabilities.manualPins;
|
||||||
const { status: connStatus } = useConnectionStatus();
|
|
||||||
const isServerOffline = connStatus === 'disconnected';
|
|
||||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
|
||||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
|
||||||
const luckyMixBase = useLuckyMixAvailable();
|
const luckyMixBase = useLuckyMixAvailable();
|
||||||
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
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 === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||||
if (cfg.id === 'luckyMix' && !luckyMixAvailable) 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 false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
vi.mock('@/api/subsonic', () => ({
|
vi.mock('@/api/subsonic', () => ({
|
||||||
savePlayQueue: vi.fn(async () => undefined),
|
savePlayQueue: vi.fn(async () => undefined),
|
||||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
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}`),
|
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||||
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${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 { useThemeStore } from '../store/themeStore';
|
||||||
import WaveformSeek from './WaveformSeek';
|
import WaveformSeek from './WaveformSeek';
|
||||||
import Equalizer from './Equalizer';
|
import Equalizer from './Equalizer';
|
||||||
import StarRating from './StarRating';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||||
import { useLyricsStore } from '../store/lyricsStore';
|
import { useLyricsStore } from '../store/lyricsStore';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Play } from 'lucide-react';
|
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 { songToTrack } from '../utils/playback/songToTrack';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
import { useState, useRef, useMemo } from 'react';
|
import { useState, useRef, useMemo } from 'react';
|
||||||
@@ -398,7 +399,10 @@ function QueuePanelHostOrSolo() {
|
|||||||
onClose={() => setLoadModalOpen(false)}
|
onClose={() => setLoadModalOpen(false)}
|
||||||
onLoad={async (id, name, mode) => {
|
onLoad={async (id, name, mode) => {
|
||||||
try {
|
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);
|
const tracks: Track[] = data.songs.map(songToTrack);
|
||||||
if (tracks.length > 0) {
|
if (tracks.length > 0) {
|
||||||
if (mode === 'append') {
|
if (mode === 'append') {
|
||||||
|
|||||||
+31
-17
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
|
||||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||||
import { clearOfflinePinTasks } from '../utils/offline/offlinePinQueue';
|
import { clearOfflinePinTasks } from '../utils/offline/offlinePinQueue';
|
||||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||||
@@ -24,10 +23,9 @@ import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnrea
|
|||||||
import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd';
|
import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd';
|
||||||
import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown';
|
import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown';
|
||||||
import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible';
|
import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible';
|
||||||
import { isOfflineSidebarLibraryNavAllowed } from '../utils/offline/favoritesOfflineBrowse';
|
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
|
||||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
|
||||||
import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
|
import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
|
||||||
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
|
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
|
||||||
import SidebarNavBody from './sidebar/SidebarNavBody';
|
import SidebarNavBody from './sidebar/SidebarNavBody';
|
||||||
@@ -61,7 +59,8 @@ export default function Sidebar({
|
|||||||
const syncJobFail = useDeviceSyncJobStore(s => s.failed);
|
const syncJobFail = useDeviceSyncJobStore(s => s.failed);
|
||||||
const syncJobTotal = useDeviceSyncJobStore(s => s.total);
|
const syncJobTotal = useDeviceSyncJobStore(s => s.total);
|
||||||
const isSyncing = syncJobStatus === 'running';
|
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 serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||||
const musicFolders = useAuthStore(s => s.musicFolders);
|
const musicFolders = useAuthStore(s => s.musicFolders);
|
||||||
@@ -73,12 +72,8 @@ export default function Sidebar({
|
|||||||
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
|
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
|
||||||
const loggingMode = useAuthStore(s => s.loggingMode);
|
const loggingMode = useAuthStore(s => s.loggingMode);
|
||||||
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
|
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
|
||||||
const { status: connStatus } = useConnectionStatus();
|
const hasOfflineContent = offlineCtx.capabilities.manualPins;
|
||||||
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
const isServerOffline = offlineCtx.active;
|
||||||
const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
|
||||||
const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled;
|
|
||||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
|
||||||
const isServerOffline = connStatus === 'disconnected';
|
|
||||||
const sidebarItems = useSidebarStore(s => s.items);
|
const sidebarItems = useSidebarStore(s => s.items);
|
||||||
const setSidebarItems = useSidebarStore(s => s.setItems);
|
const setSidebarItems = useSidebarStore(s => s.setItems);
|
||||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||||
@@ -99,7 +94,7 @@ export default function Sidebar({
|
|||||||
}, [playlistsRaw]);
|
}, [playlistsRaw]);
|
||||||
const [sidebarViewportEl, setSidebarViewportEl] = useState<HTMLDivElement | null>(null);
|
const [sidebarViewportEl, setSidebarViewportEl] = useState<HTMLDivElement | null>(null);
|
||||||
const isSidebarScrolling = useSidebarScrollVisible(sidebarViewportEl);
|
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 filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all';
|
||||||
const selectedFolderName =
|
const selectedFolderName =
|
||||||
@@ -118,20 +113,34 @@ export default function Sidebar({
|
|||||||
libraryItemsForReorder.filter(c => {
|
libraryItemsForReorder.filter(c => {
|
||||||
if (!c.visible) return false;
|
if (!c.visible) return false;
|
||||||
if (c.id === 'luckyMix' && !luckyMixAvailable) 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 false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, favoritesOfflineBrowse],
|
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, offlineNav],
|
||||||
);
|
);
|
||||||
const visibleSystemConfigs = useMemo(
|
const visibleSystemConfigs = useMemo(
|
||||||
() => systemItemsForReorder.filter(c => {
|
() => systemItemsForReorder.filter(c => {
|
||||||
if (!c.visible) return false;
|
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;
|
return true;
|
||||||
}),
|
}),
|
||||||
[systemItemsForReorder, isServerOffline],
|
[systemItemsForReorder, isServerOffline, offlineNav],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sidebarItemsRef = useRef(sidebarItems);
|
const sidebarItemsRef = useRef(sidebarItems);
|
||||||
@@ -164,10 +173,15 @@ export default function Sidebar({
|
|||||||
|
|
||||||
|
|
||||||
const pickLibrary = (id: 'all' | string) => {
|
const pickLibrary = (id: 'all' | string) => {
|
||||||
|
if (isServerOffline) return;
|
||||||
setMusicLibraryFilter(id);
|
setMusicLibraryFilter(id);
|
||||||
setLibraryDropdownOpen(false);
|
setLibraryDropdownOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isServerOffline) setLibraryDropdownOpen(false);
|
||||||
|
}, [isServerOffline, setLibraryDropdownOpen]);
|
||||||
|
|
||||||
// Fetch playlists when expanded
|
// Fetch playlists when expanded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playlistsExpanded || !isLoggedIn) return;
|
if (!playlistsExpanded || !isLoggedIn) return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ListPlus, Search, X } from 'lucide-react';
|
|||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { useSelectionStore } from '../../store/selectionStore';
|
import { useSelectionStore } from '../../store/selectionStore';
|
||||||
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
||||||
|
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filterText: string;
|
filterText: string;
|
||||||
@@ -12,6 +13,7 @@ interface Props {
|
|||||||
showPlPicker: boolean;
|
showPlPicker: boolean;
|
||||||
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
|
actionPolicy?: OfflineActionPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +33,9 @@ export function AlbumDetailToolbar({
|
|||||||
showPlPicker,
|
showPlPicker,
|
||||||
setShowPlPicker,
|
setShowPlPicker,
|
||||||
t,
|
t,
|
||||||
|
actionPolicy,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
|
||||||
return (
|
return (
|
||||||
<div className="album-track-toolbar">
|
<div className="album-track-toolbar">
|
||||||
<div className="album-track-toolbar-filter">
|
<div className="album-track-toolbar-filter">
|
||||||
@@ -59,22 +63,24 @@ export function AlbumDetailToolbar({
|
|||||||
<span className="bulk-action-count">
|
<span className="bulk-action-count">
|
||||||
{t('common.bulkSelected', { count: selectedCount })}
|
{t('common.bulkSelected', { count: selectedCount })}
|
||||||
</span>
|
</span>
|
||||||
<div className="bulk-pl-picker-wrap">
|
{policy.canAddToPlaylist && (
|
||||||
<button
|
<div className="bulk-pl-picker-wrap">
|
||||||
className="btn btn-surface btn-sm"
|
<button
|
||||||
onClick={() => setShowPlPicker(v => !v)}
|
className="btn btn-surface btn-sm"
|
||||||
>
|
onClick={() => setShowPlPicker(v => !v)}
|
||||||
<ListPlus size={14} />
|
>
|
||||||
{t('common.bulkAddToPlaylist')}
|
<ListPlus size={14} />
|
||||||
</button>
|
{t('common.bulkAddToPlaylist')}
|
||||||
{showPlPicker && (
|
</button>
|
||||||
<AddToPlaylistSubmenu
|
{showPlPicker && (
|
||||||
songIds={[...useSelectionStore.getState().selectedIds]}
|
<AddToPlaylistSubmenu
|
||||||
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
|
songIds={[...useSelectionStore.getState().selectedIds]}
|
||||||
dropDown
|
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
|
||||||
/>
|
dropDown
|
||||||
)}
|
/>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost btn-sm"
|
className="btn btn-ghost btn-sm"
|
||||||
onClick={() => useSelectionStore.getState().clearAll()}
|
onClick={() => useSelectionStore.getState().clearAll()}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrack
|
|||||||
import { formatLongDuration } from '../../utils/format/formatDuration';
|
import { formatLongDuration } from '../../utils/format/formatDuration';
|
||||||
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
|
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
|
||||||
import i18n from '../../i18n';
|
import i18n from '../../i18n';
|
||||||
|
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||||
|
|
||||||
type ContextMenuFn = (
|
type ContextMenuFn = (
|
||||||
x: number,
|
x: number,
|
||||||
@@ -41,6 +42,7 @@ interface TrackRowProps {
|
|||||||
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
|
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
|
||||||
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
|
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
|
||||||
setContextMenuSongId: (id: string | null) => void;
|
setContextMenuSongId: (id: string | null) => void;
|
||||||
|
actionPolicy?: OfflineActionPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,7 +69,9 @@ export const TrackRow = React.memo(function TrackRow({
|
|||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
setContextMenuSongId,
|
setContextMenuSongId,
|
||||||
|
actionPolicy,
|
||||||
}: TrackRowProps) {
|
}: TrackRowProps) {
|
||||||
|
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const showBitrate = useThemeStore(s => s.showBitrate);
|
const showBitrate = useThemeStore(s => s.showBitrate);
|
||||||
@@ -171,6 +175,7 @@ export const TrackRow = React.memo(function TrackRow({
|
|||||||
key="rating"
|
key="rating"
|
||||||
value={ratingValue}
|
value={ratingValue}
|
||||||
onChange={r => onRate(song.id, r)}
|
onChange={r => onRate(song.id, r)}
|
||||||
|
disabled={!policy.canRate}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'duration':
|
case 'duration':
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { CoverArtRef } from '../../cover/types';
|
|||||||
import LastfmIcon from '../LastfmIcon';
|
import LastfmIcon from '../LastfmIcon';
|
||||||
import StarRating from '../StarRating';
|
import StarRating from '../StarRating';
|
||||||
import { tooltipAttrs } from '../tooltipAttrs';
|
import { tooltipAttrs } from '../tooltipAttrs';
|
||||||
|
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
artist: SubsonicArtist;
|
artist: SubsonicArtist;
|
||||||
@@ -41,6 +42,7 @@ interface Props {
|
|||||||
coverRevision: number;
|
coverRevision: number;
|
||||||
headerCoverFailed: boolean;
|
headerCoverFailed: boolean;
|
||||||
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
|
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
actionPolicy?: OfflineActionPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArtistDetailHero({
|
export default function ArtistDetailHero({
|
||||||
@@ -49,7 +51,9 @@ export default function ArtistDetailHero({
|
|||||||
handleImageUpload, playAllLoading, radioLoading, uploading,
|
handleImageUpload, playAllLoading, radioLoading, uploading,
|
||||||
openedLink, openLink,
|
openedLink, openLink,
|
||||||
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||||
|
actionPolicy,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const policy = actionPolicy ?? offlineActionPolicy('artistDetail', false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const goBack = useAlbumDetailBack();
|
const goBack = useAlbumDetailBack();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -139,7 +143,7 @@ export default function ArtistDetailHero({
|
|||||||
<StarRating
|
<StarRating
|
||||||
value={artistEntityRating}
|
value={artistEntityRating}
|
||||||
onChange={handleArtistEntityRating}
|
onChange={handleArtistEntityRating}
|
||||||
disabled={artistEntityRatingSupport === 'track_only'}
|
disabled={!policy.canRate || artistEntityRatingSupport === 'track_only'}
|
||||||
labelKey="entityRating.artistAriaLabel"
|
labelKey="entityRating.artistAriaLabel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,15 +172,17 @@ export default function ArtistDetailHero({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{policy.canFavorite && (
|
||||||
className="artist-ext-link"
|
<button
|
||||||
onClick={toggleStar}
|
className="artist-ext-link"
|
||||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
onClick={toggleStar}
|
||||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
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')}
|
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||||
</button>
|
{t('artistDetail.favorite')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
@@ -222,7 +228,7 @@ export default function ArtistDetailHero({
|
|||||||
<Share2 size={16} />
|
<Share2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{albums.length > 0 && (
|
{policy.canCacheDiscography && albums.length > 0 && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-surface"
|
className="btn btn-surface"
|
||||||
disabled={
|
disabled={
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { getAlbum } from '../../api/subsonicLibrary';
|
import { resolveAlbum, resolveArtist, resolveMediaServerId } from '../../utils/offline/offlineMediaResolve';
|
||||||
import { getArtist } from '../../api/subsonicArtists';
|
|
||||||
import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu';
|
import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu';
|
||||||
|
|
||||||
interface AlbumProps {
|
interface AlbumProps {
|
||||||
@@ -13,8 +12,13 @@ export function AlbumToPlaylistSubmenu({ albumId, onDone, triggerId }: AlbumProp
|
|||||||
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
|
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getAlbum(albumId).then((data) => {
|
const serverId = resolveMediaServerId();
|
||||||
setResolvedIds(data.songs.map((s) => s.id));
|
if (!serverId) {
|
||||||
|
setResolvedIds([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolveAlbum(serverId, albumId).then((data) => {
|
||||||
|
setResolvedIds(data ? data.songs.map((s) => s.id) : []);
|
||||||
}).catch(() => setResolvedIds([]));
|
}).catch(() => setResolvedIds([]));
|
||||||
}, [albumId]);
|
}, [albumId]);
|
||||||
|
|
||||||
@@ -40,8 +44,19 @@ export function ArtistToPlaylistSubmenu({ artistId, onDone, triggerId }: ArtistP
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const { albums } = await getArtist(artistId);
|
const serverId = resolveMediaServerId();
|
||||||
const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs)));
|
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));
|
setResolvedIds(albumSongs.flat().map(s => s.id));
|
||||||
})().catch(() => setResolvedIds([]));
|
})().catch(() => setResolvedIds([]));
|
||||||
}, [artistId]);
|
}, [artistId]);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Play, ListPlus, Heart, Download, ChevronRight, ChevronsRight, User, ListMusic, Star, Share2 } from 'lucide-react';
|
import { Play, ListPlus, Heart, Download, ChevronRight, ChevronsRight, User, ListMusic, Star, Share2 } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { star, unstar } from '../../api/subsonicStarRating';
|
||||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
@@ -22,7 +22,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
|||||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||||
applySongRating, applyAlbumRating, applyArtistRating,
|
applySongRating, applyAlbumRating, applyArtistRating,
|
||||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||||
pinToPlaybackServer, navigateLibrary,
|
pinToPlaybackServer, navigateLibrary, offlinePolicy,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -40,7 +40,10 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
|||||||
<Play size={14} /> {t('contextMenu.openAlbum')}
|
<Play size={14} /> {t('contextMenu.openAlbum')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
<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);
|
const tracks = albumData.songs.map(songToTrack);
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
playNext(tracks);
|
playNext(tracks);
|
||||||
@@ -48,7 +51,10 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
|||||||
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
<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));
|
enqueue(albumData.songs.map(songToTrack));
|
||||||
})}>
|
})}>
|
||||||
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
|
<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}`))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => goLibrary(`/artist/${album.artistId}`))}>
|
||||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
{offlinePolicy.canFavorite && (
|
||||||
const starred = isStarred(album.id, album.starred);
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
setStarredOverride(album.id, !starred);
|
const starred = isStarred(album.id, album.starred);
|
||||||
const meta = {
|
setStarredOverride(album.id, !starred);
|
||||||
serverId: album.serverId,
|
const meta = {
|
||||||
name: album.name,
|
serverId: album.serverId,
|
||||||
artist: album.artist,
|
name: album.name,
|
||||||
artistId: album.artistId,
|
artist: album.artist,
|
||||||
coverArtId: album.coverArt,
|
artistId: album.artistId,
|
||||||
year: album.year,
|
coverArtId: album.coverArt,
|
||||||
};
|
year: album.year,
|
||||||
return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta);
|
};
|
||||||
})}>
|
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')}
|
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||||
</div>
|
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
|
||||||
<div
|
</div>
|
||||||
className="context-menu-rating-row"
|
)}
|
||||||
data-rating-kind="album"
|
{offlinePolicy.canRate && (
|
||||||
data-rating-id={album.id}
|
<div
|
||||||
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
className="context-menu-rating-row"
|
||||||
onClick={e => e.stopPropagation()}
|
data-rating-kind="album"
|
||||||
>
|
data-rating-id={album.id}
|
||||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||||
<StarRating
|
onClick={e => e.stopPropagation()}
|
||||||
value={keyboardRating?.kind === 'album' && keyboardRating.id === album.id
|
>
|
||||||
? keyboardRating.value
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
: userRatingOverrides[album.id] ?? album.userRating ?? 0}
|
<StarRating
|
||||||
disabled={albumRatingDisabled}
|
value={keyboardRating?.kind === 'album' && keyboardRating.id === album.id
|
||||||
labelKey="entityRating.albumAriaLabel"
|
? keyboardRating.value
|
||||||
onChange={r => { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }}
|
: userRatingOverrides[album.id] ?? album.userRating ?? 0}
|
||||||
/>
|
disabled={albumRatingDisabled}
|
||||||
</div>
|
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-divider" />
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('album', album.id))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('album', album.id))}>
|
||||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
{offlinePolicy.canDownload && (
|
||||||
<Download size={14} /> {t('contextMenu.download')}
|
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||||
</div>
|
<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}`}
|
{offlinePolicy.canAddToPlaylist && (
|
||||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
|
<div
|
||||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
|
||||||
>
|
data-playlist-trigger-id={`album:${album.id}`}
|
||||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||||
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
|
>
|
||||||
<AlbumToPlaylistSubmenu albumId={album.id} triggerId={`album:${album.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
)}
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
</div>
|
{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>
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||||
// Parallel — Navidrome handles concurrent getAlbum requests fine.
|
const results = await Promise.all(albums.map(async a => {
|
||||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
const serverId = resolveMediaServerId(a.serverId);
|
||||||
const allTracks = results.flatMap(r => r.songs.map(songToTrack));
|
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);
|
enqueue(allTracks);
|
||||||
})}>
|
})}>
|
||||||
<ListPlus size={14} /> {t('contextMenu.enqueueAlbums', { count: albums.length })}
|
<ListPlus size={14} /> {t('contextMenu.enqueueAlbums', { count: albums.length })}
|
||||||
</div>
|
</div>
|
||||||
<div
|
{offlinePolicy.canAddToPlaylist && (
|
||||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
|
<div
|
||||||
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
|
||||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
|
||||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
>
|
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
>
|
||||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
{playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
|
||||||
)}
|
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
</div>
|
)}
|
||||||
<div
|
</div>
|
||||||
className="context-menu-rating-row"
|
)}
|
||||||
data-rating-kind="album"
|
{offlinePolicy.canRate && (
|
||||||
data-rating-id={multiAlbumRatingId}
|
<div
|
||||||
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
className="context-menu-rating-row"
|
||||||
onClick={e => e.stopPropagation()}
|
data-rating-kind="album"
|
||||||
>
|
data-rating-id={multiAlbumRatingId}
|
||||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||||
<StarRating
|
onClick={e => e.stopPropagation()}
|
||||||
value={
|
>
|
||||||
keyboardRating?.kind === 'album' && keyboardRating.id === multiAlbumRatingId
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
? keyboardRating.value
|
<StarRating
|
||||||
: unifiedAlbumRating
|
value={
|
||||||
}
|
keyboardRating?.kind === 'album' && keyboardRating.id === multiAlbumRatingId
|
||||||
disabled={albumRatingDisabled}
|
? keyboardRating.value
|
||||||
ariaLabel={t('entityRating.selectedAlbumsRatingAriaLabel', { count: albums.length })}
|
: unifiedAlbumRating
|
||||||
onChange={r => {
|
}
|
||||||
setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
|
disabled={albumRatingDisabled}
|
||||||
for (const a of albums) applyAlbumRating(a, r);
|
ariaLabel={t('entityRating.selectedAlbumsRatingAriaLabel', { count: albums.length })}
|
||||||
}}
|
onChange={r => {
|
||||||
/>
|
setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
|
||||||
</div>
|
for (const a of albums) applyAlbumRating(a, r);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
|
|||||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||||
applySongRating, applyAlbumRating, applyArtistRating,
|
applySongRating, applyAlbumRating, applyArtistRating,
|
||||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||||
|
offlinePolicy,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const auth = useAuthStore();
|
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))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
|
||||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||||
</div>
|
</div>
|
||||||
<div
|
{offlinePolicy.canAddToPlaylist && (
|
||||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` ? 'active' : ''}`}
|
<div
|
||||||
data-playlist-trigger-id={`artist:${artist.id}`}
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` ? 'active' : ''}`}
|
||||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
|
data-playlist-trigger-id={`artist:${artist.id}`}
|
||||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
>
|
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
>
|
||||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
{playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
{playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
|
||||||
)}
|
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id))}>
|
||||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-divider" />
|
{(offlinePolicy.canFavorite || offlinePolicy.canRate) && (
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
<>
|
||||||
const starred = isStarred(artist.id, artist.starred);
|
<div className="context-menu-divider" />
|
||||||
setStarredOverride(artist.id, !starred);
|
{offlinePolicy.canFavorite && (
|
||||||
const meta = {
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
serverId: artist.serverId,
|
const starred = isStarred(artist.id, artist.starred);
|
||||||
name: artist.name,
|
setStarredOverride(artist.id, !starred);
|
||||||
albumCount: artist.albumCount,
|
const meta = {
|
||||||
};
|
serverId: artist.serverId,
|
||||||
return starred
|
name: artist.name,
|
||||||
? unstar(artist.id, 'artist', meta)
|
albumCount: artist.albumCount,
|
||||||
: star(artist.id, 'artist', meta);
|
};
|
||||||
})}>
|
return starred
|
||||||
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
? unstar(artist.id, 'artist', meta)
|
||||||
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
|
: star(artist.id, 'artist', meta);
|
||||||
</div>
|
})}>
|
||||||
<div
|
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||||
className="context-menu-rating-row"
|
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
|
||||||
data-rating-kind="artist"
|
</div>
|
||||||
data-rating-id={artist.id}
|
)}
|
||||||
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
{offlinePolicy.canRate && (
|
||||||
onClick={e => e.stopPropagation()}
|
<div
|
||||||
>
|
className="context-menu-rating-row"
|
||||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
data-rating-kind="artist"
|
||||||
<StarRating
|
data-rating-id={artist.id}
|
||||||
value={keyboardRating?.kind === 'artist' && keyboardRating.id === artist.id
|
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||||
? keyboardRating.value
|
onClick={e => e.stopPropagation()}
|
||||||
: userRatingOverrides[artist.id] ?? artist.userRating ?? 0}
|
>
|
||||||
disabled={artistRatingDisabled}
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
labelKey="entityRating.artistAriaLabel"
|
<StarRating
|
||||||
onChange={r => { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }}
|
value={keyboardRating?.kind === 'artist' && keyboardRating.id === artist.id
|
||||||
/>
|
? keyboardRating.value
|
||||||
</div>
|
: 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 })}
|
{t('contextMenu.selectedArtists', { count: artists.length })}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
<div
|
{offlinePolicy.canAddToPlaylist && (
|
||||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` ? 'active' : ''}`}
|
<div
|
||||||
data-playlist-trigger-id={`multi-artist:${artistIds.join(',')}`}
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` ? 'active' : ''}`}
|
||||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
data-playlist-trigger-id={`multi-artist:${artistIds.join(',')}`}
|
||||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
>
|
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
>
|
||||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
{playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
|
||||||
)}
|
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
</div>
|
)}
|
||||||
<div
|
</div>
|
||||||
className="context-menu-rating-row"
|
)}
|
||||||
data-rating-kind="artist"
|
{offlinePolicy.canRate && (
|
||||||
data-rating-id={multiArtistRatingId}
|
<div
|
||||||
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
className="context-menu-rating-row"
|
||||||
onClick={e => e.stopPropagation()}
|
data-rating-kind="artist"
|
||||||
>
|
data-rating-id={multiArtistRatingId}
|
||||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||||
<StarRating
|
onClick={e => e.stopPropagation()}
|
||||||
value={
|
>
|
||||||
keyboardRating?.kind === 'artist' && keyboardRating.id === multiArtistRatingId
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
? keyboardRating.value
|
<StarRating
|
||||||
: unifiedArtistRating
|
value={
|
||||||
}
|
keyboardRating?.kind === 'artist' && keyboardRating.id === multiArtistRatingId
|
||||||
disabled={artistRatingDisabled}
|
? keyboardRating.value
|
||||||
ariaLabel={t('entityRating.selectedArtistsRatingAriaLabel', { count: artists.length })}
|
: unifiedArtistRating
|
||||||
onChange={r => {
|
}
|
||||||
setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
|
disabled={artistRatingDisabled}
|
||||||
for (const a of artists) applyArtistRating(a, r);
|
ariaLabel={t('entityRating.selectedArtistsRatingAriaLabel', { count: artists.length })}
|
||||||
}}
|
onChange={r => {
|
||||||
/>
|
setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
|
||||||
</div>
|
for (const a of artists) applyArtistRating(a, r);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ListMusic, Plus } from 'lucide-react';
|
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 type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||||
import { usePlaylistStore } from '../../store/playlistStore';
|
import { usePlaylistStore } from '../../store/playlistStore';
|
||||||
import { showToast } from '../../utils/ui/toast';
|
import { showToast } from '../../utils/ui/toast';
|
||||||
@@ -26,7 +26,10 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
|
|||||||
setTotalAlbums(albumIds.length);
|
setTotalAlbums(albumIds.length);
|
||||||
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
|
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
|
||||||
(async () => {
|
(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();
|
const allSongs = albumSongs.flat();
|
||||||
setResolvedIds(allSongs.map(s => s.id));
|
setResolvedIds(allSongs.map(s => s.id));
|
||||||
})().catch(() => setResolvedIds([]));
|
})().catch(() => setResolvedIds([]));
|
||||||
@@ -34,11 +37,15 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
|
|||||||
}, [albumIds]);
|
}, [albumIds]);
|
||||||
|
|
||||||
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
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;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
try {
|
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 existingIds = new Set(existingSongs.map((s) => s.id));
|
||||||
|
|
||||||
const newIds: string[] = [];
|
const newIds: string[] = [];
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ListMusic, Plus } from 'lucide-react';
|
import { ListMusic, Plus } from 'lucide-react';
|
||||||
import { getAlbum } from '../../api/subsonicLibrary';
|
import { resolveAlbum, resolveArtist, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
|
||||||
import { getArtist } from '../../api/subsonicArtists';
|
|
||||||
import { getPlaylists } from '../../api/subsonicPlaylists';
|
import { getPlaylists } from '../../api/subsonicPlaylists';
|
||||||
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||||
import { usePlaylistStore } from '../../store/playlistStore';
|
import { usePlaylistStore } from '../../store/playlistStore';
|
||||||
@@ -29,10 +28,18 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
|
|||||||
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
|
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
|
||||||
(async () => {
|
(async () => {
|
||||||
const allSongs: string[] = [];
|
const allSongs: string[] = [];
|
||||||
|
const serverId = resolveMediaServerId();
|
||||||
|
if (!serverId) {
|
||||||
|
setResolvedIds([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const artistId of artistIds) {
|
for (const artistId of artistIds) {
|
||||||
try {
|
try {
|
||||||
const { albums } = await getArtist(artistId);
|
const artistData = await resolveArtist(serverId, artistId);
|
||||||
const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs).catch(() => [])));
|
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));
|
allSongs.push(...albumSongs.flat().map(s => s.id));
|
||||||
} catch {
|
} catch {
|
||||||
// Skip failed artists
|
// Skip failed artists
|
||||||
@@ -44,11 +51,15 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
|
|||||||
}, [artistIds]);
|
}, [artistIds]);
|
||||||
|
|
||||||
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
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;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
try {
|
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 existingIds = new Set(existingSongs.map((s) => s.id));
|
||||||
|
|
||||||
const newIds: string[] = [];
|
const newIds: string[] = [];
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
|||||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||||
applySongRating, applyAlbumRating, applyArtistRating,
|
applySongRating, applyAlbumRating, applyArtistRating,
|
||||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||||
|
offlinePolicy,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -33,18 +34,22 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
|||||||
<Play size={14} /> {t('contextMenu.playNow')}
|
<Play size={14} /> {t('contextMenu.playNow')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
<div
|
{offlinePolicy.canAddToPlaylist && (
|
||||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
|
<div
|
||||||
data-playlist-trigger-id={`playlist:${playlist.id}`}
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
|
||||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
|
data-playlist-trigger-id={`playlist:${playlist.id}`}
|
||||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
>
|
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
>
|
||||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
{playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
<SinglePlaylistToPlaylistSubmenu playlist={playlist} triggerId={`playlist:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
{playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
|
||||||
)}
|
<SinglePlaylistToPlaylistSubmenu playlist={playlist} triggerId={`playlist:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{offlinePolicy.canEditPlaylist && (
|
||||||
|
<>
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||||
const { showToast } = await import('../../utils/ui/toast');
|
const { showToast } = await import('../../utils/ui/toast');
|
||||||
@@ -64,6 +69,8 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
|||||||
})}>
|
})}>
|
||||||
<Trash2 size={14} /> {t('playlists.deletePlaylist')}
|
<Trash2 size={14} /> {t('playlists.deletePlaylist')}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -77,18 +84,21 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
|||||||
{t('contextMenu.selectedPlaylists', { count: selectedPlaylists.length })}
|
{t('contextMenu.selectedPlaylists', { count: selectedPlaylists.length })}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
<div
|
{offlinePolicy.canAddToPlaylist && (
|
||||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` ? 'active' : ''}`}
|
<div
|
||||||
data-playlist-trigger-id={`multi-playlist:${playlistIds.join(',')}`}
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` ? 'active' : ''}`}
|
||||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
data-playlist-trigger-id={`multi-playlist:${playlistIds.join(',')}`}
|
||||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||||
>
|
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
>
|
||||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
<MultiPlaylistToPlaylistSubmenu playlists={selectedPlaylists} triggerId={`multi-playlist:${playlistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
{playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
|
||||||
)}
|
<MultiPlaylistToPlaylistSubmenu playlists={selectedPlaylists} triggerId={`multi-playlist:${playlistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{offlinePolicy.canEditPlaylist && (
|
||||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||||
const { showToast } = await import('../../utils/ui/toast');
|
const { showToast } = await import('../../utils/ui/toast');
|
||||||
const { deletePlaylist } = await import('../../api/subsonicPlaylists');
|
const { deletePlaylist } = await import('../../api/subsonicPlaylists');
|
||||||
@@ -113,6 +123,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
|||||||
})}>
|
})}>
|
||||||
<Trash2 size={14} /> {t('playlists.deleteSelected')}
|
<Trash2 size={14} /> {t('playlists.deleteSelected')}
|
||||||
</div>
|
</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 { 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 { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
|
||||||
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
|
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
|
||||||
import { getAlbum } from '../../api/subsonicLibrary';
|
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
|
||||||
import { queueSongStar } from '../../store/pendingStarSync';
|
import { queueSongStar } from '../../store/pendingStarSync';
|
||||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
|
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
@@ -27,6 +27,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||||
applySongRating, applyAlbumRating, applyArtistRating,
|
applySongRating, applyAlbumRating, applyArtistRating,
|
||||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||||
|
offlinePolicy,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -80,21 +81,26 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
|
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
{offlinePolicy.canAddToPlaylist && (
|
||||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
<div
|
||||||
data-playlist-trigger-id={song.id}
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
data-playlist-trigger-id={song.id}
|
||||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||||
>
|
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
>
|
||||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||||
)}
|
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{type === 'album-song' && (
|
{type === 'album-song' && (
|
||||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
<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);
|
const tracks = albumData.songs.map(songToTrack);
|
||||||
enqueue(tracks);
|
enqueue(tracks);
|
||||||
})}>
|
})}>
|
||||||
@@ -120,12 +126,14 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
{offlinePolicy.canFavorite && (
|
||||||
queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
|
<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')}
|
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||||
</div>
|
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{auth.lastfmSessionKey && (() => {
|
{auth.lastfmSessionKey && (() => {
|
||||||
const loveKey = `${song.title}::${song.artist}`;
|
const loveKey = `${song.title}::${song.artist}`;
|
||||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||||
@@ -141,22 +149,24 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
<div
|
{offlinePolicy.canRate && (
|
||||||
className="context-menu-rating-row"
|
<div
|
||||||
data-rating-kind="song"
|
className="context-menu-rating-row"
|
||||||
data-rating-id={song.id}
|
data-rating-kind="song"
|
||||||
data-rating-disabled="false"
|
data-rating-id={song.id}
|
||||||
onClick={e => e.stopPropagation()}
|
data-rating-disabled="false"
|
||||||
>
|
onClick={e => e.stopPropagation()}
|
||||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
>
|
||||||
<StarRating
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
<StarRating
|
||||||
? keyboardRating.value
|
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
? keyboardRating.value
|
||||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||||
ariaLabel={t('albumDetail.ratingLabel')}
|
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||||
/>
|
ariaLabel={t('albumDetail.ratingLabel')}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
<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))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||||
</div>
|
</div>
|
||||||
{playlistId && playlistSongIndex !== undefined && (
|
{offlinePolicy.canEditPlaylist && playlistId && playlistSongIndex !== undefined && (
|
||||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
<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 { showToast } = await import('../../utils/ui/toast');
|
||||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
try {
|
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 prevCount = songs.length;
|
||||||
const updatedIds = songs.filter((_, i) => i !== playlistSongIndex).map(s => s.id);
|
const updatedIds = songs.filter((_, i) => i !== playlistSongIndex).map(s => s.id);
|
||||||
await updatePlaylist(playlistId, updatedIds, prevCount);
|
await updatePlaylist(playlistId, updatedIds, prevCount);
|
||||||
@@ -232,18 +246,20 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
|
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
{offlinePolicy.canAddToPlaylist && (
|
||||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
<div
|
||||||
data-playlist-trigger-id={song.id}
|
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
data-playlist-trigger-id={song.id}
|
||||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||||
>
|
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
>
|
||||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||||
)}
|
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
{song.albumId && (
|
{song.albumId && (
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!))}>
|
||||||
@@ -278,22 +294,24 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
<div
|
{offlinePolicy.canRate && (
|
||||||
className="context-menu-rating-row"
|
<div
|
||||||
data-rating-kind="song"
|
className="context-menu-rating-row"
|
||||||
data-rating-id={song.id}
|
data-rating-kind="song"
|
||||||
data-rating-disabled="false"
|
data-rating-id={song.id}
|
||||||
onClick={e => e.stopPropagation()}
|
data-rating-disabled="false"
|
||||||
>
|
onClick={e => e.stopPropagation()}
|
||||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
>
|
||||||
<StarRating
|
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
<StarRating
|
||||||
? keyboardRating.value
|
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
? keyboardRating.value
|
||||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||||
ariaLabel={t('albumDetail.ratingLabel')}
|
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||||
/>
|
ariaLabel={t('albumDetail.ratingLabel')}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="context-menu-divider" />
|
<div className="context-menu-divider" />
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
<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))}>
|
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||||
</div>
|
</div>
|
||||||
<div className="context-menu-divider" />
|
{offlinePolicy.canFavorite && (
|
||||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
<>
|
||||||
queueSongStar(song.id, false, song.serverId);
|
<div className="context-menu-divider" />
|
||||||
})}>
|
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||||
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
|
queueSongStar(song.id, false, song.serverId);
|
||||||
</div>
|
})}>
|
||||||
|
<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 { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
||||||
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
|
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
|
||||||
import type { EntityShareKind } from '../../utils/share/shareLink';
|
import type { EntityShareKind } from '../../utils/share/shareLink';
|
||||||
|
import type { OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||||
|
|
||||||
export type RatingKind = 'song' | 'album' | 'artist';
|
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. */
|
/** When true, album/artist links switch to the queue server before routing. */
|
||||||
pinToPlaybackServer: boolean;
|
pinToPlaybackServer: boolean;
|
||||||
navigateLibrary: (path: string) => void | Promise<void>;
|
navigateLibrary: (path: string) => void | Promise<void>;
|
||||||
|
offlinePolicy: OfflineActionPolicy;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
usePlayerBarLayoutStore,
|
usePlayerBarLayoutStore,
|
||||||
type PlayerBarLayoutItemId,
|
type PlayerBarLayoutItemId,
|
||||||
} from '../../store/playerBarLayoutStore';
|
} from '../../store/playerBarLayoutStore';
|
||||||
|
import { useOfflineBrowseContext } from '../../hooks/useOfflineBrowseContext';
|
||||||
|
import { offlineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentTrack: Track | null;
|
currentTrack: Track | null;
|
||||||
@@ -67,6 +69,8 @@ export function PlayerTrackInfo({
|
|||||||
const layoutItems = usePlayerBarLayoutStore(s => s.items);
|
const layoutItems = usePlayerBarLayoutStore(s => s.items);
|
||||||
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
||||||
layoutItems.find(i => i.id === id)?.visible !== false;
|
layoutItems.find(i => i.id === id)?.visible !== false;
|
||||||
|
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||||
|
const playerPolicy = offlineActionPolicy('playerBar', offlineBrowseActive);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="player-track-info">
|
<div className="player-track-info">
|
||||||
@@ -168,7 +172,7 @@ export function PlayerTrackInfo({
|
|||||||
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && (
|
{currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && playerPolicy.canRate && (
|
||||||
<StarRating
|
<StarRating
|
||||||
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
||||||
onChange={r => queueSongRating(currentTrack.id, r)}
|
onChange={r => queueSongRating(currentTrack.id, r)}
|
||||||
@@ -182,7 +186,7 @@ export function PlayerTrackInfo({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{currentTrack && !isRadio && isLayoutVisible('favorite') && (
|
{currentTrack && !isRadio && isLayoutVisible('favorite') && playerPolicy.canFavorite && (
|
||||||
<button
|
<button
|
||||||
className={`player-btn player-btn-sm player-star-btn${isStarred ? ' is-starred' : ''}`}
|
className={`player-btn player-btn-sm player-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||||
onClick={toggleStar}
|
onClick={toggleStar}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { CoverArtImage } from '../../cover/CoverArtImage';
|
|||||||
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
|
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
|
||||||
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
|
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
|
||||||
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
|
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
|
||||||
|
import type { OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
playlist: SubsonicPlaylist;
|
playlist: SubsonicPlaylist;
|
||||||
@@ -34,6 +35,7 @@ interface Props {
|
|||||||
offlineStatus: AlbumOfflineStatus;
|
offlineStatus: AlbumOfflineStatus;
|
||||||
offlineProgress: { done: number; total: number } | null;
|
offlineProgress: { done: number; total: number } | null;
|
||||||
activeServerId: string;
|
activeServerId: string;
|
||||||
|
actionPolicy: OfflineActionPolicy;
|
||||||
setEditingMeta: React.Dispatch<React.SetStateAction<boolean>>;
|
setEditingMeta: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
|
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||||
@@ -53,7 +55,7 @@ export default function PlaylistHero({
|
|||||||
playlist, songs, id,
|
playlist, songs, id,
|
||||||
customCoverId, coverQuadIds,
|
customCoverId, coverQuadIds,
|
||||||
resolvedBgUrl, saving, searchOpen, csvImporting, activeZip,
|
resolvedBgUrl, saving, searchOpen, csvImporting, activeZip,
|
||||||
offlineStatus, offlineProgress, activeServerId,
|
offlineStatus, offlineProgress, activeServerId, actionPolicy,
|
||||||
setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults,
|
setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults,
|
||||||
setSelectedSearchIds, setSearchPlPickerOpen,
|
setSelectedSearchIds, setSearchPlPickerOpen,
|
||||||
handlePlayAll, handleShuffleAll, handleEnqueueAll, handleImportCsv, handleDownload,
|
handlePlayAll, handleShuffleAll, handleEnqueueAll, handleImportCsv, handleDownload,
|
||||||
@@ -86,7 +88,7 @@ export default function PlaylistHero({
|
|||||||
{enablePlaylistCoverPhoto && (
|
{enablePlaylistCoverPhoto && (
|
||||||
<div
|
<div
|
||||||
className="playlist-hero-cover"
|
className="playlist-hero-cover"
|
||||||
onClick={() => setEditingMeta(true)}
|
onClick={() => { if (actionPolicy.canEditPlaylist) setEditingMeta(true); }}
|
||||||
>
|
>
|
||||||
{customCoverId ? (
|
{customCoverId ? (
|
||||||
<AlbumCoverArtImage
|
<AlbumCoverArtImage
|
||||||
@@ -120,14 +122,16 @@ export default function PlaylistHero({
|
|||||||
{isSmartPlaylistName(playlist.name) && <Sparkles size={16} style={{ color: 'var(--text-muted)' }} />}
|
{isSmartPlaylistName(playlist.name) && <Sparkles size={16} style={{ color: 'var(--text-muted)' }} />}
|
||||||
<span>{displayPlaylistName(playlist.name)}</span>
|
<span>{displayPlaylistName(playlist.name)}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<button
|
{actionPolicy.canEditPlaylist && (
|
||||||
className="btn btn-ghost"
|
<button
|
||||||
onClick={() => setEditingMeta(true)}
|
className="btn btn-ghost"
|
||||||
data-tooltip={t('playlists.editMeta')}
|
onClick={() => setEditingMeta(true)}
|
||||||
style={{ padding: '4px 6px', opacity: 0.7, flexShrink: 0 }}
|
data-tooltip={t('playlists.editMeta')}
|
||||||
>
|
style={{ padding: '4px 6px', opacity: 0.7, flexShrink: 0 }}
|
||||||
<Pencil size={14} />
|
>
|
||||||
</button>
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{playlist.comment && (
|
{playlist.comment && (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 2 }}>{playlist.comment}</div>
|
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 2 }}>{playlist.comment}</div>
|
||||||
@@ -172,7 +176,7 @@ export default function PlaylistHero({
|
|||||||
<ListPlus size={16} />
|
<ListPlus size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isLayoutVisible('addSongs') && (
|
{actionPolicy.canEditPlaylist && isLayoutVisible('addSongs') && (
|
||||||
<button
|
<button
|
||||||
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
|
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
|
||||||
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
|
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')}
|
<Search size={16} /> {t('playlists.addSongs')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isLayoutVisible('importCsv') && (
|
{actionPolicy.canEditPlaylist && isLayoutVisible('importCsv') && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={handleImportCsv}
|
onClick={handleImportCsv}
|
||||||
@@ -192,7 +196,7 @@ export default function PlaylistHero({
|
|||||||
{t('playlists.importCSV')}
|
{t('playlists.importCSV')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isLayoutVisible('downloadZip') && songs.length > 0 && (
|
{actionPolicy.canDownload && isLayoutVisible('downloadZip') && songs.length > 0 && (
|
||||||
activeZip && !activeZip.done && !activeZip.error ? (
|
activeZip && !activeZip.done && !activeZip.error ? (
|
||||||
<div className="download-progress-wrap">
|
<div className="download-progress-wrap">
|
||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
@@ -207,7 +211,7 @@ export default function PlaylistHero({
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{isLayoutVisible('offlineCache') && songs.length > 0 && id
|
{actionPolicy.canPinOffline && isLayoutVisible('offlineCache') && songs.length > 0 && id
|
||||||
&& (!isSmartPlaylistName(playlist.name) || offlineStatus !== 'none') && (
|
&& (!isSmartPlaylistName(playlist.name) || offlineStatus !== 'none') && (
|
||||||
<button
|
<button
|
||||||
className={`btn btn-ghost${offlineStatus === 'cached' ? ' btn-danger' : ''}${offlineStatus === 'queued' ? ' offline-cache-btn--queued' : ''}`}
|
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 {
|
import {
|
||||||
defaultSmartFilters, type SmartFilters,
|
defaultSmartFilters, type SmartFilters,
|
||||||
} from '../../utils/playlist/playlistsSmart';
|
} from '../../utils/playlist/playlistsSmart';
|
||||||
|
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectionMode: boolean;
|
selectionMode: boolean;
|
||||||
@@ -24,6 +25,7 @@ interface Props {
|
|||||||
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
|
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
|
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
|
||||||
setGenreQuery: React.Dispatch<React.SetStateAction<string>>;
|
setGenreQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
actionPolicy?: OfflineActionPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlaylistsHeader({
|
export default function PlaylistsHeader({
|
||||||
@@ -32,8 +34,10 @@ export default function PlaylistsHeader({
|
|||||||
creating, setCreating, setCreatingSmart,
|
creating, setCreating, setCreatingSmart,
|
||||||
newName, setNewName, nameInputRef, handleCreate,
|
newName, setNewName, nameInputRef, handleCreate,
|
||||||
isNavidromeServer, setEditingSmartId, setSmartFilters, setGenreQuery,
|
isNavidromeServer, setEditingSmartId, setSmartFilters, setGenreQuery,
|
||||||
|
actionPolicy,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const policy = actionPolicy ?? offlineActionPolicy('playlistsHeader', false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="playlists-header">
|
<div className="playlists-header">
|
||||||
@@ -43,7 +47,7 @@ export default function PlaylistsHeader({
|
|||||||
: t('playlists.title')}
|
: t('playlists.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
{!(selectionMode && selectedIds.size > 0) && (<>
|
{policy.canEditPlaylist && !(selectionMode && selectedIds.size > 0) && (<>
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,29 +1,38 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useOfflineBrowseContext } from '../../hooks/useOfflineBrowseContext';
|
||||||
|
import { usePlayerStatsRecordingEnabled } from '../../hooks/usePlayerStatsRecordingEnabled';
|
||||||
|
|
||||||
export default function StatisticsTabBar() {
|
export default function StatisticsTabBar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||||
|
const playerStatsEnabled = usePlayerStatsRecordingEnabled();
|
||||||
|
|
||||||
const isPlayer = location.pathname === '/player-stats';
|
const isPlayer = location.pathname === '/player-stats';
|
||||||
|
const showServerTab = !offlineBrowseActive;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
|
||||||
<button
|
{showServerTab && (
|
||||||
type="button"
|
<button
|
||||||
className={`btn btn-sm ${!isPlayer ? 'btn-primary' : 'btn-surface'}`}
|
type="button"
|
||||||
onClick={() => navigate('/statistics')}
|
className={`btn btn-sm ${!isPlayer ? 'btn-primary' : 'btn-surface'}`}
|
||||||
>
|
onClick={() => navigate('/statistics')}
|
||||||
{t('statistics.tabServer')}
|
>
|
||||||
</button>
|
{t('statistics.tabServer')}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
)}
|
||||||
className={`btn btn-sm ${isPlayer ? 'btn-primary' : 'btn-surface'}`}
|
{playerStatsEnabled && (
|
||||||
onClick={() => navigate('/player-stats')}
|
<button
|
||||||
>
|
type="button"
|
||||||
{t('statistics.tabPlayer')}
|
className={`btn btn-sm ${isPlayer || offlineBrowseActive ? 'btn-primary' : 'btn-surface'}`}
|
||||||
</button>
|
onClick={() => navigate('/player-stats')}
|
||||||
|
>
|
||||||
|
{t('statistics.tabPlayer')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</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)',
|
'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)',
|
'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 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,
|
ALBUM_YEAR_FILTER_DEBOUNCE_MS,
|
||||||
resolveAlbumYearBounds,
|
resolveAlbumYearBounds,
|
||||||
} from '../utils/library/albumYearFilter';
|
} 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 { useClientSliceInfiniteScroll } from './useClientSliceInfiniteScroll';
|
||||||
import { useDebouncedValue } from './useDebouncedValue';
|
import { useDebouncedValue } from './useDebouncedValue';
|
||||||
import { useInpageScrollSentinel } from './useInpageScrollSentinel';
|
import { useInpageScrollSentinel } from './useInpageScrollSentinel';
|
||||||
@@ -91,6 +98,8 @@ export function useAlbumBrowseData({
|
|||||||
scrollRootEl,
|
scrollRootEl,
|
||||||
restoreDisplayCount,
|
restoreDisplayCount,
|
||||||
}: UseAlbumBrowseDataArgs) {
|
}: UseAlbumBrowseDataArgs) {
|
||||||
|
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||||
|
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
@@ -232,18 +241,19 @@ export function useAlbumBrowseData({
|
|||||||
catalogLoadingRef.current = true;
|
catalogLoadingRef.current = true;
|
||||||
setCatalogLoadingMore(true);
|
setCatalogLoadingMore(true);
|
||||||
try {
|
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 (generation !== loadGenerationRef.current || chunk == null) return;
|
||||||
if (append) {
|
setAlbums(prev => {
|
||||||
setAlbums(prev => {
|
const { albums: next, offset: nextOffset } = mergeAlbumCatalogChunk(prev, chunk, append);
|
||||||
const merged = dedupeById([...prev, ...chunk.albums]);
|
catalogOffsetRef.current = nextOffset;
|
||||||
catalogOffsetRef.current = merged.length;
|
return next;
|
||||||
return merged;
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setAlbums(chunk.albums);
|
|
||||||
catalogOffsetRef.current = chunk.albums.length;
|
|
||||||
}
|
|
||||||
setCatalogHasMore(chunk.hasMore);
|
setCatalogHasMore(chunk.hasMore);
|
||||||
} finally {
|
} finally {
|
||||||
catalogLoadingRef.current = false;
|
catalogLoadingRef.current = false;
|
||||||
@@ -251,7 +261,7 @@ export function useAlbumBrowseData({
|
|||||||
setCatalogLoadingMore(false);
|
setCatalogLoadingMore(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [serverId]);
|
}, [offlineBrowseActive, serverId, starredOverrides]);
|
||||||
|
|
||||||
const loadBrowse = useCallback(async (
|
const loadBrowse = useCallback(async (
|
||||||
query: AlbumBrowseQuery,
|
query: AlbumBrowseQuery,
|
||||||
@@ -321,6 +331,28 @@ export function useAlbumBrowseData({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
void (async () => {
|
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) {
|
if (indexEnabled && serverId) {
|
||||||
const generation = ++loadGenerationRef.current;
|
const generation = ++loadGenerationRef.current;
|
||||||
coverTrafficBeginGridPagination();
|
coverTrafficBeginGridPagination();
|
||||||
@@ -353,7 +385,7 @@ export function useAlbumBrowseData({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [browseQuery, indexEnabled, serverId, loadBrowse, musicLibraryFilterVersion]);
|
}, [browseQuery, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, loadBrowse, musicLibraryFilterVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!genreCatalogActive) {
|
if (!genreCatalogActive) {
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
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 type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useConnectionStatus } from './useConnectionStatus';
|
|
||||||
import {
|
import {
|
||||||
loadAlbumFromLibraryIndex,
|
loadAlbumFromLibraryIndex,
|
||||||
loadArtistFromLibraryIndex,
|
loadArtistFromLibraryIndex,
|
||||||
resolveAlbumForServer,
|
} from '../utils/offline/offlineLibraryIndexLoad';
|
||||||
} from '../utils/offline/favoritesOfflineBrowse';
|
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 { readDetailServerId } from '../utils/navigation/detailServerScope';
|
||||||
import {
|
import {
|
||||||
shouldAttemptSubsonicForActiveServer,
|
shouldAttemptSubsonicForActiveServer,
|
||||||
shouldAttemptSubsonicForServer,
|
shouldAttemptSubsonicForServer,
|
||||||
} from '../utils/network/subsonicNetworkGuard';
|
} from '../utils/network/subsonicNetworkGuard';
|
||||||
|
|
||||||
type AlbumPayload = Awaited<ReturnType<typeof getAlbum>>;
|
type AlbumPayload = ResolvedAlbum;
|
||||||
|
|
||||||
interface UseAlbumDetailDataResult {
|
interface UseAlbumDetailDataResult {
|
||||||
album: AlbumPayload | null;
|
album: AlbumPayload | null;
|
||||||
@@ -44,7 +50,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
|||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const detailServerId = readDetailServerId(searchParams, activeServerId);
|
const detailServerId = readDetailServerId(searchParams, activeServerId);
|
||||||
const { status: connStatus } = useConnectionStatus();
|
const offlineBrowseActive = useOfflineBrowseContext().active && !!detailServerId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -64,22 +70,25 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
|||||||
serverId: string | null,
|
serverId: string | null,
|
||||||
artistId: string | undefined,
|
artistId: string | undefined,
|
||||||
useLocalArtist: boolean,
|
useLocalArtist: boolean,
|
||||||
|
localBytesOnly: boolean,
|
||||||
) => {
|
) => {
|
||||||
if (!artistId) return;
|
if (!artistId) return;
|
||||||
try {
|
try {
|
||||||
if (useLocalArtist && serverId) {
|
if (useLocalArtist && serverId) {
|
||||||
const artistLocal = await loadArtistFromLibraryIndex(serverId, artistId);
|
const artistLocal = localBytesOnly
|
||||||
|
? await loadArtistFromLocalPlayback(serverId, artistId)
|
||||||
|
: await loadArtistFromLibraryIndex(serverId, artistId);
|
||||||
if (artistLocal) {
|
if (artistLocal) {
|
||||||
setRelatedAlbums(artistLocal.albums.filter(a => a.id !== id));
|
setRelatedAlbums(artistLocal.albums.filter(a => a.id !== id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const relatedServerId = serverId ?? detailServerId ?? activeServerId;
|
const relatedServerId = serverId ?? detailServerId ?? activeServerId;
|
||||||
if (!relatedServerId || !shouldAttemptSubsonicForServer(relatedServerId)) return;
|
if (!relatedServerId) return;
|
||||||
const artistData = detailServerId
|
const artistData = await resolveArtist(relatedServerId, artistId);
|
||||||
? await getArtistForServer(detailServerId, artistId)
|
if (artistData) {
|
||||||
: await getArtist(artistId);
|
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
|
||||||
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch related albums', e);
|
console.error('Failed to fetch related albums', e);
|
||||||
}
|
}
|
||||||
@@ -88,12 +97,28 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
|||||||
const libraryFirst = favoritesOfflineEnabled && !!detailServerId;
|
const libraryFirst = favoritesOfflineEnabled && !!detailServerId;
|
||||||
|
|
||||||
void (async () => {
|
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) {
|
if (libraryFirst && detailServerId) {
|
||||||
try {
|
try {
|
||||||
const local = await resolveAlbumForServer(detailServerId, id);
|
const local = await resolveAlbum(detailServerId, id);
|
||||||
if (local) {
|
if (local) {
|
||||||
applyAlbumPayload(local);
|
applyAlbumPayload(local);
|
||||||
await loadRelatedAlbums(detailServerId, local.album.artistId, true);
|
await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch { /* fall through */ }
|
} catch { /* fall through */ }
|
||||||
@@ -106,10 +131,10 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
|||||||
if (!detailNetworkAllowed) {
|
if (!detailNetworkAllowed) {
|
||||||
if (favoritesOfflineEnabled && detailServerId) {
|
if (favoritesOfflineEnabled && detailServerId) {
|
||||||
try {
|
try {
|
||||||
const local = await loadAlbumFromLibraryIndex(detailServerId, id);
|
const local = await resolveAlbum(detailServerId, id);
|
||||||
if (local) {
|
if (local) {
|
||||||
applyAlbumPayload(local);
|
applyAlbumPayload(local);
|
||||||
await loadRelatedAlbums(detailServerId, local.album.artistId, true);
|
await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
@@ -119,18 +144,25 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = detailServerId
|
const sid = detailServerId ?? activeServerId;
|
||||||
? await getAlbumForServer(detailServerId, id)
|
if (!sid) {
|
||||||
: await getAlbum(id);
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resolveAlbum(sid, id);
|
||||||
|
if (!data) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
applyAlbumPayload(data);
|
applyAlbumPayload(data);
|
||||||
await loadRelatedAlbums(detailServerId, data.album.artistId, false);
|
await loadRelatedAlbums(detailServerId, data.album.artistId, false, false);
|
||||||
} catch {
|
} catch {
|
||||||
if (favoritesOfflineEnabled && detailServerId) {
|
if (favoritesOfflineEnabled && detailServerId) {
|
||||||
try {
|
try {
|
||||||
const local = await loadAlbumFromLibraryIndex(detailServerId, id);
|
const local = await loadAlbumFromLibraryIndex(detailServerId, id);
|
||||||
if (local) {
|
if (local) {
|
||||||
applyAlbumPayload(local);
|
applyAlbumPayload(local);
|
||||||
await loadRelatedAlbums(detailServerId, local.album.artistId, true);
|
await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
@@ -138,7 +170,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [id, connStatus, favoritesOfflineEnabled, detailServerId, searchParams]);
|
}, [activeServerId, detailServerId, favoritesOfflineEnabled, id, offlineBrowseActive, searchParams]);
|
||||||
|
|
||||||
return { album, setAlbum, relatedAlbums, loading, isStarred, setIsStarred, starredSongs, setStarredSongs };
|
return { album, setAlbum, relatedAlbums, loading, isStarred, setIsStarred, starredSongs, setStarredSongs };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import type {
|
|||||||
} from '../api/subsonicTypes';
|
} from '../api/subsonicTypes';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useConnectionStatus } from './useConnectionStatus';
|
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 { readDetailServerId } from '../utils/navigation/detailServerScope';
|
||||||
import { runLocalArtistLosslessBrowse } from '../utils/library/browseTextSearch';
|
import { runLocalArtistLosslessBrowse } from '../utils/library/browseTextSearch';
|
||||||
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
||||||
@@ -58,9 +60,10 @@ export function useArtistDetailData(
|
|||||||
s => !!(serverId && s.audiomuseNavidromeByServer[serverId]),
|
s => !!(serverId && s.audiomuseNavidromeByServer[serverId]),
|
||||||
);
|
);
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
const preferLocalArtist = connStatus === 'disconnected'
|
const offlineBrowseActive = useOfflineBrowseContext().active && !!serverId;
|
||||||
&& favoritesOfflineEnabled
|
const preferLocalBytesOnly = offlineBrowseActive && offlineLocalBrowseEnabled(serverId);
|
||||||
&& !!serverId;
|
const preferLocalArtist = preferLocalBytesOnly
|
||||||
|
|| (connStatus === 'disconnected' && favoritesOfflineEnabled && !!serverId);
|
||||||
|
|
||||||
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
@@ -82,8 +85,14 @@ export function useArtistDetailData(
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
if (offlineBrowseActive && !preferLocalBytesOnly) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (preferLocalArtist && serverId && id) {
|
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 (cancelled) return;
|
||||||
if (local) {
|
if (local) {
|
||||||
setArtist(local.artist);
|
setArtist(local.artist);
|
||||||
@@ -93,6 +102,10 @@ export function useArtistDetailData(
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (preferLocalBytesOnly) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (losslessOnly && serverId) {
|
if (losslessOnly && serverId) {
|
||||||
@@ -135,7 +148,9 @@ export function useArtistDetailData(
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
if (preferLocalArtist && serverId && id) {
|
if (preferLocalArtist && serverId && id) {
|
||||||
try {
|
try {
|
||||||
const local = await loadArtistFromLibraryIndex(serverId, id);
|
const local = preferLocalBytesOnly
|
||||||
|
? await loadArtistFromLocalPlayback(serverId, id)
|
||||||
|
: await loadArtistFromLibraryIndex(serverId, id);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (local) {
|
if (local) {
|
||||||
setArtist(local.artist);
|
setArtist(local.artist);
|
||||||
@@ -154,7 +169,7 @@ export function useArtistDetailData(
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [id, losslessOnly, serverId, preferLocalArtist, searchParams]);
|
}, [id, losslessOnly, serverId, offlineBrowseActive, preferLocalArtist, preferLocalBytesOnly, searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id || preferLocalArtist) return;
|
if (!id || preferLocalArtist) return;
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import {
|
|||||||
fetchLocalArtistCatalogChunk,
|
fetchLocalArtistCatalogChunk,
|
||||||
fetchNetworkStarredArtists,
|
fetchNetworkStarredArtists,
|
||||||
} from '../utils/library/browseTextSearch';
|
} 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. */
|
/** Local-index artist catalog buffer grows by this many rows per background SQL chunk. */
|
||||||
export const ARTIST_CATALOG_CHUNK_SIZE = 200;
|
export const ARTIST_CATALOG_CHUNK_SIZE = 200;
|
||||||
@@ -25,6 +32,8 @@ export function useArtistsBrowseCatalog({
|
|||||||
starredOnly,
|
starredOnly,
|
||||||
musicLibraryFilterVersion,
|
musicLibraryFilterVersion,
|
||||||
}: UseArtistsBrowseCatalogArgs) {
|
}: UseArtistsBrowseCatalogArgs) {
|
||||||
|
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||||
|
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
|
||||||
const [catalogArtists, setCatalogArtists] = useState<SubsonicArtist[]>([]);
|
const [catalogArtists, setCatalogArtists] = useState<SubsonicArtist[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [catalogHasMore, setCatalogHasMore] = useState(false);
|
const [catalogHasMore, setCatalogHasMore] = useState(false);
|
||||||
@@ -41,6 +50,27 @@ export function useArtistsBrowseCatalog({
|
|||||||
catalogLoadingRef.current = true;
|
catalogLoadingRef.current = true;
|
||||||
setCatalogLoadingMore(true);
|
setCatalogLoadingMore(true);
|
||||||
try {
|
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(
|
const chunk = await fetchLocalArtistCatalogChunk(
|
||||||
serverId,
|
serverId,
|
||||||
catalogOffsetRef.current,
|
catalogOffsetRef.current,
|
||||||
@@ -65,7 +95,7 @@ export function useArtistsBrowseCatalog({
|
|||||||
setCatalogLoadingMore(false);
|
setCatalogLoadingMore(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [serverId]);
|
}, [offlineBrowseActive, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -80,6 +110,27 @@ export function useArtistsBrowseCatalog({
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
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 (starredOnly) {
|
||||||
if (!cancelled && generation === loadGenerationRef.current) {
|
if (!cancelled && generation === loadGenerationRef.current) {
|
||||||
setCatalogArtists(await fetchNetworkStarredArtists());
|
setCatalogArtists(await fetchNetworkStarredArtists());
|
||||||
@@ -116,7 +167,7 @@ export function useArtistsBrowseCatalog({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [musicLibraryFilterVersion, indexEnabled, serverId, starredOnly]);
|
}, [musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
catalogArtists,
|
catalogArtists,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
runLocalBrowseAlbums,
|
runLocalBrowseAlbums,
|
||||||
runNetworkBrowseAlbums,
|
runNetworkBrowseAlbums,
|
||||||
} from '../utils/library/browseTextSearch';
|
} 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
|
* Debounced album title search with local-vs-network race when the
|
||||||
@@ -19,6 +21,7 @@ export function useBrowseAlbumTextSearch(
|
|||||||
serverId: string | null | undefined,
|
serverId: string | null | undefined,
|
||||||
losslessOnly = false,
|
losslessOnly = false,
|
||||||
) {
|
) {
|
||||||
|
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||||
const [debouncedFilter, setDebouncedFilter] = useState('');
|
const [debouncedFilter, setDebouncedFilter] = useState('');
|
||||||
const [textSearchAlbums, setTextSearchAlbums] = useState<SubsonicAlbum[] | null>(null);
|
const [textSearchAlbums, setTextSearchAlbums] = useState<SubsonicAlbum[] | null>(null);
|
||||||
const [textSearchLoading, setTextSearchLoading] = useState(false);
|
const [textSearchLoading, setTextSearchLoading] = useState(false);
|
||||||
@@ -43,6 +46,15 @@ export function useBrowseAlbumTextSearch(
|
|||||||
setTextSearchLoading(true);
|
setTextSearchLoading(true);
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
if (offlineBrowseActive) {
|
||||||
|
const albums = offlineLocalBrowseEnabled(serverId)
|
||||||
|
? await searchOfflineLocalAlbums(serverId, q, losslessOnly)
|
||||||
|
: [];
|
||||||
|
if (isStale()) return;
|
||||||
|
setTextSearchAlbums(albums);
|
||||||
|
setTextSearchLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!indexEnabled) {
|
if (!indexEnabled) {
|
||||||
const albums = await runNetworkBrowseAlbums(q);
|
const albums = await runNetworkBrowseAlbums(q);
|
||||||
if (isStale()) return;
|
if (isStale()) return;
|
||||||
@@ -66,7 +78,7 @@ export function useBrowseAlbumTextSearch(
|
|||||||
setTextSearchAlbums(outcome?.result ?? null);
|
setTextSearchAlbums(outcome?.result ?? null);
|
||||||
setTextSearchLoading(false);
|
setTextSearchLoading(false);
|
||||||
})();
|
})();
|
||||||
}, [debouncedFilter, indexEnabled, serverId, losslessOnly]);
|
}, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, losslessOnly]);
|
||||||
|
|
||||||
const effectiveFilter = textSearchAlbums != null ? '' : filter;
|
const effectiveFilter = textSearchAlbums != null ? '' : filter;
|
||||||
return { textSearchAlbums, textSearchLoading, effectiveFilter };
|
return { textSearchAlbums, textSearchLoading, effectiveFilter };
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
runNetworkBrowseArtists,
|
runNetworkBrowseArtists,
|
||||||
type LibrarySearchSurface,
|
type LibrarySearchSurface,
|
||||||
} from '../utils/library/browseTextSearch';
|
} 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
|
* Debounced artist/composer name search with local-vs-network race when the
|
||||||
@@ -22,6 +24,7 @@ export function useBrowseArtistTextSearch(
|
|||||||
serverId: string | null | undefined,
|
serverId: string | null | undefined,
|
||||||
surface: LibrarySearchSurface = 'artists_browse',
|
surface: LibrarySearchSurface = 'artists_browse',
|
||||||
) {
|
) {
|
||||||
|
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||||
const [debouncedFilter, setDebouncedFilter] = useState('');
|
const [debouncedFilter, setDebouncedFilter] = useState('');
|
||||||
const [textSearchArtists, setTextSearchArtists] = useState<SubsonicArtist[] | null>(null);
|
const [textSearchArtists, setTextSearchArtists] = useState<SubsonicArtist[] | null>(null);
|
||||||
const [textSearchLoading, setTextSearchLoading] = useState(false);
|
const [textSearchLoading, setTextSearchLoading] = useState(false);
|
||||||
@@ -46,6 +49,15 @@ export function useBrowseArtistTextSearch(
|
|||||||
setTextSearchLoading(true);
|
setTextSearchLoading(true);
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
if (offlineBrowseActive) {
|
||||||
|
const artists = offlineLocalBrowseEnabled(serverId)
|
||||||
|
? await searchOfflineLocalArtists(serverId, q)
|
||||||
|
: [];
|
||||||
|
if (isStale()) return;
|
||||||
|
setTextSearchArtists(artists);
|
||||||
|
setTextSearchLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const outcome = await raceBrowseWithLocalFallback(
|
const outcome = await raceBrowseWithLocalFallback(
|
||||||
isStale,
|
isStale,
|
||||||
() => runLocalBrowseArtists(serverId, q),
|
() => runLocalBrowseArtists(serverId, q),
|
||||||
@@ -61,7 +73,7 @@ export function useBrowseArtistTextSearch(
|
|||||||
setTextSearchArtists(outcome?.result ?? null);
|
setTextSearchArtists(outcome?.result ?? null);
|
||||||
setTextSearchLoading(false);
|
setTextSearchLoading(false);
|
||||||
})();
|
})();
|
||||||
}, [debouncedFilter, indexEnabled, serverId, surface]);
|
}, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, surface]);
|
||||||
|
|
||||||
const effectiveFilter = textSearchArtists != null ? '' : filter;
|
const effectiveFilter = textSearchArtists != null ? '' : filter;
|
||||||
return { textSearchArtists, textSearchLoading, effectiveFilter };
|
return { textSearchArtists, textSearchLoading, effectiveFilter };
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ vi.mock('@/utils/perf/perfFlags', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { pingWithCredentials } from '@/api/subsonic';
|
import { pingWithCredentials } from '@/api/subsonic';
|
||||||
|
import { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore';
|
||||||
import { useConnectionStatus } from './useConnectionStatus';
|
import { useConnectionStatus } from './useConnectionStatus';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAuthStore();
|
resetAuthStore();
|
||||||
invalidateReachableEndpointCache();
|
invalidateReachableEndpointCache();
|
||||||
|
useDevOfflineBrowseStore.getState().setForceOffline(false);
|
||||||
vi.mocked(pingWithCredentials).mockReset();
|
vi.mocked(pingWithCredentials).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,3 +135,39 @@ describe('useConnectionStatus online event', () => {
|
|||||||
expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(2);
|
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';
|
} from '../utils/server/serverEndpoint';
|
||||||
import { setActiveServerReachable } from '../utils/network/activeServerReachability';
|
import { setActiveServerReachable } from '../utils/network/activeServerReachability';
|
||||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
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.
|
// Backward-compatible re-export for call sites that still import from the hook.
|
||||||
export { isLanUrl };
|
export { isLanUrl };
|
||||||
@@ -18,6 +22,7 @@ export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
|
|||||||
|
|
||||||
export function useConnectionStatus() {
|
export function useConnectionStatus() {
|
||||||
const perfFlags = usePerfProbeFlags();
|
const perfFlags = usePerfProbeFlags();
|
||||||
|
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
|
||||||
const [status, setStatus] = useState<ConnectionStatus>('checking');
|
const [status, setStatus] = useState<ConnectionStatus>('checking');
|
||||||
const [isRetrying, setIsRetrying] = useState(false);
|
const [isRetrying, setIsRetrying] = useState(false);
|
||||||
// Tracks the kind of endpoint the last successful probe answered on so the
|
// 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'.
|
// public alternate must read as 'public', not 'local'.
|
||||||
const [activeEndpointKind, setActiveEndpointKind] = useState<ServerEndpointKind | null>(null);
|
const [activeEndpointKind, setActiveEndpointKind] = useState<ServerEndpointKind | null>(null);
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const prevDevForceOfflineRef = useRef<boolean | null>(null);
|
||||||
|
|
||||||
const check = useCallback(async () => {
|
const check = useCallback(async () => {
|
||||||
|
if (isDevOfflineBrowseForced()) {
|
||||||
|
setActiveServerReachable(false);
|
||||||
|
setStatus('disconnected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const server = useAuthStore.getState().getActiveServer();
|
const server = useAuthStore.getState().getActiveServer();
|
||||||
if (!server) {
|
if (!server) {
|
||||||
setActiveServerReachable(false);
|
setActiveServerReachable(false);
|
||||||
@@ -75,14 +87,47 @@ export function useConnectionStatus() {
|
|||||||
setIsRetrying(false);
|
setIsRetrying(false);
|
||||||
}, [check]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (perfFlags.disableBackgroundPolling) {
|
if (perfFlags.disableBackgroundPolling) {
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
}
|
}
|
||||||
setActiveServerReachable(true);
|
if (isDevOfflineBrowseForced()) {
|
||||||
setStatus('connected');
|
setActiveServerReachable(false);
|
||||||
|
setStatus('disconnected');
|
||||||
|
} else {
|
||||||
|
setActiveServerReachable(true);
|
||||||
|
setStatus('connected');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
check();
|
check();
|
||||||
@@ -108,7 +153,7 @@ export function useConnectionStatus() {
|
|||||||
window.removeEventListener('online', handleOnline);
|
window.removeEventListener('online', handleOnline);
|
||||||
window.removeEventListener('offline', handleOffline);
|
window.removeEventListener('offline', handleOffline);
|
||||||
};
|
};
|
||||||
}, [check, perfFlags.disableBackgroundPolling]);
|
}, [check, devForceOffline, perfFlags.disableBackgroundPolling]);
|
||||||
|
|
||||||
const server = useAuthStore(s => s.getActiveServer());
|
const server = useAuthStore(s => s.getActiveServer());
|
||||||
const servers = useAuthStore(s => s.servers);
|
const servers = useAuthStore(s => s.servers);
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import { usePlayerStore } from '../store/playerStore';
|
|||||||
import type { TopFavoriteArtist } from '../components/favorites/TopFavoriteArtists';
|
import type { TopFavoriteArtist } from '../components/favorites/TopFavoriteArtists';
|
||||||
import { useConnectionStatus } from './useConnectionStatus';
|
import { useConnectionStatus } from './useConnectionStatus';
|
||||||
import { isActiveServerReachable } from '../utils/network/activeServerReachability';
|
import { isActiveServerReachable } from '../utils/network/activeServerReachability';
|
||||||
|
import { useOfflineBrowseContext } from './useOfflineBrowseContext';
|
||||||
|
import { useOfflineBrowseReloadToken } from './useOfflineBrowseReloadToken';
|
||||||
import {
|
import {
|
||||||
loadStarredFromAllLibraryIndexes,
|
loadStarredFromAllLibraryIndexes,
|
||||||
loadStarredFromAllServersOnline,
|
loadStarredFromAllServersOnline,
|
||||||
} from '../utils/offline/favoritesOfflineBrowse';
|
} from '../utils/offline/offlineStarredLoad';
|
||||||
|
|
||||||
export interface FavoritesDataResult {
|
export interface FavoritesDataResult {
|
||||||
albums: SubsonicAlbum[];
|
albums: SubsonicAlbum[];
|
||||||
@@ -43,6 +45,8 @@ export function useFavoritesData(): FavoritesDataResult {
|
|||||||
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
||||||
const servers = useAuthStore(s => s.servers);
|
const servers = useAuthStore(s => s.servers);
|
||||||
const { status: connStatus } = useConnectionStatus();
|
const { status: connStatus } = useConnectionStatus();
|
||||||
|
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||||
|
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
|
||||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,7 +80,7 @@ export function useFavoritesData(): FavoritesDataResult {
|
|||||||
|
|
||||||
if (favoritesOfflineEnabled) {
|
if (favoritesOfflineEnabled) {
|
||||||
try {
|
try {
|
||||||
applyStarred(await loadStarredFromAllLibraryIndexes());
|
applyStarred(await loadStarredFromAllLibraryIndexes(offlineBrowseActive));
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
|
|
||||||
@@ -100,9 +104,8 @@ export function useFavoritesData(): FavoritesDataResult {
|
|||||||
|
|
||||||
void loadAll();
|
void loadAll();
|
||||||
return () => { cancelled = true; };
|
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 topFavoriteArtists = useMemo<TopFavoriteArtist[]>(() => {
|
||||||
const counts = new Map<string, TopFavoriteArtist>();
|
const counts = new Map<string, TopFavoriteArtist>();
|
||||||
for (const s of songs) {
|
for (const s of songs) {
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
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 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
|
* On disconnect:
|
||||||
* connection status:
|
* - No offline browse content → stay on the current page (banner only).
|
||||||
* - Disconnect with manual offline pins → push `/offline`.
|
* - Offline-capable route → stay and bump location state so data hooks reload.
|
||||||
* - Disconnect with favorites offline browse enabled → push `/favorites`.
|
* - Otherwise → redirect to All Albums.
|
||||||
* - Reconnect while sitting on `/offline` or `/favorites` → push back to `/`.
|
|
||||||
*
|
*
|
||||||
* Only fires on transitions (not on every render). Reconnect-bounce is
|
* Only runs on connection transitions, not every render.
|
||||||
* gated on `prev === 'disconnected'` so a user who navigates to `/offline`
|
|
||||||
* manually while online stays there.
|
|
||||||
*/
|
*/
|
||||||
export function useOfflineAutoNav(
|
export function useOfflineAutoNav(
|
||||||
connStatus: ConnStatus | string,
|
connStatus: ConnStatus | string,
|
||||||
hasManualOfflineContent: boolean,
|
ctx: OfflineAutoNavContext,
|
||||||
favoritesOfflineBrowse: boolean,
|
location: Pick<Location, 'pathname' | 'search' | 'state'>,
|
||||||
pathname: string,
|
|
||||||
navigate: NavigateFunction,
|
navigate: NavigateFunction,
|
||||||
): void {
|
): void {
|
||||||
const prevConnStatus = useRef(connStatus);
|
const prevConnStatus = useRef(connStatus);
|
||||||
@@ -26,25 +31,46 @@ export function useOfflineAutoNav(
|
|||||||
const prev = prevConnStatus.current;
|
const prev = prevConnStatus.current;
|
||||||
prevConnStatus.current = connStatus;
|
prevConnStatus.current = connStatus;
|
||||||
|
|
||||||
if (connStatus === 'disconnected' && prev !== 'disconnected') {
|
if (connStatus !== 'disconnected' || prev === 'disconnected') return;
|
||||||
if (hasManualOfflineContent) {
|
|
||||||
navigate('/offline', { replace: true });
|
const action = resolveOfflineDisconnectNavAction(
|
||||||
} else if (favoritesOfflineBrowse) {
|
location.pathname,
|
||||||
navigate('/favorites', { replace: true });
|
ctx.favoritesOfflineBrowse,
|
||||||
}
|
ctx.localLibraryBrowse,
|
||||||
}
|
ctx.playerStatsBrowse,
|
||||||
if (
|
ctx.playlistsOfflineBrowse,
|
||||||
connStatus === 'connected'
|
ctx.hasManualOfflineContent,
|
||||||
&& prev === 'disconnected'
|
);
|
||||||
&& (pathname === '/offline' || pathname === '/favorites')
|
|
||||||
) {
|
if (action.kind === 'stay') return;
|
||||||
navigate('/', { replace: true });
|
|
||||||
|
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,
|
connStatus,
|
||||||
hasManualOfflineContent,
|
ctx.favoritesOfflineBrowse,
|
||||||
favoritesOfflineBrowse,
|
ctx.localLibraryBrowse,
|
||||||
pathname,
|
ctx.playerStatsBrowse,
|
||||||
|
ctx.playlistsOfflineBrowse,
|
||||||
|
ctx.hasManualOfflineContent,
|
||||||
|
location.pathname,
|
||||||
|
location.search,
|
||||||
|
location.state,
|
||||||
navigate,
|
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 { filterSongsToActiveLibrary } from '../api/subsonicLibrary';
|
||||||
import { getPlaylist } from '../api/subsonicPlaylists';
|
import { getPlaylist } from '../api/subsonicPlaylists';
|
||||||
import type { SubsonicPlaylist } from '../api/subsonicTypes';
|
import type { SubsonicPlaylist } from '../api/subsonicTypes';
|
||||||
|
import { useOfflineBrowseContext } from './useOfflineBrowseContext';
|
||||||
|
|
||||||
export interface PlaylistsLibraryScopeCountsResult {
|
export interface PlaylistsLibraryScopeCountsResult {
|
||||||
filteredSongCountByPlaylist: Record<string, number>;
|
filteredSongCountByPlaylist: Record<string, number>;
|
||||||
@@ -20,6 +21,7 @@ export function usePlaylistsLibraryScopeCounts(
|
|||||||
): PlaylistsLibraryScopeCountsResult {
|
): PlaylistsLibraryScopeCountsResult {
|
||||||
const [filteredSongCountByPlaylist, setFilteredSongCountByPlaylist] = useState<Record<string, number>>({});
|
const [filteredSongCountByPlaylist, setFilteredSongCountByPlaylist] = useState<Record<string, number>>({});
|
||||||
const [filteredDurationByPlaylist, setFilteredDurationByPlaylist] = useState<Record<string, number>>({});
|
const [filteredDurationByPlaylist, setFilteredDurationByPlaylist] = useState<Record<string, number>>({});
|
||||||
|
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -31,6 +33,19 @@ export function usePlaylistsLibraryScopeCounts(
|
|||||||
}
|
}
|
||||||
return;
|
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 ids = playlists.map((pl) => pl.id);
|
||||||
const next: Record<string, number> = {};
|
const next: Record<string, number> = {};
|
||||||
const nextDuration: Record<string, number> = {};
|
const nextDuration: Record<string, number> = {};
|
||||||
@@ -60,7 +75,7 @@ export function usePlaylistsLibraryScopeCounts(
|
|||||||
};
|
};
|
||||||
run();
|
run();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [playlists, musicLibraryFilterVersion]);
|
}, [playlists, musicLibraryFilterVersion, offlineBrowseActive]);
|
||||||
|
|
||||||
return { filteredSongCountByPlaylist, filteredDurationByPlaylist };
|
return { filteredSongCountByPlaylist, filteredDurationByPlaylist };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
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 { useDragDrop, registerQueueDragHitTest } from '../contexts/DragDropContext';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
@@ -77,13 +78,11 @@ export function useQueuePanelDrag({
|
|||||||
} else if (parsedData.type === 'songs') {
|
} else if (parsedData.type === 'songs') {
|
||||||
enqueueAt(parsedData.tracks as Track[], insertIdx);
|
enqueueAt(parsedData.tracks as Track[], insertIdx);
|
||||||
} else if (parsedData.type === 'album') {
|
} else if (parsedData.type === 'album') {
|
||||||
const albumData = await getAlbum(parsedData.id);
|
const serverId = resolveMediaServerId(parsedData.serverId);
|
||||||
const tracks: Track[] = albumData.songs.map((s: any) => ({
|
if (!serverId) return;
|
||||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
const albumData = await resolveAlbum(serverId, parsedData.id);
|
||||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
if (!albumData) return;
|
||||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
|
enqueueAt(albumData.songs.map(songToTrack), insertIdx);
|
||||||
}));
|
|
||||||
enqueueAt(tracks, insertIdx);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ vi.mock('../utils/library/advancedSearchLocal', () => ({
|
|||||||
runLocalSongBrowse: vi.fn(async () => []),
|
runLocalSongBrowse: vi.fn(async () => []),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./useOfflineBrowseReloadToken', () => ({
|
||||||
|
useOfflineBrowseReloadToken: () => undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../utils/library/browseTextSearch', () => ({
|
vi.mock('../utils/library/browseTextSearch', () => ({
|
||||||
BROWSE_TEXT_DEBOUNCE_NETWORK_MS: 10,
|
BROWSE_TEXT_DEBOUNCE_NETWORK_MS: 10,
|
||||||
BROWSE_TEXT_DEBOUNCE_RACE_MS: 10,
|
BROWSE_TEXT_DEBOUNCE_RACE_MS: 10,
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import {
|
|||||||
} from '../utils/library/browseTextSearch';
|
} from '../utils/library/browseTextSearch';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
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;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
@@ -50,7 +57,10 @@ type UseSongBrowseListArgs = {
|
|||||||
/** Tracks hub song browse — all-library paging or filtered text search. */
|
/** Tracks hub song browse — all-library paging or filtered text search. */
|
||||||
export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseSongBrowseListArgs) {
|
export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseSongBrowseListArgs) {
|
||||||
const serverId = useAuthStore(s => s.activeServerId);
|
const serverId = useAuthStore(s => s.activeServerId);
|
||||||
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||||
|
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||||
|
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
|
||||||
|
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState(
|
const [debouncedQuery, setDebouncedQuery] = useState(
|
||||||
() => initialRestore?.query.trim() ?? searchQuery.trim(),
|
() => initialRestore?.query.trim() ?? searchQuery.trim(),
|
||||||
@@ -73,7 +83,6 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
|
|||||||
const restoreQueryHoldRef = useRef(
|
const restoreQueryHoldRef = useRef(
|
||||||
initialRestore?.query.trim() ? initialRestore.query.trim() : null,
|
initialRestore?.query.trim() ? initialRestore.query.trim() : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
const incoming = searchQuery.trim();
|
const incoming = searchQuery.trim();
|
||||||
@@ -88,6 +97,15 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
|
|||||||
|
|
||||||
const fetchSongPage = useCallback(
|
const fetchSongPage = useCallback(
|
||||||
async (q: string, pageOffset: number, isStale: () => boolean): Promise<SubsonicSong[]> => {
|
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 === '') {
|
if (q === '') {
|
||||||
return fetchBrowseAllPage(serverId, pageOffset);
|
return fetchBrowseAllPage(serverId, pageOffset);
|
||||||
}
|
}
|
||||||
@@ -123,7 +141,7 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
|
|||||||
|
|
||||||
return (await runNetworkBrowseSongPage(q, pageOffset, PAGE_SIZE)) ?? [];
|
return (await runNetworkBrowseSongPage(q, pageOffset, PAGE_SIZE)) ?? [];
|
||||||
},
|
},
|
||||||
[indexEnabled, serverId],
|
[indexEnabled, musicLibraryFilterVersion, offlineBrowseActive, serverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -171,7 +189,7 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [debouncedQuery, searchQuery, fetchSongPage, enabled]);
|
}, [debouncedQuery, searchQuery, fetchSongPage, enabled, musicLibraryFilterVersion, offlineBrowseReloadTs]);
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (!enabled || loading || !hasMore) return;
|
if (!enabled || loading || !hasMore) return;
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ import LosslessModeBanner from '../components/LosslessModeBanner';
|
|||||||
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
||||||
import { isLosslessMode } from '../utils/library/losslessMode';
|
import { isLosslessMode } from '../utils/library/losslessMode';
|
||||||
import { readDetailServerId } from '../utils/navigation/detailServerScope';
|
import { readDetailServerId } from '../utils/navigation/detailServerScope';
|
||||||
|
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||||
|
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||||
|
|
||||||
export default function AlbumDetail() {
|
export default function AlbumDetail() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -76,6 +78,8 @@ export default function AlbumDetail() {
|
|||||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||||
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
|
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
|
||||||
|
const offlineCtx = useOfflineBrowseContext();
|
||||||
|
const albumActionPolicy = offlineActionPolicy('albumDetail', offlineCtx.active);
|
||||||
|
|
||||||
const [albumEntityRating, setAlbumEntityRating] = useState(0);
|
const [albumEntityRating, setAlbumEntityRating] = useState(0);
|
||||||
const [filterText, setFilterText] = useState('');
|
const [filterText, setFilterText] = useState('');
|
||||||
@@ -369,6 +373,7 @@ const handleShuffleAll = () => {
|
|||||||
entityRatingValue={albumEntityRating}
|
entityRatingValue={albumEntityRating}
|
||||||
onEntityRatingChange={handleAlbumEntityRating}
|
onEntityRatingChange={handleAlbumEntityRating}
|
||||||
entityRatingSupport={albumEntityRatingSupport}
|
entityRatingSupport={albumEntityRatingSupport}
|
||||||
|
actionPolicy={albumActionPolicy}
|
||||||
/>
|
/>
|
||||||
{losslessOnly && <LosslessModeBanner />}
|
{losslessOnly && <LosslessModeBanner />}
|
||||||
|
|
||||||
@@ -381,6 +386,7 @@ const handleShuffleAll = () => {
|
|||||||
showPlPicker={showPlPicker}
|
showPlPicker={showPlPicker}
|
||||||
setShowPlPicker={setShowPlPicker}
|
setShowPlPicker={setShowPlPicker}
|
||||||
t={t}
|
t={t}
|
||||||
|
actionPolicy={albumActionPolicy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -402,6 +408,7 @@ const handleShuffleAll = () => {
|
|||||||
sortKey={sortKey}
|
sortKey={sortKey}
|
||||||
sortDir={sortDir}
|
sortDir={sortDir}
|
||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
|
actionPolicy={albumActionPolicy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{relatedAlbums.length > 0 && (
|
{relatedAlbums.length > 0 && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
||||||
import { getAlbum } from '../api/subsonicLibrary';
|
import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
|
||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
import { songToTrack } from '../utils/playback/songToTrack';
|
||||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
@@ -233,8 +233,10 @@ export default function Albums() {
|
|||||||
const handleEnqueueSelected = async () => {
|
const handleEnqueueSelected = async () => {
|
||||||
if (selectedAlbums.length === 0) return;
|
if (selectedAlbums.length === 0) return;
|
||||||
try {
|
try {
|
||||||
// Parallel — Navidrome handles concurrent getAlbum requests fine.
|
// Parallel album resolves — Navidrome handles concurrent requests fine.
|
||||||
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) : []);
|
const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []);
|
||||||
if (tracks.length > 0) {
|
if (tracks.length > 0) {
|
||||||
enqueue(tracks);
|
enqueue(tracks);
|
||||||
@@ -277,7 +279,8 @@ export default function Albums() {
|
|||||||
let queued = 0;
|
let queued = 0;
|
||||||
for (const album of selectedAlbums) {
|
for (const album of selectedAlbums) {
|
||||||
try {
|
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);
|
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||||
queued++;
|
queued++;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { uploadArtistImage } from '../api/subsonicPlaylists';
|
|||||||
import { useCoverArt } from '../cover/useCoverArt';
|
import { useCoverArt } from '../cover/useCoverArt';
|
||||||
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
||||||
import { getAlbum } from '../api/subsonicLibrary';
|
|
||||||
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
import { songToTrack } from '../utils/playback/songToTrack';
|
||||||
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
||||||
@@ -31,8 +30,11 @@ import {
|
|||||||
import { useArtistDetailData } from '../hooks/useArtistDetailData';
|
import { useArtistDetailData } from '../hooks/useArtistDetailData';
|
||||||
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
|
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
|
||||||
import {
|
import {
|
||||||
|
fetchArtistDetailTracks,
|
||||||
runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio,
|
runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio,
|
||||||
} from '../utils/componentHelpers/runArtistDetailPlay';
|
} from '../utils/componentHelpers/runArtistDetailPlay';
|
||||||
|
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||||
|
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||||
import {
|
import {
|
||||||
runArtistEntityRating, runArtistToggleStar, runArtistShare, runArtistImageUpload,
|
runArtistEntityRating, runArtistToggleStar, runArtistShare, runArtistImageUpload,
|
||||||
} from '../utils/componentHelpers/runArtistDetailActions';
|
} from '../utils/componentHelpers/runArtistDetailActions';
|
||||||
@@ -101,6 +103,8 @@ export default function ArtistDetail() {
|
|||||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||||
|
const offlineCtx = useOfflineBrowseContext();
|
||||||
|
const artistActionPolicy = offlineActionPolicy('artistDetail', offlineCtx.active);
|
||||||
|
|
||||||
const [artistEntityRating, setArtistEntityRating] = useState(0);
|
const [artistEntityRating, setArtistEntityRating] = useState(0);
|
||||||
|
|
||||||
@@ -122,8 +126,12 @@ export default function ArtistDetail() {
|
|||||||
|
|
||||||
const toggleStar = () => runArtistToggleStar({ artist, isStarred, setIsStarred });
|
const toggleStar = () => runArtistToggleStar({ artist, isStarred, setIsStarred });
|
||||||
|
|
||||||
const handlePlayAll = () => runArtistDetailPlayAll({ albums, setPlayAllLoading, playTrack });
|
const handlePlayAll = () => runArtistDetailPlayAll({
|
||||||
const handleShuffle = () => runArtistDetailShuffle({ albums, setPlayAllLoading, playTrack });
|
albums, serverId: activeServerId, setPlayAllLoading, playTrack,
|
||||||
|
});
|
||||||
|
const handleShuffle = () => runArtistDetailShuffle({
|
||||||
|
albums, serverId: activeServerId, setPlayAllLoading, playTrack,
|
||||||
|
});
|
||||||
const handleStartRadio = () => {
|
const handleStartRadio = () => {
|
||||||
if (!artist) return;
|
if (!artist) return;
|
||||||
return runArtistDetailStartRadio({ artist, t, setRadioLoading, playTrack, enqueue });
|
return runArtistDetailStartRadio({ artist, t, setRadioLoading, playTrack, enqueue });
|
||||||
@@ -139,9 +147,7 @@ export default function ArtistDetail() {
|
|||||||
setPlayAllLoading(true);
|
setPlayAllLoading(true);
|
||||||
try {
|
try {
|
||||||
// Get all artist tracks ordered by album and track number
|
// Get all artist tracks ordered by album and track number
|
||||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
const allTracks = await fetchArtistDetailTracks(albums, activeServerId);
|
||||||
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);
|
|
||||||
|
|
||||||
// Top songs from clicked index onward
|
// Top songs from clicked index onward
|
||||||
const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack);
|
const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack);
|
||||||
@@ -314,6 +320,7 @@ export default function ArtistDetail() {
|
|||||||
coverRevision={coverRevision}
|
coverRevision={coverRevision}
|
||||||
headerCoverFailed={headerCoverFailed}
|
headerCoverFailed={headerCoverFailed}
|
||||||
setHeaderCoverFailed={setHeaderCoverFailed}
|
setHeaderCoverFailed={setHeaderCoverFailed}
|
||||||
|
actionPolicy={artistActionPolicy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{losslessOnly && <LosslessModeBanner />}
|
{losslessOnly && <LosslessModeBanner />}
|
||||||
|
|||||||
+46
-13
@@ -22,10 +22,16 @@ import { useLibraryCoverPrefetch } from '../cover/useLibraryCoverPrefetch';
|
|||||||
import { primeAlbumCoversForDisplay, warmHomeMainstageCovers } from '../cover/warmDiskPeek';
|
import { primeAlbumCoversForDisplay, warmHomeMainstageCovers } from '../cover/warmDiskPeek';
|
||||||
import { readBecauseYouLikeCache } from '../store/becauseYouLikeCache';
|
import { readBecauseYouLikeCache } from '../store/becauseYouLikeCache';
|
||||||
import {
|
import {
|
||||||
|
isHomeFeedSnapshotEmpty,
|
||||||
readHomeFeedCache,
|
readHomeFeedCache,
|
||||||
|
readHomeFeedCacheStale,
|
||||||
writeHomeFeedCache,
|
writeHomeFeedCache,
|
||||||
type HomeFeedSnapshot,
|
type HomeFeedSnapshot,
|
||||||
} from '../store/homeFeedCache';
|
} 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. */
|
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
|
||||||
const HOME_RANDOM_FETCH = 100;
|
const HOME_RANDOM_FETCH = 100;
|
||||||
@@ -52,7 +58,8 @@ const HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED = 8;
|
|||||||
function getInitialHomeFeed(): HomeFeedSnapshot | null {
|
function getInitialHomeFeed(): HomeFeedSnapshot | null {
|
||||||
const { activeServerId, musicLibraryFilterVersion } = useAuthStore.getState();
|
const { activeServerId, musicLibraryFilterVersion } = useAuthStore.getState();
|
||||||
if (!activeServerId) return null;
|
if (!activeServerId) return null;
|
||||||
return readHomeFeedCache(activeServerId, musicLibraryFilterVersion);
|
return readHomeFeedCache(activeServerId, musicLibraryFilterVersion)
|
||||||
|
?? readHomeFeedCacheStale(activeServerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -63,6 +70,10 @@ export default function Home() {
|
|||||||
const homeSections = useHomeStore(s => s.sections);
|
const homeSections = useHomeStore(s => s.sections);
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
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
|
// Mix-rating deps intentionally NOT subscribed here — they change during Zustand
|
||||||
// rehydration and would trigger a second useEffect fire right after the first,
|
// 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)
|
// showing the cached home feed briefly and then replacing it (~500 ms later)
|
||||||
@@ -119,7 +130,6 @@ export default function Home() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeServerId) return;
|
if (!activeServerId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const fetchFreshHomeFeed = async (): Promise<HomeFeedSnapshot | null> => {
|
const fetchFreshHomeFeed = async (): Promise<HomeFeedSnapshot | null> => {
|
||||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||||
const albumMix =
|
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) {
|
if (cached) {
|
||||||
// When lazy initializers already pre-populated state from this same
|
// When lazy initializers already pre-populated state from this same
|
||||||
// snapshot, re-applying it would only create new array references and
|
// 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
|
// Keep the current visit visually stable, but prepare fresh data so the
|
||||||
// next re-enter opens with a newer snapshot immediately.
|
// next re-enter opens with a newer snapshot immediately.
|
||||||
void (async () => {
|
if (!offlineBrowseActive) {
|
||||||
try {
|
void (async () => {
|
||||||
const fresh = await fetchFreshHomeFeed();
|
try {
|
||||||
if (!fresh || cancelled) return;
|
const fresh = await fetchFreshHomeFeed();
|
||||||
writeHomeFeedCache(fresh);
|
if (!fresh || cancelled || isHomeFeedSnapshotEmpty(fresh)) return;
|
||||||
void warmHomeMainstageCovers(fresh);
|
writeHomeFeedCache(fresh);
|
||||||
} catch {
|
void warmHomeMainstageCovers(fresh);
|
||||||
/* ignore */
|
} catch {
|
||||||
}
|
/* ignore */
|
||||||
})();
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stale = offlineBrowseActive ? readHomeFeedCacheStale(activeServerId) : null;
|
||||||
|
if (stale) {
|
||||||
|
applyFeedSnapshot(stale);
|
||||||
|
setLoading(false);
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const snap = await fetchFreshHomeFeed();
|
const snap = await fetchFreshHomeFeed();
|
||||||
if (!snap) return;
|
if (!snap) return;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
if (offlineBrowseActive && isHomeFeedSnapshotEmpty(snap)) return;
|
||||||
writeHomeFeedCache(snap);
|
writeHomeFeedCache(snap);
|
||||||
applyFeedSnapshot(snap);
|
applyFeedSnapshot(snap);
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@@ -208,8 +229,20 @@ export default function Home() {
|
|||||||
activeServerId,
|
activeServerId,
|
||||||
musicLibraryFilterVersion,
|
musicLibraryFilterVersion,
|
||||||
homeSections,
|
homeSections,
|
||||||
|
offlineBrowseActive,
|
||||||
|
offlineBrowseReloadTs,
|
||||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
]); // 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 (
|
const loadMore = async (
|
||||||
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
|
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
|
||||||
currentList: SubsonicAlbum[],
|
currentList: SubsonicAlbum[],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
||||||
import { getAlbum } from '../api/subsonicLibrary';
|
import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
import { songToTrack } from '../utils/playback/songToTrack';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -232,7 +232,9 @@ export default function LosslessAlbums() {
|
|||||||
const handleEnqueueSelected = async () => {
|
const handleEnqueueSelected = async () => {
|
||||||
if (selectedAlbums.length === 0) return;
|
if (selectedAlbums.length === 0) return;
|
||||||
try {
|
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) : []);
|
const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []);
|
||||||
if (tracks.length > 0) {
|
if (tracks.length > 0) {
|
||||||
enqueue(tracks);
|
enqueue(tracks);
|
||||||
@@ -248,7 +250,8 @@ export default function LosslessAlbums() {
|
|||||||
let queued = 0;
|
let queued = 0;
|
||||||
for (const album of selectedAlbums) {
|
for (const album of selectedAlbums) {
|
||||||
try {
|
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);
|
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||||
queued++;
|
queued++;
|
||||||
} catch {
|
} 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 type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
import { songToTrack } from '../utils/playback/songToTrack';
|
||||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
@@ -87,17 +88,20 @@ export default function MostPlayed() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||||
const enqueue = usePlayerStore(s => s.enqueue);
|
const enqueue = usePlayerStore(s => s.enqueue);
|
||||||
|
|
||||||
const handleEnqueueAlbum = useCallback(async (albumId: string) => {
|
const handleEnqueueAlbum = useCallback(async (albumId: string) => {
|
||||||
|
if (!activeServerId) return;
|
||||||
try {
|
try {
|
||||||
const data = await getAlbum(albumId);
|
const data = await resolveAlbum(activeServerId, albumId);
|
||||||
|
if (!data) return;
|
||||||
enqueue(data.songs.map(songToTrack));
|
enqueue(data.songs.map(songToTrack));
|
||||||
} catch {
|
} catch {
|
||||||
// Network failure — silent (toast would be too noisy for a hover action).
|
// Network failure — silent (toast would be too noisy for a hover action).
|
||||||
}
|
}
|
||||||
}, [enqueue]);
|
}, [activeServerId, enqueue]);
|
||||||
|
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
||||||
import { getAlbumsByGenre } from '../api/subsonicGenres';
|
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 type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { dedupeById } from '../utils/dedupeById';
|
import { dedupeById } from '../utils/dedupeById';
|
||||||
import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } from 'react';
|
import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||||
@@ -157,7 +158,8 @@ export default function NewReleases() {
|
|||||||
let queued = 0;
|
let queued = 0;
|
||||||
for (const album of selectedAlbums) {
|
for (const album of selectedAlbums) {
|
||||||
try {
|
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);
|
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||||
queued++;
|
queued++;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ import { usePlaylistDerived } from '../hooks/usePlaylistDerived';
|
|||||||
import { usePlaylistRouteEffects } from '../hooks/usePlaylistRouteEffects';
|
import { usePlaylistRouteEffects } from '../hooks/usePlaylistRouteEffects';
|
||||||
import { useBulkPlPickerOutsideClick } from '../hooks/useBulkPlPickerOutsideClick';
|
import { useBulkPlPickerOutsideClick } from '../hooks/useBulkPlPickerOutsideClick';
|
||||||
import { usePlaylistDnDReorder } from '../hooks/usePlaylistDnDReorder';
|
import { usePlaylistDnDReorder } from '../hooks/usePlaylistDnDReorder';
|
||||||
|
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||||
|
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||||
|
|
||||||
// ── Column configuration ──────────────────────────────────────────────────────
|
// ── Column configuration ──────────────────────────────────────────────────────
|
||||||
const PL_COLUMNS: readonly ColDef[] = [
|
const PL_COLUMNS: readonly ColDef[] = [
|
||||||
@@ -179,13 +181,15 @@ export default function PlaylistDetail() {
|
|||||||
|
|
||||||
// ── Load ─────────────────────────────────────────────────────
|
// ── Load ─────────────────────────────────────────────────────
|
||||||
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
|
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
|
||||||
|
const { active: offlineBrowseActive } = useOfflineBrowseContext();
|
||||||
|
const actionPolicy = offlineActionPolicy('playlistDetail', offlineBrowseActive);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
runPlaylistLoad({
|
runPlaylistLoad({
|
||||||
id, setLoading, setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs,
|
id, setLoading, setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs,
|
||||||
});
|
});
|
||||||
}, [id, lastModified]);
|
}, [id, lastModified, offlineBrowseActive]);
|
||||||
|
|
||||||
// ── Meta edit ─────────────────────────────────────────────────
|
// ── Meta edit ─────────────────────────────────────────────────
|
||||||
const handleSaveMeta = async (opts: {
|
const handleSaveMeta = async (opts: {
|
||||||
@@ -280,6 +284,7 @@ export default function PlaylistDetail() {
|
|||||||
offlineStatus={resolvedOfflineStatus}
|
offlineStatus={resolvedOfflineStatus}
|
||||||
offlineProgress={offlineProgress}
|
offlineProgress={offlineProgress}
|
||||||
activeServerId={activeServerId}
|
activeServerId={activeServerId}
|
||||||
|
actionPolicy={actionPolicy}
|
||||||
setEditingMeta={setEditingMeta}
|
setEditingMeta={setEditingMeta}
|
||||||
setSearchOpen={setSearchOpen}
|
setSearchOpen={setSearchOpen}
|
||||||
setSearchQuery={setSearchQuery}
|
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 { getGenres } from '../api/subsonicGenres';
|
||||||
import { filterSongsToActiveLibrary } from '../api/subsonicLibrary';
|
import { filterSongsToActiveLibrary } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes';
|
import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes';
|
||||||
@@ -29,6 +29,8 @@ import PlaylistsHeader from '../components/playlists/PlaylistsHeader';
|
|||||||
import PlaylistCard from '../components/playlists/PlaylistCard';
|
import PlaylistCard from '../components/playlists/PlaylistCard';
|
||||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
|
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||||
|
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
return formatHumanHoursMinutes(seconds);
|
return formatHumanHoursMinutes(seconds);
|
||||||
@@ -49,6 +51,9 @@ export default function Playlists() {
|
|||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
const subsonicIdentityByServer = useAuthStore(s => s.subsonicServerIdentityByServer);
|
const subsonicIdentityByServer = useAuthStore(s => s.subsonicServerIdentityByServer);
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
@@ -94,8 +99,10 @@ export default function Playlists() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlaylists().finally(() => setLoading(false));
|
fetchPlaylists().finally(() => setLoading(false));
|
||||||
getGenres().then(setGenres).catch(() => {});
|
if (!offlineBrowseActive) {
|
||||||
}, [fetchPlaylists]);
|
getGenres().then(setGenres).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [fetchPlaylists, offlineBrowseActive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (creating) nameInputRef.current?.focus();
|
if (creating) nameInputRef.current?.focus();
|
||||||
@@ -138,9 +145,14 @@ export default function Playlists() {
|
|||||||
if (playingId === pl.id) return;
|
if (playingId === pl.id) return;
|
||||||
setPlayingId(pl.id);
|
setPlayingId(pl.id);
|
||||||
try {
|
try {
|
||||||
const data = await getPlaylist(pl.id);
|
const serverId = resolveMediaServerId(activeServerId);
|
||||||
const filteredSongs = await filterSongsToActiveLibrary(data.songs);
|
if (!serverId) return;
|
||||||
const tracks = filteredSongs.map(songToTrack);
|
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) {
|
if (tracks.length > 0) {
|
||||||
touchPlaylist(pl.id);
|
touchPlaylist(pl.id);
|
||||||
playTrack(tracks[0], tracks);
|
playTrack(tracks[0], tracks);
|
||||||
@@ -233,6 +245,7 @@ export default function Playlists() {
|
|||||||
setEditingSmartId={setEditingSmartId}
|
setEditingSmartId={setEditingSmartId}
|
||||||
setSmartFilters={setSmartFilters}
|
setSmartFilters={setSmartFilters}
|
||||||
setGenreQuery={setGenreQuery}
|
setGenreQuery={setGenreQuery}
|
||||||
|
actionPolicy={playlistsActionPolicy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{creatingSmart && (
|
{creatingSmart && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
||||||
import { getAlbumsByGenre } from '../api/subsonicGenres';
|
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 type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { dedupeById } from '../utils/dedupeById';
|
import { dedupeById } from '../utils/dedupeById';
|
||||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||||
@@ -199,7 +200,8 @@ export default function RandomAlbums() {
|
|||||||
let queued = 0;
|
let queued = 0;
|
||||||
for (const album of selectedAlbums) {
|
for (const album of selectedAlbums) {
|
||||||
try {
|
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);
|
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||||
queued++;
|
queued++;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { fetchStatisticsFormatSample, fetchStatisticsLibraryAggregates, fetchSta
|
|||||||
import { getAlbumList } from '../api/subsonicLibrary';
|
import { getAlbumList } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum, SubsonicGenre } from '../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicGenre } from '../api/subsonicTypes';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Share2 } from 'lucide-react';
|
import { Share2 } from 'lucide-react';
|
||||||
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
|
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
|
||||||
import AlbumRow from '../components/AlbumRow';
|
import AlbumRow from '../components/AlbumRow';
|
||||||
@@ -12,6 +13,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function relativeTime(timestamp: number, t: (key: string, opts?: any) => string): string {
|
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() {
|
export default function Statistics() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const isPlayerStats = location.pathname === '/player-stats';
|
const isPlayerStats = location.pathname === '/player-stats';
|
||||||
|
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||||
|
const playerStatsEnabled = usePlayerStatsRecordingEnabled();
|
||||||
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
|
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
||||||
@@ -62,6 +68,16 @@ export default function Statistics() {
|
|||||||
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
|
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (offlineBrowseActive && playerStatsEnabled && !isPlayerStats) {
|
||||||
|
navigate('/player-stats', { replace: true });
|
||||||
|
}
|
||||||
|
}, [offlineBrowseActive, playerStatsEnabled, isPlayerStats, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (offlineBrowseActive || isPlayerStats) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
fetchStatisticsOverview()
|
fetchStatisticsOverview()
|
||||||
.then(d => {
|
.then(d => {
|
||||||
setRecent(d.recent);
|
setRecent(d.recent);
|
||||||
@@ -71,10 +87,11 @@ export default function Statistics() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch(() => setLoading(false));
|
.catch(() => setLoading(false));
|
||||||
}, [musicLibraryFilterVersion]);
|
}, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
|
||||||
|
|
||||||
// Background: playtime, album/song counts, genre insights (cached per server+library like rating prefetch)
|
// Background: playtime, album/song counts, genre insights (cached per server+library like rating prefetch)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (offlineBrowseActive || isPlayerStats) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setTotalPlaytime(null);
|
setTotalPlaytime(null);
|
||||||
setTotalAlbums(null);
|
setTotalAlbums(null);
|
||||||
@@ -101,10 +118,11 @@ export default function Statistics() {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [musicLibraryFilterVersion]);
|
}, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
|
||||||
|
|
||||||
// Background: format distribution (cached random sample, same TTL as other Statistics fetches)
|
// Background: format distribution (cached random sample, same TTL as other Statistics fetches)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (offlineBrowseActive || isPlayerStats) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setFormatData(null);
|
setFormatData(null);
|
||||||
setFormatSampleSize(0);
|
setFormatSampleSize(0);
|
||||||
@@ -116,17 +134,19 @@ export default function Statistics() {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [musicLibraryFilterVersion]);
|
}, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (offlineBrowseActive || isPlayerStats) return;
|
||||||
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
|
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
|
||||||
setLfmRecentLoading(true);
|
setLfmRecentLoading(true);
|
||||||
lastfmGetRecentTracks(lastfmUsername, lastfmSessionKey, 20)
|
lastfmGetRecentTracks(lastfmUsername, lastfmSessionKey, 20)
|
||||||
.then(tracks => { setLfmRecentTracks(tracks); setLfmRecentLoading(false); })
|
.then(tracks => { setLfmRecentTracks(tracks); setLfmRecentLoading(false); })
|
||||||
.catch(() => setLfmRecentLoading(false));
|
.catch(() => setLfmRecentLoading(false));
|
||||||
}, [lastfmSessionKey, lastfmUsername]);
|
}, [lastfmSessionKey, lastfmUsername, offlineBrowseActive, isPlayerStats]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (offlineBrowseActive || isPlayerStats) return;
|
||||||
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
|
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
|
||||||
setLfmLoading(true);
|
setLfmLoading(true);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -139,7 +159,7 @@ export default function Statistics() {
|
|||||||
setLfmTopTracks(tracks);
|
setLfmTopTracks(tracks);
|
||||||
setLfmLoading(false);
|
setLfmLoading(false);
|
||||||
}).catch(() => setLfmLoading(false));
|
}).catch(() => setLfmLoading(false));
|
||||||
}, [lfmPeriod, lastfmSessionKey, lastfmUsername]);
|
}, [lfmPeriod, lastfmSessionKey, lastfmUsername, offlineBrowseActive, isPlayerStats]);
|
||||||
|
|
||||||
const loadMore = async (
|
const loadMore = async (
|
||||||
type: 'frequent' | 'highest',
|
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;
|
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 {
|
export function writeHomeFeedCache(data: Omit<HomeFeedSnapshot, 'savedAt'>): void {
|
||||||
snapshot = { ...data, savedAt: Date.now() };
|
snapshot = { ...data, savedAt: Date.now() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { SubsonicPlaylist } from '../api/subsonicTypes';
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { createPlaylist as apiCreatePlaylist } from '../api/subsonicPlaylists';
|
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 {
|
interface PlaylistStore {
|
||||||
recentIds: string[];
|
recentIds: string[];
|
||||||
playlists: SubsonicPlaylist[];
|
playlists: SubsonicPlaylist[];
|
||||||
@@ -32,6 +35,12 @@ export const usePlaylistStore = create<PlaylistStore>()(
|
|||||||
fetchPlaylists: async () => {
|
fetchPlaylists: async () => {
|
||||||
set({ playlistsLoading: true });
|
set({ playlistsLoading: true });
|
||||||
try {
|
try {
|
||||||
|
const serverId = useAuthStore.getState().activeServerId;
|
||||||
|
if (isOfflineBrowseActive() && serverId) {
|
||||||
|
const playlists = await fetchOfflineBrowsablePlaylists(serverId);
|
||||||
|
set({ playlists, playlistsLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const playlists = await getPlaylists();
|
const playlists = await getPlaylists();
|
||||||
set({ playlists, playlistsLoading: false });
|
set({ playlists, playlistsLoading: false });
|
||||||
} catch {
|
} 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 {
|
html[data-dev-build] .app-shell[data-mobile][data-titlebar] .dev-build-badge {
|
||||||
top: var(--titlebar-height);
|
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 type { TFunction } from 'i18next';
|
||||||
import { getAlbum } from '../../api/subsonicLibrary';
|
|
||||||
import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
|
import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
|
||||||
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
import { songToTrack } from '../playback/songToTrack';
|
import { songToTrack } from '../playback/songToTrack';
|
||||||
import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay';
|
import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay';
|
||||||
|
import { resolveAlbum, resolveMediaServerId } from '../offline/offlineMediaResolve';
|
||||||
|
|
||||||
async function fetchAllTracks(albums: SubsonicAlbum[]): Promise<Track[]> {
|
/** Ordered artist discography tracks for play-all / shuffle (network or local bytes). */
|
||||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
export async function fetchArtistDetailTracks(
|
||||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
albums: SubsonicAlbum[],
|
||||||
return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
|
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 {
|
export interface RunArtistDetailPlayDeps {
|
||||||
albums: SubsonicAlbum[];
|
albums: SubsonicAlbum[];
|
||||||
|
serverId?: string | null;
|
||||||
setPlayAllLoading: (v: boolean) => void;
|
setPlayAllLoading: (v: boolean) => void;
|
||||||
playTrack: (track: Track, queue: Track[]) => void;
|
playTrack: (track: Track, queue: Track[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise<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;
|
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> {
|
export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
const { albums, serverId, setPlayAllLoading, playTrack } = deps;
|
||||||
if (albums.length === 0) return;
|
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 {
|
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 { 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 type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||||
import {
|
import {
|
||||||
filterSongsForLuckyMixRatings,
|
filterSongsForLuckyMixRatings,
|
||||||
@@ -98,7 +99,7 @@ export async function pickSongsForAlbum(
|
|||||||
need: number,
|
need: number,
|
||||||
mixRatings: MixMinRatingsConfig,
|
mixRatings: MixMinRatingsConfig,
|
||||||
): Promise<SubsonicSong[]> {
|
): Promise<SubsonicSong[]> {
|
||||||
const full = await getAlbum(albumId).catch(() => null);
|
const full = await resolveAlbumForActiveServer(albumId).catch(() => null);
|
||||||
if (!full?.songs?.length) return [];
|
if (!full?.songs?.length) return [];
|
||||||
const scopedSongs = await filterSongsToActiveLibrary(full.songs);
|
const scopedSongs = await filterSongsToActiveLibrary(full.songs);
|
||||||
const unique = uniqueBySongId(scopedSongs);
|
const unique = uniqueBySongId(scopedSongs);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useDevOfflineBrowseStore } from '../../store/devOfflineBrowseStore';
|
||||||
import {
|
import {
|
||||||
getActiveServerReachable,
|
getActiveServerReachable,
|
||||||
isActiveServerReachable,
|
isActiveServerReachable,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
|
|
||||||
describe('activeServerReachability', () => {
|
describe('activeServerReachability', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
useDevOfflineBrowseStore.setState({ forceOffline: false });
|
||||||
setActiveServerReachable(null);
|
setActiveServerReachable(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,6 +26,13 @@ describe('activeServerReachability', () => {
|
|||||||
expect(getActiveServerReachable()).toBe(true);
|
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', () => {
|
it('onActiveServerBecameReachable fires only on false/null → true', () => {
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
onActiveServerBecameReachable(listener);
|
onActiveServerBecameReachable(listener);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isDevOfflineBrowseForced } from '../../store/devOfflineBrowseStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Active-server reachability snapshot maintained by `useConnectionStatus`.
|
* Active-server reachability snapshot maintained by `useConnectionStatus`.
|
||||||
* Non-hook code (queue sync, favorites refresh) uses this to avoid noisy
|
* 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. */
|
/** True only when the browser is online and the last active-server probe succeeded. */
|
||||||
export function isActiveServerReachable(): boolean {
|
export function isActiveServerReachable(): boolean {
|
||||||
|
if (isDevOfflineBrowseForced()) return false;
|
||||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return false;
|
if (typeof navigator !== 'undefined' && !navigator.onLine) return false;
|
||||||
return activeServerReachable === true;
|
return activeServerReachable === true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { resolvePlaybackUrl } from '../playback/resolvePlaybackUrl';
|
import { resolvePlaybackUrl } from '../playback/resolvePlaybackUrl';
|
||||||
|
import { isDevOfflineBrowseForced } from '../../store/devOfflineBrowseStore';
|
||||||
import { isActiveServerReachable } from './activeServerReachability';
|
import { isActiveServerReachable } from './activeServerReachability';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,6 +10,7 @@ import { isActiveServerReachable } from './activeServerReachability';
|
|||||||
*/
|
*/
|
||||||
export function shouldAttemptSubsonicForServer(serverId: string, trackId?: string): boolean {
|
export function shouldAttemptSubsonicForServer(serverId: string, trackId?: string): boolean {
|
||||||
if (!serverId) return false;
|
if (!serverId) return false;
|
||||||
|
if (isDevOfflineBrowseForced()) return false;
|
||||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return false;
|
if (typeof navigator !== 'undefined' && !navigator.onLine) return false;
|
||||||
if (trackId) {
|
if (trackId) {
|
||||||
const url = resolvePlaybackUrl(trackId, serverId);
|
const url = resolvePlaybackUrl(trackId, serverId);
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||||
import {
|
import {
|
||||||
favoritesOfflineBrowseEnabled,
|
favoritesOfflineBrowseEnabled,
|
||||||
hasOfflineBrowsingContent,
|
hasOfflineBrowsingContent,
|
||||||
|
} from './favoritesOfflineBrowse';
|
||||||
|
import {
|
||||||
isOfflineSidebarLibraryNavAllowed,
|
isOfflineSidebarLibraryNavAllowed,
|
||||||
|
isOfflineSidebarNavAllowed,
|
||||||
|
isOfflineSidebarSystemNavAllowed,
|
||||||
|
} from './offlineNavPolicy';
|
||||||
|
import {
|
||||||
loadStarredFromLibraryIndex,
|
loadStarredFromLibraryIndex,
|
||||||
mergeStarredFromServers,
|
mergeStarredFromServers,
|
||||||
resolveAlbumForServer,
|
} from './offlineStarredLoad';
|
||||||
} from './favoritesOfflineBrowse';
|
import { resolveAlbumForServer } from './offlineMediaResolve';
|
||||||
|
|
||||||
const isActiveServerReachableMock = vi.fn(() => true);
|
const isActiveServerReachableMock = vi.fn(() => true);
|
||||||
const shouldAttemptSubsonicForServerMock = vi.fn((_serverId: string, _trackId?: string) => true);
|
const shouldAttemptSubsonicForServerMock = vi.fn((_serverId: string, _trackId?: string) => true);
|
||||||
@@ -23,6 +30,7 @@ vi.mock('../network/subsonicNetworkGuard', () => ({
|
|||||||
const getAlbumForServerMock = vi.fn();
|
const getAlbumForServerMock = vi.fn();
|
||||||
const libraryAdvancedSearchMock = vi.fn();
|
const libraryAdvancedSearchMock = vi.fn();
|
||||||
const libraryGetTracksByAlbumMock = vi.fn();
|
const libraryGetTracksByAlbumMock = vi.fn();
|
||||||
|
const libraryGetTracksBatchChunkedMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('../../api/subsonicLibrary', () => ({
|
vi.mock('../../api/subsonicLibrary', () => ({
|
||||||
getAlbumForServer: (...args: unknown[]) => getAlbumForServerMock(...args),
|
getAlbumForServer: (...args: unknown[]) => getAlbumForServerMock(...args),
|
||||||
@@ -31,6 +39,7 @@ vi.mock('../../api/subsonicLibrary', () => ({
|
|||||||
vi.mock('../../api/library', () => ({
|
vi.mock('../../api/library', () => ({
|
||||||
libraryAdvancedSearch: (...args: unknown[]) => libraryAdvancedSearchMock(...args),
|
libraryAdvancedSearch: (...args: unknown[]) => libraryAdvancedSearchMock(...args),
|
||||||
libraryGetTracksByAlbum: (...args: unknown[]) => libraryGetTracksByAlbumMock(...args),
|
libraryGetTracksByAlbum: (...args: unknown[]) => libraryGetTracksByAlbumMock(...args),
|
||||||
|
libraryGetTracksBatchChunked: (...args: unknown[]) => libraryGetTracksBatchChunkedMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('favoritesOfflineBrowse', () => {
|
describe('favoritesOfflineBrowse', () => {
|
||||||
@@ -40,6 +49,8 @@ describe('favoritesOfflineBrowse', () => {
|
|||||||
getAlbumForServerMock.mockReset();
|
getAlbumForServerMock.mockReset();
|
||||||
libraryGetTracksByAlbumMock.mockReset();
|
libraryGetTracksByAlbumMock.mockReset();
|
||||||
libraryAdvancedSearchMock.mockReset();
|
libraryAdvancedSearchMock.mockReset();
|
||||||
|
libraryGetTracksBatchChunkedMock.mockReset();
|
||||||
|
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
favoritesOfflineEnabled: false,
|
favoritesOfflineEnabled: false,
|
||||||
activeServerId: 'srv-1',
|
activeServerId: 'srv-1',
|
||||||
@@ -87,28 +98,121 @@ describe('favoritesOfflineBrowse', () => {
|
|||||||
expect(merged.songs.map(s => s.serverId)).toEqual(['srv-1', 'srv-2']);
|
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', true)).toBe(true);
|
||||||
expect(isOfflineSidebarLibraryNavAllowed('favorites', false)).toBe(false);
|
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({
|
libraryAdvancedSearchMock.mockResolvedValue({
|
||||||
albums: [{ id: 'alb-1', name: 'A', artist: 'X', artistId: 'art-1', serverId: 'srv-1' }],
|
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' }],
|
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');
|
const starred = await loadStarredFromLibraryIndex('srv-1');
|
||||||
expect(libraryAdvancedSearchMock).toHaveBeenCalledWith(expect.objectContaining({
|
expect(libraryAdvancedSearchMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
serverId: 'srv-1',
|
||||||
entityTypes: ['album', 'track'],
|
entityTypes: ['album', 'track'],
|
||||||
starredOnly: true,
|
starredOnly: true,
|
||||||
}));
|
}));
|
||||||
|
expect(libraryGetTracksBatchChunkedMock).not.toHaveBeenCalled();
|
||||||
expect(starred.artists).toEqual([]);
|
expect(starred.artists).toEqual([]);
|
||||||
expect(starred.songs).toHaveLength(1);
|
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 () => {
|
it('resolveAlbumForServer uses library index when network fails', async () => {
|
||||||
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
||||||
shouldAttemptSubsonicForServerMock.mockReturnValue(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 { useAuthStore } from '../../store/authStore';
|
||||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||||
import type { OfflineAlbumMeta } from '../../store/offlineStore';
|
import type { OfflineAlbumMeta } from '../../store/offlineStore';
|
||||||
import {
|
|
||||||
albumToAlbum,
|
|
||||||
artistToArtist,
|
|
||||||
trackToSong,
|
|
||||||
} from '../library/advancedSearchLocal';
|
|
||||||
import { dedupeById } from '../dedupeById';
|
|
||||||
import { countFavoriteAutoTracks, hasAnyOfflineAlbums } from './offlineLibraryHelpers';
|
import { countFavoriteAutoTracks, hasAnyOfflineAlbums } from './offlineLibraryHelpers';
|
||||||
|
|
||||||
/** Saved servers with a local library index (cross-server favorites scope). */
|
/** Saved servers with a local library index (cross-server favorites scope). */
|
||||||
@@ -34,14 +17,6 @@ export function favoritesOfflineBrowseEnabled(): boolean {
|
|||||||
return favoritesServerIds().length > 0;
|
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. */
|
/** Any offline browsing surface: manual pins and/or saved favorite-auto bytes. */
|
||||||
export function hasOfflineBrowsingContent(
|
export function hasOfflineBrowsingContent(
|
||||||
offlineAlbums: Record<string, OfflineAlbumMeta>,
|
offlineAlbums: Record<string, OfflineAlbumMeta>,
|
||||||
@@ -50,203 +25,3 @@ export function hasOfflineBrowsingContent(
|
|||||||
if (favoritesOfflineBrowseEnabled() && countFavoriteAutoTracks() > 0) return true;
|
if (favoritesOfflineBrowseEnabled() && countFavoriteAutoTracks() > 0) return true;
|
||||||
return false;
|
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 { resolveIndexKey, serverIndexKeyForProfile } from '../server/serverIndexKey';
|
||||||
import { FAVORITES_OFFLINE_JOB_ID } from './favoritesOfflineConstants';
|
import { FAVORITES_OFFLINE_JOB_ID } from './favoritesOfflineConstants';
|
||||||
import { isActiveServerReachable } from '../network/activeServerReachability';
|
import { isActiveServerReachable } from '../network/activeServerReachability';
|
||||||
import { favoritesServerIds, loadAlbumFromLibraryIndex } from './favoritesOfflineBrowse';
|
import { favoritesServerIds } from './favoritesOfflineBrowse';
|
||||||
|
import { loadAlbumFromLibraryIndex } from './offlineLibraryIndexLoad';
|
||||||
import {
|
import {
|
||||||
entryBelongsToServer,
|
entryBelongsToServer,
|
||||||
hasLocalLibraryBytes,
|
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),
|
isOfflinePinCompleteMock(albumId, serverId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./favoritesOfflineBrowse', () => ({
|
vi.mock('./offlineMediaResolve', () => ({
|
||||||
resolveAlbumForServer: (serverId: string, albumId: string) =>
|
resolveAlbumForServer: (serverId: string, albumId: string) =>
|
||||||
resolveAlbumForServerMock(serverId, albumId),
|
resolveAlbumForServerMock(serverId, albumId),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { isActiveServerReachable, onActiveServerBecameReachable } from '../netwo
|
|||||||
import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard';
|
import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard';
|
||||||
import { resolveServerIdForIndexKey } from '../server/serverLookup';
|
import { resolveServerIdForIndexKey } from '../server/serverLookup';
|
||||||
import { isOfflinePinComplete } from './offlineLibraryHelpers';
|
import { isOfflinePinComplete } from './offlineLibraryHelpers';
|
||||||
import { resolveAlbumForServer } from './favoritesOfflineBrowse';
|
import { resolveAlbumForServer } from './offlineMediaResolve';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 800;
|
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 { SubsonicSong } from '../../api/subsonicTypes';
|
||||||
import type { DeviceSyncSource } from '../../store/deviceSyncStore';
|
import type { DeviceSyncSource } from '../../store/deviceSyncStore';
|
||||||
|
import {
|
||||||
|
resolveAlbum,
|
||||||
|
resolveArtist,
|
||||||
|
resolveMediaServerId,
|
||||||
|
resolvePlaylist,
|
||||||
|
} from '../offline/offlineMediaResolve';
|
||||||
|
|
||||||
export async function fetchTracksForSource(source: DeviceSyncSource): Promise<SubsonicSong[]> {
|
export async function fetchTracksForSource(source: DeviceSyncSource): Promise<SubsonicSong[]> {
|
||||||
if (source.type === 'playlist') { const { songs } = await getPlaylist(source.id); return songs; }
|
const serverId = resolveMediaServerId();
|
||||||
if (source.type === 'album') { const { songs } = await getAlbum(source.id); return songs; }
|
if (!serverId) return [];
|
||||||
const { albums } = await getArtist(source.id);
|
|
||||||
// Parallel album fetches — Navidrome handles getAlbum requests in flight
|
if (source.type === 'playlist') {
|
||||||
// without serialising. Sequential awaits here multiplied a 50-album artist
|
const result = await resolvePlaylist(serverId, source.id);
|
||||||
// sync into 50 round-trips (~7 s blocking) before any device write started.
|
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(
|
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();
|
return results.flat();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,37 @@ import { onInvoke } from '@/test/mocks/tauri';
|
|||||||
import { resetOrbitStore, resetPlayerStore } from '@/test/helpers/storeReset';
|
import { resetOrbitStore, resetPlayerStore } from '@/test/helpers/storeReset';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
|
|
||||||
vi.mock('../../api/subsonicLibrary', () => ({
|
vi.mock('../offline/offlineMediaResolve', () => ({
|
||||||
getAlbum: vi.fn(),
|
resolveAlbumForActiveServer: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./fadeOut', () => ({
|
vi.mock('./fadeOut', () => ({
|
||||||
fadeOut: vi.fn(async () => undefined),
|
fadeOut: vi.fn(async () => undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { getAlbum } from '../../api/subsonicLibrary';
|
import { resolveAlbumForActiveServer } from '../offline/offlineMediaResolve';
|
||||||
import { useOrbitStore } from '../../store/orbitStore';
|
import { useOrbitStore } from '../../store/orbitStore';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { playAlbum, playAlbumShuffled } from './playAlbum';
|
import { playAlbum, playAlbumShuffled } from './playAlbum';
|
||||||
import * as shuffleModule from './shuffleArray';
|
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() {
|
function stubPlaybackActions() {
|
||||||
onInvoke('audio_play', () => undefined);
|
onInvoke('audio_play', () => undefined);
|
||||||
onInvoke('audio_pause', () => undefined);
|
onInvoke('audio_pause', () => undefined);
|
||||||
@@ -40,22 +57,7 @@ describe('playAlbum', () => {
|
|||||||
resetPlayerStore();
|
resetPlayerStore();
|
||||||
resetOrbitStore();
|
resetOrbitStore();
|
||||||
stubPlaybackActions();
|
stubPlaybackActions();
|
||||||
vi.mocked(getAlbum).mockResolvedValue({
|
vi.mocked(resolveAlbumForActiveServer).mockResolvedValue(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' }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -90,22 +92,7 @@ describe('playAlbumShuffled', () => {
|
|||||||
resetPlayerStore();
|
resetPlayerStore();
|
||||||
resetOrbitStore();
|
resetOrbitStore();
|
||||||
stubPlaybackActions();
|
stubPlaybackActions();
|
||||||
vi.mocked(getAlbum).mockResolvedValue({
|
vi.mocked(resolveAlbumForActiveServer).mockResolvedValue(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' }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -143,7 +130,7 @@ describe('playAlbumShuffled', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not start playback for an empty album', async () => {
|
it('does not start playback for an empty album', async () => {
|
||||||
vi.mocked(getAlbum).mockResolvedValue({
|
vi.mocked(resolveAlbumForActiveServer).mockResolvedValue({
|
||||||
album: {
|
album: {
|
||||||
id: 'al-empty',
|
id: 'al-empty',
|
||||||
name: 'Empty',
|
name: 'Empty',
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { getAlbum } from '../../api/subsonicLibrary';
|
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { resolveAlbumForServer } from '../offline/favoritesOfflineBrowse';
|
import { resolveAlbumForActiveServer } from '../offline/offlineMediaResolve';
|
||||||
import { songToTrack } from './songToTrack';
|
import { songToTrack } from './songToTrack';
|
||||||
import { useOrbitStore } from '../../store/orbitStore';
|
import { useOrbitStore } from '../../store/orbitStore';
|
||||||
import { fadeOut } from './fadeOut';
|
import { fadeOut } from './fadeOut';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
import { shuffleArray } from './shuffleArray';
|
import { shuffleArray } from './shuffleArray';
|
||||||
|
|
||||||
async function fetchAlbumTracks(albumId: string, serverId?: string): Promise<Track[]> {
|
export async function fetchAlbumTracks(albumId: string, serverId?: string): Promise<Track[]> {
|
||||||
const albumData = serverId
|
const albumData = await resolveAlbumForActiveServer(albumId, serverId);
|
||||||
? await resolveAlbumForServer(serverId, albumId)
|
|
||||||
: await getAlbum(albumId).then(d => ({ album: d.album, songs: d.songs }));
|
|
||||||
if (!albumData) throw new Error(`Album ${albumId} not available`);
|
if (!albumData) throw new Error(`Album ${albumId} not available`);
|
||||||
const albumGenre = albumData.album.genre;
|
const albumGenre = albumData.album.genre;
|
||||||
const ownerServerId = serverId ?? albumData.album.serverId;
|
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