feat(folder-browser): keyboard nav, context menus, now-playing path UX

Adds full keyboard navigation (arrow keys, Enter, Ctrl+Enter for context menu) across Folder Browser columns. Context menus for all row types with keyboard-operable submenus and star-rating control via arrow keys + Enter. Now-playing path is visually emphasized and stays stable; rebuilds on hotkey re-invoke or active-path follow-along. Adaptive column layout for deep trees with right-side visibility priority. New configurable 'Open Folder Browser' keybinding. StarRating animation sync for keyboard-driven changes. All 7 locales updated.
This commit is contained in:
cucadmuh
2026-04-12 12:27:26 +03:00
committed by GitHub
parent 3d07a877f2
commit ae2e1bcb97
16 changed files with 1082 additions and 77 deletions
+4
View File
@@ -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();
-1
View File
@@ -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');
}}
+327 -26
View File
@@ -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<T>(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<HTMLDivElement>(null);
const newNameRef = useRef<HTMLInputElement>(null);
@@ -106,7 +106,7 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: s
: { left: 'calc(100% + 4px)', right: 'auto' };
return (
<div className="context-submenu" ref={subRef} style={subStyle}>
<div className="context-submenu" data-parent-trigger-id={triggerId ?? ''} ref={subRef} style={subStyle}>
{/* New Playlist row */}
{!creating ? (
<div
@@ -155,7 +155,7 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown }: { songIds: s
}
// Same as AddToPlaylistSubmenu but resolves album songs first
function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone: () => void }) {
function AlbumToPlaylistSubmenu({ albumId, onDone, triggerId }: { albumId: string; onDone: () => void; triggerId?: string }) {
const [resolvedIds, setResolvedIds] = useState<string[] | null>(null);
useEffect(() => {
@@ -172,7 +172,7 @@ function AlbumToPlaylistSubmenu({ albumId, onDone }: { albumId: string; onDone:
);
}
if (resolvedIds.length === 0) return null;
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} />;
return <AddToPlaylistSubmenu songIds={resolvedIds} onDone={onDone} triggerId={triggerId} />;
}
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<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(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<string[]>([]);
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<HTMLElement>('.context-submenu');
if (!sub || sub.offsetParent === null) return [];
return Array.from(
sub.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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<HTMLDivElement>) => {
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<HTMLElement>('.context-menu-item--submenu') ?? [])
.find(el => el.dataset.playlistTriggerId === triggerId) ?? null
: null;
if (trigger) {
menuRef.current
?.querySelectorAll<HTMLElement>('.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<void>) => {
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() {
</div>
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
data-playlist-trigger-id={song.id}
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
<AddToPlaylistSubmenu songIds={[song.id]} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
{type === 'album-song' && (
@@ -467,14 +711,23 @@ export default function ContextMenu() {
</div>
);
})()}
<div className="context-menu-divider" />
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
<div
className="context-menu-rating-row"
data-rating-kind="song"
data-rating-id={song.id}
data-rating-disabled="false"
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => { 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')}
/>
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
@@ -484,6 +737,7 @@ export default function ContextMenu() {
{type === 'album' && (() => {
const album = item as SubsonicAlbum;
const albumRatingDisabled = entityRatingSupport === 'track_only';
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${album.id}`))}>
@@ -501,18 +755,37 @@ export default function ContextMenu() {
<Heart size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
</div>
<div
className="context-menu-rating-row"
data-rating-kind="album"
data-rating-id={album.id}
data-rating-disabled={albumRatingDisabled ? 'true' : 'false'}
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={keyboardRating?.kind === 'album' && keyboardRating.id === album.id
? keyboardRating.value
: userRatingOverrides[album.id] ?? album.userRating ?? 0}
disabled={albumRatingDisabled}
labelKey="entityRating.albumAriaLabel"
onChange={r => { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }}
/>
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
<Download size={14} /> {t('contextMenu.download')}
</div>
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` ? 'active' : ''}`}
data-playlist-trigger-id={`album:${album.id}`}
onMouseEnter={() => { setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
<AlbumToPlaylistSubmenu albumId={album.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
<AlbumToPlaylistSubmenu albumId={album.id} triggerId={`album:${album.id}`} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
</>
@@ -521,6 +794,7 @@ export default function ContextMenu() {
{type === 'artist' && (() => {
const artist = item as SubsonicArtist;
const artistRatingDisabled = entityRatingSupport === 'track_only';
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
@@ -535,6 +809,23 @@ export default function ContextMenu() {
<Heart size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
</div>
<div
className="context-menu-rating-row"
data-rating-kind="artist"
data-rating-id={artist.id}
data-rating-disabled={artistRatingDisabled ? 'true' : 'false'}
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={keyboardRating?.kind === 'artist' && keyboardRating.id === artist.id
? keyboardRating.value
: userRatingOverrides[artist.id] ?? artist.userRating ?? 0}
disabled={artistRatingDisabled}
labelKey="entityRating.artistAriaLabel"
onChange={r => { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }}
/>
</div>
</>
);
})()}
@@ -553,13 +844,14 @@ export default function ContextMenu() {
</div>
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
data-playlist-trigger-id={song.id}
onMouseEnter={() => { setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
onMouseLeave={() => setPlaylistSubmenuOpen(false)}
>
<ListMusic size={14} /> {t('contextMenu.addToPlaylist')}
<ChevronRight size={13} style={{ marginLeft: 'auto' }} />
{playlistSubmenuOpen && playlistSongIds[0] === song.id && (
<AddToPlaylistSubmenu songIds={[song.id]} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
<AddToPlaylistSubmenu songIds={[song.id]} triggerId={song.id} onDone={() => { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
)}
</div>
<div className="context-menu-divider" />
@@ -568,6 +860,14 @@ export default function ContextMenu() {
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
{audiomuseNavidromeEnabled && (
<div className="context-menu-item" onClick={() => handleAction(() => startInstantMix(song))}>
<Sparkles size={14} /> {t('contextMenu.instantMix')}
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(song.id, song.starred);
setStarredOverride(song.id, !starred);
@@ -591,22 +891,23 @@ export default function ContextMenu() {
</div>
);
})()}
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
{audiomuseNavidromeEnabled && (
<div className="context-menu-item" onClick={() => handleAction(() => startInstantMix(song))}>
<Sparkles size={14} /> {t('contextMenu.instantMix')}
</div>
)}
<div className="context-menu-divider" />
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
<div
className="context-menu-rating-row"
data-rating-kind="song"
data-rating-id={song.id}
data-rating-disabled="false"
onClick={e => e.stopPropagation()}
>
<Star size={14} className="context-menu-rating-icon" aria-hidden />
<StarRating
value={userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => { 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')}
/>
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
+35
View File
@@ -34,6 +34,8 @@ export default function StarRating({
const [clearShrinkStar, setClearShrinkStar] = React.useState<number | null>(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);
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -611,6 +611,7 @@ export const ruTranslation = {
shortcutSeekForward: 'Вперёд на 10 с',
shortcutSeekBackward: 'Назад на 10 с',
shortcutToggleQueue: 'Показать / скрыть очередь',
shortcutOpenFolderBrowser: 'Открыть {{folderBrowser}}',
shortcutFullscreenPlayer: 'Полноэкранный плеер',
shortcutNativeFullscreen: 'Системный полный экран',
playbackTitle: 'Воспроизведение',
+1
View File
@@ -579,6 +579,7 @@ export const zhTranslation = {
shortcutSeekForward: '快进 10 秒',
shortcutSeekBackward: '快退 10 秒',
shortcutToggleQueue: '切换队列',
shortcutOpenFolderBrowser: '打开{{folderBrowser}}',
shortcutFullscreenPlayer: '全屏播放器',
shortcutNativeFullscreen: '原生全屏',
playbackTitle: '播放',
+541 -46
View File
@@ -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<Column[]>([]);
const [keyboardNavActive, setKeyboardNavActive] = useState(false);
const [playingPathIds, setPlayingPathIds] = useState<string[]>(persistedPlayingPathIds);
const wrapperRef = useRef<HTMLDivElement>(null);
const pendingNavColRef = useRef<number | null>(null);
const autoResolvedTrackRef = useRef<string | null>(null);
const prevTrackIdRef = useRef<string | null>(null);
const lastHotkeyRevealTsRef = useRef<number | null>(null);
const [keyboardPos, setKeyboardPos] = useState<NavPos | null>(null);
const [contextAnchorPos, setContextAnchorPos] = useState<NavPos | null>(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<HTMLElement>(
`.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<HTMLElement>(
`.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<HTMLElement>(
`.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<HTMLDivElement>) => {
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<HTMLElement>(
`.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<Column[] | null> => {
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 (
<div className="folder-browser">
<h1 className="page-title folder-browser-title">{t('sidebar.folderBrowser')}</h1>
<div className="folder-browser-columns" ref={wrapperRef}>
<div
className={`folder-browser-columns${keyboardNavActive ? ' keyboard-nav-active' : ''}${compactColumnsEnabled ? ' folder-browser-columns--compact' : ''}`}
ref={wrapperRef}
tabIndex={0}
onKeyDown={onColumnsKeyDown}
>
{columns.map((col, colIndex) => (
<div key={`${col.id}-${colIndex}`} className="folder-col">
<div
key={`${col.id}-${colIndex}`}
className={`folder-col${isColumnCompact(col, colIndex) ? ' folder-col--compact' : ''}`}
data-folder-col-index={colIndex}
>
{col.loading ? (
<div className="folder-col-status">
<div className="spinner" style={{ width: 20, height: 20 }} />
@@ -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 (
<button
key={item.id}
className={`folder-col-row${isSelected ? ' selected' : ''}`}
onClick={() =>
item.isDir
? handleDirClick(colIndex, item)
: handleFileClick(colIndex, item)
}
type="button"
title={item.title}
data-col-index={colIndex}
data-row-index={rowIndex}
data-item-id={item.id}
className={`folder-col-row${isSelected ? ' selected' : ''}${isContextRow ? ' context-active' : ''}${isKeyboardRow ? ' keyboard-active' : ''}${isNowPlayingTrack ? ' now-playing' : ''}`}
onClick={() => {
setKeyboardPos({ colIndex, rowIndex });
if (item.isDir) handleDirClick(colIndex, item);
else handleFileClick(colIndex, item);
}}
onContextMenu={e => {
setKeyboardPos({ colIndex, rowIndex });
onRowContextMenu(e, colIndex, rowIndex, col, item);
}}
>
<span className="folder-col-icon">
{item.isDir
? isSelected
? <FolderOpen size={14} />
: <Folder size={14} />
: <Music size={14} />}
<span className={`folder-col-icon${isPathPlayingIcon ? ' folder-col-path-playing-icon' : ''}`}>
{item.isDir ? (
isSelected ? (
<FolderOpen size={14} />
) : (
<Folder size={14} />
)
) : (
<Music size={14} strokeWidth={isNowPlayingTrack ? 2.5 : 2} className={isNowPlayingTrack && isPlaying ? 'folder-col-playing-icon' : undefined} />
)}
</span>
<span className="folder-col-name">{item.title}</span>
{item.isDir && (
<ChevronRight size={12} className="folder-col-chevron" />
)}
{item.isDir && <ChevronRight size={12} className="folder-col-chevron" />}
</button>
);
})
+1
View File
@@ -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]) => {
+2
View File
@@ -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',
};
-1
View File
@@ -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;
+165 -3
View File
@@ -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;