Merge pull request #186 from kveld9/redesign/albums-playlists-headers

redesign/albums playlists headers
This commit is contained in:
Psychotoxical
2026-04-15 18:02:16 +02:00
committed by GitHub
4 changed files with 215 additions and 71 deletions
+25 -3
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Search, X } 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';
@@ -136,6 +137,22 @@ const handleEnqueueAll = () => {
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 handlePlaySong = (song: SubsonicSong) => {
if (!album) return;
const albumGenre = album.album.genre;
@@ -352,6 +369,7 @@ const handleEnqueueAll = () => {
onDownload={handleDownload}
onPlayAll={handlePlayAll}
onEnqueueAll={handleEnqueueAll}
onShuffleAll={handleShuffleAll}
onBio={handleBio}
onCloseBio={() => setBioOpen(false)}
offlineStatus={resolvedOfflineStatus}
@@ -378,8 +396,9 @@ const handleEnqueueAll = () => {
{songs.length > 0 && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '0 16px 8px', flexWrap: 'wrap' }}>
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
<Search size={16} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} />
<input
className="input"
className="input-search"
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
placeholder={t('albumDetail.filterSongs')}
value={filterText}
@@ -387,9 +406,12 @@ const handleEnqueueAll = () => {
/>
{filterText && (
<button
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, lineHeight: 1 }}
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('')}
>×</button>
aria-label="Clear filter"
>
<X size={14} />
</button>
)}
</div>
</div>
+143 -52
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useParams, useNavigate } from 'react-router-dom';
import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
@@ -41,6 +42,11 @@ function formatDuration(seconds: number): string {
return `${m}:${String(s).padStart(2, '0')}`;
}
function formatSize(bytes?: number): string {
if (!bytes) return '';
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function totalDurationLabel(songs: SubsonicSong[]): string {
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
return formatHumanHoursMinutes(total);
@@ -107,7 +113,6 @@ export default function PlaylistDetail() {
const downloadFolder = useAuthStore(s => s.downloadFolder);
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto);
const showBitrate = useThemeStore(s => s.showBitrate);
@@ -224,6 +229,11 @@ export default function PlaylistDetail() {
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
// ── Column picker portal dropdown state ────────────────────────────────────
const [pickerPos, setPickerPos] = useState<{ top: number; right: number } | null>(null);
const pickerBtnRef = useRef<HTMLButtonElement>(null);
const pickerMenuRef = useRef<HTMLDivElement>(null);
// ── Column resize/visibility ──────────────────────────────────────────────
const {
colVisible, visibleCols, gridStyle,
@@ -238,6 +248,41 @@ export default function PlaylistDetail() {
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
// Click-outside handler for column picker portal dropdown
useEffect(() => {
if (!pickerOpen) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
if (
pickerBtnRef.current?.contains(target) ||
pickerRef.current?.contains(target) ||
pickerMenuRef.current?.contains(target)
) {
return;
}
setPickerOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [pickerOpen, setPickerOpen]);
// Update picker position on resize/scroll while open
useEffect(() => {
if (!pickerOpen) return;
const updatePos = () => {
if (pickerBtnRef.current) {
const rect = pickerBtnRef.current.getBoundingClientRect();
setPickerPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
}
};
window.addEventListener('resize', updatePos);
window.addEventListener('scroll', updatePos, true);
return () => {
window.removeEventListener('resize', updatePos);
window.removeEventListener('scroll', updatePos, true);
};
}, [pickerOpen]);
// ── Load ─────────────────────────────────────────────────────
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
@@ -527,6 +572,30 @@ export default function PlaylistDetail() {
setDropTargetIdx({ idx, before });
};
// ── Playback actions (encapsulated like AlbumHeader) ─────────
const handlePlayAll = useCallback(() => {
if (!songs.length || !id) return;
touchPlaylist(id);
playTrack(tracks[0], tracks);
}, [songs.length, id, tracks, touchPlaylist, playTrack]);
const handleShuffleAll = useCallback(() => {
if (!songs.length || !id) return;
touchPlaylist(id);
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]];
}
playTrack(shuffled[0], shuffled);
}, [songs.length, id, tracks, touchPlaylist, playTrack]);
const handleEnqueueAll = useCallback(() => {
if (!songs.length || !id) return;
touchPlaylist(id);
enqueue(tracks);
}, [songs.length, id, tracks, touchPlaylist, enqueue]);
// ── Render ────────────────────────────────────────────────────
if (loading) {
return (
@@ -541,7 +610,7 @@ export default function PlaylistDetail() {
}
return (
<div className="content-body animate-fade-in">
<div className="album-detail animate-fade-in">
{/* ── Hero ── */}
<div className="album-detail-header">
@@ -588,10 +657,9 @@ export default function PlaylistDetail() {
)}
<div className="album-detail-meta">
<span className="badge album-detail-badge">{t('playlists.titleBadge')}</span>
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h1 className="album-detail-title" style={{ marginBottom: 0 }}>{playlist.name}</h1>
<h1 className="album-detail-title" style={{ marginBottom: 0, marginTop: 6 }}>{playlist.name}</h1>
<button
className="btn btn-ghost"
onClick={() => setEditingMeta(true)}
@@ -619,31 +687,24 @@ export default function PlaylistDetail() {
</div>
<div className="album-detail-actions">
<div className="album-detail-actions-primary">
<button className="btn btn-primary" disabled={songs.length === 0} onClick={() => {
if (!songs.length) return;
touchPlaylist(id!);
playTrack(tracks[0], tracks);
}}>
<Play size={16} fill="currentColor" /> {t('playlists.playAll')}
<button className="btn btn-primary" disabled={songs.length === 0} onClick={handlePlayAll}>
<Play size={15} /> {t('common.play', 'Reproducir')}
</button>
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
if (!songs.length) return;
touchPlaylist(id!);
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]];
}
playTrack(shuffled[0], shuffled);
}}>
<Shuffle size={16} /> {t('playlists.shuffle', 'Shuffle')}
<button
className="btn btn-ghost"
disabled={songs.length === 0}
onClick={handleShuffleAll}
data-tooltip={t('playlists.shuffle', 'Shuffle')}
>
<Shuffle size={16} />
</button>
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
if (!songs.length) return;
touchPlaylist(id!);
enqueue(tracks);
}}>
<ListPlus size={16} /> {t('playlists.addToQueue')}
<button
className="btn btn-ghost"
disabled={songs.length === 0}
onClick={handleEnqueueAll}
data-tooltip={t('playlists.addToQueue')}
>
<ListPlus size={16} />
</button>
</div>
<button
@@ -653,6 +714,21 @@ export default function PlaylistDetail() {
<Search size={16} /> {t('playlists.addSongs')}
</button>
{/* search close resets selection */}
{songs.length > 0 && (
activeZip && !activeZip.done && !activeZip.error ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : 0}%` }} />
</div>
<span className="download-progress-pct">{activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}%</span>
</div>
) : (
<button className="btn btn-ghost" onClick={handleDownload} data-tooltip={t('playlists.downloadZip')}>
<Download size={16} /> {t('playlists.downloadZip')}{songs.reduce((acc, s) => acc + (s.size ?? 0), 0) > 0 ? ` · ${formatSize(songs.reduce((acc, s) => acc + (s.size ?? 0), 0))}` : ''}
</button>
)
)}
{songs.length > 0 && id && (
<button
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
@@ -668,26 +744,24 @@ export default function PlaylistDetail() {
? t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })
: isCached ? t('playlists.removeOffline') : t('playlists.cacheOffline')}
>
{isDownloading
? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
: isCached ? <Trash2 size={16} /> : <HardDriveDownload size={16} />}
{isDownloading ? (
<>
<div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
{t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })}
</>
) : isCached ? (
<>
<Trash2 size={16} />
{t('playlists.removeOffline')}
</>
) : (
<>
<HardDriveDownload size={16} />
{t('playlists.cacheOffline')}
</>
)}
</button>
)}
{songs.length > 0 && (
activeZip && !activeZip.done && !activeZip.error ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : 0}%` }} />
</div>
<span className="download-progress-pct">{activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}%</span>
</div>
) : (
<button className="btn btn-ghost" onClick={handleDownload} data-tooltip={t('playlists.downloadZip')}>
<Download size={16} /> {t('playlists.downloadZip')}
</button>
)
)}
</div>
</div>
</div>
@@ -793,8 +867,9 @@ export default function PlaylistDetail() {
{songs.length > 0 && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 16px', flexWrap: 'wrap' }}>
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
<Search size={16} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} />
<input
className="input"
className="input-search"
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
placeholder={t('albumDetail.filterSongs')}
value={filterText}
@@ -802,9 +877,12 @@ export default function PlaylistDetail() {
/>
{filterText && (
<button
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, lineHeight: 1 }}
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('')}
>×</button>
aria-label="Clear filter"
>
<X size={14} />
</button>
)}
</div>
</div>
@@ -966,14 +1044,26 @@ export default function PlaylistDetail() {
</div>
<div className="tracklist-col-picker" ref={pickerRef}>
<button
ref={pickerBtnRef}
className="tracklist-col-picker-btn"
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
onClick={e => {
e.stopPropagation();
if (!pickerOpen && pickerBtnRef.current) {
const rect = pickerBtnRef.current.getBoundingClientRect();
setPickerPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
}
setPickerOpen(v => !v);
}}
data-tooltip={t('albumDetail.columns')}
>
<ChevronDown size={14} />
</button>
{pickerOpen && (
<div className="tracklist-col-picker-menu">
{pickerOpen && pickerPos && createPortal(
<div
ref={pickerMenuRef}
className="tracklist-col-picker-menu"
style={{ position: 'fixed', top: pickerPos.top, right: pickerPos.right, zIndex: 9999 }}
>
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{PL_COLUMNS.filter(c => !c.required).map(c => {
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
@@ -989,7 +1079,8 @@ export default function PlaylistDetail() {
</button>
);
})}
</div>
</div>,
document.body
)}
</div>
</div>