mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
d49424e95b
* feat(ui): prototype compact hero and toolbar buttons (Large/Small appearance setting) * feat(settings): rename action-button size toggle to "Compact buttons" Promote the hero-button prototype to a real, app-wide setting. - rename heroButtonSize → buttonSize, data-hero-buttons → data-button-size, hero-action-bar/hero-btn-label → compact-action-bar/compact-btn-label - relabel "Hero buttons" → "Compact buttons" and broaden the description (action + toolbar buttons across detail pages and browse views) in all 11 locales - move the feature CSS out of cover-lightbox.css into its own compact-buttons.css - add tests: themeStore buttonSize toggle, SelectionToggleButton * docs(changelog): compact buttons appearance toggle (#1189) * docs(credits): compact buttons appearance toggle (#1189)
602 lines
25 KiB
TypeScript
602 lines
25 KiB
TypeScript
import type { EntityRatingSupportLevel, SubsonicItemGenre, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes';
|
|
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Play, Heart, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
|
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
|
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
|
import { useCoverLightboxSrc } from '../cover/lightbox';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useIsMobile } from '../hooks/useIsMobile';
|
|
import { useAlbumDetailBack } from '../hooks/useAlbumDetailBack';
|
|
import { useThemeStore } from '../store/themeStore';
|
|
import StarRating from './StarRating';
|
|
import { copyEntityShareLink } from '../utils/share/copyEntityShareLink';
|
|
import { showToast } from '../utils/ui/toast';
|
|
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
|
|
import { formatLongDuration } from '../utils/format/formatDuration';
|
|
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';
|
|
import { deriveAlbumGenreTags } from '../utils/library/genreTags';
|
|
import { genreColor } from '../utils/library/genreColor';
|
|
|
|
/** 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.
|
|
*/
|
|
function isVariousArtistsLabel(name: string | undefined | null): boolean {
|
|
if (!name) return false;
|
|
const trimmed = name.trim().toLowerCase();
|
|
return (
|
|
trimmed === 'various artists' ||
|
|
trimmed === 'various' ||
|
|
trimmed === 'va' ||
|
|
trimmed === 'multiple artists' ||
|
|
trimmed === 'verschiedene interpreten' ||
|
|
trimmed === 'verschiedene'
|
|
);
|
|
}
|
|
|
|
function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
|
|
const { t } = useTranslation();
|
|
return createPortal(
|
|
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={t('albumDetail.bioModal')}>
|
|
<div className="modal-content bio-modal" onClick={e => e.stopPropagation()}>
|
|
<button className="modal-close" onClick={onClose} aria-label={t('albumDetail.bioClose')}><X size={18} /></button>
|
|
<h3 className="modal-title">{t('albumDetail.bioModal')}</h3>
|
|
<div className="bio-modal-body">
|
|
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: sanitizeHtml(bio) }} data-selectable />
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
|
|
/** Cursor-anchored genre list (context-menu style) for the "+N" chip. */
|
|
function GenreMenu({
|
|
genres, pos, onPick, onClose,
|
|
}: {
|
|
genres: string[];
|
|
pos: { x: number; y: number };
|
|
onPick: (genre: string) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [coords, setCoords] = useState(pos);
|
|
|
|
// Clamp into the viewport once the menu has measured its own size, then move
|
|
// focus to the first genre so keyboard users land inside the menu.
|
|
useLayoutEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const rect = el.getBoundingClientRect();
|
|
const pad = 8;
|
|
setCoords({
|
|
x: Math.max(pad, Math.min(pos.x, window.innerWidth - rect.width - pad)),
|
|
y: Math.max(pad, Math.min(pos.y, window.innerHeight - rect.height - pad)),
|
|
});
|
|
el.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
|
|
}, [pos]);
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') { onClose(); return; }
|
|
const items = Array.from(
|
|
ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [],
|
|
);
|
|
if (items.length === 0) return;
|
|
const focusAt = (i: number) => items[(i + items.length) % items.length].focus();
|
|
const at = items.indexOf(document.activeElement as HTMLElement);
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); focusAt(at + 1); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); focusAt(at < 0 ? -1 : at - 1); }
|
|
else if (e.key === 'Home') { e.preventDefault(); focusAt(0); }
|
|
else if (e.key === 'End') { e.preventDefault(); focusAt(items.length - 1); }
|
|
};
|
|
const onDown = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
document.addEventListener('mousedown', onDown);
|
|
return () => {
|
|
window.removeEventListener('keydown', onKey);
|
|
document.removeEventListener('mousedown', onDown);
|
|
};
|
|
}, [onClose]);
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={ref}
|
|
className="genre-menu"
|
|
role="menu"
|
|
aria-label={t('albumDetail.genresModalTitle')}
|
|
style={{ left: coords.x, top: coords.y }}
|
|
>
|
|
{genres.map(g => (
|
|
<button
|
|
key={g}
|
|
type="button"
|
|
role="menuitem"
|
|
className="genre-menu-item"
|
|
style={{ '--genre-color': genreColor(g) } as CSSProperties}
|
|
onClick={() => onPick(g)}
|
|
>
|
|
{g}
|
|
</button>
|
|
))}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
interface AlbumInfo {
|
|
id: string;
|
|
name: string;
|
|
artist: string;
|
|
artistId: string;
|
|
year?: number;
|
|
genre?: string;
|
|
/** OpenSubsonic atomic genres — preferred over the legacy `genre` string. */
|
|
genres?: SubsonicItemGenre[];
|
|
coverArt?: string;
|
|
recordLabel?: string;
|
|
created?: string;
|
|
}
|
|
|
|
interface AlbumHeaderProps {
|
|
info: AlbumInfo;
|
|
/** OpenSubsonic album credits (derived from album + songs). */
|
|
headerArtistRefs: SubsonicOpenArtistRef[];
|
|
songs: SubsonicSong[];
|
|
coverArtId?: string;
|
|
resolvedCoverUrl: string | null;
|
|
isStarred: boolean;
|
|
downloadProgress: number | null;
|
|
offlineStatus: 'none' | 'queued' | 'downloading' | 'cached';
|
|
offlineProgress: { done: number; total: number } | null;
|
|
bio: string | null;
|
|
bioOpen: boolean;
|
|
onToggleStar: () => void;
|
|
onDownload: () => void;
|
|
onCacheOffline: () => void;
|
|
onRemoveOffline: () => void;
|
|
onPlayAll: () => void;
|
|
onEnqueueAll: () => void;
|
|
onShuffleAll?: () => void;
|
|
onBio: () => void;
|
|
onCloseBio: () => void;
|
|
entityRatingValue: number;
|
|
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({
|
|
info,
|
|
headerArtistRefs,
|
|
songs,
|
|
coverArtId,
|
|
resolvedCoverUrl,
|
|
isStarred,
|
|
downloadProgress,
|
|
offlineStatus,
|
|
offlineProgress,
|
|
bio,
|
|
bioOpen,
|
|
onToggleStar,
|
|
onDownload,
|
|
onCacheOffline,
|
|
onRemoveOffline,
|
|
onPlayAll,
|
|
onEnqueueAll,
|
|
onShuffleAll,
|
|
onBio,
|
|
onCloseBio,
|
|
entityRatingValue,
|
|
onEntityRatingChange,
|
|
entityRatingSupport,
|
|
actionPolicy,
|
|
}: AlbumHeaderProps) {
|
|
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const goBack = useAlbumDetailBack();
|
|
const isMobile = useIsMobile();
|
|
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
|
|
|
const coverRef = useAlbumCoverRef(info.id, coverArtId, undefined, { libraryResolve: true });
|
|
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, {
|
|
alt: `${info.name} Cover`,
|
|
});
|
|
|
|
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
|
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
|
|
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
|
|
const isNewAlbum = isAlbumRecentlyAdded(info.created);
|
|
const showBioButton = !isVariousArtistsLabel(info.artist);
|
|
const genreTags = deriveAlbumGenreTags(info, songs);
|
|
const [genreMenuPos, setGenreMenuPos] = useState<{ x: number; y: number } | null>(null);
|
|
const genreMoreRef = useRef<HTMLButtonElement>(null);
|
|
const goToGenre = (genre: string) => {
|
|
setGenreMenuPos(null);
|
|
navigate(`/genres/${encodeURIComponent(genre)}`, { state: { returnTo: `/album/${info.id}` } });
|
|
};
|
|
|
|
const handleShareAlbum = async () => {
|
|
try {
|
|
const ok = await copyEntityShareLink('album', info.id);
|
|
if (ok) showToast(t('contextMenu.shareCopied'));
|
|
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
|
} catch {
|
|
showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
|
{lightbox}
|
|
|
|
{genreMenuPos && (
|
|
<GenreMenu
|
|
genres={genreTags.slice(1)}
|
|
pos={genreMenuPos}
|
|
onPick={goToGenre}
|
|
onClose={() => {
|
|
setGenreMenuPos(null);
|
|
genreMoreRef.current?.focus();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<div className="album-detail-header">
|
|
{resolvedCoverUrl && enableCoverArtBackground && (
|
|
<>
|
|
<div
|
|
className="album-detail-bg"
|
|
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
|
aria-hidden="true"
|
|
/>
|
|
<div className="album-detail-overlay" aria-hidden="true" />
|
|
</>
|
|
)}
|
|
|
|
<div className="album-detail-content">
|
|
<button className="btn btn-ghost album-detail-back" onClick={goBack}>
|
|
<ChevronLeft size={16} /> {t('albumDetail.back')}
|
|
</button>
|
|
<div className="album-detail-hero">
|
|
{coverRef ? (
|
|
<button
|
|
className="album-detail-cover-btn"
|
|
onClick={openLightbox}
|
|
data-tooltip={t('albumDetail.enlargeCover')}
|
|
aria-label={`${info.name} ${t('albumDetail.enlargeCover')}`}
|
|
>
|
|
<CoverArtImage
|
|
className="album-detail-cover"
|
|
coverRef={coverRef}
|
|
displayCssPx={400}
|
|
surface="sparse"
|
|
alt={`${info.name} Cover`}
|
|
/>
|
|
</button>
|
|
) : (
|
|
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
|
)}
|
|
<div className="album-detail-meta">
|
|
{isNewAlbum && (
|
|
<span className="badge album-detail-badge">{t('common.new', 'New')}</span>
|
|
)}
|
|
<h1 className="album-detail-title">{info.name}</h1>
|
|
<p className="album-detail-artist">
|
|
<OpenArtistRefInline
|
|
refs={headerArtistRefs}
|
|
fallbackName={info.artist}
|
|
onGoArtist={id => navigate(`/artist/${id}`)}
|
|
linkClassName="album-detail-artist-link"
|
|
/>
|
|
</p>
|
|
{genreTags.length > 0 && (
|
|
<div className="album-detail-genre-row">
|
|
<button
|
|
className="album-detail-genre-pill"
|
|
data-tooltip={t('albumDetail.moreGenreAlbums', { genre: genreTags[0] })}
|
|
aria-label={t('albumDetail.moreGenreAlbums', { genre: genreTags[0] })}
|
|
onClick={() => goToGenre(genreTags[0])}
|
|
>
|
|
{genreTags[0]}
|
|
</button>
|
|
{genreTags.length > 1 && (
|
|
<button
|
|
ref={genreMoreRef}
|
|
className="album-detail-genre-pill"
|
|
data-tooltip={t('albumDetail.showAllGenres')}
|
|
aria-label={t('albumDetail.showAllGenres')}
|
|
aria-haspopup="menu"
|
|
aria-expanded={genreMenuPos != null}
|
|
onClick={e => {
|
|
// Keyboard activation (Enter/Space) reports clientX/Y = 0 →
|
|
// anchor below the chip instead of the viewport corner.
|
|
if (e.detail === 0) {
|
|
const r = e.currentTarget.getBoundingClientRect();
|
|
setGenreMenuPos({ x: r.left, y: r.bottom + 4 });
|
|
} else {
|
|
setGenreMenuPos({ x: e.clientX, y: e.clientY });
|
|
}
|
|
}}
|
|
>
|
|
{`+${genreTags.length - 1}`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="album-detail-info">
|
|
{info.year && <span>{info.year}</span>}
|
|
<span>· {songs.length} Tracks</span>
|
|
<span>· {formatLongDuration(totalDuration)}</span>
|
|
{formatLabel && <span>· {formatLabel}</span>}
|
|
{info.recordLabel && (
|
|
<>
|
|
<span className="album-info-dot">·</span>
|
|
<button
|
|
className="album-detail-artist-link"
|
|
data-tooltip={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
|
|
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
|
|
>
|
|
{info.recordLabel}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="album-detail-entity-rating">
|
|
<span className="album-detail-entity-rating-label">{t('entityRating.albumShort')}</span>
|
|
<StarRating
|
|
value={entityRatingValue}
|
|
onChange={onEntityRatingChange}
|
|
disabled={!policy.canRate || entityRatingSupport === 'track_only'}
|
|
labelKey="entityRating.albumAriaLabel"
|
|
/>
|
|
</div>
|
|
{isMobile ? (
|
|
<div className="album-detail-actions-mobile">
|
|
{/* Row 1 — Primary actions */}
|
|
<div className="album-actions-row album-actions-row--primary">
|
|
<button
|
|
className="album-icon-btn album-icon-btn--play"
|
|
onClick={onPlayAll}
|
|
aria-label={t('albumDetail.playAll')}
|
|
data-tooltip={t('albumDetail.playAll')}
|
|
>
|
|
<Play size={24} fill="currentColor" />
|
|
</button>
|
|
<button
|
|
className="album-icon-btn album-icon-btn--queue"
|
|
onClick={onEnqueueAll}
|
|
aria-label={t('albumDetail.enqueue')}
|
|
data-tooltip={t('albumDetail.enqueueTooltip')}
|
|
>
|
|
<ListPlus size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Row 2 — Secondary actions */}
|
|
<div className="album-actions-row album-actions-row--secondary">
|
|
{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"
|
|
type="button"
|
|
onClick={handleShareAlbum}
|
|
aria-label={t('albumDetail.shareAlbum')}
|
|
data-tooltip={t('albumDetail.shareAlbum')}
|
|
>
|
|
<Share2 size={16} />
|
|
</button>
|
|
|
|
{showBioButton && policy.canShowBio && (
|
|
<button
|
|
className="album-icon-btn album-icon-btn--sm"
|
|
onClick={onBio}
|
|
aria-label={t('albumDetail.artistBio')}
|
|
data-tooltip={t('albumDetail.artistBio')}
|
|
>
|
|
<Highlighter 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>
|
|
)
|
|
)}
|
|
|
|
{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>
|
|
) : (
|
|
<div className="album-detail-actions compact-action-bar">
|
|
<div className="album-detail-actions-primary">
|
|
<button
|
|
className="btn btn-primary"
|
|
id="album-play-all-btn"
|
|
onClick={onPlayAll}
|
|
{...tooltipAttrs(t('albumDetail.playTooltip'))}
|
|
>
|
|
<Play size={15} /> <span className="compact-btn-label">{t('common.play', 'Reproducir')}</span>
|
|
</button>
|
|
{onShuffleAll && (
|
|
<button
|
|
className="btn btn-surface"
|
|
onClick={onShuffleAll}
|
|
data-tooltip={t('playlists.shuffle', 'Shuffle')}
|
|
>
|
|
<Shuffle size={16} />
|
|
</button>
|
|
)}
|
|
<button
|
|
className="btn btn-surface"
|
|
onClick={onEnqueueAll}
|
|
data-tooltip={t('albumDetail.enqueueTooltip')}
|
|
>
|
|
<ListPlus size={16} />
|
|
</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"
|
|
onClick={handleShareAlbum}
|
|
aria-label={t('albumDetail.shareAlbum')}
|
|
data-tooltip={t('albumDetail.shareAlbum')}
|
|
>
|
|
<Share2 size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{showBioButton && policy.canShowBio && (
|
|
<button
|
|
className="btn btn-surface"
|
|
id="album-bio-btn"
|
|
onClick={onBio}
|
|
{...tooltipAttrs(t('albumDetail.artistBioTooltip'))}
|
|
>
|
|
<Highlighter size={16} /> <span className="compact-btn-label">{t('albumDetail.artistBio')}</span>
|
|
</button>
|
|
)}
|
|
|
|
{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>
|
|
) : (
|
|
<button
|
|
className="btn btn-surface"
|
|
id="album-download-btn"
|
|
onClick={onDownload}
|
|
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
|
|
>
|
|
<Download size={16} /> <span className="compact-btn-label">{t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}</span>
|
|
</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}
|
|
aria-label={t('albumDetail.offlineQueued')}
|
|
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
|
|
>
|
|
<HardDriveDownload size={16} />
|
|
<span className="compact-btn-label">{t('albumDetail.offlineQueued')}</span>
|
|
</button>
|
|
) : offlineStatus === 'cached' ? (
|
|
<button
|
|
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
|
|
onClick={onRemoveOffline}
|
|
aria-label={t('albumDetail.offlineCached')}
|
|
data-tooltip={t('albumDetail.removeOffline')}
|
|
>
|
|
<HardDriveDownload size={16} />
|
|
<span className="compact-btn-label">{t('albumDetail.offlineCached')}</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="btn btn-surface offline-cache-btn"
|
|
onClick={onCacheOffline}
|
|
aria-label={t('albumDetail.cacheOffline')}
|
|
data-tooltip={t('albumDetail.cacheOffline')}
|
|
>
|
|
<HardDriveDownload size={16} />
|
|
<span className="compact-btn-label">{t('albumDetail.cacheOffline')}</span>
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|