new design

This commit is contained in:
kveld9
2026-04-14 17:26:02 -03:00
parent 915f0143f7
commit 5340d696cf
5 changed files with 275 additions and 106 deletions
+33 -22
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
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 CachedImage from './CachedImage';
import CoverLightbox from './CoverLightbox';
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile';
import StarRating from './StarRating';
import type { EntityRatingSupportLevel } from '../api/subsonic';
import { useThemeStore } from '../store/themeStore';
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
@@ -85,6 +86,7 @@ interface AlbumHeaderProps {
onRemoveOffline: () => void;
onPlayAll: () => void;
onEnqueueAll: () => void;
onShuffleAll?: () => void;
onBio: () => void;
onCloseBio: () => void;
entityRatingValue: number;
@@ -111,6 +113,7 @@ export default function AlbumHeader({
onRemoveOffline,
onPlayAll,
onEnqueueAll,
onShuffleAll,
onBio,
onCloseBio,
entityRatingValue,
@@ -120,6 +123,7 @@ export default function AlbumHeader({
const { t } = useTranslation();
const navigate = useNavigate();
const isMobile = useIsMobile();
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const [lightboxOpen, setLightboxOpen] = useState(false);
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
@@ -138,14 +142,16 @@ export default function AlbumHeader({
)}
<div className="album-detail-header">
{resolvedCoverUrl && (
<div
className="album-detail-bg"
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
aria-hidden="true"
/>
{resolvedCoverUrl && enableCoverArtBackground && (
<>
<div
className="album-detail-bg"
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
aria-hidden="true"
/>
<div className="album-detail-overlay" aria-hidden="true" />
</>
)}
<div className="album-detail-overlay" aria-hidden="true" />
<div className="album-detail-content">
<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-meta">
<span className="badge album-detail-badge">{t('common.album')}</span>
<h1 className="album-detail-title">{info.name}</h1>
<p className="album-detail-artist">
<button
@@ -291,27 +296,33 @@ export default function AlbumHeader({
<div className="album-detail-actions">
<div className="album-detail-actions-primary">
<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>
{onShuffleAll && (
<button
className="btn btn-ghost"
onClick={onShuffleAll}
data-tooltip={t('playlists.shuffle', 'Shuffle')}
>
<Shuffle size={16} />
</button>
)}
<button
className="btn btn-surface"
className="btn btn-ghost"
onClick={onEnqueueAll}
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={16} /> {t('albumDetail.enqueue')}
<ListPlus size={16} />
</button>
<button
className={`btn btn-ghost${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
</div>
<button
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
id="album-star-btn"
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')}
</button>
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
<Highlighter size={16} /> {t('albumDetail.artistBio')}
</button>
+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>
+181 -81
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';
@@ -14,6 +15,7 @@ import { usePlaylistStore } from '../store/playlistStore';
import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
@@ -40,15 +42,20 @@ 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);
}
function codecLabel(song: SubsonicSong): string {
function codecLabel(song: SubsonicSong, showBitrate: boolean): string {
const parts: string[] = [];
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(' · ');
}
@@ -106,6 +113,9 @@ 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);
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
const [songs, setSongs] = useState<SubsonicSong[]>([]);
@@ -219,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,
@@ -233,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));
@@ -522,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 (
@@ -536,14 +610,16 @@ 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">
{resolvedBgUrl && (
<div className="album-detail-bg" style={{ backgroundImage: `url(${resolvedBgUrl})` }} aria-hidden="true" />
{resolvedBgUrl && enableCoverArtBackground && (
<>
<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">
<button className="btn btn-ghost album-detail-back" onClick={() => navigate('/playlists')}>
@@ -552,37 +628,38 @@ export default function PlaylistDetail() {
<div className="album-detail-hero">
{/* Cover — click to open edit modal */}
<div
className="playlist-hero-cover"
onClick={() => setEditingMeta(true)}
>
{customCoverId && customCoverFetchUrl && customCoverCacheKey ? (
<CachedImage
src={customCoverFetchUrl}
cacheKey={customCoverCacheKey}
alt=""
className="playlist-cover-grid"
style={{ objectFit: 'cover', display: 'block' }}
/>
) : (
<div className="playlist-cover-grid">
{coverQuadUrls.map((entry, i) =>
entry
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
)}
{enablePlaylistCoverPhoto && (
<div
className="playlist-hero-cover"
onClick={() => setEditingMeta(true)}
>
{customCoverId && customCoverFetchUrl && customCoverCacheKey ? (
<CachedImage
src={customCoverFetchUrl}
cacheKey={customCoverCacheKey}
alt=""
className="playlist-cover-grid"
style={{ objectFit: 'cover', display: 'block' }}
/>
) : (
<div className="playlist-cover-grid">
{coverQuadUrls.map((entry, i) =>
entry
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
)}
</div>
)}
<div className="playlist-hero-cover-overlay">
<Camera size={28} />
</div>
)}
<div className="playlist-hero-cover-overlay">
<Camera size={28} />
</div>
</div>
)}
<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)}
@@ -610,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
@@ -644,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' : ''}`}
@@ -659,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>
@@ -784,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}
@@ -793,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>
@@ -957,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;
@@ -980,7 +1079,8 @@ export default function PlaylistDetail() {
</button>
);
})}
</div>
</div>,
document.body
)}
</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 'format': return (
<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>
);
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 'format': return (
<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>
);
case 'delete': return (
+12
View File
@@ -16,6 +16,12 @@ interface ThemeState {
setTimeDayStart: (v: string) => void;
timeNightStart: string;
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 {
@@ -47,6 +53,12 @@ export const useThemeStore = create<ThemeState>()(
setTimeDayStart: (v) => set({ timeDayStart: v }),
timeNightStart: '19:00',
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',
+24
View File
@@ -3750,6 +3750,30 @@ select.input.input: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 {
background: var(--bg-card);