diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index 93edef1e..901d6807 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -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({ )}
- {t('albumDetail.playAll')} + {t('common.play', 'Reproducir')} + {onShuffleAll && ( + + + + )} - {t('albumDetail.enqueue')} + + + + - - - {t('albumDetail.favorite')} - - {t('albumDetail.artistBio')} diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index fde999c2..8f97687f 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -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 && ( + { /> {filterText && ( setFilterText('')} - >× + aria-label="Clear filter" + > + + )} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 6d49e0e9..2a9265a1 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -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(null); const [songs, setSongs] = useState([]); @@ -219,6 +229,11 @@ export default function PlaylistDetail() { const [suggestions, setSuggestions] = useState([]); const [loadingSuggestions, setLoadingSuggestions] = useState(false); + // ── Column picker portal dropdown state ──────────────────────────────────── + const [pickerPos, setPickerPos] = useState<{ top: number; right: number } | null>(null); + const pickerBtnRef = useRef(null); + const pickerMenuRef = useRef(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 ( - + {/* ── Hero ── */} - {resolvedBgUrl && ( - + {resolvedBgUrl && enableCoverArtBackground && ( + <> + + + > )} - navigate('/playlists')}> @@ -552,37 +628,38 @@ export default function PlaylistDetail() { {/* Cover — click to open edit modal */} - setEditingMeta(true)} - > - {customCoverId && customCoverFetchUrl && customCoverCacheKey ? ( - - ) : ( - - {coverQuadUrls.map((entry, i) => - entry - ? - : - )} + {enablePlaylistCoverPhoto && ( + setEditingMeta(true)} + > + {customCoverId && customCoverFetchUrl && customCoverCacheKey ? ( + + ) : ( + + {coverQuadUrls.map((entry, i) => + entry + ? + : + )} + + )} + + - )} - - - + )} - {t('playlists.titleBadge')} <> - {playlist.name} + {playlist.name} setEditingMeta(true)} @@ -610,31 +687,24 @@ export default function PlaylistDetail() { - { - if (!songs.length) return; - touchPlaylist(id!); - playTrack(tracks[0], tracks); - }}> - {t('playlists.playAll')} + + {t('common.play', 'Reproducir')} - { - 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); - }}> - {t('playlists.shuffle', 'Shuffle')} + + - { - if (!songs.length) return; - touchPlaylist(id!); - enqueue(tracks); - }}> - {t('playlists.addToQueue')} + + {t('playlists.addSongs')} {/* search close resets selection */} + {songs.length > 0 && ( + activeZip && !activeZip.done && !activeZip.error ? ( + + + + + + {activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}% + + ) : ( + + {t('playlists.downloadZip')}{songs.reduce((acc, s) => acc + (s.size ?? 0), 0) > 0 ? ` · ${formatSize(songs.reduce((acc, s) => acc + (s.size ?? 0), 0))}` : ''} + + ) + )} {songs.length > 0 && id && ( - {isDownloading - ? - : isCached ? : } + {isDownloading ? ( + <> + + {t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })} + > + ) : isCached ? ( + <> + + {t('playlists.removeOffline')} + > + ) : ( + <> + + {t('playlists.cacheOffline')} + > + )} )} - {songs.length > 0 && ( - activeZip && !activeZip.done && !activeZip.error ? ( - - - - - - {activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}% - - ) : ( - - {t('playlists.downloadZip')} - - ) - )} @@ -784,8 +867,9 @@ export default function PlaylistDetail() { {songs.length > 0 && ( + {filterText && ( setFilterText('')} - >× + aria-label="Clear filter" + > + + )} @@ -957,14 +1044,26 @@ export default function PlaylistDetail() { { 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')} > - {pickerOpen && ( - + {pickerOpen && pickerPos && createPortal( + {t('albumDetail.columns')} {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() { ); })} - + , + document.body )} @@ -1059,7 +1159,7 @@ export default function PlaylistDetail() { case 'duration': return {formatDuration(song.duration ?? 0)}; case 'format': return ( - {(song.suffix || song.bitRate) && {codecLabel(song)}} + {(song.suffix || (showBitrate && song.bitRate)) && {codecLabel(song, showBitrate)}} ); case 'delete': return ( @@ -1148,7 +1248,7 @@ export default function PlaylistDetail() { case 'duration': return {formatDuration(song.duration ?? 0)}; case 'format': return ( - {(song.suffix || song.bitRate) && {codecLabel(song)}} + {(song.suffix || (showBitrate && song.bitRate)) && {codecLabel(song, showBitrate)}} ); case 'delete': return ( diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index 03cc992d..5da21395 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -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): string { @@ -47,6 +53,12 @@ export const useThemeStore = create()( 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', diff --git a/src/styles/theme.css b/src/styles/theme.css index d6b2c73a..2d4c22b9 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -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);