mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
new design
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';
|
||||||
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import StarRating from './StarRating';
|
import StarRating from './StarRating';
|
||||||
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
import type { EntityRatingSupportLevel } from '../api/subsonic';
|
||||||
|
import { useThemeStore } from '../store/themeStore';
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
@@ -85,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;
|
||||||
@@ -111,6 +113,7 @@ export default function AlbumHeader({
|
|||||||
onRemoveOffline,
|
onRemoveOffline,
|
||||||
onPlayAll,
|
onPlayAll,
|
||||||
onEnqueueAll,
|
onEnqueueAll,
|
||||||
|
onShuffleAll,
|
||||||
onBio,
|
onBio,
|
||||||
onCloseBio,
|
onCloseBio,
|
||||||
entityRatingValue,
|
entityRatingValue,
|
||||||
@@ -120,6 +123,7 @@ 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);
|
||||||
@@ -138,14 +142,16 @@ export default function AlbumHeader({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="album-detail-header">
|
<div className="album-detail-header">
|
||||||
{resolvedCoverUrl && (
|
{resolvedCoverUrl && enableCoverArtBackground && (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className="album-detail-bg"
|
className="album-detail-bg"
|
||||||
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<div className="album-detail-overlay" aria-hidden="true" />
|
<div className="album-detail-overlay" aria-hidden="true" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="album-detail-content">
|
<div className="album-detail-content">
|
||||||
<button className="btn btn-ghost album-detail-back" onClick={() => navigate(-1)}>
|
<button className="btn btn-ghost album-detail-back" onClick={() => navigate(-1)}>
|
||||||
@@ -165,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
|
||||||
@@ -291,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>
|
||||||
|
|||||||
+156
-56
@@ -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';
|
||||||
@@ -14,6 +15,7 @@ import { usePlaylistStore } from '../store/playlistStore';
|
|||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { useThemeStore } from '../store/themeStore';
|
||||||
import { useDownloadModalStore } from '../store/downloadModalStore';
|
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { join } from '@tauri-apps/api/path';
|
import { join } from '@tauri-apps/api/path';
|
||||||
@@ -40,15 +42,20 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function codecLabel(song: SubsonicSong): string {
|
function codecLabel(song: SubsonicSong, showBitrate: boolean): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||||
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
|
if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +113,9 @@ 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 enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto);
|
||||||
|
const showBitrate = useThemeStore(s => s.showBitrate);
|
||||||
|
|
||||||
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
|
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
|
||||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||||
@@ -219,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,
|
||||||
@@ -233,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));
|
||||||
|
|
||||||
@@ -522,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 (
|
||||||
@@ -536,14 +610,16 @@ 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">
|
||||||
{resolvedBgUrl && (
|
{resolvedBgUrl && enableCoverArtBackground && (
|
||||||
|
<>
|
||||||
<div className="album-detail-bg" style={{ backgroundImage: `url(${resolvedBgUrl})` }} aria-hidden="true" />
|
<div className="album-detail-bg" style={{ backgroundImage: `url(${resolvedBgUrl})` }} aria-hidden="true" />
|
||||||
)}
|
|
||||||
<div className="album-detail-overlay" aria-hidden="true" />
|
<div className="album-detail-overlay" aria-hidden="true" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="album-detail-content">
|
<div className="album-detail-content">
|
||||||
<button className="btn btn-ghost album-detail-back" onClick={() => navigate('/playlists')}>
|
<button className="btn btn-ghost album-detail-back" onClick={() => navigate('/playlists')}>
|
||||||
@@ -552,6 +628,7 @@ export default function PlaylistDetail() {
|
|||||||
|
|
||||||
<div className="album-detail-hero">
|
<div className="album-detail-hero">
|
||||||
{/* Cover — click to open edit modal */}
|
{/* Cover — click to open edit modal */}
|
||||||
|
{enablePlaylistCoverPhoto && (
|
||||||
<div
|
<div
|
||||||
className="playlist-hero-cover"
|
className="playlist-hero-cover"
|
||||||
onClick={() => setEditingMeta(true)}
|
onClick={() => setEditingMeta(true)}
|
||||||
@@ -577,12 +654,12 @@ export default function PlaylistDetail() {
|
|||||||
<Camera size={28} />
|
<Camera size={28} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<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)}
|
||||||
@@ -610,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
|
||||||
@@ -644,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' : ''}`}
|
||||||
@@ -659,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>
|
||||||
@@ -784,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}
|
||||||
@@ -793,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>
|
||||||
@@ -957,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;
|
||||||
@@ -980,7 +1079,8 @@ export default function PlaylistDetail() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1059,7 +1159,7 @@ export default function PlaylistDetail() {
|
|||||||
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
|
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
|
||||||
case 'format': return (
|
case 'format': return (
|
||||||
<div key="format" className="track-meta">
|
<div key="format" className="track-meta">
|
||||||
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'delete': return (
|
case 'delete': return (
|
||||||
@@ -1148,7 +1248,7 @@ export default function PlaylistDetail() {
|
|||||||
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
|
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
|
||||||
case 'format': return (
|
case 'format': return (
|
||||||
<div key="format" className="track-meta">
|
<div key="format" className="track-meta">
|
||||||
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
|
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'delete': return (
|
case 'delete': return (
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ interface ThemeState {
|
|||||||
setTimeDayStart: (v: string) => void;
|
setTimeDayStart: (v: string) => void;
|
||||||
timeNightStart: string;
|
timeNightStart: string;
|
||||||
setTimeNightStart: (v: string) => void;
|
setTimeNightStart: (v: string) => void;
|
||||||
|
enableCoverArtBackground: boolean;
|
||||||
|
setEnableCoverArtBackground: (v: boolean) => void;
|
||||||
|
enablePlaylistCoverPhoto: boolean;
|
||||||
|
setEnablePlaylistCoverPhoto: (v: boolean) => void;
|
||||||
|
showBitrate: boolean;
|
||||||
|
setShowBitrate: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string {
|
export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string {
|
||||||
@@ -47,6 +53,12 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
setTimeDayStart: (v) => set({ timeDayStart: v }),
|
setTimeDayStart: (v) => set({ timeDayStart: v }),
|
||||||
timeNightStart: '19:00',
|
timeNightStart: '19:00',
|
||||||
setTimeNightStart: (v) => set({ timeNightStart: v }),
|
setTimeNightStart: (v) => set({ timeNightStart: v }),
|
||||||
|
enableCoverArtBackground: true,
|
||||||
|
setEnableCoverArtBackground: (v) => set({ enableCoverArtBackground: v }),
|
||||||
|
enablePlaylistCoverPhoto: true,
|
||||||
|
setEnablePlaylistCoverPhoto: (v) => set({ enablePlaylistCoverPhoto: v }),
|
||||||
|
showBitrate: false,
|
||||||
|
setShowBitrate: (v) => set({ showBitrate: v }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'psysonic_theme',
|
name: 'psysonic_theme',
|
||||||
|
|||||||
@@ -3750,6 +3750,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