mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
Merge pull request #186 from kveld9/redesign/albums-playlists-headers
redesign/albums playlists headers
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter } from 'lucide-react';
|
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Loader2, Highlighter, Shuffle } from 'lucide-react';
|
||||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||||
import CachedImage from './CachedImage';
|
import CachedImage from './CachedImage';
|
||||||
import CoverLightbox from './CoverLightbox';
|
import CoverLightbox from './CoverLightbox';
|
||||||
@@ -86,6 +86,7 @@ interface AlbumHeaderProps {
|
|||||||
onRemoveOffline: () => void;
|
onRemoveOffline: () => void;
|
||||||
onPlayAll: () => void;
|
onPlayAll: () => void;
|
||||||
onEnqueueAll: () => void;
|
onEnqueueAll: () => void;
|
||||||
|
onShuffleAll?: () => void;
|
||||||
onBio: () => void;
|
onBio: () => void;
|
||||||
onCloseBio: () => void;
|
onCloseBio: () => void;
|
||||||
entityRatingValue: number;
|
entityRatingValue: number;
|
||||||
@@ -112,6 +113,7 @@ export default function AlbumHeader({
|
|||||||
onRemoveOffline,
|
onRemoveOffline,
|
||||||
onPlayAll,
|
onPlayAll,
|
||||||
onEnqueueAll,
|
onEnqueueAll,
|
||||||
|
onShuffleAll,
|
||||||
onBio,
|
onBio,
|
||||||
onCloseBio,
|
onCloseBio,
|
||||||
entityRatingValue,
|
entityRatingValue,
|
||||||
@@ -121,12 +123,12 @@ export default function AlbumHeader({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
|
||||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||||
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 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 formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
|
||||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -169,7 +171,6 @@ export default function AlbumHeader({
|
|||||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||||
)}
|
)}
|
||||||
<div className="album-detail-meta">
|
<div className="album-detail-meta">
|
||||||
<span className="badge album-detail-badge">{t('common.album')}</span>
|
|
||||||
<h1 className="album-detail-title">{info.name}</h1>
|
<h1 className="album-detail-title">{info.name}</h1>
|
||||||
<p className="album-detail-artist">
|
<p className="album-detail-artist">
|
||||||
<button
|
<button
|
||||||
@@ -295,26 +296,32 @@ export default function AlbumHeader({
|
|||||||
<div className="album-detail-actions">
|
<div className="album-detail-actions">
|
||||||
<div className="album-detail-actions-primary">
|
<div className="album-detail-actions-primary">
|
||||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
|
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
|
||||||
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
|
<Play size={15} /> {t('common.play', 'Reproducir')}
|
||||||
</button>
|
</button>
|
||||||
|
{onShuffleAll && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-surface"
|
className="btn btn-ghost"
|
||||||
|
onClick={onShuffleAll}
|
||||||
|
data-tooltip={t('playlists.shuffle', 'Shuffle')}
|
||||||
|
>
|
||||||
|
<Shuffle size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
onClick={onEnqueueAll}
|
onClick={onEnqueueAll}
|
||||||
data-tooltip={t('albumDetail.enqueueTooltip')}
|
data-tooltip={t('albumDetail.enqueueTooltip')}
|
||||||
>
|
>
|
||||||
<ListPlus size={16} /> {t('albumDetail.enqueue')}
|
<ListPlus size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
|
className={`btn btn-ghost${isStarred ? ' is-starred' : ''}`}
|
||||||
id="album-star-btn"
|
|
||||||
onClick={onToggleStar}
|
onClick={onToggleStar}
|
||||||
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
|
||||||
>
|
>
|
||||||
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
|
||||||
{t('albumDetail.favorite')}
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
|
||||||
<Highlighter size={16} /> {t('albumDetail.artistBio')}
|
<Highlighter size={16} /> {t('albumDetail.artistBio')}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Search, X } from 'lucide-react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
|
||||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
@@ -136,6 +137,22 @@ const handleEnqueueAll = () => {
|
|||||||
enqueue(tracks);
|
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) => {
|
const handlePlaySong = (song: SubsonicSong) => {
|
||||||
if (!album) return;
|
if (!album) return;
|
||||||
const albumGenre = album.album.genre;
|
const albumGenre = album.album.genre;
|
||||||
@@ -352,6 +369,7 @@ const handleEnqueueAll = () => {
|
|||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onPlayAll={handlePlayAll}
|
onPlayAll={handlePlayAll}
|
||||||
onEnqueueAll={handleEnqueueAll}
|
onEnqueueAll={handleEnqueueAll}
|
||||||
|
onShuffleAll={handleShuffleAll}
|
||||||
onBio={handleBio}
|
onBio={handleBio}
|
||||||
onCloseBio={() => setBioOpen(false)}
|
onCloseBio={() => setBioOpen(false)}
|
||||||
offlineStatus={resolvedOfflineStatus}
|
offlineStatus={resolvedOfflineStatus}
|
||||||
@@ -378,8 +396,9 @@ const handleEnqueueAll = () => {
|
|||||||
{songs.length > 0 && (
|
{songs.length > 0 && (
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '0 16px 8px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '0 16px 8px', flexWrap: 'wrap' }}>
|
||||||
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
|
<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
|
<input
|
||||||
className="input"
|
className="input-search"
|
||||||
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
|
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
|
||||||
placeholder={t('albumDetail.filterSongs')}
|
placeholder={t('albumDetail.filterSongs')}
|
||||||
value={filterText}
|
value={filterText}
|
||||||
@@ -387,9 +406,12 @@ const handleEnqueueAll = () => {
|
|||||||
/>
|
/>
|
||||||
{filterText && (
|
{filterText && (
|
||||||
<button
|
<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('')}
|
onClick={() => setFilterText('')}
|
||||||
>×</button>
|
aria-label="Clear filter"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+142
-51
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useParams, useNavigate } from 'react-router-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 { 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';
|
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||||
@@ -41,6 +42,11 @@ function formatDuration(seconds: number): string {
|
|||||||
return `${m}:${String(s).padStart(2, '0')}`;
|
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 {
|
function totalDurationLabel(songs: SubsonicSong[]): string {
|
||||||
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
|
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
|
||||||
return formatHumanHoursMinutes(total);
|
return formatHumanHoursMinutes(total);
|
||||||
@@ -107,7 +113,6 @@ export default function PlaylistDetail() {
|
|||||||
const downloadFolder = useAuthStore(s => s.downloadFolder);
|
const downloadFolder = useAuthStore(s => s.downloadFolder);
|
||||||
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
|
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
|
||||||
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
||||||
|
|
||||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||||
const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto);
|
const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto);
|
||||||
const showBitrate = useThemeStore(s => s.showBitrate);
|
const showBitrate = useThemeStore(s => s.showBitrate);
|
||||||
@@ -224,6 +229,11 @@ export default function PlaylistDetail() {
|
|||||||
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
|
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
|
||||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
|
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 ──────────────────────────────────────────────
|
// ── Column resize/visibility ──────────────────────────────────────────────
|
||||||
const {
|
const {
|
||||||
colVisible, visibleCols, gridStyle,
|
colVisible, visibleCols, gridStyle,
|
||||||
@@ -238,6 +248,41 @@ export default function PlaylistDetail() {
|
|||||||
if (!contextMenuOpen) setContextMenuSongId(null);
|
if (!contextMenuOpen) setContextMenuSongId(null);
|
||||||
}, [contextMenuOpen]);
|
}, [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 ─────────────────────────────────────────────────────
|
// ── Load ─────────────────────────────────────────────────────
|
||||||
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
|
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
|
||||||
|
|
||||||
@@ -527,6 +572,30 @@ export default function PlaylistDetail() {
|
|||||||
setDropTargetIdx({ idx, before });
|
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 ────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -541,7 +610,7 @@ export default function PlaylistDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-body animate-fade-in">
|
<div className="album-detail animate-fade-in">
|
||||||
|
|
||||||
{/* ── Hero ── */}
|
{/* ── Hero ── */}
|
||||||
<div className="album-detail-header">
|
<div className="album-detail-header">
|
||||||
@@ -588,10 +657,9 @@ export default function PlaylistDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="album-detail-meta">
|
<div className="album-detail-meta">
|
||||||
<span className="badge album-detail-badge">{t('playlists.titleBadge')}</span>
|
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<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
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={() => setEditingMeta(true)}
|
onClick={() => setEditingMeta(true)}
|
||||||
@@ -619,31 +687,24 @@ export default function PlaylistDetail() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="album-detail-actions">
|
<div className="album-detail-actions">
|
||||||
<div className="album-detail-actions-primary">
|
<div className="album-detail-actions-primary">
|
||||||
<button className="btn btn-primary" disabled={songs.length === 0} onClick={() => {
|
<button className="btn btn-primary" disabled={songs.length === 0} onClick={handlePlayAll}>
|
||||||
if (!songs.length) return;
|
<Play size={15} /> {t('common.play', 'Reproducir')}
|
||||||
touchPlaylist(id!);
|
|
||||||
playTrack(tracks[0], tracks);
|
|
||||||
}}>
|
|
||||||
<Play size={16} fill="currentColor" /> {t('playlists.playAll')}
|
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
<button
|
||||||
if (!songs.length) return;
|
className="btn btn-ghost"
|
||||||
touchPlaylist(id!);
|
disabled={songs.length === 0}
|
||||||
const shuffled = [...tracks];
|
onClick={handleShuffleAll}
|
||||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
data-tooltip={t('playlists.shuffle', 'Shuffle')}
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
>
|
||||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
<Shuffle size={16} />
|
||||||
}
|
|
||||||
playTrack(shuffled[0], shuffled);
|
|
||||||
}}>
|
|
||||||
<Shuffle size={16} /> {t('playlists.shuffle', 'Shuffle')}
|
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-surface" disabled={songs.length === 0} onClick={() => {
|
<button
|
||||||
if (!songs.length) return;
|
className="btn btn-ghost"
|
||||||
touchPlaylist(id!);
|
disabled={songs.length === 0}
|
||||||
enqueue(tracks);
|
onClick={handleEnqueueAll}
|
||||||
}}>
|
data-tooltip={t('playlists.addToQueue')}
|
||||||
<ListPlus size={16} /> {t('playlists.addToQueue')}
|
>
|
||||||
|
<ListPlus size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -653,6 +714,21 @@ export default function PlaylistDetail() {
|
|||||||
<Search size={16} /> {t('playlists.addSongs')}
|
<Search size={16} /> {t('playlists.addSongs')}
|
||||||
</button>
|
</button>
|
||||||
{/* search close resets selection */}
|
{/* 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 && (
|
{songs.length > 0 && id && (
|
||||||
<button
|
<button
|
||||||
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
|
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
|
||||||
@@ -668,25 +744,23 @@ export default function PlaylistDetail() {
|
|||||||
? t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })
|
? t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })
|
||||||
: isCached ? t('playlists.removeOffline') : t('playlists.cacheOffline')}
|
: isCached ? t('playlists.removeOffline') : t('playlists.cacheOffline')}
|
||||||
>
|
>
|
||||||
{isDownloading
|
{isDownloading ? (
|
||||||
? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
|
<>
|
||||||
: isCached ? <Trash2 size={16} /> : <HardDriveDownload size={16} />}
|
<div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
|
||||||
</button>
|
{t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })}
|
||||||
)}
|
</>
|
||||||
{songs.length > 0 && (
|
) : isCached ? (
|
||||||
activeZip && !activeZip.done && !activeZip.error ? (
|
<>
|
||||||
<div className="download-progress-wrap">
|
<Trash2 size={16} />
|
||||||
<Download size={14} />
|
{t('playlists.removeOffline')}
|
||||||
<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')}
|
<HardDriveDownload size={16} />
|
||||||
|
{t('playlists.cacheOffline')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -793,8 +867,9 @@ export default function PlaylistDetail() {
|
|||||||
{songs.length > 0 && (
|
{songs.length > 0 && (
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 16px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 16px', flexWrap: 'wrap' }}>
|
||||||
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
|
<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
|
<input
|
||||||
className="input"
|
className="input-search"
|
||||||
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
|
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
|
||||||
placeholder={t('albumDetail.filterSongs')}
|
placeholder={t('albumDetail.filterSongs')}
|
||||||
value={filterText}
|
value={filterText}
|
||||||
@@ -802,9 +877,12 @@ export default function PlaylistDetail() {
|
|||||||
/>
|
/>
|
||||||
{filterText && (
|
{filterText && (
|
||||||
<button
|
<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('')}
|
onClick={() => setFilterText('')}
|
||||||
>×</button>
|
aria-label="Clear filter"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -966,14 +1044,26 @@ export default function PlaylistDetail() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="tracklist-col-picker" ref={pickerRef}>
|
<div className="tracklist-col-picker" ref={pickerRef}>
|
||||||
<button
|
<button
|
||||||
|
ref={pickerBtnRef}
|
||||||
className="tracklist-col-picker-btn"
|
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')}
|
data-tooltip={t('albumDetail.columns')}
|
||||||
>
|
>
|
||||||
<ChevronDown size={14} />
|
<ChevronDown size={14} />
|
||||||
</button>
|
</button>
|
||||||
{pickerOpen && (
|
{pickerOpen && pickerPos && createPortal(
|
||||||
<div className="tracklist-col-picker-menu">
|
<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>
|
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
|
||||||
{PL_COLUMNS.filter(c => !c.required).map(c => {
|
{PL_COLUMNS.filter(c => !c.required).map(c => {
|
||||||
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
|
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
|
||||||
@@ -989,7 +1079,8 @@ export default function PlaylistDetail() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3756,6 +3756,30 @@ select.input.input:focus {
|
|||||||
/* Keep arrow on focus */
|
/* Keep arrow on focus */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Input Search ─── */
|
||||||
|
.input-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
padding-left: 36px;
|
||||||
|
background: var(--ctp-base);
|
||||||
|
border: 1px solid var(--ctp-overlay0);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-search::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-search:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Card ─── */
|
/* ─── Card ─── */
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
|
|||||||
Reference in New Issue
Block a user