mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
9ad0f8af6d
* feat(ui): unify queue toggle handle behavior Show the queue toggle in the header when the queue is collapsed and use a seam-aligned drag handle when it is open. Hide the seam handle while the main content is actively scrolling to reduce accidental interactions. * feat(ui): add adaptive header search collapse behavior Collapse header search to a magnifier when top controls get crowded and expand it as an overlay only while active. Use measured header space with hysteresis to avoid flicker and keep neighboring controls stable. * chore(ui): remove leftover search prototype artifacts Drop an unused icon import from live search and remove an unused header container-type rule left from an earlier layout experiment. * feat(ui): persist sidebar and queue visibility state Save left sidebar collapse and right queue open/closed visibility in local storage helpers so both panel modes are restored after app restart. * feat(ui): unify player overflow menu behavior Use a single overflow menu for click and wheel interactions, with a volume-only mode that keeps the same layout and volume controls as the full menu. * feat(ui): add wheel seek controls to waveform Apply 10-second wheel seek steps with trailing 1-second debounce and keep the waveform preview stable so the playhead moves smoothly during rapid scroll input. * fix(now-playing): stabilize narrow dashboard layout Switch now-playing responsiveness to container-based breakpoints and prevent stacked widgets from overlapping when width is constrained. * fix(search): reduce collapse jitter and avoid header overlap Add a short collapse-state cooldown to prevent threshold flicker and hide conflicting header controls while collapsed search expands as an overlay. * fix(i18n): localize player overflow controls across locales Replace hardcoded player overflow labels with translation keys and add the missing keys for all shipped locale files. * fix(search): keep advanced control clickable in collapsed mode Prevent focus loss on the advanced search button in collapsed overlay mode so its click handler consistently runs. * fix(i18n): restore queue translation in offline library Use the existing queue.appendToQueue key for the offline enqueue button tooltip and label instead of a missing key and hardcoded English text. * fix(ui): apply overlay scrollbar to right-panel text tabs Switch now-playing content, lyrics, and info panes to OverlayScrollArea and harden tour-item layout so long concert metadata stays within panel bounds. * fix(ui): add unread indicator for new releases and guard sidebar drag clicks Track unread new-release IDs per server/library scope and clear the badge when opening the New Releases page. Also prevent click-through navigation after sidebar drag release and keep related i18n/responsive sidebar-adjacent refinements in this snapshot. * fix(ui): stabilize live dropdown layering and unread reset flow Render the topbar Live dropdown via a portal so it consistently overlays sidebar layers. Rework new-releases unread tracking to handle library scope baselines, ignore stale refresh races, and mark items as seen after a 5-second stay on the New Releases page. * feat(ui): add localized New badges for recently added albums Show a theme-consistent New badge on album cards and album detail for albums created within the last 48 hours. Localize the badge label across all supported locales and centralize recency logic in a shared utility to avoid duplication. * fix(album): prevent tracklist jump when entering multiselect Move bulk selection actions from the tracklist body into the album toolbar next to the track filter. Keep selection controls stable in the header area so enabling multiselect no longer shifts the tracklist content downward. * fix(tray): add playback-state badge and finalize queue handle tooltip Show play/pause/stop icons in the Linux tray now-playing entry and persist state safely in Tauri managed state. Also switch the queue-resize handle tooltip to the dedicated localized key across all locales. * fix(header): prioritize search collapse before Live/Orbit labels Make topbar compression deterministic by collapsing search first and compacting Live/Orbit labels only in sustained low-space mode. Add sticky hysteresis-based header compact state to prevent oscillation while resizing. * fix(ui): stabilize header compaction and show tray state icons Prevent topbar flicker in the narrow-width range by tightening compact-mode thresholds, gating on real overflow, and removing width transitions from live search. Also include playback state icons in tray tooltip text across platforms while preserving tooltip length limits. * fix(tray): keep tooltip iconization Windows-only Revert Linux tray tooltip/title fallback attempts and keep state icons only in Windows tray tooltips, while Linux continues to show playback state in the now-playing menu entry. * fix(ui): restore queue resize response after overlay scroll interactions Hide the queue handle while scrolling on both the main route viewport and the now-playing viewport, and clear stale thumb-drag state before starting queue resize. Also ignore inactive/faded overlay thumbs in resizer suppression so horizontal pointer transitions no longer leave the queue seam unresponsive. * docs(changelog): summarize ui-refinements branch features Document the branch-level feature additions in 1.45.0 as separate changelog sections and group remaining branch-local fixes under a single polish entry. * docs(changelog): add PR #397 references for ui-refinements Attach PR metadata to the new 1.45.0 ui-refinement sections and the polish entry so release notes map directly to the merged branch discussion.
507 lines
20 KiB
TypeScript
507 lines
20 KiB
TypeScript
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import { Search, X, ListPlus } from 'lucide-react';
|
||
import { invoke } from '@tauri-apps/api/core';
|
||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||
import { useAuthStore } from '../store/authStore';
|
||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||
import { useOfflineStore } from '../store/offlineStore';
|
||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||
import { join } from '@tauri-apps/api/path';
|
||
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||
import AlbumCard from '../components/AlbumCard';
|
||
import AlbumHeader from '../components/AlbumHeader';
|
||
import AlbumTrackList from '../components/AlbumTrackList';
|
||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||
import { useCachedUrl } from '../components/CachedImage';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { showToast } from '../utils/toast';
|
||
import { useSelectionStore } from '../store/selectionStore';
|
||
|
||
function sanitizeFilename(name: string): string {
|
||
return name
|
||
.replace(/[/\\?%*:|"<>]/g, '-')
|
||
.replace(/\.{2,}/g, '.')
|
||
.replace(/^[\s.]+|[\s.]+$/g, '')
|
||
.substring(0, 200) || 'download';
|
||
}
|
||
|
||
export default function AlbumDetail() {
|
||
const { t } = useTranslation();
|
||
const { id } = useParams<{ id: string }>();
|
||
const navigate = useNavigate();
|
||
const auth = useAuthStore();
|
||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||
const playTrack = usePlayerStore(s => s.playTrack);
|
||
const enqueue = usePlayerStore(s => s.enqueue);
|
||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||
|
||
const [album, setAlbum] = useState<Awaited<ReturnType<typeof getAlbum>> | null>(null);
|
||
const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]);
|
||
const [ratings, setRatings] = useState<Record<string, number>>({});
|
||
const [bio, setBio] = useState<string | null>(null);
|
||
const [bioOpen, setBioOpen] = useState(false);
|
||
const [loading, setLoading] = useState(true);
|
||
const [isStarred, setIsStarred] = useState(false);
|
||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||
const [offlineStorageFull, setOfflineStorageFull] = useState(false);
|
||
|
||
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
|
||
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
|
||
const serverId = auth.activeServerId ?? '';
|
||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
||
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
|
||
|
||
const [albumEntityRating, setAlbumEntityRating] = useState(0);
|
||
const [filterText, setFilterText] = useState('');
|
||
const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'>('natural');
|
||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||
const [sortClickCount, setSortClickCount] = useState(0);
|
||
const [showPlPicker, setShowPlPicker] = useState(false);
|
||
const selectedCount = useSelectionStore(s => s.selectedIds.size);
|
||
const inSelectMode = selectedCount > 0;
|
||
|
||
// Derive a stable albumId for the selectors below (empty string when not yet loaded).
|
||
const albumId = album?.album.id ?? '';
|
||
|
||
// Selectors return primitives so Zustand only triggers a re-render when the VALUE
|
||
// actually changes — not on every `jobs` array mutation during batch downloads.
|
||
const offlineStatus = useOfflineStore((s): 'none' | 'downloading' | 'cached' => {
|
||
if (!albumId) return 'none';
|
||
const meta = s.albums[`${serverId}:${albumId}`];
|
||
const isDownloaded = meta && meta.trackIds.length > 0 && meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
|
||
return isDownloaded ? 'cached' : 'none';
|
||
});
|
||
const isOfflineDownloading = useOfflineJobStore(s =>
|
||
!!albumId && s.jobs.some(j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading'))
|
||
);
|
||
const offlineProgressDone = useOfflineJobStore(s => {
|
||
if (!albumId) return 0;
|
||
return s.jobs.filter(j => j.albumId === albumId && (j.status === 'done' || j.status === 'error')).length;
|
||
});
|
||
const offlineProgressTotal = useOfflineJobStore(s => {
|
||
if (!albumId) return 0;
|
||
return s.jobs.filter(j => j.albumId === albumId).length;
|
||
});
|
||
const resolvedOfflineStatus = isOfflineDownloading ? 'downloading' : offlineStatus;
|
||
const offlineProgress = offlineProgressTotal > 0
|
||
? { done: offlineProgressDone, total: offlineProgressTotal }
|
||
: null;
|
||
|
||
useEffect(() => {
|
||
if (!id) return;
|
||
setLoading(true);
|
||
setRelatedAlbums([]);
|
||
getAlbum(id).then(async data => {
|
||
setAlbum(data);
|
||
setIsStarred(!!data.album.starred);
|
||
const initialStarred = new Set<string>();
|
||
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
|
||
setStarredSongs(initialStarred);
|
||
setLoading(false);
|
||
try {
|
||
const artistData = await getArtist(data.album.artistId);
|
||
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
|
||
} catch (e) {
|
||
console.error('Failed to fetch related albums', e);
|
||
}
|
||
}).catch(() => setLoading(false));
|
||
}, [id]);
|
||
|
||
useEffect(() => {
|
||
if (!id) return;
|
||
if (album && album.album.id === id) setAlbumEntityRating(album.album.userRating ?? 0);
|
||
}, [id, album?.album.id, album?.album.userRating]);
|
||
|
||
const handlePlayAll = () => {
|
||
if (!album) return;
|
||
const albumGenre = album.album.genre;
|
||
const tracks = album.songs.map(s => {
|
||
const t = songToTrack(s);
|
||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||
return t;
|
||
});
|
||
if (tracks[0]) playTrack(tracks[0], tracks);
|
||
};
|
||
|
||
const handleEnqueueAll = () => {
|
||
if (!album) return;
|
||
const albumGenre = album.album.genre;
|
||
const tracks = album.songs.map(s => {
|
||
const t = songToTrack(s);
|
||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||
return t;
|
||
});
|
||
enqueue(tracks);
|
||
};
|
||
|
||
const handleShuffleAll = () => {
|
||
if (!album) return;
|
||
const albumGenre = album.album.genre;
|
||
const tracks = album.songs.map(s => {
|
||
const t = songToTrack(s);
|
||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||
return t;
|
||
});
|
||
const shuffled = [...tracks];
|
||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||
}
|
||
if (shuffled[0]) playTrack(shuffled[0], shuffled);
|
||
};
|
||
|
||
const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior();
|
||
|
||
const handlePlaySong = (song: SubsonicSong) => {
|
||
if (orbitActive) { queueHint(); return; }
|
||
if (!album) return;
|
||
const albumGenre = album.album.genre;
|
||
const tracks = album.songs.map(s => {
|
||
const t = songToTrack(s);
|
||
if (!t.genre && albumGenre) t.genre = albumGenre;
|
||
return t;
|
||
});
|
||
const track = tracks.find(t => t.id === song.id) || songToTrack(song);
|
||
playTrack(track, tracks);
|
||
};
|
||
|
||
const handleDoubleClickSong = (song: SubsonicSong) => addTrackToOrbit(song.id);
|
||
|
||
const handleRate = async (songId: string, rating: number) => {
|
||
setRatings(r => ({ ...r, [songId]: rating }));
|
||
usePlayerStore.getState().setUserRatingOverride(songId, rating);
|
||
await setRating(songId, rating);
|
||
};
|
||
|
||
const handleAlbumEntityRating = async (rating: number) => {
|
||
if (!album || album.album.id !== id) return;
|
||
const albumId = album.album.id;
|
||
const ratingAtStart = album.album.userRating ?? 0;
|
||
|
||
setAlbumEntityRating(rating);
|
||
|
||
if (albumEntityRatingSupport !== 'full') return;
|
||
|
||
try {
|
||
await setRating(albumId, rating);
|
||
setAlbum(cur =>
|
||
cur && cur.album.id === albumId
|
||
? { ...cur, album: { ...cur.album, userRating: rating } }
|
||
: cur,
|
||
);
|
||
} catch (err) {
|
||
setAlbumEntityRating(ratingAtStart);
|
||
setEntityRatingSupport(serverId, 'track_only');
|
||
showToast(
|
||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||
4500,
|
||
'error',
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleBio = async () => {
|
||
if (!album) return;
|
||
if (bio) { setBioOpen(true); return; }
|
||
const info = await getArtistInfo(album.album.artistId);
|
||
setBio(info.biography ?? t('albumDetail.noBio'));
|
||
setBioOpen(true);
|
||
};
|
||
|
||
const handleDownload = async () => {
|
||
if (!album) return;
|
||
const { name, id: albumId } = album.album;
|
||
|
||
const folder = auth.downloadFolder || await requestDownloadFolder();
|
||
if (!folder) return;
|
||
|
||
const filename = `${sanitizeFilename(name)}.zip`;
|
||
const destPath = await join(folder, filename);
|
||
const url = buildDownloadUrl(albumId);
|
||
const downloadId = crypto.randomUUID();
|
||
|
||
const { start, complete, fail } = useZipDownloadStore.getState();
|
||
start(downloadId, filename);
|
||
try {
|
||
await invoke('download_zip', { id: downloadId, url, destPath });
|
||
complete(downloadId);
|
||
} catch (e) {
|
||
fail(downloadId);
|
||
console.error('ZIP download failed:', e);
|
||
}
|
||
};
|
||
|
||
const toggleStar = async () => {
|
||
if (!album) return;
|
||
const wasStarred = isStarred;
|
||
setIsStarred(!wasStarred);
|
||
try {
|
||
if (wasStarred) await unstar(album.album.id);
|
||
else await star(album.album.id);
|
||
} catch (e) {
|
||
console.error('Failed to toggle star', e);
|
||
setIsStarred(wasStarred);
|
||
}
|
||
};
|
||
|
||
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
const wasStarred = starredSongs.has(song.id);
|
||
const next = new Set(starredSongs);
|
||
if (wasStarred) next.delete(song.id); else next.add(song.id);
|
||
setStarredSongs(next);
|
||
setStarredOverride(song.id, !wasStarred);
|
||
try {
|
||
if (wasStarred) await unstar(song.id, 'song');
|
||
else await star(song.id, 'song');
|
||
} catch (err) {
|
||
console.error('Failed to toggle song star', err);
|
||
setStarredSongs(new Set(starredSongs));
|
||
setStarredOverride(song.id, wasStarred);
|
||
}
|
||
};
|
||
|
||
const handleCacheOffline = useCallback(async () => {
|
||
if (!album) return;
|
||
const maxBytes = auth.maxCacheMb * 1024 * 1024;
|
||
try {
|
||
const usedBytes = await invoke<number>('get_offline_cache_size');
|
||
if (usedBytes >= maxBytes) {
|
||
setOfflineStorageFull(true);
|
||
return;
|
||
}
|
||
} catch {
|
||
// If we can't check, proceed anyway
|
||
}
|
||
setOfflineStorageFull(false);
|
||
downloadAlbum(album.album.id, album.album.name, album.album.artist, album.album.coverArt, album.album.year, album.songs, serverId);
|
||
}, [album, auth.maxCacheMb, downloadAlbum, serverId]);
|
||
|
||
const handleRemoveOffline = () => {
|
||
if (!album) return;
|
||
deleteAlbum(album.album.id, serverId);
|
||
};
|
||
|
||
const handleSort = (key: typeof sortKey) => {
|
||
if (key === 'natural') return;
|
||
if (sortKey === key) {
|
||
const nextCount = sortClickCount + 1;
|
||
if (nextCount >= 3) {
|
||
setSortKey('natural');
|
||
setSortDir('asc');
|
||
setSortClickCount(0);
|
||
} else {
|
||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||
setSortClickCount(nextCount);
|
||
}
|
||
} else {
|
||
setSortKey(key);
|
||
setSortDir('asc');
|
||
setSortClickCount(1);
|
||
}
|
||
};
|
||
|
||
// Must be before early returns — hooks must be called unconditionally.
|
||
const mergedStarredSongs = useMemo(() => new Set([
|
||
...[...starredSongs].filter(id => starredOverrides[id] !== false),
|
||
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
|
||
]), [starredSongs, starredOverrides]);
|
||
|
||
const displayedSongs = useMemo(() => {
|
||
if (!album) return [];
|
||
const q = filterText.trim().toLowerCase();
|
||
if (!q && sortKey === 'natural') return album.songs;
|
||
let result = [...album.songs];
|
||
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
|
||
if (sortKey !== 'natural') {
|
||
result.sort((a, b) => {
|
||
let av: string | number;
|
||
let bv: string | number;
|
||
switch (sortKey) {
|
||
case 'title': av = a.title; bv = b.title; break;
|
||
case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break;
|
||
case 'album': av = a.album ?? ''; bv = b.album ?? ''; break;
|
||
case 'favorite':
|
||
av = mergedStarredSongs.has(a.id) ? 1 : 0;
|
||
bv = mergedStarredSongs.has(b.id) ? 1 : 0;
|
||
break;
|
||
case 'rating':
|
||
av = ratings[a.id] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0;
|
||
bv = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0;
|
||
break;
|
||
case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break;
|
||
default: av = a.title; bv = b.title;
|
||
}
|
||
if (typeof av === 'number' && typeof bv === 'number') {
|
||
return sortDir === 'asc' ? av - bv : bv - av;
|
||
}
|
||
return sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string);
|
||
});
|
||
}
|
||
return result;
|
||
}, [album, filterText, sortKey, sortDir, mergedStarredSongs, ratings, userRatingOverrides]);
|
||
|
||
// Hooks must be called unconditionally — derive from nullable album state.
|
||
// useMemo is required: buildCoverArtUrl generates a new salt on every call, so without
|
||
// memoization every re-render (e.g. currentTrack change) produces a new fetchUrl,
|
||
// which cancels and restarts the useCachedUrl effect → background never resolves.
|
||
const coverUrl = useMemo(() => album?.album.coverArt ? buildCoverArtUrl(album.album.coverArt, 400) : '', [album?.album.coverArt]);
|
||
const coverKey = useMemo(() => album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '', [album?.album.coverArt]);
|
||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||
|
||
useEffect(() => {
|
||
if (!showPlPicker) return;
|
||
const handler = (e: MouseEvent) => {
|
||
if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
|
||
};
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, [showPlPicker]);
|
||
|
||
useEffect(() => {
|
||
if (!inSelectMode) setShowPlPicker(false);
|
||
}, [inSelectMode]);
|
||
|
||
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
|
||
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
|
||
|
||
const { album: info, songs } = album;
|
||
const hasVariousArtists = songs.some(s => s.artist !== info.artist);
|
||
|
||
return (
|
||
<div className="album-detail animate-fade-in">
|
||
<AlbumHeader
|
||
info={info}
|
||
songs={songs}
|
||
coverUrl={coverUrl}
|
||
coverKey={coverKey}
|
||
resolvedCoverUrl={resolvedCoverUrl}
|
||
isStarred={isStarred}
|
||
downloadProgress={null}
|
||
bio={bio}
|
||
bioOpen={bioOpen}
|
||
onToggleStar={toggleStar}
|
||
onDownload={handleDownload}
|
||
onPlayAll={handlePlayAll}
|
||
onEnqueueAll={handleEnqueueAll}
|
||
onShuffleAll={handleShuffleAll}
|
||
onBio={handleBio}
|
||
onCloseBio={() => setBioOpen(false)}
|
||
offlineStatus={resolvedOfflineStatus}
|
||
offlineProgress={offlineProgress}
|
||
onCacheOffline={handleCacheOffline}
|
||
onRemoveOffline={handleRemoveOffline}
|
||
entityRatingValue={albumEntityRating}
|
||
onEntityRatingChange={handleAlbumEntityRating}
|
||
entityRatingSupport={albumEntityRatingSupport}
|
||
/>
|
||
{offlineStorageFull && (
|
||
<div className="offline-storage-full-banner" role="alert">
|
||
<span>{t('albumDetail.offlineStorageFull', { mb: auth.maxCacheMb })}</span>
|
||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => navigate('/offline')}>
|
||
{t('albumDetail.offlineStorageGoToLibrary')}
|
||
</button>
|
||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => navigate('/settings', { state: { tab: 'library' } })}>
|
||
{t('albumDetail.offlineStorageGoToSettings')}
|
||
</button>
|
||
<button className="offline-storage-full-dismiss" onClick={() => setOfflineStorageFull(false)} aria-label="Dismiss">×</button>
|
||
</div>
|
||
)}
|
||
|
||
{songs.length > 0 && (
|
||
<div className="album-track-toolbar">
|
||
<div className="album-track-toolbar-filter">
|
||
<Search size={16} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} />
|
||
<input
|
||
className="input-search"
|
||
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
|
||
placeholder={t('albumDetail.filterSongs')}
|
||
value={filterText}
|
||
onChange={e => setFilterText(e.target.value)}
|
||
/>
|
||
{filterText && (
|
||
<button
|
||
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||
onClick={() => setFilterText('')}
|
||
aria-label="Clear filter"
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="album-track-toolbar-actions">
|
||
{inSelectMode && (
|
||
<>
|
||
<span className="bulk-action-count">
|
||
{t('common.bulkSelected', { count: selectedCount })}
|
||
</span>
|
||
<div className="bulk-pl-picker-wrap">
|
||
<button
|
||
className="btn btn-surface btn-sm"
|
||
onClick={() => setShowPlPicker(v => !v)}
|
||
>
|
||
<ListPlus size={14} />
|
||
{t('common.bulkAddToPlaylist')}
|
||
</button>
|
||
{showPlPicker && (
|
||
<AddToPlaylistSubmenu
|
||
songIds={[...useSelectionStore.getState().selectedIds]}
|
||
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
|
||
dropDown
|
||
/>
|
||
)}
|
||
</div>
|
||
<button
|
||
className="btn btn-ghost btn-sm"
|
||
onClick={() => useSelectionStore.getState().clearAll()}
|
||
>
|
||
<X size={13} />
|
||
{t('common.bulkClear')}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<AlbumTrackList
|
||
songs={displayedSongs}
|
||
sorted={sortKey !== 'natural' || !!filterText.trim()}
|
||
hasVariousArtists={hasVariousArtists}
|
||
currentTrack={currentTrack}
|
||
isPlaying={isPlaying}
|
||
ratings={ratings}
|
||
userRatingOverrides={userRatingOverrides}
|
||
starredSongs={mergedStarredSongs}
|
||
onPlaySong={handlePlaySong}
|
||
onDoubleClickSong={orbitActive ? handleDoubleClickSong : undefined}
|
||
onRate={handleRate}
|
||
onToggleSongStar={toggleSongStar}
|
||
onContextMenu={openContextMenu}
|
||
sortKey={sortKey}
|
||
sortDir={sortDir}
|
||
onSort={handleSort}
|
||
/>
|
||
|
||
{relatedAlbums.length > 0 && (
|
||
<div className="album-related">
|
||
<div className="album-related-divider" />
|
||
<h2 className="section-title album-related-title">{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
|
||
<div className="album-grid-wrap">
|
||
{relatedAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|