mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(offline): unify local playback, offline library, and favorites sync (#1008)
* feat(local-playback): LP-1 media layout and download_track_local
Add library-index-backed path builder in psysonic-core and a unified
Tauri download command that writes under media/{cache|library}/ with
layout fingerprints; legacy hot/offline commands unchanged for now.
* feat(local-playback): LP-2 localPlaybackStore and media tier Rust helpers
Add unified Zustand index with legacy offline/hot-cache import, media_layout
TS mirror, and Rust commands for tier size/purge/delete/promote.
* feat(local-playback): LP-3 wire prefetch and playback to unified index
Route downloads through download_track_local, delegate hot/offline shims
to localPlaybackStore, and update resolve/promote/prefetch plus key rewrite.
* feat(local-playback): LP-4–LP-6 offline UI, invalidation, and mediaDir
Offline Library loads pinned groups via library index; sync-idle invalidates
stale paths; Settings uses a single mediaDir with cache/library tier sizes.
* feat(local-playback): migrate legacy offline files to media/library layout
Move flat psysonic-offline downloads into nested media/library paths using
library index metadata, with retry on sync-idle when tracks are not yet indexed.
* feat(local-playback): simplify offline disk migration and restore Offline Library UI
Scan psysonic-offline on disk and relocate by library track id; restore pinSource
and cover art for migrated pins; add find_live_by_id for segment/key resolution.
* feat(local-playback): disk-first offline reconcile and fast library tier discovery
Reconcile library-tier index against on-disk files using candidate track IDs
instead of scanning the full catalog. Refresh Offline Library from disk on
open and focus so deleted folders drop out of the UI. Add Rust discover/prune
helpers and wire album/server reconcile through the unified path.
* fix(offline): resolve local playback URLs across server index-key variants
Offline Library play failed when library-tier files were indexed under a
host key while playback looked up only the active profile UUID. Use
findLocalPlaybackEntry for URL resolution, pin queueServerId to the card
server, and build play queues from tracks that still have on-disk bytes.
* fix(offline): playlist cards, playback from Offline Library, and local URL routing
Show playlists with name and quad/custom cover instead of the first track's
album artist. Build play queues with library-batch fallback and offline-only
server switch. Prefer library-tier URLs in playTrack; add playback-unavailable
toast and missing trackToSong import.
* fix(cache): ephemeral disk reconcile, empty-dir prune, and Storage UI
Sweep media/cache after eviction (orphan files, stale index, empty folders).
Settings: split media folder from cover cache; in-browser image cache lives
under Cover art cache with aligned columns; clear only IndexedDB images.
* feat(offline): show library disk usage in Offline Library header
Query media/library tier size on reconcile and display it in a right-aligned
stat block beside the page title and album count.
* fix(cache): defer unindexed hot-cache eviction; drop legacy offline size cap
Reconcile ephemeral cache without deleting files from other app instances;
evict unindexed hot-cache files oldest-first only when over hotCacheMaxMb.
Remove the hidden maxCacheMb gate and offline-full banner on album pages.
* feat(offline): play-all cache card and stable Offline Library grid rows
Add a shuffle-and-play card for all on-disk library pins plus hot-cache
tracks when buffering is enabled. Fix virtual row height for offline cards
and reserve the year line so grid rows no longer overlap.
* feat(offline): queue-cache grid card limited to media/cache
Replace the full-width play-all banner with a playlist-style grid tile.
Shuffle/enqueue only ephemeral hot-cache tracks when buffering is enabled,
not offline library pins.
* chore(licenses): regenerate bundled OSS list for 1.48.0-dev
Set GPL-3.0-or-later on workspace crates and extend cargo-about accepted
licenses so generate-licenses.mjs runs; refresh src/data/licenses.json.
* feat(favorites): auto-sync starred tracks into separate media/favorites tier
Keep manual Offline Library in media/library/ and favorites offline in
favorite-auto/index + media/favorites/ so toggling sync cannot purge
user-pinned bytes; playback resolves library before favorites.
* feat(favorites): compact offline toggle with disk icon and sync semaphore
Move control to the page header (disk + switch, tooltips); show red/yellow/green
LED when enabled instead of the full-width save-offline card.
* fix(favorites): trigger offline sync on star/unstar from anywhere
Hook star/unstar API so favorites offline reconcile runs globally (songs,
albums, artists); optimistic unstar removes local bytes; drop Favorites-page-only sync.
* fix(cache): skip hot-cache prefetch when favorites or library bytes exist
Treat favorite-auto tier like offline library for prefetch, stream promote,
and same-track replay so synced favorites are not duplicated in media/cache.
* fix(favorites): reconcile offline files on merged track union only
Dedupe artist/album/song stars into one target set per track id; drop eager
unstar deletes so overlapping favorites do not remove bytes still needed.
* feat(offline): add Favorites card to Offline Library
Mirror queue-cache card for favorite-auto tier with play, enqueue, and
navigation to Favorites on card click.
* feat(offline): show library+favorites disk total with icon breakdown
Sum media/library and media/favorites in the On disk widget and open an
icon popover on hover with per-tier sizes for screen readers and sighted users.
* feat(favorites): enable offline Favorites tab when auto-save is on
Keep Favorites in the sidebar when disconnected, land on /favorites without
manual pins, and load starred rows from the local library index.
* feat(favorites): cross-server offline browse with per-server covers
When auto-save is on, Favorites merges starred items from every indexed
server and syncs each server independently. Detail links carry ?server=
for offline album/artist pages; cover art resolves disk cache by entity
serverId instead of the active server only.
* feat(playback): mixed-server queue scope and cross-server favorites sync
Per-ref server identity for playback (URL index key in queue refs, profile
UUID for API): trackServerScope, playbackServer helpers, gapless/scrobble/covers
by playing ref. Remove cross-server enqueue block; remap queueItems on URL
remigration.
Favorites: star/unstar and favorite-auto sync target the owning serverId
(not only active); queueSongStar passes server through pending sync.
* fix(offline): suppress Subsonic calls during favorites and local playback
Add reachability guards so offline favorites browse, album detail, queue
sync, scrobble, and Now Playing metadata skip network when the server is
down or the track plays from psysonic-local. Load starred albums/tracks
from the library index only (not the full artist table), refresh favorites
from index first, and pass server scope in favorites navigation.
* fix(queue): export share and playlist save for active server only
Mixed-server queues now filter queue refs by the browsed server profile
before copying a share link or saving/updating a playlist from the toolbar.
* fix(offline): complete album pins and resume interrupted downloads
Prefer full getAlbum track lists when online so partial library index
does not truncate offline pins; refresh songs before pin from album
detail. Resume incomplete persisted pins after reconcile and reconnect,
cancel in-flight work on delete, and chunk library batch fetches past
100 refs.
* fix(offline): pin queue, queued UI, and re-pin after remove
Serialize album and playlist offline pins so parallel enqueue no longer
drops in-flight work. Show an explicit queued state on album and playlist
actions with dequeue on repeat click, sidebar tooltips for long labels,
and clear stale cancel flags so Make available offline works after remove.
* fix(offline): remove Offline Library cards without full page reload
Optimistically drop the deleted card from local grid state, show the
loading spinner only on first visit, and ignore stale disk refreshes so
pin updates no longer flash the whole library view.
* fix(offline): artist discography pin state and queue handling
Detect cached/queued/downloading from persisted album pins instead of
ephemeral bulkProgress, skip already offline or in-flight albums when
enqueueing discography, and show the correct hero button after revisit.
* fix(local-playback): address LP-1 review handoff (B1, M1–M7)
Harden media path sanitization and tier containment, align Rust/TS layout
fingerprints, serialize per-track downloads, and fix favorites re-enable,
multi-server debounce, prev-track promote key, now-playing reachability,
and ephemeral prefetch soft-skip for unindexed tracks.
* fix(offline): cancel in-flight favorites downloads on unstar
Abort Rust streams with the real favorites downloadId when sync is
rescheduled or disabled, and drop completed bytes that no longer belong
in the starred set so unstar does not leave orphan files on disk.
* fix(build): resolve TypeScript errors blocking prod nix build
Align mediaLayout with LibraryTrackDto camelCase, extend analysis-sync
reasons, fix OfflineLibrary grid cover typing, and tighten vitest mocks
so `tsc && vite build` passes under the flake beforeBuildCommand.
* fix(rust): satisfy clippy too_many_arguments for CI
Bundle offline-library analysis and local path/migration helpers into
parameter structs so `cargo clippy -D warnings` passes on the branch.
* fix(settings): show correct hot-cache track count in Buffering section
Count ephemeral localPlayback rows instead of prefix-matching index keys,
which always missed host:port server segments and showed zero tracks.
* fix(media-layout): align truncation threshold on code points (M1)
Rust sanitize_and_truncate_segment now uses char count like TS so long
non-ASCII metadata does not diverge layout fingerprints; add Cyrillic
parity tests and clarify ephemeral cold-miss doc on download_track_local.
* fix(test): use numeric cachedAt in hotCacheStore count test
Align test fixture with LocalPlaybackEntry type so tsc passes in CI.
* docs: add CHANGELOG and credits for offline experience PR #1008
* feat(offline): auto-sync manually cached playlists when track list changes
Re-download new tracks and prune removed ones for playlist pins only, triggered
from updatePlaylist, playlist detail load, smart-playlist polling, and reconnect.
* docs: note cached-playlist sync in CHANGELOG and credits for PR #1008
* fix(offline): exclude smart playlists from manual offline cache and sync
Hide cache-offline for psy-smart-* playlists, block download/sync paths, and
document the distinction in CHANGELOG.
* feat(offline): auto-sync cached albums and artist discographies
Generalize pinned playlist reconcile into pinnedOfflineSync so manually
pinned albums and artist discographies re-download added tracks and prune
removed ones on reopen, reconnect, and catalog changes.
* feat(offline): split pinned sync triggers by pin kind
Album and artist pins reconcile after library index sync and reconnect;
regular playlists reconcile hourly and on in-app playlist edits only.
Remove reconcile-on-open for album, artist, and playlist detail views.
* fix(offline): address PR #1008 review (N1, tests, pin queue)
Scope playlist reconcile to the owning server via getPlaylistForServer.
Add artist discography and mixed-server playlist tests; dedupe pending
sync jobs; skip pinTasks overwrite during active downloads.
This commit is contained in:
@@ -7,8 +7,9 @@ import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
|
||||
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
|
||||
import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { coverStorageKeyFromRef } from '../cover/storageKeys';
|
||||
@@ -23,6 +24,8 @@ import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
|
||||
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
import { coverServerScopeForServerId } from '../cover/serverScope';
|
||||
import { appendServerQuery } from '../utils/navigation/detailServerScope';
|
||||
|
||||
interface AlbumCardProps {
|
||||
album: SubsonicAlbum;
|
||||
@@ -63,21 +66,28 @@ function AlbumCard({
|
||||
}: AlbumCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isHolding, pressBind } = useLongPressAction({
|
||||
onShortPress: () => playAlbum(album.id),
|
||||
onLongPress: () => playAlbumShuffled(album.id),
|
||||
onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined),
|
||||
onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined),
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const navigateToAlbum = useNavigateToAlbum();
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isOffline = useOfflineStore(s => {
|
||||
const meta = s.albums[`${serverId}:${album.id}`];
|
||||
if (!meta || meta.trackIds.length === 0) return false;
|
||||
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
|
||||
});
|
||||
const activeServerId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const offlineServerId = album.serverId ?? activeServerId;
|
||||
const localEntries = useLocalPlaybackStore(s => s.entries);
|
||||
const isOffline = isOfflinePinComplete(album.id, offlineServerId);
|
||||
const albumLinkQuery = useMemo(
|
||||
() => appendServerQuery(linkQuery, album.serverId),
|
||||
[linkQuery, album.serverId],
|
||||
);
|
||||
void localEntries;
|
||||
const psyDrag = useDragDrop();
|
||||
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve });
|
||||
const coverServerScope = useMemo(
|
||||
() => coverServerScopeForServerId(album.serverId),
|
||||
[album.serverId],
|
||||
);
|
||||
const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScope, { libraryResolve });
|
||||
const dragCoverKey = useMemo(() => {
|
||||
if (!coverRef) return '';
|
||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
|
||||
@@ -88,7 +98,7 @@ function AlbumCard({
|
||||
|
||||
const handleClick = (opts?: { shiftKey?: boolean }) => {
|
||||
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
|
||||
navigateToAlbum(album.id, { search: linkQuery });
|
||||
navigateToAlbum(album.id, { search: albumLinkQuery });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -74,7 +74,7 @@ interface AlbumHeaderProps {
|
||||
resolvedCoverUrl: string | null;
|
||||
isStarred: boolean;
|
||||
downloadProgress: number | null;
|
||||
offlineStatus: 'none' | 'downloading' | 'cached';
|
||||
offlineStatus: 'none' | 'queued' | 'downloading' | 'cached';
|
||||
offlineProgress: { done: number; total: number } | null;
|
||||
bio: string | null;
|
||||
bioOpen: boolean;
|
||||
@@ -300,6 +300,15 @@ export default function AlbumHeader({
|
||||
<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"
|
||||
@@ -400,6 +409,15 @@ export default function AlbumHeader({
|
||||
<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"
|
||||
|
||||
@@ -251,7 +251,7 @@ export default function AlbumRow({
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{uniqueAlbums.map((a, idx) => (
|
||||
<AlbumCard
|
||||
key={a.id}
|
||||
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
|
||||
album={a}
|
||||
showRating={showRating}
|
||||
linkQuery={albumLinkQuery}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
|
||||
import { coverServerScopeForServerId } from '../cover/serverScope';
|
||||
import { appendServerQuery } from '../utils/navigation/detailServerScope';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -18,12 +20,17 @@ interface Props {
|
||||
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigateToArtist = useNavigateToArtist();
|
||||
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, { libraryResolve });
|
||||
const coverServerScope = useMemo(
|
||||
() => coverServerScopeForServerId(artist.serverId),
|
||||
[artist.serverId],
|
||||
);
|
||||
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, coverServerScope, { libraryResolve });
|
||||
const artistLinkQuery = appendServerQuery(linkQuery, artist.serverId);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="artist-card"
|
||||
onClick={() => navigateToArtist(artist.id, linkQuery ? { search: linkQuery } : undefined)}
|
||||
onClick={() => navigateToArtist(artist.id, artistLinkQuery ? { search: artistLinkQuery } : undefined)}
|
||||
>
|
||||
<div className="artist-card-avatar">
|
||||
{coverRef ? (
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function ArtistRow({
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{artists.map(a => (
|
||||
<ArtistCardLocal
|
||||
key={a.id}
|
||||
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
|
||||
artist={a}
|
||||
linkQuery={artistLinkQuery}
|
||||
libraryResolve={libraryResolve}
|
||||
|
||||
@@ -7,7 +7,10 @@ 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';
|
||||
|
||||
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
|
||||
|
||||
@@ -15,6 +18,12 @@ 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 luckyMixBase = useLuckyMixAvailable();
|
||||
@@ -29,6 +38,9 @@ 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)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
|
||||
|
||||
@@ -238,7 +238,7 @@ export default function MobilePlayerView() {
|
||||
|
||||
const toggleStar = useCallback(() => {
|
||||
if (!currentTrack) return;
|
||||
queueSongStar(currentTrack.id, !isStarred);
|
||||
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
|
||||
}, [currentTrack, isStarred]);
|
||||
|
||||
// Scrubber touch/mouse drag
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { HardDriveDownload, Heart } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatBytes } from '../utils/format/formatBytes';
|
||||
|
||||
const OPEN_DELAY_MS = 450;
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
totalBytes: number | null;
|
||||
libraryBytes: number | null;
|
||||
favoritesBytes: number | null;
|
||||
}
|
||||
|
||||
export function OfflineLibraryDiskStat({
|
||||
label,
|
||||
totalBytes,
|
||||
libraryBytes,
|
||||
favoritesBytes,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
|
||||
const openTimerRef = useRef<number | null>(null);
|
||||
|
||||
const breakdownReady = libraryBytes !== null && favoritesBytes !== null;
|
||||
|
||||
const clearOpenTimer = () => {
|
||||
if (openTimerRef.current !== null) {
|
||||
clearTimeout(openTimerRef.current);
|
||||
openTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleOpen = () => {
|
||||
if (!breakdownReady) return;
|
||||
clearOpenTimer();
|
||||
openTimerRef.current = window.setTimeout(() => setOpen(true), OPEN_DELAY_MS);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
clearOpenTimer();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const breakdownAriaLabel = breakdownReady
|
||||
? [
|
||||
t('connection.offlineLibraryDiskTierLibrary', { size: formatBytes(libraryBytes) }),
|
||||
t('connection.offlineLibraryDiskTierFavorites', { size: formatBytes(favoritesBytes) }),
|
||||
].join('. ')
|
||||
: undefined;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !anchorRef.current || !popoverRef.current) {
|
||||
setStyle({ opacity: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = anchorRef.current.getBoundingClientRect();
|
||||
const box = popoverRef.current.getBoundingClientRect();
|
||||
const GAP = 8;
|
||||
const MARGIN = 8;
|
||||
|
||||
let top = anchor.bottom + GAP;
|
||||
let left = anchor.left + anchor.width / 2 - box.width / 2;
|
||||
left = Math.max(MARGIN, Math.min(left, window.innerWidth - box.width - MARGIN));
|
||||
top = Math.max(MARGIN, Math.min(top, window.innerHeight - box.height - MARGIN));
|
||||
|
||||
setStyle({ opacity: 1, top, left });
|
||||
}, [open, libraryBytes, favoritesBytes]);
|
||||
|
||||
useEffect(() => () => clearOpenTimer(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={anchorRef}
|
||||
className={`offline-library-header-stat${breakdownReady ? ' offline-library-header-stat--interactive' : ''}`}
|
||||
aria-live="polite"
|
||||
aria-label={breakdownAriaLabel}
|
||||
onMouseEnter={scheduleOpen}
|
||||
onMouseLeave={close}
|
||||
onFocus={scheduleOpen}
|
||||
onBlur={close}
|
||||
tabIndex={breakdownReady ? 0 : undefined}
|
||||
>
|
||||
<span className="offline-library-disk-label">{label}</span>
|
||||
<span className="offline-library-disk-value">
|
||||
{totalBytes !== null ? formatBytes(totalBytes) : '…'}
|
||||
</span>
|
||||
</div>
|
||||
{open && breakdownReady && createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="offline-library-disk-breakdown"
|
||||
style={{ position: 'fixed', pointerEvents: 'auto', ...style }}
|
||||
role="tooltip"
|
||||
onMouseEnter={() => {
|
||||
clearOpenTimer();
|
||||
setOpen(true);
|
||||
}}
|
||||
onMouseLeave={close}
|
||||
>
|
||||
<div className="offline-library-disk-breakdown-row">
|
||||
<span
|
||||
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--library"
|
||||
aria-hidden
|
||||
>
|
||||
<HardDriveDownload size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="offline-library-disk-breakdown-size">
|
||||
{formatBytes(libraryBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="offline-library-disk-breakdown-divider" aria-hidden />
|
||||
<div className="offline-library-disk-breakdown-row">
|
||||
<span
|
||||
className="offline-library-disk-breakdown-icon offline-library-disk-breakdown-icon--favorites"
|
||||
aria-hidden
|
||||
>
|
||||
<Heart size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="offline-library-disk-breakdown-size">
|
||||
{formatBytes(favoritesBytes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export default function PlayerBar() {
|
||||
|
||||
const toggleStar = useCallback(() => {
|
||||
if (!currentTrack) return;
|
||||
queueSongStar(currentTrack.id, !isStarred);
|
||||
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
|
||||
}, [currentTrack, isStarred]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
@@ -33,6 +33,7 @@ import { QueueToolbar } from './queuePanel/QueueToolbar';
|
||||
import { QueueList } from './queuePanel/QueueList';
|
||||
import { QueueTabBar } from './queuePanel/QueueTabBar';
|
||||
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
|
||||
import { activeServerQueueTrackIds } from '../utils/playback/trackServerScope';
|
||||
|
||||
export default function QueuePanel() {
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
@@ -170,11 +171,12 @@ function QueuePanelHostOrSolo() {
|
||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (queueItems.length === 0) return;
|
||||
const exportTrackIds = activeServerQueueTrackIds(queueItems);
|
||||
if (exportTrackIds.length === 0) return;
|
||||
if (activePlaylist) {
|
||||
setSaveState('saving');
|
||||
try {
|
||||
await updatePlaylist(activePlaylist.id, queueItems.map(r => r.trackId));
|
||||
await updatePlaylist(activePlaylist.id, exportTrackIds);
|
||||
setSaveState('saved');
|
||||
setTimeout(() => setSaveState('idle'), 1500);
|
||||
} catch (e) {
|
||||
@@ -196,7 +198,8 @@ function QueuePanelHostOrSolo() {
|
||||
};
|
||||
|
||||
const handleCopyQueueShare = async () => {
|
||||
if (queueItems.length === 0) {
|
||||
const ids = activeServerQueueTrackIds(queueItems);
|
||||
if (ids.length === 0) {
|
||||
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
|
||||
return;
|
||||
}
|
||||
@@ -207,7 +210,6 @@ function QueuePanelHostOrSolo() {
|
||||
if (!active) return;
|
||||
const srv = serverShareBaseUrl(active);
|
||||
if (!srv) return;
|
||||
const ids = queueItems.map(r => r.trackId);
|
||||
const ok = await copyTextToClipboard(encodeSharePayload({ srv, k: 'queue', ids }));
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
@@ -381,7 +383,7 @@ function QueuePanelHostOrSolo() {
|
||||
onSave={async (name) => {
|
||||
try {
|
||||
const createPlaylist = usePlaylistStore.getState().createPlaylist;
|
||||
const pl = await createPlaylist(name, queueItems.map(r => r.trackId));
|
||||
const pl = await createPlaylist(name, activeServerQueueTrackIds(queueItems));
|
||||
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
|
||||
setSaveModalOpen(false);
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
@@ -23,7 +24,10 @@ 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 { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
|
||||
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
|
||||
import SidebarNavBody from './sidebar/SidebarNavBody';
|
||||
@@ -41,8 +45,16 @@ export default function Sidebar({
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const offlineJobs = useOfflineJobStore(s => s.jobs);
|
||||
const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads);
|
||||
const pinQueue = useOfflineJobStore(s => s.pinQueue);
|
||||
const cancelAllDownloadsStore = useOfflineJobStore(s => s.cancelAllDownloads);
|
||||
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
|
||||
const activePin = pinQueue.find(p => p.status === 'downloading')
|
||||
?? pinQueue.find(p => p.status === 'queued');
|
||||
const queuedPinCount = pinQueue.filter(p => p.status === 'queued').length;
|
||||
const cancelAllDownloads = () => {
|
||||
clearOfflinePinTasks();
|
||||
cancelAllDownloadsStore();
|
||||
};
|
||||
const syncJobStatus = useDeviceSyncJobStore(s => s.status);
|
||||
const syncJobDone = useDeviceSyncJobStore(s => s.done);
|
||||
const syncJobSkip = useDeviceSyncJobStore(s => s.skipped);
|
||||
@@ -61,7 +73,12 @@ 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 sidebarItems = useSidebarStore(s => s.items);
|
||||
const setSidebarItems = useSidebarStore(s => s.setItems);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
@@ -101,13 +118,20 @@ 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)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[libraryItemsForReorder, luckyMixAvailable],
|
||||
[libraryItemsForReorder, luckyMixAvailable, isServerOffline, favoritesOfflineBrowse],
|
||||
);
|
||||
const visibleSystemConfigs = useMemo(
|
||||
() => systemItemsForReorder.filter(c => c.visible),
|
||||
[systemItemsForReorder],
|
||||
() => systemItemsForReorder.filter(c => {
|
||||
if (!c.visible) return false;
|
||||
if (isServerOffline) return false;
|
||||
return true;
|
||||
}),
|
||||
[systemItemsForReorder, isServerOffline],
|
||||
);
|
||||
|
||||
const sidebarItemsRef = useRef(sidebarItems);
|
||||
@@ -231,6 +255,8 @@ export default function Sidebar({
|
||||
nowPlayingAtTop={nowPlayingAtTop}
|
||||
hasOfflineContent={hasOfflineContent}
|
||||
activeJobsCount={activeJobs.length}
|
||||
activePinName={activePin?.albumName ?? null}
|
||||
queuedPinCount={queuedPinCount}
|
||||
cancelAllDownloads={cancelAllDownloads}
|
||||
isSyncing={isSyncing}
|
||||
syncJobDone={syncJobDone}
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '../../api/subsonicTypes';
|
||||
import { useOfflineStore } from '../../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../../store/offlineJobStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useArtistOfflineState } from '../../hooks/useArtistOfflineState';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { ArtistHeroCover } from '../../cover/artistHero';
|
||||
import { useCoverLightboxSrc } from '../../cover/lightbox';
|
||||
@@ -55,8 +55,13 @@ export default function ArtistDetailHero({
|
||||
const isMobile = useIsMobile();
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const downloadArtist = useOfflineStore(s => s.downloadArtist);
|
||||
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
const artistAlbumIds = useMemo(() => albums.map(a => a.id), [albums]);
|
||||
const { status: artistOfflineStatus, progress: artistOfflineProgress } = useArtistOfflineState(
|
||||
id ?? '',
|
||||
activeServerId,
|
||||
artistAlbumIds,
|
||||
);
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||
|
||||
@@ -217,28 +222,51 @@ export default function ArtistDetailHero({
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
{albums.length > 0 && (() => {
|
||||
const progress = id ? bulkProgress[id] : undefined;
|
||||
const isDone = progress && progress.done === progress.total;
|
||||
const isDownloading = progress && !isDone;
|
||||
return (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
disabled={!!isDownloading}
|
||||
onClick={() => { if (id && artist) downloadArtist(id, artist.name, activeServerId); }}
|
||||
data-tooltip={isDownloading
|
||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
|
||||
>
|
||||
{isDownloading
|
||||
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
: isDone ? <Check size={16} /> : <HardDriveDownload size={16} />}
|
||||
{!isMobile && (isDownloading
|
||||
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
|
||||
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline'))}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{albums.length > 0 && (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
disabled={
|
||||
artistOfflineStatus === 'downloading'
|
||||
|| artistOfflineStatus === 'queued'
|
||||
|| artistOfflineStatus === 'cached'
|
||||
}
|
||||
onClick={() => {
|
||||
if (id && artist && artistOfflineStatus !== 'cached') {
|
||||
downloadArtist(id, artist.name, activeServerId);
|
||||
}
|
||||
}}
|
||||
data-tooltip={
|
||||
artistOfflineStatus === 'downloading' && artistOfflineProgress
|
||||
? t('artistDetail.offlineDownloading', {
|
||||
done: artistOfflineProgress.done,
|
||||
total: artistOfflineProgress.total,
|
||||
})
|
||||
: artistOfflineStatus === 'queued'
|
||||
? t('artistDetail.offlineQueued')
|
||||
: artistOfflineStatus === 'cached'
|
||||
? t('artistDetail.offlineCached')
|
||||
: t('artistDetail.cacheOffline')
|
||||
}
|
||||
>
|
||||
{artistOfflineStatus === 'downloading'
|
||||
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
: artistOfflineStatus === 'cached'
|
||||
? <Check size={16} />
|
||||
: <HardDriveDownload size={16} />}
|
||||
{!isMobile && (
|
||||
artistOfflineStatus === 'downloading' && artistOfflineProgress
|
||||
? t('artistDetail.offlineDownloading', {
|
||||
done: artistOfflineProgress.done,
|
||||
total: artistOfflineProgress.total,
|
||||
})
|
||||
: artistOfflineStatus === 'queued'
|
||||
? t('artistDetail.offlineQueued')
|
||||
: artistOfflineStatus === 'cached'
|
||||
? t('artistDetail.offlineCached')
|
||||
: t('artistDetail.cacheOffline')
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,6 +61,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
|
||||
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,
|
||||
|
||||
@@ -54,7 +54,11 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
const starred = isStarred(artist.id, artist.starred);
|
||||
setStarredOverride(artist.id, !starred);
|
||||
const meta = { name: artist.name, albumCount: artist.albumCount };
|
||||
const meta = {
|
||||
serverId: artist.serverId,
|
||||
name: artist.name,
|
||||
albumCount: artist.albumCount,
|
||||
};
|
||||
return starred
|
||||
? unstar(artist.id, 'artist', meta)
|
||||
: star(artist.id, 'artist', meta);
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, !isStarred(song.id, song.starred));
|
||||
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')}
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, !isStarred(song.id, song.starred));
|
||||
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')}
|
||||
@@ -303,7 +303,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
||||
</div>
|
||||
<div className="context-menu-divider" />
|
||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(() => {
|
||||
queueSongStar(song.id, false);
|
||||
queueSongStar(song.id, false, song.serverId);
|
||||
})}>
|
||||
<HeartCrack size={14} /> {t('contextMenu.unfavorite')}
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,8 @@ export interface FavoriteSongRowCallbacks {
|
||||
startPreview: (song: SubsonicSong) => void;
|
||||
rate: (songId: string, rating: number) => void;
|
||||
remove: (songId: string) => void;
|
||||
navArtist: (artistId: string) => void;
|
||||
navAlbum: (albumId: string) => void;
|
||||
navArtist: (artistId: string, serverId?: string) => void;
|
||||
navAlbum: (albumId: string, serverId?: string) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -100,12 +100,12 @@ function FavoriteSongRow({
|
||||
);
|
||||
case 'artist': return (
|
||||
<div key="artist" className="track-artist-cell">
|
||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId); } }}>{song.artist}</span>
|
||||
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId, song.serverId); } }}>{song.artist}</span>
|
||||
</div>
|
||||
);
|
||||
case 'album': return (
|
||||
<div key="album" className="track-artist-cell">
|
||||
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId); } }}>{song.album}</span>
|
||||
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId, song.serverId); } }}>{song.album}</span>
|
||||
</div>
|
||||
);
|
||||
case 'genre': return (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { HardDrive } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
useFavoritesOfflineStatus,
|
||||
type FavoritesOfflineSemaphore,
|
||||
} from '../../hooks/useFavoritesOfflineStatus';
|
||||
import {
|
||||
disableFavoritesOfflineSync,
|
||||
scheduleFavoritesOfflineSync,
|
||||
} from '../../utils/offline/favoritesOfflineSync';
|
||||
|
||||
function semaphoreTooltipKey(semaphore: FavoritesOfflineSemaphore): string {
|
||||
switch (semaphore) {
|
||||
case 'red':
|
||||
return 'favorites.offlineSemaphoreError';
|
||||
case 'yellow':
|
||||
return 'favorites.offlineSemaphoreSyncing';
|
||||
case 'green':
|
||||
return 'favorites.offlineSemaphoreSynced';
|
||||
}
|
||||
}
|
||||
|
||||
export default function FavoritesOfflineHeader() {
|
||||
const { t } = useTranslation();
|
||||
const setEnabled = useAuthStore(s => s.setFavoritesOfflineEnabled);
|
||||
const { enabled, semaphore, savedCount, targetCount } = useFavoritesOfflineStatus();
|
||||
|
||||
const semaphoreLabel = semaphore
|
||||
? t(semaphoreTooltipKey(semaphore), { saved: savedCount, total: targetCount })
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="favorites-offline-control">
|
||||
{enabled && semaphore && (
|
||||
<span
|
||||
className={`favorites-offline-led favorites-offline-led--${semaphore}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={semaphoreLabel}
|
||||
data-tooltip={semaphoreLabel}
|
||||
data-tooltip-pos="bottom"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="favorites-offline-toggle"
|
||||
data-tooltip={t('favorites.offlineTooltip')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<HardDrive
|
||||
size={16}
|
||||
className={`favorites-offline-disk-icon${enabled ? ' favorites-offline-disk-icon--on' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<label className="toggle-switch" aria-label={t('favorites.offlineTooltip')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={async e => {
|
||||
const next = e.target.checked;
|
||||
if (!next) {
|
||||
await disableFavoritesOfflineSync();
|
||||
} else {
|
||||
setEnabled(true);
|
||||
scheduleFavoritesOfflineSync();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { useThemeStore } from '../../store/themeStore';
|
||||
import { useDragDrop } from '../../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { appendServerQuery } from '../../utils/navigation/detailServerScope';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
|
||||
import { useElementClientHeightById } from '../../hooks/useResizeClientHeight';
|
||||
import { SORTABLE_COLUMNS } from '../../hooks/useFavoritesSongFiltering';
|
||||
@@ -127,8 +128,14 @@ export default function FavoritesSongsTracklist({
|
||||
),
|
||||
rate: (songId, r) => latest.current.handleRate(songId, r),
|
||||
remove: (songId) => latest.current.removeSong(songId),
|
||||
navArtist: (artistId) => latest.current.navigate(`/artist/${artistId}`),
|
||||
navAlbum: (albumId) => latest.current.navigate(`/album/${albumId}`),
|
||||
navArtist: (artistId, serverId) => {
|
||||
const query = appendServerQuery(undefined, serverId);
|
||||
latest.current.navigate(query ? `/artist/${artistId}?${query}` : `/artist/${artistId}`);
|
||||
},
|
||||
navAlbum: (albumId, serverId) => {
|
||||
const query = appendServerQuery(undefined, serverId);
|
||||
latest.current.navigate(query ? `/album/${albumId}?${query}` : `/album/${albumId}`);
|
||||
},
|
||||
}), []);
|
||||
|
||||
const listWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -4,12 +4,17 @@ import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||
import { coverServerScopeForServerId } from '../../cover/serverScope';
|
||||
|
||||
export interface TopFavoriteArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
coverArtId: string;
|
||||
/** Present when favorites are merged across servers. */
|
||||
serverId?: string;
|
||||
/** Raw artist id for song filtering (without server prefix). */
|
||||
artistId?: string;
|
||||
}
|
||||
|
||||
interface TopFavoriteArtistsRowProps {
|
||||
@@ -84,6 +89,7 @@ interface TopFavoriteArtistCardProps {
|
||||
|
||||
function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }: TopFavoriteArtistCardProps) {
|
||||
const coverId = artist.coverArtId;
|
||||
const artistEntityId = artist.artistId ?? artist.coverArtId;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -94,8 +100,9 @@ function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }:
|
||||
<div className="artist-card-avatar">
|
||||
{coverId ? (
|
||||
<ArtistCoverArtImage
|
||||
artistId={artist.id}
|
||||
artistId={artistEntityId}
|
||||
coverArt={artist.coverArtId}
|
||||
serverScope={coverServerScopeForServerId(artist.serverId)}
|
||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={artist.name}
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function FullscreenPlayerStatic({ onClose }: Props) {
|
||||
});
|
||||
const toggleStar = useCallback(() => {
|
||||
if (!currentTrack) return;
|
||||
queueSongStar(currentTrack.id, !isStarred);
|
||||
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
|
||||
}, [currentTrack, isStarred]);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import type { ZipDownload } from '../../store/zipDownloadStore';
|
||||
import type { AlbumOfflineStatus } from '../../hooks/useAlbumOfflineState';
|
||||
import { dequeueOfflinePin } from '../../utils/offline/offlinePinQueue';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '../../store/playlistLayoutStore';
|
||||
import {
|
||||
@@ -29,8 +31,7 @@ interface Props {
|
||||
searchOpen: boolean;
|
||||
csvImporting: boolean;
|
||||
activeZip: ZipDownload | undefined;
|
||||
isCached: boolean;
|
||||
isDownloading: boolean;
|
||||
offlineStatus: AlbumOfflineStatus;
|
||||
offlineProgress: { done: number; total: number } | null;
|
||||
activeServerId: string;
|
||||
setEditingMeta: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -52,7 +53,7 @@ export default function PlaylistHero({
|
||||
playlist, songs, id,
|
||||
customCoverId, coverQuadIds,
|
||||
resolvedBgUrl, saving, searchOpen, csvImporting, activeZip,
|
||||
isCached, isDownloading, offlineProgress, activeServerId,
|
||||
offlineStatus, offlineProgress, activeServerId,
|
||||
setEditingMeta, setSearchOpen, setSearchQuery, setSearchResults,
|
||||
setSelectedSearchIds, setSearchPlPickerOpen,
|
||||
handlePlayAll, handleShuffleAll, handleEnqueueAll, handleImportCsv, handleDownload,
|
||||
@@ -206,27 +207,39 @@ export default function PlaylistHero({
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{isLayoutVisible('offlineCache') && songs.length > 0 && id && (
|
||||
{isLayoutVisible('offlineCache') && songs.length > 0 && id
|
||||
&& (!isSmartPlaylistName(playlist.name) || offlineStatus !== 'none') && (
|
||||
<button
|
||||
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
|
||||
disabled={isDownloading}
|
||||
className={`btn btn-ghost${offlineStatus === 'cached' ? ' btn-danger' : ''}${offlineStatus === 'queued' ? ' offline-cache-btn--queued' : ''}`}
|
||||
disabled={offlineStatus === 'downloading'}
|
||||
onClick={() => {
|
||||
if (isCached) {
|
||||
if (offlineStatus === 'cached') {
|
||||
deleteAlbum(id, activeServerId);
|
||||
} else if (offlineStatus === 'queued') {
|
||||
dequeueOfflinePin(id);
|
||||
} else if (playlist) {
|
||||
downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isDownloading
|
||||
data-tooltip={offlineStatus === 'downloading'
|
||||
? t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })
|
||||
: isCached ? t('playlists.removeOffline') : t('playlists.cacheOffline')}
|
||||
: offlineStatus === 'queued'
|
||||
? t('albumDetail.removeFromOfflineQueue')
|
||||
: offlineStatus === 'cached'
|
||||
? t('playlists.removeOffline')
|
||||
: t('playlists.cacheOffline')}
|
||||
>
|
||||
{isDownloading ? (
|
||||
{offlineStatus === 'downloading' ? (
|
||||
<>
|
||||
<div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
|
||||
{t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })}
|
||||
</>
|
||||
) : isCached ? (
|
||||
) : offlineStatus === 'queued' ? (
|
||||
<>
|
||||
<HardDriveDownload size={16} />
|
||||
{t('albumDetail.offlineQueued')}
|
||||
</>
|
||||
) : offlineStatus === 'cached' ? (
|
||||
<>
|
||||
<Trash2 size={16} />
|
||||
{t('playlists.removeOffline')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Image, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
@@ -16,18 +16,46 @@ import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
|
||||
import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
import { formatBytes } from '../../utils/format/formatBytes';
|
||||
import { clearImageCache, getImageCacheSize } from '../../utils/imageCache';
|
||||
import { wakeLibraryCoverBackfill } from '../../utils/library/coverBackfillWake';
|
||||
import {
|
||||
COVER_CACHE_STRATEGIES,
|
||||
type CoverCacheStrategy,
|
||||
} from '../../utils/library/coverStrategy';
|
||||
|
||||
type ClearTarget = {
|
||||
serverId: string;
|
||||
indexKey: string;
|
||||
label: string;
|
||||
type ClearTarget =
|
||||
| { kind: 'image' }
|
||||
| { kind: 'disk'; serverId: string; indexKey: string; label: string };
|
||||
|
||||
const ROW_BORDER = { borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' } as const;
|
||||
const TH_STYLE = {
|
||||
textAlign: 'left' as const,
|
||||
padding: '8px 10px',
|
||||
fontSize: 12,
|
||||
color: 'var(--text-muted)',
|
||||
};
|
||||
|
||||
const TABLE_STYLE = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
minWidth: 520,
|
||||
tableLayout: 'fixed',
|
||||
} as const;
|
||||
|
||||
const STRATEGY_GAP_TH: CSSProperties = { ...TH_STYLE, padding: '8px 10px' };
|
||||
const STRATEGY_GAP_TD: CSSProperties = { padding: '10px' };
|
||||
|
||||
function CoverCacheColGroup() {
|
||||
return (
|
||||
<colgroup>
|
||||
<col style={{ width: '22%' }} />
|
||||
<col style={{ width: '38%' }} />
|
||||
<col style={{ width: '22%' }} />
|
||||
<col style={{ width: '18%' }} />
|
||||
</colgroup>
|
||||
);
|
||||
}
|
||||
|
||||
type ServerRowState = {
|
||||
bytes: number;
|
||||
entryCount: number;
|
||||
@@ -38,12 +66,14 @@ type ServerRowState = {
|
||||
|
||||
export default function CoverCacheStrategySection() {
|
||||
const { t } = useTranslation();
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const auth = useAuthStore();
|
||||
const servers = auth.servers;
|
||||
const activeServerId = auth.activeServerId;
|
||||
const { strategyByServer, setServerStrategy, getStrategyForServer } = useCoverStrategyStore();
|
||||
const [rowState, setRowState] = useState<Record<string, ServerRowState>>({});
|
||||
const [clearTarget, setClearTarget] = useState<ClearTarget | null>(null);
|
||||
const [clearingKey, setClearingKey] = useState<string | null>(null);
|
||||
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
|
||||
|
||||
const activeIndexKeys = useMemo(
|
||||
() => new Set(servers.map(server => serverIndexKeyForProfile(server))),
|
||||
@@ -85,6 +115,7 @@ export default function CoverCacheStrategySection() {
|
||||
// safety net for changes made outside this view (e.g. browsing-time caching); it is
|
||||
// not needed for correctness, so we avoid re-walking the cover dirs in a tight loop.
|
||||
useEffect(() => {
|
||||
void getImageCacheSize().then(setImageCacheBytes);
|
||||
refreshAll();
|
||||
const id = window.setInterval(refreshAll, 300_000);
|
||||
return () => window.clearInterval(id);
|
||||
@@ -168,8 +199,19 @@ export default function CoverCacheStrategySection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCoverCache = async () => {
|
||||
const handleClearConfirm = async () => {
|
||||
if (!clearTarget) return;
|
||||
if (clearTarget.kind === 'image') {
|
||||
setClearingKey('image');
|
||||
try {
|
||||
await clearImageCache();
|
||||
setImageCacheBytes(await getImageCacheSize());
|
||||
} finally {
|
||||
setClearingKey(null);
|
||||
setClearTarget(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setClearingKey(clearTarget.indexKey);
|
||||
try {
|
||||
await coverCacheClearServer(clearTarget.indexKey);
|
||||
@@ -191,21 +233,62 @@ export default function CoverCacheStrategySection() {
|
||||
<SettingsSubSection title={t('settings.coverCacheStrategyTitle')} icon={<Image size={16} />}>
|
||||
<div className="settings-card">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 520 }}>
|
||||
<table style={TABLE_STYLE}>
|
||||
<CoverCacheColGroup />
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.coverCacheStrategyServerLabel')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.coverCacheStrategyLabel')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.coverCacheStrategyProgressLabel')}
|
||||
</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.coverCacheStrategyActionsLabel')}
|
||||
</th>
|
||||
<th style={TH_STYLE}>{t('settings.imageCacheScopeLabel')}</th>
|
||||
<th style={STRATEGY_GAP_TH} aria-hidden="true" />
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>
|
||||
{t('settings.imageCacheSubTitle')}
|
||||
</td>
|
||||
<td style={STRATEGY_GAP_TD} aria-hidden="true" />
|
||||
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{imageCacheBytes !== null
|
||||
? t('settings.coverCacheStrategyDiskUsage', { size: formatBytes(imageCacheBytes) })
|
||||
: '—'}
|
||||
</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => setClearTarget({ kind: 'image' })}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
margin: '20px 0 10px',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('settings.coverDiskCacheSubTitle')}
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={TABLE_STYLE}>
|
||||
<CoverCacheColGroup />
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyServerLabel')}</th>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyLabel')}</th>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
|
||||
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -215,7 +298,7 @@ export default function CoverCacheStrategySection() {
|
||||
const row = rowState[key];
|
||||
const label = serverListDisplayLabel(server, servers);
|
||||
return (
|
||||
<tr key={server.id} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
|
||||
<tr key={server.id} style={ROW_BORDER}>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>{label}</td>
|
||||
<td style={{ padding: '10px' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
@@ -245,7 +328,7 @@ export default function CoverCacheStrategySection() {
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() =>
|
||||
setClearTarget({ serverId: server.id, indexKey: key, label })}
|
||||
setClearTarget({ kind: 'disk', serverId: server.id, indexKey: key, label })}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
|
||||
</button>
|
||||
@@ -254,19 +337,20 @@ export default function CoverCacheStrategySection() {
|
||||
);
|
||||
})}
|
||||
{removedServerKeys.map(key => (
|
||||
<tr key={`removed-${key}`} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
|
||||
<tr key={`removed-${key}`} style={ROW_BORDER}>
|
||||
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{key}
|
||||
<span style={{ marginLeft: 6, fontSize: 11 }}>({t('settings.coverCacheStrategyServerRemoved')})</span>
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
<td style={STRATEGY_GAP_TD} aria-hidden="true" />
|
||||
<td style={STRATEGY_GAP_TD} aria-hidden="true" />
|
||||
<td style={{ padding: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() =>
|
||||
setClearTarget({ serverId: key, indexKey: key, label: key })}
|
||||
setClearTarget({ kind: 'disk', serverId: key, indexKey: key, label: key })}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
|
||||
</button>
|
||||
@@ -291,19 +375,30 @@ export default function CoverCacheStrategySection() {
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t('settings.coverCacheStrategyClearTitle')}</div>
|
||||
<div style={{ marginBottom: 10, lineHeight: 1.5 }}>
|
||||
{t('settings.coverCacheStrategyClearDesc', { server: clearTarget.label })}
|
||||
</div>
|
||||
{clearTarget.kind === 'image' ? (
|
||||
<>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t('settings.cacheClearBtn')}</div>
|
||||
<div style={{ marginBottom: 10, lineHeight: 1.5 }}>{t('settings.cacheClearWarning')}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t('settings.coverCacheStrategyClearTitle')}</div>
|
||||
<div style={{ marginBottom: 10, lineHeight: 1.5 }}>
|
||||
{t('settings.coverCacheStrategyClearDesc', { server: clearTarget.label })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
|
||||
disabled={clearingKey !== null}
|
||||
onClick={() => void handleClearCoverCache()}
|
||||
onClick={() => void handleClearConfirm()}
|
||||
>
|
||||
{t('settings.coverCacheStrategyClearConfirm')}
|
||||
{clearTarget.kind === 'image'
|
||||
? t('settings.cacheClearConfirm')
|
||||
: t('settings.coverCacheStrategyClearConfirm')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -312,7 +407,9 @@ export default function CoverCacheStrategySection() {
|
||||
disabled={clearingKey !== null}
|
||||
onClick={() => setClearTarget(null)}
|
||||
>
|
||||
{t('settings.coverCacheStrategyClearCancel')}
|
||||
{clearTarget.kind === 'image'
|
||||
? t('settings.cacheClearCancel')
|
||||
: t('settings.coverCacheStrategyClearCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,8 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { Download, FolderOpen, Trash2, X } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useHotCacheStore } from '../../store/hotCacheStore';
|
||||
import { useOfflineStore } from '../../store/offlineStore';
|
||||
import { clearImageCache, getImageCacheSize } from '../../utils/imageCache';
|
||||
import { countHotCacheTracks } from '../../store/hotCacheStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import CoverCacheStrategySection from './CoverCacheStrategySection';
|
||||
@@ -14,79 +13,50 @@ import CoverCacheStrategySection from './CoverCacheStrategySection';
|
||||
export function StorageTab() {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
const serverId = auth.activeServerId ?? '';
|
||||
const clearAllOffline = useOfflineStore(s => s.clearAll);
|
||||
const clearHotCacheDisk = useHotCacheStore(s => s.clearAllDisk);
|
||||
const hotCacheEntries = useHotCacheStore(s => s.entries);
|
||||
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
|
||||
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
|
||||
const clearHotCacheDisk = useLocalPlaybackStore(s => s.purgeEphemeralDisk);
|
||||
const localPlaybackEntries = useLocalPlaybackStore(s => s.entries);
|
||||
const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
|
||||
const hotCacheTrackCount = useMemo(() => {
|
||||
const prefix = `${serverId}:`;
|
||||
return Object.keys(hotCacheEntries).filter(k => k.startsWith(prefix)).length;
|
||||
}, [hotCacheEntries, serverId]);
|
||||
const mediaDir = auth.mediaDir || null;
|
||||
|
||||
/** Match ephemeral disk usage (all servers); resolve UUID vs URL index keys. */
|
||||
const hotCacheTrackCount = useMemo(
|
||||
() => countHotCacheTracks(localPlaybackEntries),
|
||||
[localPlaybackEntries],
|
||||
);
|
||||
|
||||
const refreshHotCacheSize = useCallback(() => {
|
||||
invoke<number>('get_media_tier_size', { tier: 'ephemeral', mediaDir })
|
||||
.then(setHotCacheBytes)
|
||||
.catch(() => setHotCacheBytes(0));
|
||||
}, [mediaDir]);
|
||||
|
||||
// Load all three size readouts on mount.
|
||||
useEffect(() => {
|
||||
getImageCacheSize().then(setImageCacheBytes);
|
||||
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||||
}, [auth.offlineDownloadDir, auth.hotCacheDownloadDir]);
|
||||
refreshHotCacheSize();
|
||||
}, [refreshHotCacheSize]);
|
||||
|
||||
/** Live disk usage for hot cache (interval + refresh when index changes). */
|
||||
useEffect(() => {
|
||||
const customDir = auth.hotCacheDownloadDir || null;
|
||||
const refresh = () => {
|
||||
invoke<number>('get_hot_cache_size', { customDir })
|
||||
.then(setHotCacheBytes)
|
||||
.catch(() => setHotCacheBytes(0));
|
||||
};
|
||||
refresh();
|
||||
if (!auth.hotCacheEnabled) return;
|
||||
const interval = window.setInterval(refresh, 15_000);
|
||||
refreshHotCacheSize();
|
||||
const interval = window.setInterval(refreshHotCacheSize, 15_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [auth.hotCacheEnabled, auth.hotCacheDownloadDir]);
|
||||
}, [auth.hotCacheEnabled, refreshHotCacheSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.hotCacheEnabled) return;
|
||||
const handle = window.setTimeout(() => {
|
||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
|
||||
.then(setHotCacheBytes)
|
||||
.catch(() => setHotCacheBytes(0));
|
||||
}, 400);
|
||||
const handle = window.setTimeout(refreshHotCacheSize, 400);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [hotCacheEntries, auth.hotCacheEnabled, auth.hotCacheDownloadDir]);
|
||||
}, [localPlaybackEntries, auth.hotCacheEnabled, refreshHotCacheSize]);
|
||||
|
||||
const handleClearCache = useCallback(async () => {
|
||||
setClearing(true);
|
||||
await clearImageCache();
|
||||
await clearAllOffline(serverId);
|
||||
const [imgBytes, offBytes] = await Promise.all([
|
||||
getImageCacheSize(),
|
||||
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).catch(() => 0),
|
||||
]);
|
||||
setImageCacheBytes(imgBytes);
|
||||
setOfflineCacheBytes(offBytes);
|
||||
setShowClearConfirm(false);
|
||||
setClearing(false);
|
||||
}, [clearAllOffline, serverId, auth.offlineDownloadDir]);
|
||||
|
||||
const pickOfflineDir = async () => {
|
||||
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') });
|
||||
const pickMediaDir = async () => {
|
||||
const selected = await openDialog({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: t('settings.mediaDirChange'),
|
||||
});
|
||||
if (selected && typeof selected === 'string') {
|
||||
auth.setOfflineDownloadDir(selected);
|
||||
}
|
||||
};
|
||||
|
||||
const pickHotCacheDir = async () => {
|
||||
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.hotCacheDirChange') });
|
||||
if (selected && typeof selected === 'string') {
|
||||
auth.setHotCacheDownloadDir(selected);
|
||||
useHotCacheStore.setState({ entries: {} });
|
||||
invoke<number>('get_hot_cache_size', { customDir: selected }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||||
auth.setMediaDir(selected);
|
||||
refreshHotCacheSize();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,111 +69,51 @@ export function StorageTab() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Offline Library (In-App) — includes cache settings */}
|
||||
<SettingsSubSection
|
||||
title={t('settings.offlineDirTitle')}
|
||||
icon={<Download size={16} />}
|
||||
title={t('settings.mediaDirTitle')}
|
||||
icon={<FolderOpen size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||||
{t('settings.offlineDirDesc')}
|
||||
{t('settings.mediaDirDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.offlineDownloadDir || t('settings.offlineDirDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.offlineDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
value={auth.mediaDir || t('settings.mediaDirDefault')}
|
||||
style={{ flex: 1, fontSize: 13, color: auth.mediaDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.offlineDownloadDir && (
|
||||
{auth.mediaDir && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => auth.setOfflineDownloadDir('')}
|
||||
data-tooltip={t('settings.offlineDirClear')}
|
||||
onClick={() => { auth.setMediaDir(''); refreshHotCacheSize(); }}
|
||||
data-tooltip={t('settings.mediaDirClear')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-surface" onClick={pickOfflineDir} style={{ flexShrink: 0 }} id="settings-offline-dir-btn">
|
||||
<FolderOpen size={16} /> {t('settings.offlineDirChange')}
|
||||
<button className="btn btn-surface" onClick={pickMediaDir} style={{ flexShrink: 0 }}>
|
||||
<FolderOpen size={16} /> {t('settings.mediaDirChange')}
|
||||
</button>
|
||||
</div>
|
||||
{auth.offlineDownloadDir && (
|
||||
{auth.mediaDir && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||
{t('settings.offlineDirHint')}
|
||||
{t('settings.mediaDirHint')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||
|
||||
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
|
||||
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedImages')}</span>
|
||||
{imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedOffline')}</span>
|
||||
{offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.cacheMaxLabel')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={100}
|
||||
max={50000}
|
||||
step={100}
|
||||
value={auth.maxCacheMb}
|
||||
onChange={e => {
|
||||
const v = Number(e.target.value);
|
||||
if (v >= 100) auth.setMaxCacheMb(v);
|
||||
}}
|
||||
style={{ width: 80, padding: '4px 8px', fontSize: 13 }}
|
||||
id="cache-size-input"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>MB</span>
|
||||
</div>
|
||||
|
||||
{showClearConfirm ? (
|
||||
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
|
||||
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
|
||||
onClick={handleClearCache}
|
||||
disabled={clearing}
|
||||
>
|
||||
{t('settings.cacheClearConfirm')}
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(false)} disabled={clearing}>
|
||||
{t('settings.cacheClearCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(true)}>
|
||||
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<CoverCacheStrategySection />
|
||||
|
||||
{/* Buffering */}
|
||||
<SettingsSubSection
|
||||
title={t('settings.nextTrackBufferingTitle')}
|
||||
icon={<Download size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
{/* Hot Cache */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheTitle')}</div>
|
||||
@@ -216,14 +126,12 @@ export function StorageTab() {
|
||||
onChange={async e => {
|
||||
const enabled = e.target.checked;
|
||||
if (!enabled) {
|
||||
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
||||
await clearHotCacheDisk(mediaDir);
|
||||
setHotCacheBytes(0);
|
||||
auth.setHotCacheEnabled(false);
|
||||
} else {
|
||||
auth.setHotCacheEnabled(true);
|
||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
|
||||
.then(setHotCacheBytes)
|
||||
.catch(() => setHotCacheBytes(0));
|
||||
refreshHotCacheSize();
|
||||
}
|
||||
}}
|
||||
id="hot-cache-enabled-toggle"
|
||||
@@ -234,41 +142,6 @@ export function StorageTab() {
|
||||
|
||||
{auth.hotCacheEnabled && (
|
||||
<div style={{ marginTop: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
readOnly
|
||||
value={auth.hotCacheDownloadDir || t('settings.hotCacheDirDefault')}
|
||||
style={{ flex: 1, minWidth: 0, fontSize: 13, color: auth.hotCacheDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||||
/>
|
||||
{auth.hotCacheDownloadDir && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
auth.setHotCacheDownloadDir('');
|
||||
useHotCacheStore.setState({ entries: {} });
|
||||
invoke<number>('get_hot_cache_size', { customDir: null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||||
}}
|
||||
data-tooltip={t('settings.hotCacheDirClear')}
|
||||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-surface" onClick={pickHotCacheDir} style={{ flexShrink: 0 }}>
|
||||
<FolderOpen size={16} /> {t('settings.hotCacheDirChange')}
|
||||
</button>
|
||||
</div>
|
||||
{auth.hotCacheDownloadDir && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||
{t('settings.hotCacheDirHint')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||
|
||||
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
|
||||
@@ -305,20 +178,17 @@ export function StorageTab() {
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 13 }}
|
||||
onClick={async () => {
|
||||
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
||||
const b = await invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).catch(() => 0);
|
||||
setHotCacheBytes(b);
|
||||
await clearHotCacheDisk(mediaDir);
|
||||
refreshHotCacheSize();
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
{/* ZIP Export & Archiving */}
|
||||
<SettingsSubSection
|
||||
title={t('settings.downloadsTitle')}
|
||||
icon={<FolderOpen size={16} />}
|
||||
|
||||
@@ -55,8 +55,8 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||
{ tab: 'library', titleKey: 'settings.analyticsStrategyTitle', keywords: 'analytics strategy analysis bpm enrichment waveform lazy advanced library backfill' },
|
||||
{ tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
|
||||
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
|
||||
{ tab: 'storage', titleKey: 'settings.coverCacheStrategyTitle', keywords: 'cover art cache webp aggressive lazy disk per server' },
|
||||
{ tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' },
|
||||
{ tab: 'storage', titleKey: 'settings.coverCacheStrategyTitle', keywords: 'cover art cache webp aggressive lazy disk per server image idb preview limit clear' },
|
||||
{ tab: 'storage', titleKey: 'settings.mediaDirTitle', keywords: 'media folder offline library cache directory local playback' },
|
||||
{ tab: 'storage', titleKey: 'settings.nextTrackBufferingTitle', keywords: 'next track buffering hot cache streaming' },
|
||||
{ tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' },
|
||||
{ tab: 'appearance', titleKey: 'settings.theme', keywords: 'theme color palette dark light' },
|
||||
|
||||
@@ -4,6 +4,8 @@ import { HardDriveDownload, HardDriveUpload, X } from 'lucide-react';
|
||||
interface Props {
|
||||
isCollapsed: boolean;
|
||||
activeJobsCount: number;
|
||||
activePinName: string | null;
|
||||
queuedPinCount: number;
|
||||
cancelAllDownloads: () => void;
|
||||
isSyncing: boolean;
|
||||
syncJobDone: number;
|
||||
@@ -13,21 +15,32 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function SidebarActiveJobs({
|
||||
isCollapsed, activeJobsCount, cancelAllDownloads,
|
||||
isCollapsed, activeJobsCount, activePinName, queuedPinCount, cancelAllDownloads,
|
||||
isSyncing, syncJobDone, syncJobSkip, syncJobFail, syncJobTotal,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const showPinQueue = !!activePinName || queuedPinCount > 0;
|
||||
const offlineQueueLabel = showPinQueue
|
||||
? (queuedPinCount > 0
|
||||
? t('sidebar.offlinePinActiveQueued', { name: activePinName ?? '', queued: queuedPinCount })
|
||||
: t('sidebar.offlinePinActive', { name: activePinName ?? '' }))
|
||||
: t('sidebar.downloadingTracks', { n: activeJobsCount });
|
||||
const syncLabel = t('sidebar.syncingTracks', {
|
||||
done: syncJobDone + syncJobSkip + syncJobFail,
|
||||
total: syncJobTotal,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeJobsCount > 0 && (
|
||||
{(activeJobsCount > 0 || showPinQueue) && (
|
||||
<div
|
||||
className={`sidebar-offline-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
|
||||
data-tooltip={isCollapsed ? t('sidebar.downloadingTracks', { n: activeJobsCount }) : undefined}
|
||||
data-tooltip={offlineQueueLabel}
|
||||
data-tooltip-pos="right"
|
||||
>
|
||||
<HardDriveDownload size={isCollapsed ? 18 : 14} className="spin-slow" />
|
||||
{!isCollapsed && (
|
||||
<span>{t('sidebar.downloadingTracks', { n: activeJobsCount })}</span>
|
||||
<span>{offlineQueueLabel}</span>
|
||||
)}
|
||||
<button
|
||||
className="sidebar-offline-cancel"
|
||||
@@ -44,12 +57,12 @@ export default function SidebarActiveJobs({
|
||||
{isSyncing && (
|
||||
<div
|
||||
className={`sidebar-offline-queue sidebar-sync-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
|
||||
data-tooltip={isCollapsed ? t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal }) : undefined}
|
||||
data-tooltip={syncLabel}
|
||||
data-tooltip-pos="right"
|
||||
>
|
||||
<HardDriveUpload size={isCollapsed ? 18 : 14} className="spin-slow" />
|
||||
{!isCollapsed && (
|
||||
<span>{t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal })}</span>
|
||||
<span>{syncLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -44,6 +44,8 @@ interface Props {
|
||||
nowPlayingAtTop: boolean;
|
||||
hasOfflineContent: boolean;
|
||||
activeJobsCount: number;
|
||||
activePinName: string | null;
|
||||
queuedPinCount: number;
|
||||
cancelAllDownloads: () => void;
|
||||
isSyncing: boolean;
|
||||
syncJobDone: number;
|
||||
@@ -62,7 +64,7 @@ export default function SidebarNavBody(props: Props) {
|
||||
playlistsExpanded, setPlaylistsExpanded, playlists, playlistsLoading,
|
||||
newReleasesUnreadCount, navDnd, navDndRowClass, handleNavRowPointerDown,
|
||||
isPlaying, hasNowPlayingTrack, nowPlayingAtTop, hasOfflineContent,
|
||||
activeJobsCount, cancelAllDownloads,
|
||||
activeJobsCount, activePinName, queuedPinCount, cancelAllDownloads,
|
||||
isSyncing, syncJobDone, syncJobSkip, syncJobFail, syncJobTotal,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
@@ -289,6 +291,8 @@ export default function SidebarNavBody(props: Props) {
|
||||
<SidebarActiveJobs
|
||||
isCollapsed={isCollapsed}
|
||||
activeJobsCount={activeJobsCount}
|
||||
activePinName={activePinName}
|
||||
queuedPinCount={queuedPinCount}
|
||||
cancelAllDownloads={cancelAllDownloads}
|
||||
isSyncing={isSyncing}
|
||||
syncJobDone={syncJobDone}
|
||||
|
||||
Reference in New Issue
Block a user