diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index fafe3b38..ac4d2e84 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'; @@ -86,6 +86,7 @@ interface AlbumHeaderProps { onRemoveOffline: () => void; onPlayAll: () => void; onEnqueueAll: () => void; + onShuffleAll?: () => void; onBio: () => void; onCloseBio: () => void; entityRatingValue: number; @@ -112,6 +113,7 @@ export default function AlbumHeader({ onRemoveOffline, onPlayAll, onEnqueueAll, + onShuffleAll, onBio, onCloseBio, entityRatingValue, @@ -121,12 +123,12 @@ 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); 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 enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground); return ( <> @@ -169,7 +171,6 @@ export default function AlbumHeader({
)}
- {t('common.album')}

{info.name}

+ {onShuffleAll && ( + + )} +

- - 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 && ( + aria-label="Clear filter" + > + + )}
diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 90017df8..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'; @@ -41,6 +42,11 @@ function formatDuration(seconds: number): string { return `${m}:${String(s).padStart(2, '0')}`; } +function formatSize(bytes?: number): string { + if (!bytes) return ''; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + function totalDurationLabel(songs: SubsonicSong[]): string { const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0); return formatHumanHoursMinutes(total); @@ -107,7 +113,6 @@ export default function PlaylistDetail() { const downloadFolder = useAuthStore(s => s.downloadFolder); const setDownloadFolder = useAuthStore(s => s.setDownloadFolder); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); - const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground); const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto); const showBitrate = useThemeStore(s => s.showBitrate); @@ -224,6 +229,11 @@ export default function PlaylistDetail() { const [suggestions, setSuggestions] = useState([]); 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, @@ -238,6 +248,41 @@ export default function PlaylistDetail() { if (!contextMenuOpen) setContextMenuSongId(null); }, [contextMenuOpen]); + // Click-outside handler for column picker portal dropdown + useEffect(() => { + if (!pickerOpen) return; + const handler = (e: MouseEvent) => { + const target = e.target as Node; + if ( + pickerBtnRef.current?.contains(target) || + pickerRef.current?.contains(target) || + pickerMenuRef.current?.contains(target) + ) { + return; + } + setPickerOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [pickerOpen, setPickerOpen]); + + // Update picker position on resize/scroll while open + useEffect(() => { + if (!pickerOpen) return; + const updatePos = () => { + if (pickerBtnRef.current) { + const rect = pickerBtnRef.current.getBoundingClientRect(); + setPickerPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right }); + } + }; + window.addEventListener('resize', updatePos); + window.addEventListener('scroll', updatePos, true); + return () => { + window.removeEventListener('resize', updatePos); + window.removeEventListener('scroll', updatePos, true); + }; + }, [pickerOpen]); + // ── Load ───────────────────────────────────────────────────── const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined)); @@ -527,6 +572,30 @@ export default function PlaylistDetail() { setDropTargetIdx({ idx, before }); }; + // ── Playback actions (encapsulated like AlbumHeader) ───────── + const handlePlayAll = useCallback(() => { + if (!songs.length || !id) return; + touchPlaylist(id); + playTrack(tracks[0], tracks); + }, [songs.length, id, tracks, touchPlaylist, playTrack]); + + const handleShuffleAll = useCallback(() => { + if (!songs.length || !id) return; + touchPlaylist(id); + const shuffled = [...tracks]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + playTrack(shuffled[0], shuffled); + }, [songs.length, id, tracks, touchPlaylist, playTrack]); + + const handleEnqueueAll = useCallback(() => { + if (!songs.length || !id) return; + touchPlaylist(id); + enqueue(tracks); + }, [songs.length, id, tracks, touchPlaylist, enqueue]); + // ── Render ──────────────────────────────────────────────────── if (loading) { return ( @@ -541,7 +610,7 @@ export default function PlaylistDetail() { } return ( -
+
{/* ── Hero ── */}
@@ -588,10 +657,9 @@ export default function PlaylistDetail() { )}
- {t('playlists.titleBadge')} <>
-

{playlist.name}

+

{playlist.name}

- - -
{/* search close resets selection */} + {songs.length > 0 && ( + activeZip && !activeZip.done && !activeZip.error ? ( +
+ +
+
+
+ {activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}% +
+ ) : ( + + ) + )} {songs.length > 0 && id && ( )} - {songs.length > 0 && ( - activeZip && !activeZip.done && !activeZip.error ? ( -
- -
-
-
- {activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}% -
- ) : ( - - ) - )}
@@ -793,8 +867,9 @@ export default function PlaylistDetail() { {songs.length > 0 && (
+ {filterText && ( + aria-label="Clear filter" + > + + )}
@@ -966,14 +1044,26 @@ export default function PlaylistDetail() {
- {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; @@ -989,7 +1079,8 @@ export default function PlaylistDetail() { ); })} -
+
, + document.body )}
diff --git a/src/styles/theme.css b/src/styles/theme.css index ffc0f023..b7ff2979 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -3756,6 +3756,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);