mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -1,6 +1,4 @@
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||
@@ -18,7 +16,7 @@ import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
import { resolveCoverDisplayTier } from '../cover/tiers';
|
||||
import { acquireUrl } from '../utils/imageCache/urlPool';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
||||
import { fetchAlbumTracks, playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
||||
import { useLongPressAction } from '../hooks/useLongPressAction';
|
||||
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
@@ -192,10 +190,13 @@ function AlbumCard({
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const data = await getAlbum(album.id);
|
||||
enqueue(data.songs.map(songToTrack));
|
||||
const tracks = await fetchAlbumTracks(
|
||||
album.id,
|
||||
offlineServerId || undefined,
|
||||
);
|
||||
if (tracks.length > 0) enqueue(tracks);
|
||||
} catch {
|
||||
// Network failure — silent (toast would be too noisy for a hover action)
|
||||
// Unavailable offline or network failure — silent on hover action
|
||||
}
|
||||
}}
|
||||
aria-label={t('contextMenu.enqueueAlbum')}
|
||||
|
||||
+128
-111
@@ -19,6 +19,7 @@ import { formatMb } from '../utils/format/formatBytes';
|
||||
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
|
||||
/** True when the album artist label means "no single artist" — `getArtistInfo`
|
||||
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
|
||||
@@ -91,6 +92,8 @@ interface AlbumHeaderProps {
|
||||
onEntityRatingChange: (rating: number) => void;
|
||||
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
|
||||
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
|
||||
/** Offline browse action gates (favorites, download, cache, bio, ratings). */
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
export default function AlbumHeader({
|
||||
@@ -117,7 +120,9 @@ export default function AlbumHeader({
|
||||
entityRatingValue,
|
||||
onEntityRatingChange,
|
||||
entityRatingSupport,
|
||||
actionPolicy,
|
||||
}: AlbumHeaderProps) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const goBack = useAlbumDetailBack();
|
||||
@@ -222,7 +227,7 @@ export default function AlbumHeader({
|
||||
<StarRating
|
||||
value={entityRatingValue}
|
||||
onChange={onEntityRatingChange}
|
||||
disabled={entityRatingSupport === 'track_only'}
|
||||
disabled={!policy.canRate || entityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.albumAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
@@ -250,14 +255,16 @@ export default function AlbumHeader({
|
||||
|
||||
{/* Row 2 — Secondary actions */}
|
||||
<div className="album-actions-row album-actions-row--secondary">
|
||||
<button
|
||||
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
{policy.canFavorite && (
|
||||
<button
|
||||
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
@@ -269,7 +276,7 @@ export default function AlbumHeader({
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
|
||||
{showBioButton && (
|
||||
{showBioButton && policy.canShowBio && (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onBio}
|
||||
@@ -280,53 +287,57 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Download size={14} />
|
||||
<span className="album-icon-btn-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onDownload}
|
||||
aria-label={t('albumDetail.download')}
|
||||
data-tooltip={t('albumDetail.download')}
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
{policy.canDownload && (
|
||||
downloadProgress !== null ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Download size={14} />
|
||||
<span className="album-icon-btn-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onDownload}
|
||||
aria-label={t('albumDetail.download')}
|
||||
data-tooltip={t('albumDetail.download')}
|
||||
>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{offlineStatus === 'downloading' ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
</div>
|
||||
) : offlineStatus === 'queued' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.offlineQueued')}
|
||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onRemoveOffline}
|
||||
aria-label={t('albumDetail.offlineCached')}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.cacheOffline')}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
{policy.canPinOffline && (
|
||||
offlineStatus === 'downloading' ? (
|
||||
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
</div>
|
||||
) : offlineStatus === 'queued' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.offlineQueued')}
|
||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
|
||||
onClick={onRemoveOffline}
|
||||
aria-label={t('albumDetail.offlineCached')}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="album-icon-btn album-icon-btn--sm"
|
||||
onClick={onCacheOffline}
|
||||
aria-label={t('albumDetail.cacheOffline')}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -357,13 +368,15 @@ export default function AlbumHeader({
|
||||
>
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
{policy.canFavorite && (
|
||||
<button
|
||||
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={onToggleStar}
|
||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||
>
|
||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface"
|
||||
@@ -375,7 +388,7 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showBioButton && (
|
||||
{showBioButton && policy.canShowBio && (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-bio-btn"
|
||||
@@ -386,56 +399,60 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
{policy.canDownload && (
|
||||
downloadProgress !== null ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
<div className="download-progress-bar">
|
||||
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-download-btn"
|
||||
onClick={onDownload}
|
||||
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
|
||||
>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-download-btn"
|
||||
onClick={onDownload}
|
||||
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
|
||||
>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{offlineStatus === 'downloading' && offlineProgress ? (
|
||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||
</div>
|
||||
) : offlineStatus === 'queued' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineQueued')}
|
||||
</button>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
{policy.canPinOffline && (
|
||||
offlineStatus === 'downloading' && offlineProgress ? (
|
||||
<div className="offline-cache-btn offline-cache-btn--progress">
|
||||
<Loader2 size={14} className="spin" />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
|
||||
</div>
|
||||
) : offlineStatus === 'queued' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineQueued')}
|
||||
</button>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
||||
onClick={onRemoveOffline}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineCached')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-surface offline-cache-btn"
|
||||
onClick={onCacheOffline}
|
||||
data-tooltip={t('albumDetail.cacheOffline')}
|
||||
>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.cacheOffline')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useTracklistColumns } from '../utils/useTracklistColumns';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -15,6 +15,7 @@ import { TrackRow } from './albumTrackList/TrackRow';
|
||||
import { AlbumTrackListMobile } from './albumTrackList/AlbumTrackListMobile';
|
||||
import { TracklistColumnPicker } from './albumTrackList/TracklistColumnPicker';
|
||||
import { TracklistHeaderRow } from './albumTrackList/TracklistHeaderRow';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||
|
||||
export type { SortKey } from '../utils/componentHelpers/albumTrackListHelpers';
|
||||
|
||||
@@ -38,6 +39,7 @@ interface AlbumTrackListProps {
|
||||
sortKey?: SortKey;
|
||||
sortDir?: 'asc' | 'desc';
|
||||
onSort?: (key: SortKey) => void;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
// ── AlbumTrackList ────────────────────────────────────────────────────────────
|
||||
@@ -60,7 +62,9 @@ export default function AlbumTrackList({
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSort,
|
||||
actionPolicy,
|
||||
}: AlbumTrackListProps) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||
@@ -98,6 +102,10 @@ export default function AlbumTrackList({
|
||||
);
|
||||
|
||||
const currentTrackId = currentTrack?.id ?? null;
|
||||
const displayCols = useMemo(
|
||||
() => (policy.canFavorite ? visibleCols : visibleCols.filter(c => c.key !== 'favorite')),
|
||||
[policy.canFavorite, visibleCols],
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -139,7 +147,7 @@ export default function AlbumTrackList({
|
||||
>
|
||||
|
||||
<TracklistHeaderRow
|
||||
visibleCols={visibleCols}
|
||||
visibleCols={displayCols}
|
||||
gridStyle={gridStyle}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
@@ -170,7 +178,7 @@ export default function AlbumTrackList({
|
||||
key={song.id}
|
||||
song={song}
|
||||
globalIdx={globalIdx}
|
||||
visibleCols={visibleCols}
|
||||
visibleCols={displayCols}
|
||||
gridStyle={gridStyle}
|
||||
currentTrackId={currentTrackId}
|
||||
isPlaying={isPlaying}
|
||||
@@ -186,6 +194,7 @@ export default function AlbumTrackList({
|
||||
onToggleSelect={onToggleSelect}
|
||||
onDragStart={onDragStart}
|
||||
setContextMenuSongId={setContextMenuSongId}
|
||||
actionPolicy={policy}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
|
||||
import { filterAlbumsToActiveLibrary, getAlbum } from '../api/subsonicLibrary';
|
||||
import { filterAlbumsToActiveLibrary } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { shuffleArray } from '../utils/playback/shuffleArray';
|
||||
@@ -596,7 +597,10 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
|
||||
const handleEnqueue = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const data = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const data = await resolveAlbum(serverId, album.id);
|
||||
if (!data) return;
|
||||
enqueue(data.songs.map(songToTrack));
|
||||
} catch {
|
||||
/* silent — toast would be too noisy for a hover action */
|
||||
|
||||
@@ -43,6 +43,23 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||
orbitBulkGuard: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useOfflineBrowseContext', () => ({
|
||||
useOfflineBrowseContext: () => ({
|
||||
active: false,
|
||||
serverId: 'srv-1',
|
||||
capabilities: {
|
||||
localLibrary: false,
|
||||
favorites: false,
|
||||
playlists: false,
|
||||
manualPins: false,
|
||||
playerStats: false,
|
||||
},
|
||||
hasBrowseCapability: false,
|
||||
hasBrowsingContent: false,
|
||||
connStatus: 'connected' as const,
|
||||
}),
|
||||
}));
|
||||
|
||||
import ContextMenu from './ContextMenu';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
|
||||
@@ -17,8 +17,29 @@ import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav';
|
||||
import { useContextMenuRating } from '../hooks/useContextMenuRating';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import {
|
||||
offlineActionPolicy,
|
||||
type OfflineSurface,
|
||||
} from '../utils/offline/offlineActionPolicy';
|
||||
import ContextMenuItems from './contextMenu/ContextMenuItems';
|
||||
|
||||
function contextMenuSurfaceForType(type: string | null): OfflineSurface {
|
||||
switch (type) {
|
||||
case 'album':
|
||||
case 'multi-album':
|
||||
return 'contextMenuAlbum';
|
||||
case 'artist':
|
||||
case 'multi-artist':
|
||||
return 'contextMenuArtist';
|
||||
case 'playlist':
|
||||
case 'multi-playlist':
|
||||
return 'contextMenuPlaylist';
|
||||
default:
|
||||
return 'contextMenuSong';
|
||||
}
|
||||
}
|
||||
|
||||
export { AddToPlaylistSubmenu };
|
||||
|
||||
|
||||
@@ -180,6 +201,12 @@ export default function ContextMenu() {
|
||||
|
||||
const downloadAlbum = downloadAlbumAction;
|
||||
|
||||
const { active: offlineBrowseActive } = useOfflineBrowseContext();
|
||||
const offlinePolicy = offlineActionPolicy(
|
||||
contextMenuSurfaceForType(type),
|
||||
offlineBrowseActive,
|
||||
);
|
||||
|
||||
if (!contextMenu.isOpen || !contextMenu.item) return null;
|
||||
|
||||
return (
|
||||
@@ -233,6 +260,7 @@ export default function ContextMenu() {
|
||||
isStarred={isStarred}
|
||||
pinToPlaybackServer={pinToPlaybackServer}
|
||||
navigateLibrary={navigateLibrary}
|
||||
offlinePolicy={offlinePolicy}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Cloud, CloudOff } from 'lucide-react';
|
||||
import { useDevOfflineBrowseStore } from '../store/devOfflineBrowseStore';
|
||||
|
||||
/** DEV-only: simulate full offline (disconnect UI, block Subsonic, local playback only). */
|
||||
export default function DevNetworkModeToggle() {
|
||||
const forceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
|
||||
const toggle = useDevOfflineBrowseStore(s => s.toggleForceOffline);
|
||||
|
||||
if (!import.meta.env.DEV) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`dev-network-mode-toggle${forceOffline ? ' dev-network-mode-toggle--offline' : ''}`}
|
||||
onClick={toggle}
|
||||
title={forceOffline ? 'DEV: forced offline (click for online)' : 'DEV: online (click to simulate offline)'}
|
||||
aria-pressed={forceOffline}
|
||||
>
|
||||
{forceOffline ? <CloudOff size={16} aria-hidden /> : <Cloud size={16} aria-hidden />}
|
||||
<span>{forceOffline ? 'Offline' : 'Online'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+17
-4
@@ -1,4 +1,5 @@
|
||||
import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
|
||||
import { getRandomAlbums } from '../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
@@ -259,7 +260,13 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
if (!album || albumFormats[album.id] !== undefined) return;
|
||||
getAlbum(album.id).then(data => {
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
resolveAlbum(serverId, album.id).then(data => {
|
||||
if (!data) {
|
||||
setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
|
||||
return;
|
||||
}
|
||||
const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
|
||||
setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
|
||||
}).catch(() => {
|
||||
@@ -339,7 +346,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={async e => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack));
|
||||
} catch (_) {}
|
||||
}}
|
||||
@@ -369,7 +379,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
usePlayerStore.getState().enqueue(tracks);
|
||||
} catch (_) {}
|
||||
|
||||
@@ -4,13 +4,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Settings, HardDriveDownload } from 'lucide-react';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||
import { isOfflineSidebarLibraryNavAllowed } from '../utils/offline/favoritesOfflineBrowse';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||
|
||||
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
|
||||
|
||||
@@ -18,14 +16,10 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
|
||||
const { t } = useTranslation();
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
||||
const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled;
|
||||
const { status: connStatus } = useConnectionStatus();
|
||||
const isServerOffline = connStatus === 'disconnected';
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const isServerOffline = offlineCtx.active;
|
||||
const hasOfflineContent = offlineCtx.capabilities.manualPins;
|
||||
const luckyMixBase = useLuckyMixAvailable();
|
||||
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
||||
|
||||
@@ -38,7 +32,13 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
|
||||
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
|
||||
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
|
||||
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||
if (isServerOffline && !isOfflineSidebarLibraryNavAllowed(cfg.id, favoritesOfflineBrowse)) {
|
||||
if (isServerOffline && !isOfflineSidebarNavAllowed(
|
||||
cfg.id,
|
||||
offlineNav.favoritesOfflineBrowse,
|
||||
offlineNav.localLibraryBrowse,
|
||||
offlineNav.playerStatsBrowse,
|
||||
offlineNav.playlistsOfflineBrowse,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -10,6 +10,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
vi.mock('@/api/subsonic', () => ({
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||
pingWithCredentials: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: 'navidrome',
|
||||
serverVersion: '0.55.0',
|
||||
openSubsonic: true,
|
||||
})),
|
||||
scheduleInstantMixProbeForServer: vi.fn(),
|
||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import WaveformSeek from './WaveformSeek';
|
||||
import Equalizer from './Equalizer';
|
||||
import StarRating from './StarRating';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Play } from 'lucide-react';
|
||||
import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists';
|
||||
import { updatePlaylist } from '../api/subsonicPlaylists';
|
||||
import { resolvePlaylist, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import type { Track } from '../store/playerStoreTypes';
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
@@ -398,7 +399,10 @@ function QueuePanelHostOrSolo() {
|
||||
onClose={() => setLoadModalOpen(false)}
|
||||
onLoad={async (id, name, mode) => {
|
||||
try {
|
||||
const data = await getPlaylist(id);
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return;
|
||||
const data = await resolvePlaylist(serverId, id);
|
||||
if (!data) return;
|
||||
const tracks: Track[] = data.songs.map(songToTrack);
|
||||
if (tracks.length > 0) {
|
||||
if (mode === 'append') {
|
||||
|
||||
+31
-17
@@ -1,7 +1,6 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { clearOfflinePinTasks } from '../utils/offline/offlinePinQueue';
|
||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||
@@ -24,10 +23,9 @@ import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnrea
|
||||
import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd';
|
||||
import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown';
|
||||
import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible';
|
||||
import { isOfflineSidebarLibraryNavAllowed } from '../utils/offline/favoritesOfflineBrowse';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy';
|
||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||
import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext';
|
||||
import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
|
||||
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
|
||||
import SidebarNavBody from './sidebar/SidebarNavBody';
|
||||
@@ -61,7 +59,8 @@ export default function Sidebar({
|
||||
const syncJobFail = useDeviceSyncJobStore(s => s.failed);
|
||||
const syncJobTotal = useDeviceSyncJobStore(s => s.total);
|
||||
const isSyncing = syncJobStatus === 'running';
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const musicFolders = useAuthStore(s => s.musicFolders);
|
||||
@@ -73,12 +72,8 @@ export default function Sidebar({
|
||||
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
|
||||
const loggingMode = useAuthStore(s => s.loggingMode);
|
||||
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
|
||||
const { status: connStatus } = useConnectionStatus();
|
||||
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
||||
const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled;
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const isServerOffline = connStatus === 'disconnected';
|
||||
const hasOfflineContent = offlineCtx.capabilities.manualPins;
|
||||
const isServerOffline = offlineCtx.active;
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const setSidebarItems = useSidebarStore(s => s.setItems);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
@@ -99,7 +94,7 @@ export default function Sidebar({
|
||||
}, [playlistsRaw]);
|
||||
const [sidebarViewportEl, setSidebarViewportEl] = useState<HTMLDivElement | null>(null);
|
||||
const isSidebarScrolling = useSidebarScrollVisible(sidebarViewportEl);
|
||||
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
|
||||
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1 && !isServerOffline;
|
||||
|
||||
const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all';
|
||||
const selectedFolderName =
|
||||
@@ -118,20 +113,34 @@ export default function Sidebar({
|
||||
libraryItemsForReorder.filter(c => {
|
||||
if (!c.visible) return false;
|
||||
if (c.id === 'luckyMix' && !luckyMixAvailable) return false;
|
||||
if (isServerOffline && !isOfflineSidebarLibraryNavAllowed(c.id, favoritesOfflineBrowse)) {
|
||||
if (isServerOffline && !isOfflineSidebarNavAllowed(
|
||||
c.id,
|
||||
offlineNav.favoritesOfflineBrowse,
|
||||
offlineNav.localLibraryBrowse,
|
||||
offlineNav.playerStatsBrowse,
|
||||
offlineNav.playlistsOfflineBrowse,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, favoritesOfflineBrowse],
|
||||
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, offlineNav],
|
||||
);
|
||||
const visibleSystemConfigs = useMemo(
|
||||
() => systemItemsForReorder.filter(c => {
|
||||
if (!c.visible) return false;
|
||||
if (isServerOffline) return false;
|
||||
if (isServerOffline && !isOfflineSidebarNavAllowed(
|
||||
c.id,
|
||||
offlineNav.favoritesOfflineBrowse,
|
||||
offlineNav.localLibraryBrowse,
|
||||
offlineNav.playerStatsBrowse,
|
||||
offlineNav.playlistsOfflineBrowse,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[systemItemsForReorder, isServerOffline],
|
||||
[systemItemsForReorder, isServerOffline, offlineNav],
|
||||
);
|
||||
|
||||
const sidebarItemsRef = useRef(sidebarItems);
|
||||
@@ -164,10 +173,15 @@ export default function Sidebar({
|
||||
|
||||
|
||||
const pickLibrary = (id: 'all' | string) => {
|
||||
if (isServerOffline) return;
|
||||
setMusicLibraryFilter(id);
|
||||
setLibraryDropdownOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isServerOffline) setLibraryDropdownOpen(false);
|
||||
}, [isServerOffline, setLibraryDropdownOpen]);
|
||||
|
||||
// Fetch playlists when expanded
|
||||
useEffect(() => {
|
||||
if (!playlistsExpanded || !isLoggedIn) return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ListPlus, Search, X } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useSelectionStore } from '../../store/selectionStore';
|
||||
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
interface Props {
|
||||
filterText: string;
|
||||
@@ -12,6 +13,7 @@ interface Props {
|
||||
showPlPicker: boolean;
|
||||
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
t: TFunction;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +33,9 @@ export function AlbumDetailToolbar({
|
||||
showPlPicker,
|
||||
setShowPlPicker,
|
||||
t,
|
||||
actionPolicy,
|
||||
}: Props) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
|
||||
return (
|
||||
<div className="album-track-toolbar">
|
||||
<div className="album-track-toolbar-filter">
|
||||
@@ -59,22 +63,24 @@ export function AlbumDetailToolbar({
|
||||
<span className="bulk-action-count">
|
||||
{t('common.bulkSelected', { count: selectedCount })}
|
||||
</span>
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...useSelectionStore.getState().selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{policy.canAddToPlaylist && (
|
||||
<div className="bulk-pl-picker-wrap">
|
||||
<button
|
||||
className="btn btn-surface btn-sm"
|
||||
onClick={() => setShowPlPicker(v => !v)}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
{t('common.bulkAddToPlaylist')}
|
||||
</button>
|
||||
{showPlPicker && (
|
||||
<AddToPlaylistSubmenu
|
||||
songIds={[...useSelectionStore.getState().selectedIds]}
|
||||
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
|
||||
dropDown
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => useSelectionStore.getState().clearAll()}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrack
|
||||
import { formatLongDuration } from '../../utils/format/formatDuration';
|
||||
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
|
||||
import i18n from '../../i18n';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
type ContextMenuFn = (
|
||||
x: number,
|
||||
@@ -41,6 +42,7 @@ interface TrackRowProps {
|
||||
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
|
||||
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
|
||||
setContextMenuSongId: (id: string | null) => void;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +69,9 @@ export const TrackRow = React.memo(function TrackRow({
|
||||
onToggleSelect,
|
||||
onDragStart,
|
||||
setContextMenuSongId,
|
||||
actionPolicy,
|
||||
}: TrackRowProps) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const showBitrate = useThemeStore(s => s.showBitrate);
|
||||
@@ -171,6 +175,7 @@ export const TrackRow = React.memo(function TrackRow({
|
||||
key="rating"
|
||||
value={ratingValue}
|
||||
onChange={r => onRate(song.id, r)}
|
||||
disabled={!policy.canRate}
|
||||
/>
|
||||
);
|
||||
case 'duration':
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { CoverArtRef } from '../../cover/types';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import StarRating from '../StarRating';
|
||||
import { tooltipAttrs } from '../tooltipAttrs';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -41,6 +42,7 @@ interface Props {
|
||||
coverRevision: number;
|
||||
headerCoverFailed: boolean;
|
||||
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
export default function ArtistDetailHero({
|
||||
@@ -49,7 +51,9 @@ export default function ArtistDetailHero({
|
||||
handleImageUpload, playAllLoading, radioLoading, uploading,
|
||||
openedLink, openLink,
|
||||
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||
actionPolicy,
|
||||
}: Props) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('artistDetail', false);
|
||||
const { t } = useTranslation();
|
||||
const goBack = useAlbumDetailBack();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -139,7 +143,7 @@ export default function ArtistDetailHero({
|
||||
<StarRating
|
||||
value={artistEntityRating}
|
||||
onChange={handleArtistEntityRating}
|
||||
disabled={artistEntityRatingSupport === 'track_only'}
|
||||
disabled={!policy.canRate || artistEntityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
@@ -168,15 +172,17 @@ export default function ArtistDetailHero({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('artistDetail.favorite')}
|
||||
</button>
|
||||
{policy.canFavorite && (
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={toggleStar}
|
||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
{t('artistDetail.favorite')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
@@ -222,7 +228,7 @@ export default function ArtistDetailHero({
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
{albums.length > 0 && (
|
||||
{policy.canCacheDiscography && albums.length > 0 && (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
disabled={
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { getArtist } from '../../api/subsonicArtists';
|
||||
import { resolveAlbum, resolveArtist, resolveMediaServerId } from '../../utils/offline/offlineMediaResolve';
|
||||
import { AddToPlaylistSubmenu } from './AddToPlaylistSubmenu';
|
||||
|
||||
interface AlbumProps {
|
||||
@@ -13,8 +12,13 @@ export function AlbumToPlaylistSubmenu({ albumId, onDone, triggerId }: AlbumProp
|
||||
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAlbum(albumId).then((data) => {
|
||||
setResolvedIds(data.songs.map((s) => s.id));
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) {
|
||||
setResolvedIds([]);
|
||||
return;
|
||||
}
|
||||
resolveAlbum(serverId, albumId).then((data) => {
|
||||
setResolvedIds(data ? data.songs.map((s) => s.id) : []);
|
||||
}).catch(() => setResolvedIds([]));
|
||||
}, [albumId]);
|
||||
|
||||
@@ -40,8 +44,19 @@ export function ArtistToPlaylistSubmenu({ artistId, onDone, triggerId }: ArtistP
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const { albums } = await getArtist(artistId);
|
||||
const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs)));
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) {
|
||||
setResolvedIds([]);
|
||||
return;
|
||||
}
|
||||
const artistData = await resolveArtist(serverId, artistId);
|
||||
if (!artistData) {
|
||||
setResolvedIds([]);
|
||||
return;
|
||||
}
|
||||
const albumSongs = await Promise.all(
|
||||
artistData.albums.map(a => resolveAlbum(serverId, a.id).then(r => r?.songs ?? [])),
|
||||
);
|
||||
setResolvedIds(albumSongs.flat().map(s => s.id));
|
||||
})().catch(() => setResolvedIds([]));
|
||||
}, [artistId]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, ListPlus, Heart, Download, ChevronRight, ChevronsRight, User, ListMusic, Star, Share2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../../utils/offline/offlineMediaResolve';
|
||||
import { star, unstar } from '../../api/subsonicStarRating';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
@@ -22,7 +22,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||
applySongRating, applyAlbumRating, applyArtistRating,
|
||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||
pinToPlaybackServer, navigateLibrary,
|
||||
pinToPlaybackServer, navigateLibrary, offlinePolicy,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
@@ -40,7 +40,10 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
<Play size={14} /> {t('contextMenu.openAlbum')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
if (tracks.length === 0) return;
|
||||
playNext(tracks);
|
||||
@@ -48,7 +51,10 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
<ChevronsRight size={14} /> {t('contextMenu.playNext')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(album.id);
|
||||
const serverId = resolveMediaServerId(album.serverId);
|
||||
if (!serverId) return;
|
||||
const albumData = await resolveAlbum(serverId, album.id);
|
||||
if (!albumData) return;
|
||||
enqueue(albumData.songs.map(songToTrack));
|
||||
})}>
|
||||
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
|
||||
@@ -57,58 +63,66 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => goLibrary(`/artist/${album.artistId}`))}>
|
||||
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(album.id, album.starred);
|
||||
setStarredOverride(album.id, !starred);
|
||||
const meta = {
|
||||
serverId: album.serverId,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
artistId: album.artistId,
|
||||
coverArtId: album.coverArt,
|
||||
year: album.year,
|
||||
};
|
||||
return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="album"
|
||||
data-rating-id={album.id}
|
||||
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'album' && keyboardRating.id === album.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[album.id] ?? album.userRating ?? 0}
|
||||
disabled={albumRatingDisabled}
|
||||
labelKey="entityRating.albumAriaLabel"
|
||||
onChange={r => { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canFavorite && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(album.id, album.starred);
|
||||
setStarredOverride(album.id, !starred);
|
||||
const meta = {
|
||||
serverId: album.serverId,
|
||||
name: album.name,
|
||||
artist: album.artist,
|
||||
artistId: album.artistId,
|
||||
coverArtId: album.coverArt,
|
||||
year: album.year,
|
||||
};
|
||||
return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="album"
|
||||
data-rating-id={album.id}
|
||||
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'album' && keyboardRating.id === album.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[album.id] ?? album.userRating ?? 0}
|
||||
disabled={albumRatingDisabled}
|
||||
labelKey="entityRating.albumAriaLabel"
|
||||
onChange={r => { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('album', album.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||
<Download size={14} /> {t('contextMenu.download')}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`album:${album.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
|
||||
<AlbumToPlaylistSubmenu albumId={album.id} triggerId={`album:${album.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canDownload && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
|
||||
<Download size={14} /> {t('contextMenu.download')}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`album:${album.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
|
||||
<AlbumToPlaylistSubmenu albumId={album.id} triggerId={`album:${album.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -131,47 +145,56 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
// Parallel — Navidrome handles concurrent getAlbum requests fine.
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const allTracks = results.flatMap(r => r.songs.map(songToTrack));
|
||||
const results = await Promise.all(albums.map(async a => {
|
||||
const serverId = resolveMediaServerId(a.serverId);
|
||||
if (!serverId) return null;
|
||||
return resolveAlbum(serverId, a.id);
|
||||
}));
|
||||
const allTracks = results
|
||||
.filter((r): r is NonNullable<typeof r> => r != null)
|
||||
.flatMap(r => r.songs.map(songToTrack));
|
||||
enqueue(allTracks);
|
||||
})}>
|
||||
<ListPlus size={14} /> {t('contextMenu.enqueueAlbums', { count: albums.length })}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
|
||||
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="album"
|
||||
data-rating-id={multiAlbumRatingId}
|
||||
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={
|
||||
keyboardRating?.kind === 'album' && keyboardRating.id === multiAlbumRatingId
|
||||
? keyboardRating.value
|
||||
: unifiedAlbumRating
|
||||
}
|
||||
disabled={albumRatingDisabled}
|
||||
ariaLabel={t('entityRating.selectedAlbumsRatingAriaLabel', { count: albums.length })}
|
||||
onChange={r => {
|
||||
setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
|
||||
for (const a of albums) applyAlbumRating(a, r);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
|
||||
<MultiAlbumToPlaylistSubmenu albumIds={albumIds} triggerId={`multi-album:${albumIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="album"
|
||||
data-rating-id={multiAlbumRatingId}
|
||||
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={
|
||||
keyboardRating?.kind === 'album' && keyboardRating.id === multiAlbumRatingId
|
||||
? keyboardRating.value
|
||||
: unifiedAlbumRating
|
||||
}
|
||||
disabled={albumRatingDisabled}
|
||||
ariaLabel={t('entityRating.selectedAlbumsRatingAriaLabel', { count: albums.length })}
|
||||
onChange={r => {
|
||||
setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
|
||||
for (const a of albums) applyAlbumRating(a, r);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
|
||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||
applySongRating, applyAlbumRating, applyArtistRating,
|
||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||
offlinePolicy,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
@@ -35,54 +36,64 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
|
||||
<Radio size={14} /> {t('contextMenu.startRadio')}
|
||||
</div>
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`artist:${artist.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
|
||||
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`artist:${artist.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
|
||||
<ArtistToPlaylistSubmenu artistId={artist.id} triggerId={`artist:${artist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(artist.id, artist.starred);
|
||||
setStarredOverride(artist.id, !starred);
|
||||
const meta = {
|
||||
serverId: artist.serverId,
|
||||
name: artist.name,
|
||||
albumCount: artist.albumCount,
|
||||
};
|
||||
return starred
|
||||
? unstar(artist.id, 'artist', meta)
|
||||
: star(artist.id, 'artist', meta);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="artist"
|
||||
data-rating-id={artist.id}
|
||||
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'artist' && keyboardRating.id === artist.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[artist.id] ?? artist.userRating ?? 0}
|
||||
disabled={artistRatingDisabled}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
onChange={r => { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }}
|
||||
/>
|
||||
</div>
|
||||
{(offlinePolicy.canFavorite || offlinePolicy.canRate) && (
|
||||
<>
|
||||
<div className="context-menu-divider" />
|
||||
{offlinePolicy.canFavorite && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(artist.id, artist.starred);
|
||||
setStarredOverride(artist.id, !starred);
|
||||
const meta = {
|
||||
serverId: artist.serverId,
|
||||
name: artist.name,
|
||||
albumCount: artist.albumCount,
|
||||
};
|
||||
return starred
|
||||
? unstar(artist.id, 'artist', meta)
|
||||
: star(artist.id, 'artist', meta);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="artist"
|
||||
data-rating-id={artist.id}
|
||||
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'artist' && keyboardRating.id === artist.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[artist.id] ?? artist.userRating ?? 0}
|
||||
disabled={artistRatingDisabled}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
onChange={r => { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -104,40 +115,44 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
|
||||
{t('contextMenu.selectedArtists', { count: artists.length })}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-artist:${artistIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
|
||||
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="artist"
|
||||
data-rating-id={multiArtistRatingId}
|
||||
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={
|
||||
keyboardRating?.kind === 'artist' && keyboardRating.id === multiArtistRatingId
|
||||
? keyboardRating.value
|
||||
: unifiedArtistRating
|
||||
}
|
||||
disabled={artistRatingDisabled}
|
||||
ariaLabel={t('entityRating.selectedArtistsRatingAriaLabel', { count: artists.length })}
|
||||
onChange={r => {
|
||||
setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
|
||||
for (const a of artists) applyArtistRating(a, r);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-artist:${artistIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
|
||||
<MultiArtistToPlaylistSubmenu artistIds={artistIds} triggerId={`multi-artist:${artistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="artist"
|
||||
data-rating-id={multiArtistRatingId}
|
||||
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={
|
||||
keyboardRating?.kind === 'artist' && keyboardRating.id === multiArtistRatingId
|
||||
? keyboardRating.value
|
||||
: unifiedArtistRating
|
||||
}
|
||||
disabled={artistRatingDisabled}
|
||||
ariaLabel={t('entityRating.selectedArtistsRatingAriaLabel', { count: artists.length })}
|
||||
onChange={r => {
|
||||
setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
|
||||
for (const a of artists) applyArtistRating(a, r);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ListMusic, Plus } from 'lucide-react';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
|
||||
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||
import { usePlaylistStore } from '../../store/playlistStore';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
@@ -26,7 +26,10 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
|
||||
setTotalAlbums(albumIds.length);
|
||||
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
|
||||
(async () => {
|
||||
const albumSongs = await Promise.all(albumIds.map(id => getAlbum(id).then(r => r.songs).catch(() => [])));
|
||||
const serverId = resolveMediaServerId();
|
||||
const albumSongs = serverId
|
||||
? await Promise.all(albumIds.map(id => resolveAlbum(serverId, id).then(r => r?.songs ?? []).catch(() => [])))
|
||||
: [];
|
||||
const allSongs = albumSongs.flat();
|
||||
setResolvedIds(allSongs.map(s => s.id));
|
||||
})().catch(() => setResolvedIds([]));
|
||||
@@ -34,11 +37,15 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
|
||||
}, [albumIds]);
|
||||
|
||||
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const { updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
|
||||
try {
|
||||
const { songs: existingSongs } = await getPlaylist(pl.id);
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return;
|
||||
const resolved = await resolvePlaylist(serverId, pl.id);
|
||||
if (!resolved) return;
|
||||
const { songs: existingSongs } = resolved;
|
||||
const existingIds = new Set(existingSongs.map((s) => s.id));
|
||||
|
||||
const newIds: string[] = [];
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ListMusic, Plus } from 'lucide-react';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { getArtist } from '../../api/subsonicArtists';
|
||||
import { resolveAlbum, resolveArtist, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
|
||||
import { getPlaylists } from '../../api/subsonicPlaylists';
|
||||
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||
import { usePlaylistStore } from '../../store/playlistStore';
|
||||
@@ -29,10 +28,18 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
|
||||
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
|
||||
(async () => {
|
||||
const allSongs: string[] = [];
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) {
|
||||
setResolvedIds([]);
|
||||
return;
|
||||
}
|
||||
for (const artistId of artistIds) {
|
||||
try {
|
||||
const { albums } = await getArtist(artistId);
|
||||
const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs).catch(() => [])));
|
||||
const artistData = await resolveArtist(serverId, artistId);
|
||||
if (!artistData) continue;
|
||||
const albumSongs = await Promise.all(
|
||||
artistData.albums.map(a => resolveAlbum(serverId, a.id).then(r => r?.songs ?? []).catch(() => [])),
|
||||
);
|
||||
allSongs.push(...albumSongs.flat().map(s => s.id));
|
||||
} catch {
|
||||
// Skip failed artists
|
||||
@@ -44,11 +51,15 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
|
||||
}, [artistIds]);
|
||||
|
||||
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const { updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
|
||||
try {
|
||||
const { songs: existingSongs } = await getPlaylist(pl.id);
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return;
|
||||
const resolved = await resolvePlaylist(serverId, pl.id);
|
||||
if (!resolved) return;
|
||||
const { songs: existingSongs } = resolved;
|
||||
const existingIds = new Set(existingSongs.map((s) => s.id));
|
||||
|
||||
const newIds: string[] = [];
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||
applySongRating, applyAlbumRating, applyArtistRating,
|
||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||
offlinePolicy,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
@@ -33,18 +34,22 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
<Play size={14} /> {t('contextMenu.playNow')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`playlist:${playlist.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
|
||||
<SinglePlaylistToPlaylistSubmenu playlist={playlist} triggerId={`playlist:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`playlist:${playlist.id}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
|
||||
<SinglePlaylistToPlaylistSubmenu playlist={playlist} triggerId={`playlist:${playlist.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canEditPlaylist && (
|
||||
<>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||
const { showToast } = await import('../../utils/ui/toast');
|
||||
@@ -64,6 +69,8 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
})}>
|
||||
<Trash2 size={14} /> {t('playlists.deletePlaylist')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
@@ -77,18 +84,21 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
{t('contextMenu.selectedPlaylists', { count: selectedPlaylists.length })}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-playlist:${playlistIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
|
||||
<MultiPlaylistToPlaylistSubmenu playlists={selectedPlaylists} triggerId={`multi-playlist:${playlistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={`multi-playlist:${playlistIds.join(',')}`}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
|
||||
<MultiPlaylistToPlaylistSubmenu playlists={selectedPlaylists} triggerId={`multi-playlist:${playlistIds.join(',')}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{offlinePolicy.canEditPlaylist && (
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||
const { showToast } = await import('../../utils/ui/toast');
|
||||
const { deletePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
@@ -113,6 +123,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
|
||||
})}>
|
||||
<Trash2 size={14} /> {t('playlists.deleteSelected')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Play, ListPlus, Radio, Heart, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
|
||||
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
|
||||
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
|
||||
import { queueSongStar } from '../../store/pendingStarSync';
|
||||
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
@@ -27,6 +27,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
|
||||
applySongRating, applyAlbumRating, applyArtistRating,
|
||||
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
|
||||
offlinePolicy,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
@@ -80,21 +81,26 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{type === 'album-song' && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(async () => {
|
||||
const albumData = await getAlbum(song.albumId);
|
||||
const serverId = resolveMediaServerId(song.serverId);
|
||||
if (!serverId || !song.albumId) return;
|
||||
const albumData = await resolveAlbum(serverId, song.albumId);
|
||||
if (!albumData) return;
|
||||
const tracks = albumData.songs.map(songToTrack);
|
||||
enqueue(tracks);
|
||||
})}>
|
||||
@@ -120,12 +126,14 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<Sparkles size={14} /> {t('contextMenu.instantMix')}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
{offlinePolicy.canFavorite && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
|
||||
})}>
|
||||
<Heart size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
|
||||
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
</div>
|
||||
)}
|
||||
{auth.lastfmSessionKey && (() => {
|
||||
const loveKey = `${song.title}::${song.artist}`;
|
||||
const loved = lastfmLovedCache[loveKey] ?? false;
|
||||
@@ -141,22 +149,24 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="song"
|
||||
data-rating-id={song.id}
|
||||
data-rating-disabled="false"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="song"
|
||||
data-rating-id={song.id}
|
||||
data-rating-disabled="false"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
@@ -164,13 +174,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
{playlistId && playlistSongIndex !== undefined && (
|
||||
{offlinePolicy.canEditPlaylist && playlistId && playlistSongIndex !== undefined && (
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
||||
const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const { updatePlaylist } = await import('../../api/subsonicPlaylists');
|
||||
const { showToast } = await import('../../utils/ui/toast');
|
||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||
try {
|
||||
const { songs } = await getPlaylist(playlistId);
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return;
|
||||
const resolved = await resolvePlaylist(serverId, playlistId);
|
||||
if (!resolved) return;
|
||||
const { songs } = resolved;
|
||||
const prevCount = songs.length;
|
||||
const updatedIds = songs.filter((_, i) => i !== playlistSongIndex).map(s => s.id);
|
||||
await updatePlaylist(playlistId, updatedIds, prevCount);
|
||||
@@ -232,18 +246,20 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSessionHost')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
{offlinePolicy.canAddToPlaylist && (
|
||||
<div
|
||||
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
|
||||
data-playlist-trigger-id={song.id}
|
||||
onMouseEnter={() => { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
|
||||
onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
|
||||
>
|
||||
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
|
||||
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
|
||||
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
|
||||
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
{song.albumId && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!))}>
|
||||
@@ -278,22 +294,24 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="song"
|
||||
data-rating-id={song.id}
|
||||
data-rating-disabled="false"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
{offlinePolicy.canRate && (
|
||||
<div
|
||||
className="context-menu-rating-row"
|
||||
data-rating-kind="song"
|
||||
data-rating-id={song.id}
|
||||
data-rating-disabled="false"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Star size={14} className="context-menu-rating-icon" aria-hidden />
|
||||
<StarRating
|
||||
value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id
|
||||
? keyboardRating.value
|
||||
: userRatingOverrides[song.id] ?? song.userRating ?? 0}
|
||||
onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
|
||||
ariaLabel={t('albumDetail.ratingLabel')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
|
||||
<Share2 size={14} /> {t('contextMenu.shareLink')}
|
||||
@@ -301,12 +319,16 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
|
||||
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, false, song.serverId);
|
||||
})}>
|
||||
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
|
||||
</div>
|
||||
{offlinePolicy.canFavorite && (
|
||||
<>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, false, song.serverId);
|
||||
})}>
|
||||
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type React from 'react';
|
||||
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
|
||||
import type { EntityShareKind } from '../../utils/share/shareLink';
|
||||
import type { OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
export type RatingKind = 'song' | 'album' | 'artist';
|
||||
|
||||
@@ -56,4 +57,5 @@ export interface ContextMenuItemsProps {
|
||||
/** When true, album/artist links switch to the queue server before routing. */
|
||||
pinToPlaybackServer: boolean;
|
||||
navigateLibrary: (path: string) => void | Promise<void>;
|
||||
offlinePolicy: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
usePlayerBarLayoutStore,
|
||||
type PlayerBarLayoutItemId,
|
||||
} from '../../store/playerBarLayoutStore';
|
||||
import { useOfflineBrowseContext } from '../../hooks/useOfflineBrowseContext';
|
||||
import { offlineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
interface Props {
|
||||
currentTrack: Track | null;
|
||||
@@ -67,6 +69,8 @@ export function PlayerTrackInfo({
|
||||
const layoutItems = usePlayerBarLayoutStore(s => s.items);
|
||||
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
||||
layoutItems.find(i => i.id === id)?.visible !== false;
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const playerPolicy = offlineActionPolicy('playerBar', offlineBrowseActive);
|
||||
|
||||
return (
|
||||
<div className="player-track-info">
|
||||
@@ -168,7 +172,7 @@ export function PlayerTrackInfo({
|
||||
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
/>
|
||||
)}
|
||||
{currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && (
|
||||
{currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && playerPolicy.canRate && (
|
||||
<StarRating
|
||||
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
||||
onChange={r => queueSongRating(currentTrack.id, r)}
|
||||
@@ -182,7 +186,7 @@ export function PlayerTrackInfo({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{currentTrack && !isRadio && isLayoutVisible('favorite') && (
|
||||
{currentTrack && !isRadio && isLayoutVisible('favorite') && playerPolicy.canFavorite && (
|
||||
<button
|
||||
className={`player-btn player-btn-sm player-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||
onClick={toggleStar}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
|
||||
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
|
||||
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
|
||||
import type { OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
interface Props {
|
||||
playlist: SubsonicPlaylist;
|
||||
@@ -34,6 +35,7 @@ interface Props {
|
||||
offlineStatus: AlbumOfflineStatus;
|
||||
offlineProgress: { done: number; total: number } | null;
|
||||
activeServerId: string;
|
||||
actionPolicy: OfflineActionPolicy;
|
||||
setEditingMeta: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||
@@ -53,7 +55,7 @@ export default function PlaylistHero({
|
||||
playlist, songs, id,
|
||||
customCoverId, coverQuadIds,
|
||||
resolvedBgUrl, saving, searchOpen, csvImporting, activeZip,
|
||||
offlineStatus, offlineProgress, activeServerId,
|
||||
offlineStatus, offlineProgress, activeServerId, actionPolicy,
|
||||
setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults,
|
||||
setSelectedSearchIds, setSearchPlPickerOpen,
|
||||
handlePlayAll, handleShuffleAll, handleEnqueueAll, handleImportCsv, handleDownload,
|
||||
@@ -86,7 +88,7 @@ export default function PlaylistHero({
|
||||
{enablePlaylistCoverPhoto && (
|
||||
<div
|
||||
className="playlist-hero-cover"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
onClick={() => { if (actionPolicy.canEditPlaylist) setEditingMeta(true); }}
|
||||
>
|
||||
{customCoverId ? (
|
||||
<AlbumCoverArtImage
|
||||
@@ -120,14 +122,16 @@ export default function PlaylistHero({
|
||||
{isSmartPlaylistName(playlist.name) && <Sparkles size={16} style={{ color: 'var(--text-muted)' }} />}
|
||||
<span>{displayPlaylistName(playlist.name)}</span>
|
||||
</h1>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
data-tooltip={t('playlists.editMeta')}
|
||||
style={{ padding: '4px 6px', opacity: 0.7, flexShrink: 0 }}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
{actionPolicy.canEditPlaylist && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setEditingMeta(true)}
|
||||
data-tooltip={t('playlists.editMeta')}
|
||||
style={{ padding: '4px 6px', opacity: 0.7, flexShrink: 0 }}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{playlist.comment && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 2 }}>{playlist.comment}</div>
|
||||
@@ -172,7 +176,7 @@ export default function PlaylistHero({
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{isLayoutVisible('addSongs') && (
|
||||
{actionPolicy.canEditPlaylist && isLayoutVisible('addSongs') && (
|
||||
<button
|
||||
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
|
||||
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
|
||||
@@ -181,7 +185,7 @@ export default function PlaylistHero({
|
||||
<Search size={16} /> {t('playlists.addSongs')}
|
||||
</button>
|
||||
)}
|
||||
{isLayoutVisible('importCsv') && (
|
||||
{actionPolicy.canEditPlaylist && isLayoutVisible('importCsv') && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={handleImportCsv}
|
||||
@@ -192,7 +196,7 @@ export default function PlaylistHero({
|
||||
{t('playlists.importCSV')}
|
||||
</button>
|
||||
)}
|
||||
{isLayoutVisible('downloadZip') && songs.length > 0 && (
|
||||
{actionPolicy.canDownload && isLayoutVisible('downloadZip') && songs.length > 0 && (
|
||||
activeZip && !activeZip.done && !activeZip.error ? (
|
||||
<div className="download-progress-wrap">
|
||||
<Download size={14} />
|
||||
@@ -207,7 +211,7 @@ export default function PlaylistHero({
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{isLayoutVisible('offlineCache') && songs.length > 0 && id
|
||||
{actionPolicy.canPinOffline && isLayoutVisible('offlineCache') && songs.length > 0 && id
|
||||
&& (!isSmartPlaylistName(playlist.name) || offlineStatus !== 'none') && (
|
||||
<button
|
||||
className={`btn btn-ghost${offlineStatus === 'cached' ? ' btn-danger' : ''}${offlineStatus === 'queued' ? ' offline-cache-btn--queued' : ''}`}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||
import {
|
||||
defaultSmartFilters, type SmartFilters,
|
||||
} from '../../utils/playlist/playlistsSmart';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
|
||||
|
||||
interface Props {
|
||||
selectionMode: boolean;
|
||||
@@ -24,6 +25,7 @@ interface Props {
|
||||
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
|
||||
setGenreQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
export default function PlaylistsHeader({
|
||||
@@ -32,8 +34,10 @@ export default function PlaylistsHeader({
|
||||
creating, setCreating, setCreatingSmart,
|
||||
newName, setNewName, nameInputRef, handleCreate,
|
||||
isNavidromeServer, setEditingSmartId, setSmartFilters, setGenreQuery,
|
||||
actionPolicy,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const policy = actionPolicy ?? offlineActionPolicy('playlistsHeader', false);
|
||||
|
||||
return (
|
||||
<div className="playlists-header">
|
||||
@@ -43,7 +47,7 @@ export default function PlaylistsHeader({
|
||||
: t('playlists.title')}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{!(selectionMode && selectedIds.size > 0) && (<>
|
||||
{policy.canEditPlaylist && !(selectionMode && selectedIds.size > 0) && (<>
|
||||
{creating ? (
|
||||
<>
|
||||
<input
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useOfflineBrowseContext } from '../../hooks/useOfflineBrowseContext';
|
||||
import { usePlayerStatsRecordingEnabled } from '../../hooks/usePlayerStatsRecordingEnabled';
|
||||
|
||||
export default function StatisticsTabBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const playerStatsEnabled = usePlayerStatsRecordingEnabled();
|
||||
|
||||
const isPlayer = location.pathname === '/player-stats';
|
||||
const showServerTab = !offlineBrowseActive;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${!isPlayer ? 'btn-primary' : 'btn-surface'}`}
|
||||
onClick={() => navigate('/statistics')}
|
||||
>
|
||||
{t('statistics.tabServer')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${isPlayer ? 'btn-primary' : 'btn-surface'}`}
|
||||
onClick={() => navigate('/player-stats')}
|
||||
>
|
||||
{t('statistics.tabPlayer')}
|
||||
</button>
|
||||
{showServerTab && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${!isPlayer ? 'btn-primary' : 'btn-surface'}`}
|
||||
onClick={() => navigate('/statistics')}
|
||||
>
|
||||
{t('statistics.tabServer')}
|
||||
</button>
|
||||
)}
|
||||
{playerStatsEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${isPlayer || offlineBrowseActive ? 'btn-primary' : 'btn-surface'}`}
|
||||
onClick={() => navigate('/player-stats')}
|
||||
>
|
||||
{t('statistics.tabPlayer')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user