diff --git a/src/App.tsx b/src/App.tsx index 6a22d950..d16461ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -409,6 +409,7 @@ function AppShell() { // Media key + tray event handler function TauriEventBridge() { + const navigate = useNavigate(); const togglePlay = usePlayerStore(s => s.togglePlay); const next = usePlayerStore(s => s.next); const previous = usePlayerStore(s => s.previous); @@ -486,6 +487,9 @@ function TauriEventBridge() { break; } case 'toggle-queue': toggleQueue(); break; + case 'open-folder-browser': + navigate('/folders', { state: { folderBrowserRevealTs: Date.now() } }); + break; case 'fullscreen-player': toggleFullscreen(); break; case 'native-fullscreen': { const win = getCurrentWindow(); diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index 154b9175..11ec3210 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -43,7 +43,6 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating aria-label={`${album.name} von ${album.artist}`} onKeyDown={e => e.key === 'Enter' && handleClick()} onContextMenu={(e) => { - if (selectionMode) { e.preventDefault(); return; } e.preventDefault(); openContextMenu(e.clientX, e.clientY, album, 'album'); }} diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index e33af375..29afe305 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles } from 'lucide-react'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star } from 'lucide-react'; import LastfmIcon from './LastfmIcon'; import StarRating from './StarRating'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; @@ -36,7 +36,7 @@ function shuffleArray(arr: T[]): T[] { } // ── Add-to-Playlist submenu ─────────────────────────────────────── -export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: string[]; onDone: () => void; dropDown?: boolean }) { +export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: { songIds: string[]; onDone: () => void; dropDown?: boolean; triggerId?: string }) { const { t } = useTranslation(); const subRef = useRef(null); const newNameRef = useRef(null); @@ -106,7 +106,7 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: s : { left: 'calc(100% + 4px)', right: 'auto' }; return ( -
+
{/* New Playlist row */} {!creating ? (
void }) { +function AlbumToPlaylistSubmenu({ albumId, onDone, triggerId }: { albumId: string; onDone: () => void; triggerId?: string }) { const [resolvedIds, setResolvedIds] = useState(null); useEffect(() => { @@ -172,7 +172,7 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone: ); } if (resolvedIds.length === 0) return null; - return ; + return ; } export default function ContextMenu() { @@ -196,21 +196,29 @@ export default function ContextMenu() { })) ); const auth = useAuthStore(); + const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); + const entityRatingSupport = + auth.activeServerId ? auth.entityRatingSupportByServer[auth.activeServerId] ?? 'unknown' : 'unknown'; const audiomuseNavidromeEnabled = !!(auth.activeServerId && auth.audiomuseNavidromeByServer[auth.activeServerId]); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const navigate = useNavigate(); const menuRef = useRef(null); + const previousFocusRef = useRef(null); // Adjusted coordinates to keep menu on screen const [coords, setCoords] = useState({ x: 0, y: 0 }); const [playlistSubmenuOpen, setPlaylistSubmenuOpen] = useState(false); const [playlistSongIds, setPlaylistSongIds] = useState([]); + const [keyboardRating, setKeyboardRating] = useState<{ kind: 'song' | 'album' | 'artist'; id: string; value: number } | null>(null); + const [pendingSubmenuKeyboardFocus, setPendingSubmenuKeyboardFocus] = useState(false); useEffect(() => { if (contextMenu.isOpen) { setCoords({ x: contextMenu.x, y: contextMenu.y }); setPlaylistSubmenuOpen(false); setPlaylistSongIds([]); + setKeyboardRating(null); + setPendingSubmenuKeyboardFocus(false); } }, [contextMenu.isOpen, contextMenu.x, contextMenu.y]); @@ -227,13 +235,244 @@ export default function ContextMenu() { } }, [contextMenu.isOpen, contextMenu.x, contextMenu.y]); - if (!contextMenu.isOpen || !contextMenu.item) return null; + useEffect(() => { + if (contextMenu.isOpen) { + previousFocusRef.current = document.activeElement as HTMLElement | null; + return; + } + const prev = previousFocusRef.current; + previousFocusRef.current = null; + if (prev?.isConnected) { + requestAnimationFrame(() => { + prev.focus({ preventScroll: true }); + }); + } + }, [contextMenu.isOpen, closeContextMenu]); + + const getMenuNavItems = useCallback( + (scope: 'main' | 'submenu' = 'main') => { + if (!menuRef.current) return []; + if (scope === 'submenu') { + const sub = menuRef.current.querySelector('.context-submenu'); + if (!sub || sub.offsetParent === null) return []; + return Array.from( + sub.querySelectorAll('.context-menu-item, .context-submenu-create-btn'), + ).filter(el => el.offsetParent !== null); + } + return Array.from(menuRef.current.children) + .filter((el): el is HTMLElement => + el instanceof HTMLElement && + (el.classList.contains('context-menu-item') || el.classList.contains('context-menu-rating-row')) && + el.offsetParent !== null, + ); + }, + [], + ); + + const focusMenuItemAt = useCallback((scope: 'main' | 'submenu', index: number) => { + const items = getMenuNavItems(scope); + if (items.length === 0) return; + menuRef.current + ?.querySelectorAll('.context-menu-keyboard-active') + .forEach(el => el.classList.remove('context-menu-keyboard-active')); + const safeIdx = ((index % items.length) + items.length) % items.length; + const target = items[safeIdx]; + target.classList.add('context-menu-keyboard-active'); + target.tabIndex = -1; + target.focus({ preventScroll: true }); + target.scrollIntoView({ block: 'nearest' }); + }, [getMenuNavItems]); + + useEffect(() => { + if (!contextMenu.isOpen) return; + requestAnimationFrame(() => { + menuRef.current?.focus({ preventScroll: true }); + // Do not pre-highlight any menu row; keyboard outline appears only + // after explicit arrow navigation. + }); + }, [contextMenu.isOpen]); + + useEffect(() => { + if (!pendingSubmenuKeyboardFocus || !playlistSubmenuOpen) return; + let cancelled = false; + const tryFocus = (attemptsLeft: number) => { + if (cancelled) return; + const items = getMenuNavItems('submenu'); + if (items.length > 0) { + focusMenuItemAt('submenu', 0); + setPendingSubmenuKeyboardFocus(false); + return; + } + if (attemptsLeft <= 0) { + setPendingSubmenuKeyboardFocus(false); + return; + } + requestAnimationFrame(() => tryFocus(attemptsLeft - 1)); + }; + requestAnimationFrame(() => tryFocus(8)); + return () => { + cancelled = true; + }; + }, [pendingSubmenuKeyboardFocus, playlistSubmenuOpen, getMenuNavItems, focusMenuItemAt]); const { type, item, queueIndex } = contextMenu; const isStarred = (id: string, itemStarred?: string) => id in starredOverrides ? starredOverrides[id] : !!itemStarred; + const applySongRating = useCallback((songId: string, rating: number) => { + setUserRatingOverride(songId, rating); + setRating(songId, rating).catch(() => {}); + }, [setUserRatingOverride]); + + const applyAlbumRating = useCallback((album: SubsonicAlbum, rating: number) => { + setUserRatingOverride(album.id, rating); + if (entityRatingSupport !== 'full') return; + setRating(album.id, rating).catch(err => { + if (auth.activeServerId) setEntityRatingSupport(auth.activeServerId, 'track_only'); + showToast( + typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'), + 4500, + 'error', + ); + }); + }, [setUserRatingOverride, entityRatingSupport, auth.activeServerId, setEntityRatingSupport, t]); + + const applyArtistRating = useCallback((artist: SubsonicArtist, rating: number) => { + setUserRatingOverride(artist.id, rating); + if (entityRatingSupport !== 'full') return; + setRating(artist.id, rating).catch(err => { + if (auth.activeServerId) setEntityRatingSupport(auth.activeServerId, 'track_only'); + showToast( + typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'), + 4500, + 'error', + ); + }); + }, [setUserRatingOverride, entityRatingSupport, auth.activeServerId, setEntityRatingSupport, t]); + + const getRatingValueByKind = useCallback((kind: 'song' | 'album' | 'artist', id: string): number => { + if (kind === 'song' && (type === 'song' || type === 'album-song' || type === 'queue-item')) { + const song = item as Track; + if (song.id === id) return userRatingOverrides[id] ?? song.userRating ?? 0; + } + if (kind === 'album' && type === 'album') { + const album = item as SubsonicAlbum; + if (album.id === id) return userRatingOverrides[id] ?? album.userRating ?? 0; + } + if (kind === 'artist' && type === 'artist') { + const artist = item as SubsonicArtist; + if (artist.id === id) return userRatingOverrides[id] ?? artist.userRating ?? 0; + } + return userRatingOverrides[id] ?? 0; + }, [type, item, userRatingOverrides]); + + const commitRatingByKind = useCallback((kind: 'song' | 'album' | 'artist', id: string, rating: number) => { + if (kind === 'song') { + applySongRating(id, rating); + return; + } + if (kind === 'album' && type === 'album') { + applyAlbumRating(item as SubsonicAlbum, rating); + return; + } + if (kind === 'artist' && type === 'artist') { + applyArtistRating(item as SubsonicArtist, rating); + } + }, [applySongRating, applyAlbumRating, applyArtistRating, type, item]); + + const onMenuKeyDown = useCallback((e: React.KeyboardEvent) => { + const active = document.activeElement as HTMLElement | null; + const ratingRow = active?.closest('.context-menu-rating-row') as HTMLElement | null; + + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + closeContextMenu(); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + if (ratingRow) { + const kind = ratingRow.dataset.ratingKind as ('song' | 'album' | 'artist' | undefined); + const id = ratingRow.dataset.ratingId; + if (!kind || !id) return; + if (ratingRow.dataset.ratingDisabled === 'true') return; + const value = keyboardRating && keyboardRating.kind === kind && keyboardRating.id === id + ? keyboardRating.value + : getRatingValueByKind(kind, id); + commitRatingByKind(kind, id, value); + setKeyboardRating({ kind, id, value }); + return; + } + active?.click(); + return; + } + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + if (ratingRow) { + const kind = ratingRow.dataset.ratingKind as ('song' | 'album' | 'artist' | undefined); + const id = ratingRow.dataset.ratingId; + if (!kind || !id) return; + if (ratingRow.dataset.ratingDisabled === 'true') return; + e.preventDefault(); + e.stopPropagation(); + const currentValue = keyboardRating && keyboardRating.kind === kind && keyboardRating.id === id + ? keyboardRating.value + : getRatingValueByKind(kind, id); + const delta = e.key === 'ArrowRight' ? 1 : -1; + const nextValue = Math.max(0, Math.min(5, currentValue + delta)); + setKeyboardRating({ kind, id, value: nextValue }); + return; + } + } + if (e.key === 'ArrowRight') { + const trigger = active?.closest('.context-menu-item--submenu') as HTMLElement | null; + const triggerId = trigger?.dataset.playlistTriggerId; + if (!trigger || !triggerId) return; + e.preventDefault(); + e.stopPropagation(); + setPlaylistSongIds([triggerId]); + setPlaylistSubmenuOpen(true); + setPendingSubmenuKeyboardFocus(true); + return; + } + if (e.key === 'ArrowLeft') { + const sub = active?.closest('.context-submenu') as HTMLElement | null; + if (!sub) return; + e.preventDefault(); + e.stopPropagation(); + const triggerId = sub.dataset.parentTriggerId; + setPlaylistSubmenuOpen(false); + requestAnimationFrame(() => { + const trigger = triggerId + ? Array.from(menuRef.current?.querySelectorAll('.context-menu-item--submenu') ?? []) + .find(el => el.dataset.playlistTriggerId === triggerId) ?? null + : null; + if (trigger) { + menuRef.current + ?.querySelectorAll('.context-menu-keyboard-active') + .forEach(el => el.classList.remove('context-menu-keyboard-active')); + trigger.classList.add('context-menu-keyboard-active'); + trigger.focus({ preventScroll: true }); + } + }); + return; + } + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; + e.preventDefault(); + e.stopPropagation(); + const scope: 'main' | 'submenu' = active?.closest('.context-submenu') ? 'submenu' : 'main'; + const items = getMenuNavItems(scope); + if (items.length === 0) return; + const activeIdx = items.findIndex(el => el === document.activeElement); + const nextIdx = + activeIdx >= 0 + ? (e.key === 'ArrowDown' ? activeIdx + 1 : activeIdx - 1) + : (e.key === 'ArrowDown' ? 0 : items.length - 1); + focusMenuItemAt(scope, nextIdx); + }, [closeContextMenu, keyboardRating, getRatingValueByKind, commitRatingByKind, getMenuNavItems, focusMenuItemAt]); + const handleAction = async (action: () => void | Promise) => { closeContextMenu(); await action(); @@ -376,6 +615,8 @@ export default function ContextMenu() { } }; + if (!contextMenu.isOpen || !contextMenu.item) return null; + return ( <> {/* Transparent backdrop — catches all outside clicks cleanly, preventing freeze */} @@ -387,6 +628,8 @@ export default function ContextMenu() { ref={menuRef} className="context-menu animate-fade-in" style={{ left: coords.x, top: coords.y, zIndex: 999 }} + tabIndex={-1} + onKeyDown={onMenuKeyDown} > {(type === 'song' || type === 'album-song') && (() => { const song = item as Track; @@ -412,13 +655,14 @@ export default function ContextMenu() {
{ setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} onMouseLeave={() => setPlaylistSubmenuOpen(false)} > {t('contextMenu.addToPlaylist')} {playlistSubmenuOpen && playlistSongIds[0] === song.id && ( - { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> )}
{type === 'album-song' && ( @@ -467,14 +711,23 @@ export default function ContextMenu() {
); })()} -
-
e.stopPropagation()}> +
e.stopPropagation()} + > + { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }} + value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id + ? keyboardRating.value + : userRatingOverrides[song.id] ?? song.userRating ?? 0} + onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }} ariaLabel={t('albumDetail.ratingLabel')} />
+
handleAction(() => openSongInfo(song.id))}> {t('contextMenu.songInfo')}
@@ -484,6 +737,7 @@ export default function ContextMenu() { {type === 'album' && (() => { const album = item as SubsonicAlbum; + const albumRatingDisabled = entityRatingSupport === 'track_only'; return ( <>
handleAction(() => navigate(`/album/${album.id}`))}> @@ -501,18 +755,37 @@ export default function ContextMenu() { {isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
+
e.stopPropagation()} + > + + { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }} + /> +
+
handleAction(() => downloadAlbum(album.name, album.id))}> {t('contextMenu.download')}
{ setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }} onMouseLeave={() => setPlaylistSubmenuOpen(false)} > {t('contextMenu.addToPlaylist')} {playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && ( - { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> )}
@@ -521,6 +794,7 @@ export default function ContextMenu() { {type === 'artist' && (() => { const artist = item as SubsonicArtist; + const artistRatingDisabled = entityRatingSupport === 'track_only'; return ( <>
handleAction(() => startRadio(artist.id, artist.name))}> @@ -535,6 +809,23 @@ export default function ContextMenu() { {isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
+
e.stopPropagation()} + > + + { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }} + /> +
); })()} @@ -553,13 +844,14 @@ export default function ContextMenu() {
{ setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} onMouseLeave={() => setPlaylistSubmenuOpen(false)} > {t('contextMenu.addToPlaylist')} {playlistSubmenuOpen && playlistSongIds[0] === song.id && ( - { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> )}
@@ -568,6 +860,14 @@ export default function ContextMenu() { {t('contextMenu.openAlbum')}
)} +
handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}> + {t('contextMenu.startRadio')} +
+ {audiomuseNavidromeEnabled && ( +
handleAction(() => startInstantMix(song))}> + {t('contextMenu.instantMix')} +
+ )}
handleAction(() => { const starred = isStarred(song.id, song.starred); setStarredOverride(song.id, !starred); @@ -591,22 +891,23 @@ export default function ContextMenu() {
); })()} -
handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}> - {t('contextMenu.startRadio')} -
- {audiomuseNavidromeEnabled && ( -
handleAction(() => startInstantMix(song))}> - {t('contextMenu.instantMix')} -
- )} -
-
e.stopPropagation()}> +
e.stopPropagation()} + > + { setUserRatingOverride(song.id, r); setRating(song.id, r).catch(() => {}); }} + value={keyboardRating?.kind === 'song' && keyboardRating.id === song.id + ? keyboardRating.value + : userRatingOverrides[song.id] ?? song.userRating ?? 0} + onChange={r => { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }} ariaLabel={t('albumDetail.ratingLabel')} />
+
handleAction(() => openSongInfo(song.id))}> {t('contextMenu.songInfo')}
diff --git a/src/components/StarRating.tsx b/src/components/StarRating.tsx index baea128c..44515c17 100644 --- a/src/components/StarRating.tsx +++ b/src/components/StarRating.tsx @@ -34,6 +34,8 @@ export default function StarRating({ const [clearShrinkStar, setClearShrinkStar] = React.useState(null); /** After clear: ignore hover so stars stay grey until pointer leaves widget or next click */ const [suppressHoverPreview, setSuppressHoverPreview] = React.useState(false); + const prevValueRef = React.useRef(value); + const internalClickRef = React.useRef(false); const cappedValue = Math.min(Math.max(0, value), selectCap); @@ -41,6 +43,38 @@ export default function StarRating({ if (value > 0) setSuppressHoverPreview(false); }, [value]); + // Keep keyboard-driven changes visually in sync with mouse click effects. + React.useEffect(() => { + const prev = prevValueRef.current; + const next = value; + prevValueRef.current = value; + + if (internalClickRef.current) { + internalClickRef.current = false; + return; + } + if (prev === next) return; + + setPulseStar(null); + setClearShrinkStar(null); + + if (next > prev) { + const star = Math.max(1, Math.min(selectCap, next)); + requestAnimationFrame(() => { + requestAnimationFrame(() => setPulseStar(star)); + }); + return; + } + + if (next < prev) { + const star = Math.max(1, Math.min(selectCap, prev)); + if (next === 0) setSuppressHoverPreview(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => setClearShrinkStar(star)); + }); + } + }, [value, selectCap]); + const effectiveHover = suppressHoverPreview ? 0 : Math.min(hover, selectCap); const filled = (n: number) => (effectiveHover || cappedValue) >= n; @@ -49,6 +83,7 @@ export default function StarRating({ setSuppressHoverPreview(false); const next = cappedValue === n ? 0 : n; + internalClickRef.current = true; onChange(next); setHover(0); diff --git a/src/locales/de.ts b/src/locales/de.ts index c63b0075..8beb600f 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -562,6 +562,7 @@ export const deTranslation = { shortcutSeekForward: '10s vorspulen', shortcutSeekBackward: '10s zurückspulen', shortcutToggleQueue: 'Warteschlange ein-/ausblenden', + shortcutOpenFolderBrowser: '{{folderBrowser}} öffnen', shortcutFullscreenPlayer: 'Vollbild-Player', shortcutNativeFullscreen: 'Nativer Vollbildmodus', tabSystem: 'System', diff --git a/src/locales/en.ts b/src/locales/en.ts index f4fbe940..830c7e7c 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -587,6 +587,7 @@ export const enTranslation = { shortcutSeekForward: 'Seek forward 10s', shortcutSeekBackward: 'Seek backward 10s', shortcutToggleQueue: 'Toggle queue', + shortcutOpenFolderBrowser: 'Open {{folderBrowser}}', shortcutFullscreenPlayer: 'Fullscreen player', shortcutNativeFullscreen: 'Native fullscreen', playbackTitle: 'Playback', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 0f804162..bfa728a7 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -560,6 +560,7 @@ export const frTranslation = { shortcutSeekForward: 'Avancer de 10s', shortcutSeekBackward: 'Reculer de 10s', shortcutToggleQueue: 'Afficher/masquer la file', + shortcutOpenFolderBrowser: 'Ouvrir {{folderBrowser}}', shortcutFullscreenPlayer: 'Lecteur plein écran', shortcutNativeFullscreen: 'Plein écran natif', tabSystem: 'Système', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 56ce9c30..8b4ab567 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -582,6 +582,7 @@ export const nbTranslation = { shortcutSeekForward: 'Søk fremover 10 sekunder', shortcutSeekBackward: 'Søk bakover 10 sekunder', shortcutToggleQueue: 'Veksle kø', + shortcutOpenFolderBrowser: 'Åpne {{folderBrowser}}', shortcutFullscreenPlayer: 'Fullskjermsspiller', shortcutNativeFullscreen: 'Naturlig fullskjerm', playbackTitle: 'Avspilling', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 6dbf756b..916379a0 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -560,6 +560,7 @@ export const nlTranslation = { shortcutSeekForward: '10s vooruitspoelen', shortcutSeekBackward: '10s terugspoelen', shortcutToggleQueue: 'Wachtrij tonen/verbergen', + shortcutOpenFolderBrowser: '{{folderBrowser}} openen', shortcutFullscreenPlayer: 'Volledigschermspeler', shortcutNativeFullscreen: 'Systeemvolledig scherm', tabSystem: 'Systeem', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 67f8973d..ea17daf8 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -611,6 +611,7 @@ export const ruTranslation = { shortcutSeekForward: 'Вперёд на 10 с', shortcutSeekBackward: 'Назад на 10 с', shortcutToggleQueue: 'Показать / скрыть очередь', + shortcutOpenFolderBrowser: 'Открыть {{folderBrowser}}', shortcutFullscreenPlayer: 'Полноэкранный плеер', shortcutNativeFullscreen: 'Системный полный экран', playbackTitle: 'Воспроизведение', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 9bbb38af..4313417f 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -579,6 +579,7 @@ export const zhTranslation = { shortcutSeekForward: '快进 10 秒', shortcutSeekBackward: '快退 10 秒', shortcutToggleQueue: '切换队列', + shortcutOpenFolderBrowser: '打开{{folderBrowser}}', shortcutFullscreenPlayer: '全屏播放器', shortcutNativeFullscreen: '原生全屏', playbackTitle: '播放', diff --git a/src/pages/FolderBrowser.tsx b/src/pages/FolderBrowser.tsx index 47a5734a..c52f4a0f 100644 --- a/src/pages/FolderBrowser.tsx +++ b/src/pages/FolderBrowser.tsx @@ -1,10 +1,20 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; -import { getMusicFolders, getMusicDirectory, getMusicIndexes, SubsonicDirectoryEntry } from '../api/subsonic'; +import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { + getMusicFolders, + getMusicDirectory, + getMusicIndexes, + SubsonicDirectoryEntry, + SubsonicArtist, + SubsonicAlbum, +} from '../api/subsonic'; import { usePlayerStore, Track } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; import { Folder, FolderOpen, Music, ChevronRight } from 'lucide-react'; +import { useLocation } from 'react-router-dom'; -// ── types ───────────────────────────────────────────────────────────────────── +type ColumnKind = 'roots' | 'indexes' | 'directory'; +type NavPos = { colIndex: number; rowIndex: number }; +let persistedPlayingPathIds: string[] = []; type Column = { id: string; @@ -13,9 +23,28 @@ type Column = { selectedId: string | null; loading: boolean; error: boolean; + kind: ColumnKind; }; -// ── helpers ─────────────────────────────────────────────────────────────────── +/** getMusicDirectory: `albumId` or `album` + row `id` (Navidrome). */ +function entryToAlbumIfPresent(item: SubsonicDirectoryEntry): SubsonicAlbum | null { + if (!item.isDir) return null; + const albumId = item.albumId ?? (item.album ? item.id : undefined); + if (!albumId) return null; + return { + id: albumId, + name: item.album ?? item.title, + artist: item.artist ?? '', + artistId: item.artistId ?? '', + coverArt: item.coverArt, + year: item.year, + genre: item.genre, + starred: item.starred, + userRating: item.userRating, + songCount: 0, + duration: 0, + }; +} function entryToTrack(e: SubsonicDirectoryEntry): Track { return { @@ -37,17 +66,36 @@ function entryToTrack(e: SubsonicDirectoryEntry): Track { }; } -// ── component ───────────────────────────────────────────────────────────────── - export default function FolderBrowser() { const { t } = useTranslation(); const [columns, setColumns] = useState([]); + const [keyboardNavActive, setKeyboardNavActive] = useState(false); + const [playingPathIds, setPlayingPathIds] = useState(persistedPlayingPathIds); const wrapperRef = useRef(null); + const pendingNavColRef = useRef(null); + const autoResolvedTrackRef = useRef(null); + const prevTrackIdRef = useRef(null); + const lastHotkeyRevealTsRef = useRef(null); + const [keyboardPos, setKeyboardPos] = useState(null); + const [contextAnchorPos, setContextAnchorPos] = useState(null); + const [columnsViewportWidth, setColumnsViewportWidth] = useState(0); + const location = useLocation(); + const currentTrack = usePlayerStore(s => s.currentTrack); + const isPlaying = usePlayerStore(s => s.isPlaying); const playTrack = usePlayerStore(s => s.playTrack); + const openContextMenu = usePlayerStore(s => s.openContextMenu); + const isContextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); - // ── root: load music folders on mount ───────────────────────────────────── useEffect(() => { - const placeholder: Column = { id: 'root', name: '', items: [], selectedId: null, loading: true, error: false }; + const placeholder: Column = { + id: 'root', + name: '', + items: [], + selectedId: null, + loading: true, + error: false, + kind: 'roots', + }; setColumns([placeholder]); getMusicFolders() .then(folders => { @@ -63,28 +111,180 @@ export default function FolderBrowser() { }); }, []); - // ── auto-scroll to newly added column ───────────────────────────────────── useEffect(() => { const el = wrapperRef.current; if (!el) return; - requestAnimationFrame(() => { el.scrollLeft = el.scrollWidth; }); + requestAnimationFrame(() => { + el.scrollLeft = el.scrollWidth; + }); }, [columns.length]); - // ── click a directory ────────────────────────────────────────────────────── + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + setColumnsViewportWidth(el.clientWidth); + const observer = new ResizeObserver(() => { + setColumnsViewportWidth(el.clientWidth); + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!wrapperRef.current) return; + requestAnimationFrame(() => { + columns.forEach((col, colIndex) => { + const selectedId = col.selectedId; + if (!selectedId) return; + const row = wrapperRef.current?.querySelector( + `.folder-col[data-folder-col-index="${colIndex}"] .folder-col-row[data-item-id="${selectedId}"]`, + ); + row?.scrollIntoView({ block: 'nearest' }); + }); + + if (keyboardPos) { + const kbdRow = wrapperRef.current?.querySelector( + `.folder-col[data-folder-col-index="${keyboardPos.colIndex}"] .folder-col-row[data-row-index="${keyboardPos.rowIndex}"]`, + ); + kbdRow?.scrollIntoView({ block: 'nearest' }); + } + + const fallbackColIndex = [...columns] + .map((c, i) => (c.selectedId ? i : -1)) + .filter(i => i >= 0) + .pop(); + const baseColIndex = keyboardPos?.colIndex ?? fallbackColIndex ?? Math.max(0, columns.length - 1); + const focusColIndex = Math.min(Math.max(0, columns.length - 1), baseColIndex + 1); + const focusCol = wrapperRef.current?.querySelector( + `.folder-col[data-folder-col-index="${focusColIndex}"]`, + ); + focusCol?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }); + }, [columns, keyboardPos]); + + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + const hasRows = columns.some(c => !c.loading && !c.error && c.items.length > 0); + if (!hasRows) return; + requestAnimationFrame(() => { + el.focus({ preventScroll: true }); + }); + }, [columns]); + + useEffect(() => { + if (!keyboardNavActive) return; + const onMouseMove = () => setKeyboardNavActive(false); + window.addEventListener('mousemove', onMouseMove, { once: true }); + return () => window.removeEventListener('mousemove', onMouseMove); + }, [keyboardNavActive]); + + useEffect(() => { + if (!isContextMenuOpen) setContextAnchorPos(null); + }, [isContextMenuOpen]); + + useEffect(() => { + if (!currentTrack?.id) { + setPlayingPathIds([]); + return; + } + setPlayingPathIds(prev => (prev[prev.length - 1] === currentTrack.id ? prev : [])); + }, [currentTrack?.id]); + + useEffect(() => { + if (!isPlaying || !currentTrack?.id) return; + const selectedChain = columns + .map(c => c.selectedId) + .filter((id): id is string => !!id); + if (selectedChain.length === 0) return; + + const lastSelectedId = selectedChain[selectedChain.length - 1]; + const leafColumn = [...columns].reverse().find(c => c.selectedId); + const leafItem = leafColumn?.items.find(it => it.id === lastSelectedId); + if (!leafItem || leafItem.isDir || leafItem.id !== currentTrack.id) return; + + setPlayingPathIds(prev => { + if ( + prev.length === selectedChain.length && + prev.every((id, idx) => id === selectedChain[idx]) + ) { + return prev; + } + return selectedChain; + }); + }, [columns, currentTrack?.id, isPlaying]); + + useEffect(() => { + persistedPlayingPathIds = playingPathIds; + }, [playingPathIds]); + + const preferredRowIndex = useCallback((col: Column): number => { + if (col.items.length === 0) return -1; + if (col.selectedId) { + const selectedIdx = col.items.findIndex(it => it.id === col.selectedId); + if (selectedIdx >= 0) return selectedIdx; + } + return 0; + }, []); + + const fallbackNavPos = useCallback((cols: Column[]): NavPos | null => { + for (let c = 0; c < cols.length; c++) { + const rowIndex = preferredRowIndex(cols[c]); + if (rowIndex >= 0) return { colIndex: c, rowIndex }; + } + return null; + }, [preferredRowIndex]); + + useEffect(() => { + if (pendingNavColRef.current !== null) { + const targetColIndex = pendingNavColRef.current; + const targetCol = columns[targetColIndex]; + if (targetCol && targetCol.items.length > 0 && !targetCol.loading && !targetCol.error) { + const rowIndex = preferredRowIndex(targetCol); + const targetItem = targetCol.items[rowIndex]; + setColumns(prev => + prev.map((c, i) => (i === targetColIndex ? { ...c, selectedId: targetItem.id } : c)), + ); + setKeyboardPos({ + colIndex: targetColIndex, + rowIndex, + }); + pendingNavColRef.current = null; + return; + } + } + + setKeyboardPos(prev => { + if (!prev) return fallbackNavPos(columns); + if (prev.colIndex >= columns.length) return fallbackNavPos(columns); + const col = columns[prev.colIndex]; + if (col.loading || col.error || col.items.length === 0) return fallbackNavPos(columns); + if (prev.rowIndex >= col.items.length) { + return { colIndex: prev.colIndex, rowIndex: col.items.length - 1 }; + } + return prev; + }); + }, [columns, fallbackNavPos, preferredRowIndex]); + const handleDirClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => { - // Mark selected + truncate columns after this one + add loading column + const nextKind: ColumnKind = colIndex === 0 ? 'indexes' : 'directory'; setColumns(prev => [ ...prev.slice(0, colIndex + 1).map((c, i) => i === colIndex ? { ...c, selectedId: item.id } : c, ), - { id: item.id, name: item.title, items: [], selectedId: null, loading: true, error: false }, + { + id: item.id, + name: item.title, + items: [], + selectedId: null, + loading: true, + error: false, + kind: nextKind, + }, ]); - // Column 0 holds music folder roots — their IDs are only valid for - // getIndexes.view (musicFolderId), not getMusicDirectory.view - const fetchItems = colIndex === 0 - ? getMusicIndexes(item.id) - : getMusicDirectory(item.id).then(d => d.child); + const fetchItems = + colIndex === 0 ? getMusicIndexes(item.id) : getMusicDirectory(item.id).then(d => d.child); fetchItems .then(items => { @@ -107,24 +307,301 @@ export default function FolderBrowser() { }); }, []); - // ── click a file (track) ─────────────────────────────────────────────────── - const handleFileClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => { - setColumns(prev => prev.map((c, i) => - i === colIndex ? { ...c, selectedId: item.id } : c, - )); - // Build queue from all tracks in this column - const col = columns[colIndex]; - const queue = col.items.filter(it => !it.isDir).map(entryToTrack); - playTrack(entryToTrack(item), queue.length > 0 ? queue : [entryToTrack(item)]); - }, [columns, playTrack]); + const handleFileClick = useCallback( + (colIndex: number, item: SubsonicDirectoryEntry) => { + setColumns(prev => + prev.map((c, i) => (i === colIndex ? { ...c, selectedId: item.id } : c)), + ); + const path = [ + ...columns.slice(0, colIndex).map(c => c.selectedId).filter((id): id is string => !!id), + item.id, + ]; + setPlayingPathIds(path); + const col = columns[colIndex]; + const queue = col.items.filter(it => !it.isDir).map(entryToTrack); + playTrack(entryToTrack(item), queue.length > 0 ? queue : [entryToTrack(item)]); + }, + [columns, playTrack], + ); + + const setSelectedInColumn = useCallback((colIndex: number, itemId: string) => { + setColumns(prev => + prev.map((c, i) => (i === colIndex ? { ...c, selectedId: itemId } : c)), + ); + }, []); + + const clearSelectedInColumn = useCallback((colIndex: number) => { + setColumns(prev => + prev.map((c, i) => (i === colIndex ? { ...c, selectedId: null } : c)), + ); + }, []); + + const handleActivate = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => { + if (item.isDir) { + handleDirClick(colIndex, item); + pendingNavColRef.current = colIndex + 1; + return; + } + handleFileClick(colIndex, item); + }, [handleDirClick, handleFileClick]); + + const openContextMenuForEntry = useCallback( + (col: Column, item: SubsonicDirectoryEntry, x: number, y: number) => { + if (item.isDir) { + if (col.kind === 'indexes') { + const artist: SubsonicArtist = { id: item.id, name: item.title, coverArt: item.coverArt }; + openContextMenu(x, y, artist, 'artist'); + return; + } + const album = entryToAlbumIfPresent(item); + if (album) { + openContextMenu(x, y, album, 'album'); + return; + } + if (item.artistId) { + const artist: SubsonicArtist = { + id: item.artistId, + name: item.artist ?? item.title, + coverArt: item.coverArt, + }; + openContextMenu(x, y, artist, 'artist'); + return; + } + return; + } + openContextMenu(x, y, entryToTrack(item), 'song'); + }, + [openContextMenu], + ); + + const onColumnsKeyDown = useCallback((e: React.KeyboardEvent) => { + if (isContextMenuOpen) return; + const key = e.key; + if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return; + setKeyboardNavActive(true); + const current = keyboardPos ?? fallbackNavPos(columns); + if (!current) return; + + const col = columns[current.colIndex]; + const item = col?.items[current.rowIndex]; + if (!col || !item) return; + + e.preventDefault(); + + if (key === 'Enter' && e.ctrlKey) { + setContextAnchorPos(current); + const rowEl = wrapperRef.current?.querySelector( + `.folder-col-row[data-col-index="${current.colIndex}"][data-row-index="${current.rowIndex}"]`, + ); + const rect = rowEl?.getBoundingClientRect(); + const x = rect ? rect.left + 24 : 24; + const y = rect ? rect.top + rect.height / 2 : 24; + openContextMenuForEntry(col, item, x, y); + return; + } + + if (key === 'ArrowUp') { + if (current.rowIndex > 0) { + const nextRowIndex = current.rowIndex - 1; + const nextItem = col.items[nextRowIndex]; + setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex }); + if (nextItem.isDir) handleDirClick(current.colIndex, nextItem); + else setSelectedInColumn(current.colIndex, nextItem.id); + } + return; + } + if (key === 'ArrowDown') { + if (current.rowIndex < col.items.length - 1) { + const nextRowIndex = current.rowIndex + 1; + const nextItem = col.items[nextRowIndex]; + setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex }); + if (nextItem.isDir) handleDirClick(current.colIndex, nextItem); + else setSelectedInColumn(current.colIndex, nextItem.id); + } + return; + } + if (key === 'ArrowLeft') { + if (current.colIndex > 0) { + clearSelectedInColumn(current.colIndex); + const nextColIndex = current.colIndex - 1; + const rowIndex = preferredRowIndex(columns[nextColIndex]); + if (rowIndex >= 0) setKeyboardPos({ colIndex: nextColIndex, rowIndex }); + } + return; + } + if (key === 'ArrowRight') { + const nextColIndex = current.colIndex + 1; + if (nextColIndex < columns.length) { + const rowIndex = preferredRowIndex(columns[nextColIndex]); + if (rowIndex >= 0) { + const nextItem = columns[nextColIndex].items[rowIndex]; + setSelectedInColumn(nextColIndex, nextItem.id); + setKeyboardPos({ colIndex: nextColIndex, rowIndex }); + return; + } + } + if (item.isDir) handleActivate(current.colIndex, item); + return; + } + if (key === 'Enter') { + handleActivate(current.colIndex, item); + } + }, [keyboardPos, fallbackNavPos, columns, preferredRowIndex, handleActivate, handleDirClick, setSelectedInColumn, clearSelectedInColumn, openContextMenuForEntry, isContextMenuOpen]); + + const onRowContextMenu = useCallback( + (e: React.MouseEvent, colIndex: number, rowIndex: number, col: Column, item: SubsonicDirectoryEntry) => { + e.preventDefault(); + e.stopPropagation(); + setContextAnchorPos({ colIndex, rowIndex }); + openContextMenuForEntry(col, item, e.clientX, e.clientY); + }, + [openContextMenuForEntry], + ); + + const resolveColumnsForTrack = useCallback(async ( + track: Track, + roots: SubsonicDirectoryEntry[], + ): Promise => { + for (const root of roots) { + let indexes: SubsonicDirectoryEntry[]; + try { + indexes = await getMusicIndexes(root.id); + } catch { + continue; + } + + const artistEntry = + indexes.find(it => it.isDir && !!track.artistId && it.id === track.artistId) ?? + indexes.find(it => it.isDir && it.title === track.artist); + if (!artistEntry) continue; + + let artistChildren: SubsonicDirectoryEntry[]; + try { + artistChildren = (await getMusicDirectory(artistEntry.id)).child; + } catch { + continue; + } + + const albumEntry = artistChildren.find(it => + it.isDir && + ( + (!!track.albumId && (it.albumId === track.albumId || it.id === track.albumId)) || + (!!track.album && (it.album === track.album || it.title === track.album)) + ), + ); + if (!albumEntry) continue; + + let albumChildren: SubsonicDirectoryEntry[]; + try { + albumChildren = (await getMusicDirectory(albumEntry.id)).child; + } catch { + continue; + } + const songEntry = albumChildren.find(it => !it.isDir && it.id === track.id); + if (!songEntry) continue; + + return [ + { id: 'root', name: '', items: roots, selectedId: root.id, loading: false, error: false, kind: 'roots' }, + { id: root.id, name: root.title, items: indexes, selectedId: artistEntry.id, loading: false, error: false, kind: 'indexes' }, + { id: artistEntry.id, name: artistEntry.title, items: artistChildren, selectedId: albumEntry.id, loading: false, error: false, kind: 'directory' }, + { id: albumEntry.id, name: albumEntry.title, items: albumChildren, selectedId: songEntry.id, loading: false, error: false, kind: 'directory' }, + ]; + } + return null; + }, []); + + const isSelectedPathForCurrentTrack = + isPlaying && currentTrack && playingPathIds[playingPathIds.length - 1] === currentTrack.id; + + const activeColIndex = useMemo(() => { + if (keyboardPos) return keyboardPos.colIndex; + const fromSelection = [...columns] + .map((c, i) => (c.selectedId ? i : -1)) + .filter(i => i >= 0); + if (fromSelection.length > 0) return fromSelection[fromSelection.length - 1]; + return Math.max(0, columns.length - 1); + }, [columns, keyboardPos]); + + const visibleAnchorColIndex = useMemo( + () => Math.min(Math.max(0, columns.length - 1), activeColIndex + 1), + [activeColIndex, columns.length], + ); + + const compactColumnsEnabled = useMemo(() => { + if (columns.length < 4 || columnsViewportWidth <= 0) return false; + const expandedColumnWidth = 220; + return columns.length * expandedColumnWidth > columnsViewportWidth; + }, [columns.length, columnsViewportWidth]); + + const isColumnCompact = useCallback((col: Column, colIndex: number) => { + if (!compactColumnsEnabled) return false; + if (col.loading || col.error || col.items.length === 0) return false; + return Math.abs(colIndex - visibleAnchorColIndex) > 1; + }, [compactColumnsEnabled, visibleAnchorColIndex]); + + useEffect(() => { + if (!currentTrack?.id) { + autoResolvedTrackRef.current = null; + return; + } + + const hotkeyRevealTs = (location.state as { folderBrowserRevealTs?: number } | null)?.folderBrowserRevealTs ?? null; + const hotkeyRevealRequested = hotkeyRevealTs !== null && hotkeyRevealTs !== lastHotkeyRevealTsRef.current; + const forceReveal = hotkeyRevealRequested; + if (autoResolvedTrackRef.current === currentTrack.id && !forceReveal) return; + + const rootCol = columns[0]; + if (!rootCol || rootCol.loading || rootCol.error || rootCol.items.length === 0) return; + + const selectedLeafId = + [...columns].reverse().find(c => c.selectedId)?.selectedId ?? null; + const wasOnPreviousTrackPath = !!prevTrackIdRef.current && selectedLeafId === prevTrackIdRef.current; + if (selectedLeafId === currentTrack.id) { + autoResolvedTrackRef.current = currentTrack.id; + if (hotkeyRevealRequested) { + lastHotkeyRevealTsRef.current = hotkeyRevealTs; + } + return; + } + if (!forceReveal && !wasOnPreviousTrackPath) return; + + let cancelled = false; + resolveColumnsForTrack(currentTrack, rootCol.items).then((resolved) => { + if (cancelled || !resolved) return; + setColumns(resolved); + const path = resolved.map(c => c.selectedId).filter((id): id is string => !!id); + setPlayingPathIds(path); + const leafColIndex = resolved.length - 1; + const leafRowIndex = resolved[leafColIndex].items.findIndex(it => it.id === currentTrack.id); + if (leafRowIndex >= 0) setKeyboardPos({ colIndex: leafColIndex, rowIndex: leafRowIndex }); + autoResolvedTrackRef.current = currentTrack.id; + if (hotkeyRevealRequested) { + lastHotkeyRevealTsRef.current = hotkeyRevealTs; + } + }); + + return () => { cancelled = true; }; + }, [columns, currentTrack, resolveColumnsForTrack, location.state]); + + useEffect(() => { + prevTrackIdRef.current = currentTrack?.id ?? null; + }, [currentTrack?.id]); - // ── render ───────────────────────────────────────────────────────────────── return (

{t('sidebar.folderBrowser')}

-
+
{columns.map((col, colIndex) => ( -
+
{col.loading ? (
@@ -138,27 +615,45 @@ export default function FolderBrowser() { ) : ( col.items.map(item => { const isSelected = col.selectedId === item.id; + const rowIndex = col.items.findIndex(it => it.id === item.id); + const isContextRow = + contextAnchorPos?.colIndex === colIndex && contextAnchorPos.rowIndex === rowIndex; + const isKeyboardRow = + keyboardPos?.colIndex === colIndex && keyboardPos?.rowIndex === rowIndex; + const isNowPlayingTrack = !item.isDir && currentTrack?.id === item.id; + const isPathPlayingIcon = !!(isSelectedPathForCurrentTrack && playingPathIds.includes(item.id)); return ( ); }) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index e3f2fa6f..d9844e9b 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1541,6 +1541,7 @@ export default function Settings() { ['seek-forward', t('settings.shortcutSeekForward')], ['seek-backward', t('settings.shortcutSeekBackward')], ['toggle-queue', t('settings.shortcutToggleQueue')], + ['open-folder-browser', t('settings.shortcutOpenFolderBrowser', { folderBrowser: t('sidebar.folderBrowser') })], ['fullscreen-player', t('settings.shortcutFullscreenPlayer')], ['native-fullscreen', t('settings.shortcutNativeFullscreen')], ] as [KeyAction, string][]).map(([action, label]) => { diff --git a/src/store/keybindingsStore.ts b/src/store/keybindingsStore.ts index 8949b0eb..d86cd62a 100644 --- a/src/store/keybindingsStore.ts +++ b/src/store/keybindingsStore.ts @@ -10,6 +10,7 @@ export type KeyAction = | 'seek-forward' | 'seek-backward' | 'toggle-queue' + | 'open-folder-browser' | 'fullscreen-player' | 'native-fullscreen'; @@ -25,6 +26,7 @@ export const DEFAULT_BINDINGS: Bindings = { 'seek-forward': null, 'seek-backward': null, 'toggle-queue': null, + 'open-folder-browser': null, 'fullscreen-player': null, 'native-fullscreen': 'F11', }; diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 46e723f9..422a9017 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -167,7 +167,6 @@ let seekTarget: number | null = null; // Guard against rapid double-click play/pause sending two state transitions // to the Rust backend before it has finished the previous one. let togglePlayLock = false; - /** * Skip → 1★: counts in `authStore.skipStarManualSkipCountsByKey` (persisted). * Only user-initiated `next()` increments. Natural track end (incl. gapless) clears the count; diff --git a/src/styles/components.css b/src/styles/components.css index 8fd74394..1433aef3 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -3701,6 +3701,26 @@ html.no-compositing .fs-lyrics-rail { color: var(--accent); } +.context-menu-item.context-menu-keyboard-active, +.context-menu-rating-row.context-menu-keyboard-active { + background: var(--surface-2); + color: var(--accent); +} + +.context-menu-item.context-menu-keyboard-active { + box-shadow: inset 2px 0 0 var(--accent), inset 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent); +} + +.context-menu-rating-row.context-menu-keyboard-active { + background: color-mix(in srgb, var(--accent) 18%, var(--surface-2)); + box-shadow: inset 3px 0 0 var(--accent), inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent); +} + +.context-menu-rating-row.context-menu-keyboard-active .context-menu-rating-icon { + color: var(--accent); + opacity: 1; +} + .context-menu-divider { height: 1px; background: var(--border-subtle); @@ -3708,12 +3728,48 @@ html.no-compositing .fs-lyrics-rail { } .context-menu-rating-row { - padding: 6px 12px; + /* Match .context-menu-item padding so the row lines up with other entries */ + padding: var(--space-2) var(--space-4); + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--text-primary); +} + +.context-menu-rating-row .context-menu-rating-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + color: var(--text-muted); + opacity: 0.95; +} + +.context-menu-rating-row .context-menu-rating-icon svg { + display: block; } .context-menu-rating-row .star-rating { + flex: 1; + min-width: 0; width: auto; justify-content: flex-start; + align-items: center; + line-height: 1; +} + +.context-menu-rating-row .star-rating .star { + padding: 0; + margin: 0; + line-height: 1; + font-size: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1em; + min-height: 1em; } /* ─ CSS Tooltips ─ */ @@ -7077,19 +7133,87 @@ html.no-compositing .fs-lyrics-rail { /* height fallback for browsers that don't support flex: 1 correctly here */ height: calc(100vh - var(--player-height, 88px) - var(--titlebar-height, 0px) - 70px); border-top: 1px solid var(--border-subtle); + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--accent) 35%, var(--text-muted)) transparent; +} + +.folder-browser-columns:focus, +.folder-browser-columns:focus-visible { + outline: none; } .folder-browser-columns::-webkit-scrollbar { - height: 6px; + height: 8px; +} + +.folder-browser-columns::-webkit-scrollbar-track { + background: transparent; +} + +.folder-browser-columns::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--accent) 35%, var(--text-muted)); + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; } .folder-col { - flex: 1; + flex: 1 1 220px; min-width: 200px; height: 100%; overflow-y: auto; overflow-x: hidden; border-right: 1px solid var(--border-subtle); + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--accent) 35%, var(--text-muted)) transparent; + transition: min-width 0.18s ease, max-width 0.18s ease, flex-basis 0.18s ease, flex-grow 0.18s ease; +} + +.folder-browser-columns--compact .folder-col { + flex: 0 0 var(--folder-col-width, 220px); + min-width: var(--folder-col-width, 220px); + max-width: var(--folder-col-width, 220px); +} + +.folder-col.folder-col--compact { + flex-basis: 56px; + min-width: 56px; + max-width: 56px; +} + +.folder-col.folder-col--compact .folder-col-row { + justify-content: center; + gap: 0; + padding-left: 0.2rem; + padding-right: 0.2rem; +} + +.folder-col.folder-col--compact .folder-col-name, +.folder-col.folder-col--compact .folder-col-chevron { + display: none; +} + +.folder-col.folder-col--compact .folder-col-icon { + opacity: 0.95; +} + +.folder-col::-webkit-scrollbar { + width: 8px; +} + +.folder-col::-webkit-scrollbar-track { + background: transparent; +} + +.folder-col::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--accent) 35%, var(--text-muted)); + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; +} + +.folder-col::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--accent) 55%, var(--text-primary)); } .folder-col-status { @@ -7127,6 +7251,19 @@ html.no-compositing .fs-lyrics-rail { background: var(--bg-hover, color-mix(in srgb, var(--accent) 8%, transparent)); } +.folder-browser-columns.keyboard-nav-active .folder-col-row:hover:not(.selected):not(.context-active):not(.keyboard-active) { + background: none; +} + +/* Context-menu anchor only — does not replace Miller “open” selection (accent). */ +.folder-col-row.context-active:not(.selected) { + background: var(--bg-hover, color-mix(in srgb, var(--accent) 8%, transparent)); +} + +.folder-col-row.keyboard-active:not(.selected) { + background: var(--bg-hover, color-mix(in srgb, var(--accent) 8%, transparent)); +} + .folder-col-row.selected { background: var(--accent); color: #fff; @@ -7147,6 +7284,31 @@ html.no-compositing .fs-lyrics-rail { opacity: 1; } +.folder-col-row.now-playing .folder-col-name { + font-weight: 600; +} + +.folder-col-playing-icon { + animation: folderTrackPulse 1.2s ease-in-out infinite; + transform-origin: center; +} + +.folder-col-path-playing-icon { + animation: folderTrackPulse 1.2s ease-in-out infinite; + transform-origin: center; +} + +.folder-col-path-playing-icon svg { + stroke-width: 2.6; + filter: saturate(1.08) contrast(1.06); +} + +@keyframes folderTrackPulse { + 0% { transform: scale(1); opacity: 0.92; } + 50% { transform: scale(1.16); opacity: 1; } + 100% { transform: scale(1); opacity: 0.92; } +} + .folder-col-name { flex: 1; min-width: 0;