feat(ui): UI refinements — sidebar indicators, adaptive header, and interaction polish (#397)

* feat(ui): unify queue toggle handle behavior

Show the queue toggle in the header when the queue is collapsed and use a seam-aligned drag handle when it is open. Hide the seam handle while the main content is actively scrolling to reduce accidental interactions.

* feat(ui): add adaptive header search collapse behavior

Collapse header search to a magnifier when top controls get crowded and expand it as an overlay only while active. Use measured header space with hysteresis to avoid flicker and keep neighboring controls stable.

* chore(ui): remove leftover search prototype artifacts

Drop an unused icon import from live search and remove an unused header container-type rule left from an earlier layout experiment.

* feat(ui): persist sidebar and queue visibility state

Save left sidebar collapse and right queue open/closed visibility in local storage helpers so both panel modes are restored after app restart.

* feat(ui): unify player overflow menu behavior

Use a single overflow menu for click and wheel interactions, with a volume-only mode that keeps the same layout and volume controls as the full menu.

* feat(ui): add wheel seek controls to waveform

Apply 10-second wheel seek steps with trailing 1-second debounce and keep the waveform preview stable so the playhead moves smoothly during rapid scroll input.

* fix(now-playing): stabilize narrow dashboard layout

Switch now-playing responsiveness to container-based breakpoints and prevent stacked widgets from overlapping when width is constrained.

* fix(search): reduce collapse jitter and avoid header overlap

Add a short collapse-state cooldown to prevent threshold flicker and hide conflicting header controls while collapsed search expands as an overlay.

* fix(i18n): localize player overflow controls across locales

Replace hardcoded player overflow labels with translation keys and add the missing keys for all shipped locale files.

* fix(search): keep advanced control clickable in collapsed mode

Prevent focus loss on the advanced search button in collapsed overlay mode so its click handler consistently runs.

* fix(i18n): restore queue translation in offline library

Use the existing queue.appendToQueue key for the offline enqueue button tooltip and label instead of a missing key and hardcoded English text.

* fix(ui): apply overlay scrollbar to right-panel text tabs

Switch now-playing content, lyrics, and info panes to OverlayScrollArea and harden tour-item layout so long concert metadata stays within panel bounds.

* fix(ui): add unread indicator for new releases and guard sidebar drag clicks

Track unread new-release IDs per server/library scope and clear the badge when opening the New Releases page. Also prevent click-through navigation after sidebar drag release and keep related i18n/responsive sidebar-adjacent refinements in this snapshot.

* fix(ui): stabilize live dropdown layering and unread reset flow

Render the topbar Live dropdown via a portal so it consistently overlays sidebar layers. Rework new-releases unread tracking to handle library scope baselines, ignore stale refresh races, and mark items as seen after a 5-second stay on the New Releases page.

* feat(ui): add localized New badges for recently added albums

Show a theme-consistent New badge on album cards and album detail for albums created within the last 48 hours. Localize the badge label across all supported locales and centralize recency logic in a shared utility to avoid duplication.

* fix(album): prevent tracklist jump when entering multiselect

Move bulk selection actions from the tracklist body into the album toolbar next to the track filter. Keep selection controls stable in the header area so enabling multiselect no longer shifts the tracklist content downward.

* fix(tray): add playback-state badge and finalize queue handle tooltip

Show play/pause/stop icons in the Linux tray now-playing entry and persist state safely in Tauri managed state.
Also switch the queue-resize handle tooltip to the dedicated localized key across all locales.

* fix(header): prioritize search collapse before Live/Orbit labels

Make topbar compression deterministic by collapsing search first and compacting Live/Orbit labels only in sustained low-space mode.
Add sticky hysteresis-based header compact state to prevent oscillation while resizing.

* fix(ui): stabilize header compaction and show tray state icons

Prevent topbar flicker in the narrow-width range by tightening compact-mode thresholds, gating on real overflow, and removing width transitions from live search.
Also include playback state icons in tray tooltip text across platforms while preserving tooltip length limits.

* fix(tray): keep tooltip iconization Windows-only

Revert Linux tray tooltip/title fallback attempts and keep state icons only in Windows tray tooltips, while Linux continues to show playback state in the now-playing menu entry.

* fix(ui): restore queue resize response after overlay scroll interactions

Hide the queue handle while scrolling on both the main route viewport and the now-playing viewport, and clear stale thumb-drag state before starting queue resize.
Also ignore inactive/faded overlay thumbs in resizer suppression so horizontal pointer transitions no longer leave the queue seam unresponsive.

* docs(changelog): summarize ui-refinements branch features

Document the branch-level feature additions in 1.45.0 as separate changelog sections and group remaining branch-local fixes under a single polish entry.

* docs(changelog): add PR #397 references for ui-refinements

Attach PR metadata to the new 1.45.0 ui-refinement sections and the polish entry so release notes map directly to the merged branch discussion.
This commit is contained in:
cucadmuh
2026-05-01 15:42:40 +03:00
committed by GitHub
parent 2ea22635e5
commit 9ad0f8af6d
32 changed files with 1642 additions and 197 deletions
+23
View File
@@ -77,10 +77,33 @@ Each theme defines the full token set, including background, accent, text, Catpp
The theme picker now groups Open Source Classics by family with dedicated family headings. Theme scheduler dropdown labels are also family-prefixed, making it clearer which palette family a scheduled theme belongs to.
### Sidebar Discovery Indicators
**By [@cucadmuh](https://github.com/cucadmuh), PR [#397](https://github.com/Psychotoxical/psysonic/pull/397)**
Sidebar navigation now includes a dedicated unread indicator for **New Releases**, with persistence per server/library scope and delayed mark-as-seen behavior after opening the New Releases page.
Albums added within the last 48 hours now receive a localized **New** badge in both album cards and album detail header.
### Adaptive Header Controls
**By [@cucadmuh](https://github.com/cucadmuh), PR [#397](https://github.com/Psychotoxical/psysonic/pull/397)**
The top header behavior was reworked for narrow widths: search, Live and Orbit controls now compress in a deterministic order with improved stability in edge-width ranges.
### Waveform Wheel Seeking
**By [@cucadmuh](https://github.com/cucadmuh), PR [#397](https://github.com/Psychotoxical/psysonic/pull/397)**
Waveform mouse-wheel seeking now uses fixed step-based jumps with debounce smoothing for more predictable navigation and less jitter during rapid scrolling.
## Fixed
- **Settings → Audio no longer blanks the app on macOS** *(Issue [#382](https://github.com/Psychotoxical/psysonic/issues/382), PR [#384](https://github.com/Psychotoxical/psysonic/pull/384), by [@Psychotoxical](https://github.com/Psychotoxical))*: Fixed a macOS-only crash where opening Settings → Audio could turn the whole app into a blank window. The Equalizer canvas now waits until it has valid layout dimensions before drawing, and redraws automatically once the section is visible.
- **Polish** *(PR [#397](https://github.com/Psychotoxical/psysonic/pull/397), by [@cucadmuh](https://github.com/cucadmuh))*: multiple branch-local interaction fixes around sidebar drag/drop behavior, Live dropdown layering, queue-resize handle behavior during scroll/overlay-scrollbar interaction, and now-playing narrow-layout stability.
## [1.44.0] - 2026-04-29
+67 -15
View File
@@ -490,6 +490,17 @@ type TrayState = Mutex<Option<TrayIcon>>;
/// Empty string means "use the default `Psysonic` tooltip".
type TrayTooltip = Mutex<String>;
#[derive(Default)]
struct TrayPlaybackState(Mutex<String>);
fn tray_state_icon(state: &str) -> &'static str {
match state {
"play" => "",
"pause" => "",
_ => "",
}
}
/// Handles to all updatable tray menu items, kept around so `set_tray_menu_labels`
/// (i18n refresh) and `set_tray_tooltip` (track change) can re-text them without
/// rebuilding the whole tray icon. The `now_playing` slot is `Some` on Linux
@@ -501,6 +512,7 @@ struct TrayMenuItems {
previous: tauri::menu::MenuItem<tauri::Wry>,
show_hide: tauri::menu::MenuItem<tauri::Wry>,
quit: tauri::menu::MenuItem<tauri::Wry>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
now_playing: Option<tauri::menu::MenuItem<tauri::Wry>>,
}
@@ -515,6 +527,7 @@ struct TrayMenuLabels {
previous: String,
show_hide: String,
quit: String,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
nothing_playing: String,
}
@@ -3677,28 +3690,37 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
if g.is_empty() { None } else { Some(g.clone()) }
})
.unwrap_or_else(|| "Psysonic".to_string());
let playback_state = app
.try_state::<TrayPlaybackState>()
.map(|s| s.0.lock().unwrap().clone())
.unwrap_or_else(|| "stop".to_string());
#[cfg(target_os = "windows")]
let tooltip_with_icon = format!("{} {}", tray_state_icon(&playback_state), cached_tooltip);
// Linux/AppIndicator has no hover tooltip; surface the now-playing track as
// a disabled menu entry at the top instead. The label is updated by
// `set_tray_tooltip` on every track change.
#[cfg(target_os = "linux")]
let (now_playing, sep_now_playing) = {
let icon = tray_state_icon(&playback_state);
let label = if cached_tooltip == "Psysonic" {
labels.nothing_playing.as_str()
format!("{icon} {}", labels.nothing_playing)
} else {
cached_tooltip.as_str()
format!("{icon} {cached_tooltip}")
};
let item = MenuItemBuilder::with_id("now_playing", label)
let item = MenuItemBuilder::with_id("now_playing", &label)
.enabled(false)
.build(app)?;
(item, PredefinedMenuItem::separator(app)?)
};
let mut menu_builder = MenuBuilder::new(app);
#[cfg(target_os = "linux")]
{
menu_builder = menu_builder.item(&now_playing).item(&sep_now_playing);
}
let menu_builder = MenuBuilder::new(app)
.item(&now_playing)
.item(&sep_now_playing);
#[cfg(not(target_os = "linux"))]
let menu_builder = MenuBuilder::new(app);
let menu = menu_builder
.item(&play_pause)
.item(&previous)
@@ -3725,10 +3747,18 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
});
}
TrayIconBuilder::new()
#[cfg(target_os = "windows")]
let tray_builder = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip(&cached_tooltip)
.tooltip(&tooltip_with_icon);
#[cfg(not(target_os = "windows"))]
let tray_builder = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip(&cached_tooltip);
tray_builder
.on_menu_event(|app, event| match event.id.as_ref() {
"play_pause" => { let _ = app.emit("tray:play-pause", ()); }
"next" => { let _ = app.emit("tray:next", ()); }
@@ -3825,19 +3855,34 @@ fn set_tray_tooltip(
app: tauri::AppHandle,
tray_state: tauri::State<TrayState>,
tooltip_cache: tauri::State<TrayTooltip>,
playback_state_cache: tauri::State<TrayPlaybackState>,
tooltip: String,
playback_state: Option<String>,
) -> Result<(), String> {
let truncated = if tooltip.chars().count() > 127 {
tooltip.chars().take(124).collect::<String>() + "..."
let has_track_input = !tooltip.is_empty();
let state = playback_state.as_deref().unwrap_or(if has_track_input { "play" } else { "stop" });
let icon = tray_state_icon(state);
let icon_prefix_len = format!("{icon} ").chars().count();
let max_text_chars = 127usize.saturating_sub(icon_prefix_len);
let ellipsis_reserve = 3usize;
let truncated = if tooltip.chars().count() > max_text_chars {
let take = max_text_chars.saturating_sub(ellipsis_reserve);
tooltip.chars().take(take).collect::<String>() + "..."
} else {
tooltip
};
let has_track = !truncated.is_empty();
let effective = if has_track { truncated.clone() } else { "Psysonic".to_string() };
#[cfg(target_os = "windows")]
let effective_with_icon = format!("{icon} {effective}");
*tooltip_cache.lock().unwrap() = truncated.clone();
*playback_state_cache.0.lock().unwrap() = state.to_string();
if let Some(tray) = tray_state.lock().unwrap().as_ref() {
#[cfg(target_os = "windows")]
tray.set_tooltip(Some(&effective_with_icon)).map_err(|e| e.to_string())?;
#[cfg(not(target_os = "windows"))]
tray.set_tooltip(Some(&effective)).map_err(|e| e.to_string())?;
}
@@ -3847,11 +3892,12 @@ fn set_tray_tooltip(
if let Some(items) = state.lock().unwrap().as_ref() {
if let Some(np) = items.now_playing.as_ref() {
let label = if has_track {
effective.clone()
format!("{icon} {effective}")
} else {
app.try_state::<TrayMenuLabelsState>()
let nothing = app.try_state::<TrayMenuLabelsState>()
.map(|s| s.lock().unwrap().nothing_playing.clone())
.unwrap_or_else(|| "Nothing playing".to_string())
.unwrap_or_else(|| "Nothing playing".to_string());
format!("{icon} {nothing}")
};
let _ = np.set_text(&label);
}
@@ -3905,7 +3951,12 @@ fn set_tray_menu_labels(
if let Some(np) = items.now_playing.as_ref() {
let has_track = !tooltip_cache.lock().unwrap().is_empty();
if !has_track {
let _ = np.set_text(&new_labels.nothing_playing);
let state = app
.try_state::<TrayPlaybackState>()
.map(|s| s.0.lock().unwrap().clone())
.unwrap_or_else(|| "stop".to_string());
let label = format!("{} {}", tray_state_icon(&state), new_labels.nothing_playing);
let _ = np.set_text(&label);
}
}
}
@@ -4534,6 +4585,7 @@ pub fn run() {
.manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore)
.manage(TrayState::default())
.manage(TrayTooltip::default())
.manage(TrayPlaybackState::default())
.manage(TrayMenuItemsState::default())
.manage(TrayMenuLabelsState::default())
.plugin(tauri_plugin_process::init())
+188 -20
View File
@@ -113,6 +113,25 @@ import PasteClipboardHandler from './components/PasteClipboardHandler';
/** Volume before last `psysonic --player mute` (CLI only; in-memory). */
let cliPremuteVolume: number | null = null;
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'psysonic_sidebar_collapsed';
function readInitialSidebarCollapsed(): boolean {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true';
} catch {
return false;
}
}
function persistSidebarCollapsed(collapsed: boolean): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed));
} catch {
// Ignore storage failures and keep in-memory UI state.
}
}
function RequireAuth({ children }: { children: React.ReactNode }) {
const { isLoggedIn, servers, activeServerId } = useAuthStore();
@@ -142,7 +161,13 @@ function shouldSuppressQueueResizerMouseDown(clientX: number, clientY: number, q
const xSlop = 22;
const vPad = 40;
for (let i = 0; i < thumbs.length; i++) {
const r = thumbs[i].getBoundingClientRect();
const thumb = thumbs[i];
const style = window.getComputedStyle(thumb);
const pointerActive = style.pointerEvents !== 'none';
const visible = Number.parseFloat(style.opacity || '0') > 0.01;
if (!pointerActive && !visible) continue;
const r = thumb.getBoundingClientRect();
if (r.height < 4 || r.width < 1) continue;
if (clientY < r.top - vPad || clientY > r.bottom + vPad) continue;
const thumbHitRight = Math.min(r.right + xSlop, mainRight);
@@ -313,11 +338,15 @@ function AppShell() {
await appWindow.setTitle(title);
await invoke('set_tray_tooltip', {
tooltip: `${currentTrack.artist} ${currentTrack.title}`,
playbackState: isPlaying ? 'play' : 'pause',
}).catch(() => {});
} else {
document.title = 'Psysonic';
await appWindow.setTitle('Psysonic');
await invoke('set_tray_tooltip', { tooltip: '' }).catch(() => {});
await invoke('set_tray_tooltip', {
tooltip: '',
playbackState: 'stop',
}).catch(() => {});
}
} catch (err) {}
};
@@ -344,15 +373,16 @@ function AppShell() {
// sidebar (WhatsNewBanner) that links to the /whats-new page — no auto
// modal takeover on startup.
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
});
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(readInitialSidebarCollapsed);
const [queueWidth, setQueueWidth] = useState(340);
const [isDraggingQueue, setIsDraggingQueue] = useState(false);
const [queueHandleTop, setQueueHandleTop] = useState<number | null>(null);
const [isMainScrolling, setIsMainScrolling] = useState(false);
useEffect(() => {
localStorage.setItem('psysonic_sidebar_collapsed', isSidebarCollapsed.toString());
}, [isSidebarCollapsed]);
const setSidebarCollapsed = useCallback((collapsed: boolean) => {
persistSidebarCollapsed(collapsed);
setIsSidebarCollapsed(collapsed);
}, []);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (isDraggingQueue) {
@@ -384,6 +414,107 @@ function AppShell() {
};
}, [isDraggingQueue, handleMouseMove, handleMouseUp]);
useEffect(() => {
const viewports = new Set<HTMLElement>();
const appViewport = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (appViewport) viewports.add(appViewport);
const nowPlayingViewport = document.querySelector<HTMLElement>('.np-main__viewport');
if (nowPlayingViewport) viewports.add(nowPlayingViewport);
if (viewports.size === 0) return;
let scrollHideTimer: number | null = null;
const onScroll = () => {
setIsMainScrolling(true);
if (scrollHideTimer != null) window.clearTimeout(scrollHideTimer);
scrollHideTimer = window.setTimeout(() => {
setIsMainScrolling(false);
scrollHideTimer = null;
}, 180);
};
viewports.forEach(viewport => {
viewport.addEventListener('scroll', onScroll, { passive: true });
});
return () => {
viewports.forEach(viewport => {
viewport.removeEventListener('scroll', onScroll);
});
if (scrollHideTimer != null) window.clearTimeout(scrollHideTimer);
setIsMainScrolling(false);
};
}, [location.pathname]);
const syncQueueHandleTop = useCallback(() => {
const leftBtn = document.querySelector('.sidebar .collapse-btn') as HTMLElement | null;
if (!leftBtn) return;
const r = leftBtn.getBoundingClientRect();
setQueueHandleTop(r.top + r.height / 2);
}, []);
useEffect(() => {
if (isMobile) return;
const leftBtn = document.querySelector('.sidebar .collapse-btn') as HTMLElement | null;
if (!leftBtn) return;
syncQueueHandleTop();
const raf = requestAnimationFrame(syncQueueHandleTop);
const onResize = () => syncQueueHandleTop();
window.addEventListener('resize', onResize);
const observer = new ResizeObserver(onResize);
observer.observe(leftBtn);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
observer.disconnect();
};
}, [isMobile, isSidebarCollapsed, syncQueueHandleTop]);
const handleQueueHandleMouseDown = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const DRAG_THRESHOLD_PX = 4;
const startX = e.clientX;
const startY = e.clientY;
let didDrag = false;
const cleanup = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp, true);
document.body.style.cursor = '';
document.body.classList.remove('is-dragging');
};
const applyWidthFromClientX = (clientX: number) => {
const newWidth = Math.max(310, Math.min(window.innerWidth - clientX, 500));
setQueueWidth(newWidth);
};
const onMove = (me: MouseEvent) => {
const movedEnough = Math.hypot(me.clientX - startX, me.clientY - startY) >= DRAG_THRESHOLD_PX;
if (!didDrag && movedEnough) {
didDrag = true;
if (!isQueueVisible) toggleQueue();
document.body.style.cursor = 'col-resize';
document.body.classList.add('is-dragging');
}
if (!didDrag) return;
applyWidthFromClientX(me.clientX);
};
const onUp = () => {
cleanup();
if (!didDrag) toggleQueue();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp, true);
}, [isQueueVisible, toggleQueue]);
// ── Global DnD fix for Linux/WebKitGTK / Wayland ─────────────────
// dragover/dragenter: WebKitGTK needs preventDefault so external drops are not
// a permanent "forbidden" cursor. dragstart (capture): cancel native drags from
@@ -460,7 +591,9 @@ function AppShell() {
data-fullscreen={isWindowFullscreen || undefined}
style={{
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
'--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px')
'--queue-width': isMobile
? '0px'
: (isQueueVisible ? `${queueWidth}px` : '0px')
} as React.CSSProperties}
onContextMenu={e => e.preventDefault()}
>
@@ -468,7 +601,7 @@ function AppShell() {
{!isMobile && (
<Sidebar
isCollapsed={isSidebarCollapsed}
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
toggleCollapse={() => setSidebarCollapsed(!isSidebarCollapsed)}
/>
)}
<main className="main-content">
@@ -480,14 +613,16 @@ function AppShell() {
<LastfmIndicator />
<NowPlayingDropdown />
<OrbitStartTrigger />
<button
className="queue-toggle-btn"
onClick={toggleQueue}
data-tooltip={t('player.toggleQueue')}
data-tooltip-pos="bottom"
>
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
</button>
{!isMobile && !isQueueVisible && (
<button
className="queue-toggle-btn"
onClick={toggleQueue}
data-tooltip={t('player.toggleQueue')}
data-tooltip-pos="bottom"
>
<PanelRight size={18} />
</button>
)}
</header>
<OrbitSessionBar />
{connStatus === 'disconnected' && (
@@ -543,13 +678,46 @@ function AppShell() {
className="resizer resizer-queue"
onMouseDown={(e) => {
e.preventDefault();
if (document.body.classList.contains('is-overlay-scrollbar-thumb-drag')) return;
if (document.body.classList.contains('is-overlay-scrollbar-thumb-drag')) {
// Self-heal stale drag flag: if no thumb is actually dragging,
// unblock the queue resizer immediately.
const activeThumbDrag = document.querySelector('.overlay-scroll__thumb.is-thumb-dragging');
if (!activeThumbDrag) {
document.body.classList.remove('is-overlay-scrollbar-thumb-drag');
} else {
return;
}
}
if (shouldSuppressQueueResizerMouseDown(e.clientX, e.clientY, queueWidth)) return;
setIsDraggingQueue(true);
}}
style={{ display: isQueueVisible ? 'block' : 'none' }}
style={{
display: isQueueVisible ? 'block' : 'none',
right: `${Math.max(0, queueWidth - 3)}px`,
}}
/>
)}
{!isMobile && isQueueVisible && (
<button
type="button"
className="resizer-queue-handle"
onMouseDown={handleQueueHandleMouseDown}
style={{
position: 'fixed',
top: queueHandleTop != null ? `${queueHandleTop}px` : '50%',
right: `${Math.max(0, queueWidth - 11)}px`,
transform: 'translateY(-50%)',
zIndex: 101,
opacity: isMainScrolling ? 0 : 1,
pointerEvents: isMainScrolling ? 'none' : 'auto',
}}
data-tooltip={t('player.collapseQueueResize')}
data-tooltip-pos="left"
aria-label={t('player.collapseQueueResize')}
>
{isQueueVisible ? <PanelRightClose size={14} /> : <PanelRight size={14} />}
</button>
)}
{!isMobile && <QueuePanel />}
{isMobile && !isMobilePlayer && <BottomNav />}
{!isMobilePlayer && <PlayerBar />}
+7
View File
@@ -9,6 +9,7 @@ import { useAuthStore } from '../store/authStore';
import CachedImage from './CachedImage';
import { playAlbum } from '../utils/playAlbum';
import { useDragDrop } from '../contexts/DragDropContext';
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
interface AlbumCardProps {
album: SubsonicAlbum;
@@ -32,6 +33,7 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
});
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const psyDrag = useDragDrop();
const isNewAlbum = isAlbumRecentlyAdded(album.created);
const handleClick = () => {
if (selectionMode) { onToggleSelect?.(album.id); return; }
@@ -86,6 +88,11 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
<HardDriveDownload size={12} />
</div>
)}
{isNewAlbum && (
<div className="album-card-new-badge" aria-label={t('common.new', 'New')}>
{t('common.new', 'New')}
</div>
)}
{selectionMode && (
<div className={`album-card-select-check${selected ? ' album-card-select-check--on' : ''}`}>
{selected && <Check size={14} strokeWidth={3} />}
+6
View File
@@ -11,6 +11,7 @@ import StarRating from './StarRating';
import type { EntityRatingSupportLevel } from '../api/subsonic';
import { copyEntityShareLink } from '../utils/copyEntityShareLink';
import { showToast } from '../utils/toast';
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
@@ -68,6 +69,7 @@ interface AlbumInfo {
genre?: string;
coverArt?: string;
recordLabel?: string;
created?: string;
}
interface AlbumHeaderProps {
@@ -131,6 +133,7 @@ export default function AlbumHeader({
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
const isNewAlbum = isAlbumRecentlyAdded(info.created);
const handleShareAlbum = async () => {
try {
@@ -183,6 +186,9 @@ export default function AlbumHeader({
<div className="album-detail-cover album-cover-placeholder"></div>
)}
<div className="album-detail-meta">
{isNewAlbum && (
<span className="badge album-detail-badge">{t('common.new', 'New')}</span>
)}
<h1 className="album-detail-title">{info.name}</h1>
<p className="album-detail-artist">
<button
+1 -45
View File
@@ -1,12 +1,11 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Play, ChevronRight, Heart, ListPlus, X, ChevronDown, Check, RotateCcw, Square } from 'lucide-react';
import { Play, ChevronRight, Heart, ChevronDown, Check, RotateCcw, Square } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { SubsonicSong } from '../api/subsonic';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useDragDrop } from '../contexts/DragDropContext';
import { AddToPlaylistSubmenu } from './ContextMenu';
import { useIsMobile } from '../hooks/useIsMobile';
import StarRating from './StarRating';
import { useSelectionStore } from '../store/selectionStore';
@@ -323,8 +322,6 @@ export default function AlbumTrackList({
const allSelected = selectedCount === songs.length && songs.length > 0;
const lastSelectedIdxRef = useRef<number | null>(null);
const [showPlPicker, setShowPlPicker] = useState(false);
// ── Column state ──────────────────────────────────────────────────────────
const {
colVisible, visibleCols, gridStyle,
@@ -354,15 +351,6 @@ export default function AlbumTrackList({
return () => document.removeEventListener('mousedown', handler);
}, [inSelectMode, tracklistRef]);
useEffect(() => {
if (!showPlPicker) return;
const handler = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
// ── Stable callbacks passed to memoised TrackRow ──────────────────────────
const onToggleSelect = useCallback((id: string, globalIdx: number, shift: boolean) => {
@@ -619,38 +607,6 @@ export default function AlbumTrackList({
}}
>
{/* ── Bulk action bar ── */}
{inSelectMode && (
<div className="bulk-action-bar">
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedCount })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...useSelectionStore.getState().selectedIds]}
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
dropDown
/>
)}
</div>
<button
className="btn btn-ghost btn-sm"
onClick={() => useSelectionStore.getState().clearAll()}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</div>
)}
{/* ── Header ── */}
<div className="tracklist-header-wrapper">
<div className="tracklist-header" style={gridStyle}>
+138 -4
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Disc3, Users, Music, SlidersVertical, TextSearch } from 'lucide-react';
import { Search, Disc3, Users, Music, TextSearch } from 'lucide-react';
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -23,6 +23,8 @@ export default function LiveSearch() {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [isFocused, setIsFocused] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false);
const navigate = useNavigate();
const enqueue = usePlayerStore(state => state.enqueue);
const openContextMenu = usePlayerStore(state => state.openContextMenu);
@@ -31,6 +33,9 @@ export default function LiveSearch() {
const ctxType = usePlayerStore(state => state.contextMenu.type);
const ref = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const collapsedRef = useRef(false);
const compactHeaderControlsRef = useRef(false);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const doSearch = useCallback(
@@ -50,6 +55,110 @@ export default function LiveSearch() {
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]);
const isSearchActive = isFocused || open || query.trim().length > 0;
useEffect(() => {
const root = ref.current;
if (!root) return;
const header = root.closest('.content-header') as HTMLElement | null;
if (!header) return;
const overlayActive = isCollapsed && isSearchActive;
if (overlayActive) {
header.dataset.liveSearchOverlay = 'true';
} else {
delete header.dataset.liveSearchOverlay;
}
return () => {
delete header.dataset.liveSearchOverlay;
};
}, [isCollapsed, isSearchActive]);
useEffect(() => {
const root = ref.current;
if (!root) return;
const header = root.closest('.content-header') as HTMLElement | null;
if (!header) return;
const spacer = header.querySelector('.spacer') as HTMLElement | null;
if (!spacer) return;
const MIN_EXPANDED_WIDTH = 260;
const SPACER_RESERVE = 24;
const HYSTERESIS_PX = 20;
// Live/Orbit compact-mode is intentionally stickier than search collapse,
// otherwise both systems can feed each other and oscillate.
const HEADER_CONTROLS_COMPACT_ON_SPACER = 36;
const HEADER_CONTROLS_COMPACT_OFF_SPACER = 108;
const SWITCH_COOLDOWN_MS = 180;
const collapseThreshold = MIN_EXPANDED_WIDTH + SPACER_RESERVE;
const expandThreshold = collapseThreshold + HYSTERESIS_PX;
let lastSwitchAt = 0;
let cooldownTimer: number | null = null;
const updateCollapsed = () => {
const searchWidth = root.getBoundingClientRect().width;
const spacerWidth = spacer.getBoundingClientRect().width;
const budget = searchWidth + spacerWidth;
const headerOverflowing = header.scrollWidth - header.clientWidth > 1;
let nextCollapsed = collapsedRef.current
? budget < expandThreshold
: budget < collapseThreshold;
// Priority rule: if we are already compacting Live/Orbit labels, search
// must stay collapsed until compact mode can be released.
if (compactHeaderControlsRef.current) {
nextCollapsed = true;
}
if (nextCollapsed !== collapsedRef.current) {
const now = performance.now();
const remaining = SWITCH_COOLDOWN_MS - (now - lastSwitchAt);
if (remaining > 0) {
if (cooldownTimer == null) {
cooldownTimer = window.setTimeout(() => {
cooldownTimer = null;
updateCollapsed();
}, remaining);
}
return;
}
lastSwitchAt = now;
collapsedRef.current = nextCollapsed;
setIsCollapsed(nextCollapsed);
}
const nextCompactControls = nextCollapsed
? (
compactHeaderControlsRef.current
// Stay compact until we clearly have room and no overflow.
? (headerOverflowing || spacerWidth < HEADER_CONTROLS_COMPACT_OFF_SPACER)
// Enter compact only when both tight spacer and real overflow exist.
: (headerOverflowing && spacerWidth < HEADER_CONTROLS_COMPACT_ON_SPACER)
)
: false;
if (nextCompactControls !== compactHeaderControlsRef.current) {
compactHeaderControlsRef.current = nextCompactControls;
if (nextCompactControls) {
header.dataset.liveHeaderCompact = 'true';
} else {
delete header.dataset.liveHeaderCompact;
}
}
};
updateCollapsed();
const ro = new ResizeObserver(updateCollapsed);
ro.observe(header);
ro.observe(spacer);
ro.observe(root);
window.addEventListener('resize', updateCollapsed);
return () => {
ro.disconnect();
window.removeEventListener('resize', updateCollapsed);
delete header.dataset.liveHeaderCompact;
if (cooldownTimer != null) {
window.clearTimeout(cooldownTimer);
}
};
}, []);
// Close on click outside — but stay open while a song context menu is up.
// The CM renders a fullscreen transparent backdrop (z-index 998) above the
// dropdown, so any mousedown — including a second right-click on another
@@ -103,8 +212,23 @@ export default function LiveSearch() {
};
return (
<div className="live-search" ref={ref} role="search">
<div className="live-search-input-wrap">
<div
className="live-search"
ref={ref}
role="search"
data-collapsed={isCollapsed || undefined}
data-active={isSearchActive || undefined}
>
<div
className="live-search-input-wrap"
onMouseDown={(e) => {
if (isSearchActive) return;
if (!isCollapsed) return;
e.preventDefault();
setIsFocused(true);
requestAnimationFrame(() => inputRef.current?.focus());
}}
>
{loading ? (
<span className="live-search-icon animate-spin" style={{ opacity: 0.6 }}>
<div style={{ width: 16, height: 16, border: '2px solid var(--border)', borderTopColor: 'var(--accent)', borderRadius: '50%' }} />
@@ -113,13 +237,18 @@ export default function LiveSearch() {
<Search size={16} className="live-search-icon" />
)}
<input
ref={inputRef}
id="live-search-input"
className="input live-search-field"
type="search"
placeholder={t('search.placeholder')}
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => results && setOpen(true)}
onFocus={() => {
setIsFocused(true);
if (results) setOpen(true);
}}
onBlur={() => setIsFocused(false)}
onKeyDown={handleKeyDown}
aria-autocomplete="list"
aria-controls="search-results"
@@ -134,6 +263,11 @@ export default function LiveSearch() {
<button
className="live-search-adv-btn"
type="button"
onMouseDown={(e) => {
// Keep focus on the search input so collapsed-overlay controls
// remain active long enough for this button click to fire.
e.preventDefault();
}}
onClick={() => navigate(query.trim() ? `/search/advanced?q=${encodeURIComponent(query.trim())}` : '/search/advanced')}
data-tooltip={t('search.advanced')}
data-tooltip-pos="bottom"
+21 -5
View File
@@ -7,6 +7,7 @@ import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import type { Track } from '../store/playerStore';
import { EaseScroller, targetForFraction } from '../utils/easeScroll';
import OverlayScrollArea from './OverlayScrollArea';
interface Props {
currentTrack: Track | null;
@@ -153,11 +154,26 @@ export default function LyricsPane({ currentTrack }: Props) {
);
return (
<div
<OverlayScrollArea
className="lyrics-pane"
ref={setContainerRef}
onWheel={handleUserScroll}
onTouchMove={handleUserScroll}
viewportClassName="lyrics-pane__viewport"
viewportRef={setContainerRef}
measureDeps={[
currentTrack?.id,
loading,
notFound,
source,
useWords,
hasSynced,
staticOnly,
sidebarLyricsStyle,
plainLyrics?.length ?? 0,
syncedLines?.length ?? 0,
wordLines?.length ?? 0,
]}
railInset="panel"
viewportOnWheel={handleUserScroll}
viewportOnTouchMove={handleUserScroll}
>
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
{notFound && !loading && <p className="lyrics-status">{t('player.lyricsNotFound')}</p>}
@@ -227,7 +243,7 @@ export default function LyricsPane({ currentTrack }: Props) {
{sourceLabel && !loading && !notFound && (
<p className="lyrics-source">{sourceLabel}</p>
)}
</div>
</OverlayScrollArea>
);
}
+45 -15
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { PlayCircle, User, Radio, RefreshCw } from 'lucide-react';
import { getNowPlaying, SubsonicNowPlaying, buildCoverArtUrl } from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
@@ -15,7 +16,21 @@ export default function NowPlayingDropdown() {
const ownUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
const [loading, setLoading] = useState(false);
const [spinning, setSpinning] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerWrapRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [panelPos, setPanelPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const PANEL_WIDTH = 340;
const updatePanelPos = useCallback(() => {
const el = triggerWrapRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const margin = 8;
const top = r.bottom + 10;
const maxLeft = window.innerWidth - PANEL_WIDTH - margin;
const left = Math.max(margin, Math.min(r.right - PANEL_WIDTH, maxLeft));
setPanelPos({ top, left });
}, []);
const fetchNowPlaying = async () => {
setLoading(true);
@@ -46,12 +61,25 @@ export default function NowPlayingDropdown() {
return () => clearInterval(id);
}, [isOpen]);
useLayoutEffect(() => {
if (!isOpen) return;
updatePanelPos();
const onWin = () => updatePanelPos();
window.addEventListener('resize', onWin);
window.addEventListener('scroll', onWin, true);
return () => {
window.removeEventListener('resize', onWin);
window.removeEventListener('scroll', onWin, true);
};
}, [isOpen, updatePanelPos]);
// Click outside to close
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
const target = event.target as Node;
if (triggerWrapRef.current?.contains(target)) return;
if (panelRef.current?.contains(target)) return;
setIsOpen(false);
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
@@ -64,16 +92,16 @@ export default function NowPlayingDropdown() {
);
return (
<div className="now-playing-dropdown" ref={dropdownRef} style={{ position: 'relative' }}>
<div className="now-playing-dropdown" ref={triggerWrapRef} style={{ position: 'relative' }}>
<button
className="btn btn-surface"
className="btn btn-surface now-playing-dropdown__trigger"
onClick={() => setIsOpen(!isOpen)}
data-tooltip={t('nowPlaying.tooltip')}
data-tooltip-pos="bottom"
style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem' }}
>
<Radio size={18} className={visible.length > 0 ? 'animate-pulse' : ''} style={{ color: visible.length > 0 ? 'var(--accent)' : 'inherit' }} />
<span>Live</span>
<span className="now-playing-dropdown__label">Live</span>
{visible.length > 0 && (
<span style={{
background: 'var(--accent)',
@@ -88,20 +116,21 @@ export default function NowPlayingDropdown() {
)}
</button>
{isOpen && (
{isOpen && createPortal(
<div
ref={panelRef}
className="glass animate-fade-in"
style={{
position: 'absolute',
top: 'calc(100% + 10px)',
right: 0,
width: '340px',
position: 'fixed',
top: panelPos.top,
left: panelPos.left,
width: `${PANEL_WIDTH}px`,
maxHeight: '400px',
overflowY: 'auto',
borderRadius: '12px',
boxShadow: 'var(--shadow-lg)',
padding: '1rem',
zIndex: 1000,
zIndex: 10050,
display: 'flex',
flexDirection: 'column',
gap: '1rem'
@@ -154,7 +183,8 @@ export default function NowPlayingDropdown() {
))}
</div>
)}
</div>
</div>,
document.body
)}
</div>
);
+20 -2
View File
@@ -7,6 +7,7 @@ import { useAuthStore } from '../store/authStore';
import { getArtistInfo, getSong, type SubsonicArtistInfo, type SubsonicSong } from '../api/subsonic';
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
import CachedImage from './CachedImage';
import OverlayScrollArea from './OverlayScrollArea';
const TOUR_LIMIT = 5;
const BIO_CLAMP_LINES = 4;
@@ -161,7 +162,24 @@ export default function NowPlayingInfo() {
const hiddenTourCount = Math.max(0, tourEvents.length - visibleTours.length);
return (
<div className="np-info">
<OverlayScrollArea
className="np-info"
viewportClassName="np-info__viewport"
railInset="panel"
measureDeps={[
currentTrack?.id,
artistId,
songId,
enableBandsintown,
tourLoading,
tourEvents.length,
showAllTours,
bioExpanded,
bioOverflows,
bioClean.length,
contributorRows.length,
]}
>
{/* Artist card */}
<section className="np-info-section np-info-artist">
{heroImage && heroCacheKey && (
@@ -306,6 +324,6 @@ export default function NowPlayingInfo() {
</div>
</section>
)}
</div>
</OverlayScrollArea>
);
}
+1 -1
View File
@@ -76,7 +76,7 @@ export default function OrbitStartTrigger() {
style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem' }}
>
<OrbitIcon size={18} className="orbit-start-trigger__spin" />
<span style={{ display: 'inline-flex', alignItems: 'center', height: '1.5em' }}>
<span className="orbit-start-trigger__label" style={{ display: 'inline-flex', alignItems: 'center', height: '1.5em' }}>
<OrbitWordmark height={14} />
</span>
</button>
+8
View File
@@ -22,6 +22,10 @@ export type OverlayScrollAreaProps = {
viewportRef?: React.Ref<HTMLDivElement>;
/** Optional id on the viewport (e.g. main app scroll for route pages). */
viewportId?: string;
/** Optional wheel handler on the scrollable viewport. */
viewportOnWheel?: React.WheelEventHandler<HTMLDivElement>;
/** Optional touch-move handler on the scrollable viewport. */
viewportOnTouchMove?: React.TouchEventHandler<HTMLDivElement>;
};
const RAIL_INSET_CLASS: Record<OverlayScrollRailInset, string> = {
@@ -46,6 +50,8 @@ export default function OverlayScrollArea({
viewportScrollBehaviorAuto = false,
viewportRef: viewportRefProp,
viewportId,
viewportOnWheel,
viewportOnTouchMove,
}: OverlayScrollAreaProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
@@ -121,6 +127,8 @@ export default function OverlayScrollArea({
ref={setViewportNode}
className={viewportClass}
onScroll={recompute}
onWheel={viewportOnWheel}
onTouchMove={viewportOnTouchMove}
>
{children}
</div>
+307 -61
View File
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast,
PictureInPicture2, ArrowLeftRight, Moon, Sunrise,
PictureInPicture2, ArrowLeftRight, Moon, Sunrise, Ellipsis,
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../store/playerStore';
@@ -108,6 +108,15 @@ export default function PlayerBar() {
const { lastfmSessionKey } = useAuthStore();
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
const [floatingStyle, setFloatingStyle] = useState<React.CSSProperties>({});
const playerBarRef = useRef<HTMLElement>(null);
const [utilityOverflow, setUtilityOverflow] = useState(false);
const [utilityMenuOpen, setUtilityMenuOpen] = useState(false);
const [utilityMenuMode, setUtilityMenuMode] = useState<'full' | 'volume'>('full');
const utilityMenuRef = useRef<HTMLDivElement>(null);
const utilityBtnRef = useRef<HTMLButtonElement>(null);
const [utilityMenuStyle, setUtilityMenuStyle] = useState<React.CSSProperties>({});
const volumeWheelMenuTimerRef = useRef<number | null>(null);
const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false);
useEffect(() => {
if (!floatingPlayerBar) return;
@@ -141,6 +150,88 @@ export default function PlayerBar() {
};
}, [floatingPlayerBar]);
useEffect(() => {
const updateOverflow = () => {
const width = playerBarRef.current?.clientWidth ?? window.innerWidth;
const threshold = floatingPlayerBar ? 980 : 1140;
setUtilityOverflow(width < threshold);
};
updateOverflow();
const ro = typeof ResizeObserver !== 'undefined'
? new ResizeObserver(updateOverflow)
: null;
const el = playerBarRef.current;
if (ro && el) ro.observe(el);
window.addEventListener('resize', updateOverflow);
return () => {
ro?.disconnect();
window.removeEventListener('resize', updateOverflow);
};
}, [floatingPlayerBar]);
useEffect(() => {
if (!utilityOverflow) setUtilityMenuOpen(false);
if (!utilityOverflow && volumeWheelMenuTimerRef.current != null) {
window.clearTimeout(volumeWheelMenuTimerRef.current);
volumeWheelMenuTimerRef.current = null;
}
}, [utilityOverflow]);
useEffect(() => {
if (!utilityMenuOpen) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (utilityBtnRef.current?.contains(target)) return;
if (utilityMenuRef.current?.contains(target)) return;
setUtilityMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setUtilityMenuOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [utilityMenuOpen]);
useEffect(() => () => {
if (volumeWheelMenuTimerRef.current != null) {
window.clearTimeout(volumeWheelMenuTimerRef.current);
}
}, []);
useEffect(() => {
if (!utilityMenuOpen) return;
const MENU_WIDTH = 238;
const MARGIN = 8;
const updateMenuPos = () => {
const btn = utilityBtnRef.current;
if (!btn) return;
const r = btn.getBoundingClientRect();
const left = Math.min(
Math.max(r.right - MENU_WIDTH, MARGIN),
window.innerWidth - MENU_WIDTH - MARGIN,
);
setUtilityMenuStyle({
position: 'fixed',
left,
width: MENU_WIDTH,
bottom: window.innerHeight - r.top + 8,
zIndex: 10050,
});
};
updateMenuPos();
window.addEventListener('resize', updateMenuPos);
window.addEventListener('scroll', updateMenuPos, true);
return () => {
window.removeEventListener('resize', updateMenuPos);
window.removeEventListener('scroll', updateMenuPos, true);
};
}, [utilityMenuOpen]);
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const transportAnchorRef = useRef<HTMLDivElement>(null);
const playSlotRef = useRef<HTMLSpanElement>(null);
@@ -193,11 +284,25 @@ export default function PlayerBar() {
setVolume(parseFloat(e.target.value));
}, [setVolume]);
const handleVolumeWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
const handleVolumeWheel = useCallback((e: React.WheelEvent<HTMLElement>) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
setVolume(Math.max(0, Math.min(1, volume + delta)));
}, [volume, setVolume]);
if (utilityOverflow) {
setSuppressOverflowTooltip(true);
setUtilityMenuMode('volume');
setUtilityMenuOpen(true);
if (volumeWheelMenuTimerRef.current != null) {
window.clearTimeout(volumeWheelMenuTimerRef.current);
}
volumeWheelMenuTimerRef.current = window.setTimeout(() => {
setUtilityMenuOpen(false);
setSuppressOverflowTooltip(false);
volumeWheelMenuTimerRef.current = null;
}, 1000);
}
}, [volume, setVolume, utilityOverflow]);
const volumeStyle = {
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
@@ -206,6 +311,7 @@ export default function PlayerBar() {
const playerBarContent = (
<>
<footer
ref={playerBarRef}
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}${showPreviewMeta ? ' is-previewing' : ''}`}
style={floatingPlayerBar ? floatingStyle : undefined}
role="region"
@@ -435,65 +541,205 @@ export default function PlayerBar() {
)}
</div>
{/* EQ Button */}
<button
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
onClick={() => setEqOpen(v => !v)}
aria-label="Equalizer"
data-tooltip="Equalizer"
>
<SlidersVertical size={15} />
</button>
{/* Mini Player */}
<button
className="player-btn player-btn-sm"
onClick={() => invoke('open_mini_player').catch(() => {})}
aria-label="Mini Player"
data-tooltip="Mini Player"
>
<PictureInPicture2 size={15} />
</button>
{/* Volume */}
<div className="player-volume-section">
<button
className="player-btn player-btn-sm"
onClick={() => {
if (volume === 0) {
setVolume(premuteVolumeRef.current);
} else {
premuteVolumeRef.current = volume;
setVolume(0);
}
}}
aria-label={t('player.volume')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<div className="player-volume-slider-wrap" onWheel={handleVolumeWheel}>
{showVolPct && (
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
{Math.round(volume * 100)}%
</span>
)}
<input
type="range"
id="player-volume"
min={0}
max={1}
step={0.01}
value={volume}
onChange={handleVolume}
style={volumeStyle}
aria-label={t('player.volume')}
className="player-volume-slider"
onMouseEnter={() => setShowVolPct(true)}
onMouseLeave={() => setShowVolPct(false)}
/>
{utilityOverflow ? (
<div className="player-overflow-wrap">
<button
ref={utilityBtnRef}
className={`player-btn player-btn-sm${utilityMenuOpen ? ' active' : ''}`}
onClick={() => {
setUtilityMenuMode('full');
setUtilityMenuOpen(v => !v);
if (volumeWheelMenuTimerRef.current != null) {
window.clearTimeout(volumeWheelMenuTimerRef.current);
volumeWheelMenuTimerRef.current = null;
}
setSuppressOverflowTooltip(false);
}}
onWheel={handleVolumeWheel}
aria-label={t('player.moreOptions')}
data-tooltip={suppressOverflowTooltip ? undefined : t('player.moreOptions')}
>
<Ellipsis size={15} />
</button>
</div>
</div>
) : (
<>
{/* EQ Button */}
<button
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
onClick={() => setEqOpen(v => !v)}
aria-label={t('player.equalizer')}
data-tooltip={t('player.equalizer')}
>
<SlidersVertical size={15} />
</button>
{/* Mini Player */}
<button
className="player-btn player-btn-sm"
onClick={() => invoke('open_mini_player').catch(() => {})}
aria-label={t('player.miniPlayer')}
data-tooltip={t('player.miniPlayer')}
>
<PictureInPicture2 size={15} />
</button>
{/* Volume */}
<div className="player-volume-section">
<button
className="player-btn player-btn-sm"
onClick={() => {
if (volume === 0) {
setVolume(premuteVolumeRef.current);
} else {
premuteVolumeRef.current = volume;
setVolume(0);
}
}}
aria-label={t('player.volume')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<div className="player-volume-slider-wrap" onWheel={handleVolumeWheel}>
{showVolPct && (
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
{Math.round(volume * 100)}%
</span>
)}
<input
type="range"
id="player-volume"
min={0}
max={1}
step={0.01}
value={volume}
onChange={handleVolume}
style={volumeStyle}
aria-label={t('player.volume')}
className="player-volume-slider"
onMouseEnter={() => setShowVolPct(true)}
onMouseLeave={() => setShowVolPct(false)}
/>
</div>
</div>
</>
)}
{/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */}
{utilityMenuOpen && createPortal(
<div
className={`player-overflow-menu${utilityMenuMode === 'volume' ? ' player-overflow-menu--volume-only' : ''}`}
ref={utilityMenuRef}
style={utilityMenuStyle}
onWheel={handleVolumeWheel}
>
{utilityMenuMode === 'full' && (
<div className="player-overflow-menu-row">
<button
className={`player-overflow-menu-btn${eqOpen ? ' active' : ''}`}
onClick={() => {
setEqOpen(v => !v);
setUtilityMenuOpen(false);
}}
>
<SlidersVertical size={14} />
{t('player.equalizer')}
</button>
<button
className="player-overflow-menu-btn"
onClick={() => {
invoke('open_mini_player').catch(() => {});
setUtilityMenuOpen(false);
}}
>
<PictureInPicture2 size={14} />
{t('player.miniPlayer')}
</button>
</div>
)}
{utilityMenuMode === 'full' ? (
<div className="player-volume-section player-volume-section--menu">
<button
className="player-btn player-btn-sm"
onClick={() => {
if (volume === 0) {
setVolume(premuteVolumeRef.current);
} else {
premuteVolumeRef.current = volume;
setVolume(0);
}
}}
aria-label={t('player.volume')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<div className="player-volume-slider-wrap" onWheel={handleVolumeWheel}>
{showVolPct && (
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
{Math.round(volume * 100)}%
</span>
)}
<input
type="range"
id="player-volume-overflow"
min={0}
max={1}
step={0.01}
value={volume}
onChange={handleVolume}
style={volumeStyle}
aria-label={t('player.volume')}
className="player-volume-slider"
onMouseEnter={() => setShowVolPct(true)}
onMouseLeave={() => setShowVolPct(false)}
/>
</div>
</div>
) : (
<div className="player-volume-section player-volume-section--menu">
<button
className="player-btn player-btn-sm"
onClick={() => {
if (volume === 0) {
setVolume(premuteVolumeRef.current);
} else {
premuteVolumeRef.current = volume;
setVolume(0);
}
}}
aria-label={t('player.volume')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
{volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<div className="player-volume-slider-wrap player-volume-slider-wrap--menu-only" onWheel={handleVolumeWheel}>
{showVolPct && (
<span className="player-volume-pct" style={{ left: `${volume * 100}%` }}>
{Math.round(volume * 100)}%
</span>
)}
<input
type="range"
id="player-volume-overflow-wheel"
min={0}
max={1}
step={0.01}
value={volume}
onChange={handleVolume}
style={volumeStyle}
aria-label={t('player.volume')}
className="player-volume-slider"
onMouseEnter={() => setShowVolPct(true)}
onMouseLeave={() => setShowVolPct(false)}
/>
</div>
</div>
)}
</div>,
document.body
)}
{/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */}
{eqOpen && createPortal(
+190 -2
View File
@@ -6,7 +6,7 @@ import { useOfflineJobStore } from '../store/offlineJobStore';
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
import { useAuthStore } from '../store/authStore';
import { useSidebarStore, type SidebarItemConfig } from '../store/sidebarStore';
import { NavLink } from 'react-router-dom';
import { NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Settings,
@@ -16,7 +16,7 @@ import {
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
import WhatsNewBanner from './WhatsNewBanner';
import { getPlaylists } from '../api/subsonic';
import { getAlbumList, getPlaylists } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
import OverlayScrollArea from './OverlayScrollArea';
@@ -32,6 +32,10 @@ import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
const SIDEBAR_NAV_LONG_PRESS_MS = 1000;
const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10;
const SMART_PREFIX = 'psy-smart-';
const NEW_RELEASES_UNREAD_STORAGE_PREFIX = 'psy_new_releases_unread_seen_v1';
const NEW_RELEASES_UNREAD_SAMPLE_SIZE = 80;
const NEW_RELEASES_UNREAD_POLL_MS = 2 * 60 * 1000;
const NEW_RELEASES_RESET_DELAY_MS = 5_000;
function isSmartPlaylistName(name: string): boolean {
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
@@ -59,6 +63,7 @@ export default function Sidebar({
toggleCollapse?: () => void;
}) {
const { t } = useTranslation();
const location = useLocation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const offlineJobs = useOfflineJobStore(s => s.jobs);
@@ -95,6 +100,8 @@ export default function Sidebar({
}, [playlistsRaw]);
const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 });
const libraryTriggerRef = useRef<HTMLButtonElement>(null);
const [sidebarViewportEl, setSidebarViewportEl] = useState<HTMLDivElement | null>(null);
const [isSidebarScrolling, setIsSidebarScrolling] = useState(false);
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all';
@@ -140,6 +147,106 @@ export default function Sidebar({
const suppressNavClickRef = useRef(false);
const lastPointerDuringNavDndRef = useRef({ x: 0, y: 0 });
const [navDndTrashHint, setNavDndTrashHint] = useState<{ x: number; y: number } | null>(null);
const [newReleasesUnreadCount, setNewReleasesUnreadCount] = useState(0);
const newReleasesRefreshSeqRef = useRef(0);
const newReleasesPageEnteredAtRef = useRef<number | null>(null);
const newReleasesResetTimerRef = useRef<number | null>(null);
const newReleasesSeenStorageKey = useMemo(
() => `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${serverId || 'no-server'}:${filterId || 'all'}`,
[serverId, filterId],
);
const newReleasesSeenAllScopeStorageKey = useMemo(
() => `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${serverId || 'no-server'}:all`,
[serverId],
);
const readSeenNewReleaseIdsByKey = useCallback((key: string): string[] => {
try {
const raw = localStorage.getItem(key);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((id): id is string => typeof id === 'string' && id.length > 0);
} catch {
return [];
}
}, []);
const readSeenNewReleaseIds = useCallback(
() => readSeenNewReleaseIdsByKey(newReleasesSeenStorageKey),
[newReleasesSeenStorageKey, readSeenNewReleaseIdsByKey],
);
const writeSeenNewReleaseIdsByKey = useCallback((key: string, ids: string[]) => {
const normalized = Array.from(new Set(ids.filter(Boolean))).slice(0, 500);
localStorage.setItem(key, JSON.stringify(normalized));
}, []);
const writeSeenNewReleaseIds = useCallback(
(ids: string[]) => writeSeenNewReleaseIdsByKey(newReleasesSeenStorageKey, ids),
[newReleasesSeenStorageKey, writeSeenNewReleaseIdsByKey],
);
const refreshNewReleasesUnread = useCallback(async (markAsSeen = false) => {
const seq = ++newReleasesRefreshSeqRef.current;
const isCurrent = () => seq === newReleasesRefreshSeqRef.current;
if (!isLoggedIn || !serverId) {
if (isCurrent()) setNewReleasesUnreadCount(0);
return;
}
try {
const newest = await getAlbumList('newest', NEW_RELEASES_UNREAD_SAMPLE_SIZE, 0);
const newestIds = newest.map(a => a.id).filter(Boolean);
let seenIds = readSeenNewReleaseIds();
// For a concrete library scope, bootstrap from the server-wide "all libraries"
// baseline when available, so switching scope doesn't hide existing unread.
if (seenIds.length === 0 && filterId !== 'all') {
const allScopeSeen = readSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey);
if (allScopeSeen.length > 0) {
seenIds = allScopeSeen;
writeSeenNewReleaseIdsByKey(newReleasesSeenStorageKey, allScopeSeen);
}
}
if (seenIds.length === 0) {
// First bootstrap for this server/scope: baseline is "already seen".
writeSeenNewReleaseIds(newestIds);
if (isCurrent()) setNewReleasesUnreadCount(0);
return;
}
if (markAsSeen) {
writeSeenNewReleaseIds([...seenIds, ...newestIds]);
// Keep server-wide baseline in sync so scope fallback never resurrects
// already-viewed items after opening the New Releases page.
const allScopeSeen = readSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey);
writeSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey, [...allScopeSeen, ...newestIds]);
if (isCurrent()) setNewReleasesUnreadCount(0);
return;
}
const seenSet = new Set(seenIds);
let unread = newestIds.reduce((count, id) => count + (seenSet.has(id) ? 0 : 1), 0);
if (isCurrent()) setNewReleasesUnreadCount(unread);
} catch {
// Keep previous value on transient network/API errors.
}
}, [
filterId,
isLoggedIn,
newReleasesSeenAllScopeStorageKey,
newReleasesSeenStorageKey,
readSeenNewReleaseIds,
readSeenNewReleaseIdsByKey,
serverId,
writeSeenNewReleaseIds,
writeSeenNewReleaseIdsByKey,
]);
useEffect(() => {
if (!navDnd) return;
@@ -236,6 +343,8 @@ export default function Sidebar({
const onUp = (e: PointerEvent) => {
lastPointerDuringNavDndRef.current = { x: e.clientX, y: e.clientY };
// Prevent synthetic click/navigation right after finishing a drag gesture.
suppressNavClickRef.current = true;
endDrag(true);
};
@@ -399,6 +508,70 @@ export default function Sidebar({
longPressTimersRef.current.clear();
}, []);
useEffect(() => {
if (!sidebarViewportEl) return;
let hideTimer: number | null = null;
const onScroll = () => {
setIsSidebarScrolling(true);
if (hideTimer != null) window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => {
setIsSidebarScrolling(false);
hideTimer = null;
}, 180);
};
sidebarViewportEl.addEventListener('scroll', onScroll, { passive: true });
return () => {
sidebarViewportEl.removeEventListener('scroll', onScroll);
if (hideTimer != null) window.clearTimeout(hideTimer);
};
}, [sidebarViewportEl]);
useEffect(() => {
const onNewReleasesPage = location.pathname.startsWith('/new-releases');
if (newReleasesResetTimerRef.current != null) {
window.clearTimeout(newReleasesResetTimerRef.current);
newReleasesResetTimerRef.current = null;
}
if (onNewReleasesPage) {
if (newReleasesPageEnteredAtRef.current == null) {
newReleasesPageEnteredAtRef.current = Date.now();
}
const elapsed = Date.now() - newReleasesPageEnteredAtRef.current;
const shouldMarkAsSeen = elapsed >= NEW_RELEASES_RESET_DELAY_MS;
void refreshNewReleasesUnread(shouldMarkAsSeen);
if (!shouldMarkAsSeen) {
const remaining = NEW_RELEASES_RESET_DELAY_MS - elapsed;
newReleasesResetTimerRef.current = window.setTimeout(() => {
newReleasesResetTimerRef.current = null;
void refreshNewReleasesUnread(true);
}, remaining);
}
} else {
newReleasesPageEnteredAtRef.current = null;
void refreshNewReleasesUnread(false);
}
const timer = window.setInterval(() => {
const activeOnNewReleases = location.pathname.startsWith('/new-releases');
const enteredAt = newReleasesPageEnteredAtRef.current;
const delayedSeenReached =
activeOnNewReleases &&
enteredAt != null &&
Date.now() - enteredAt >= NEW_RELEASES_RESET_DELAY_MS;
void refreshNewReleasesUnread(delayedSeenReached);
}, NEW_RELEASES_UNREAD_POLL_MS);
return () => {
window.clearInterval(timer);
if (newReleasesResetTimerRef.current != null) {
window.clearTimeout(newReleasesResetTimerRef.current);
newReleasesResetTimerRef.current = null;
}
};
}, [location.pathname, refreshNewReleasesUnread]);
return (
<>
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
@@ -412,6 +585,10 @@ export default function Sidebar({
<button
className="collapse-btn"
onClick={toggleCollapse}
style={{
opacity: isSidebarScrolling ? 0 : 1,
pointerEvents: isSidebarScrolling ? 'none' : 'auto',
}}
data-tooltip={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
data-tooltip-pos="right"
>
@@ -432,6 +609,7 @@ export default function Sidebar({
<OverlayScrollArea
className="sidebar-nav-scroll"
viewportClassName="sidebar-nav-viewport"
viewportRef={setSidebarViewportEl}
railInset="panel"
measureDeps={[
isCollapsed,
@@ -601,6 +779,11 @@ export default function Sidebar({
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{item.to === '/new-releases' && newReleasesUnreadCount > 0 && (
<span className="sidebar-nav-unread-badge" aria-hidden>
{newReleasesUnreadCount > 99 ? '99+' : newReleasesUnreadCount}
</span>
)}
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
) : (
@@ -619,6 +802,11 @@ export default function Sidebar({
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
{item.to === '/new-releases' && newReleasesUnreadCount > 0 && (
<span className="sidebar-nav-unread-badge" aria-hidden>
{newReleasesUnreadCount > 99 ? '99+' : newReleasesUnreadCount}
</span>
)}
</NavLink>
</div>
);
+7
View File
@@ -40,15 +40,22 @@ export default function TooltipPortal() {
const t = (e.target as HTMLElement).closest('[data-tooltip]');
if (t) setTooltip(null);
};
/** Wheel interactions (e.g. volume on overflow button) should suppress tooltip immediately. */
const onWheel = (e: WheelEvent) => {
const t = (e.target as HTMLElement).closest('[data-tooltip]');
if (t) setTooltip(null);
};
document.addEventListener('mouseover', onOver);
document.addEventListener('mouseout', onOut);
document.addEventListener('mousemove', onMove, { passive: true });
document.addEventListener('mousedown', onDown, true);
document.addEventListener('wheel', onWheel, { capture: true, passive: true });
return () => {
document.removeEventListener('mouseover', onOver);
document.removeEventListener('mouseout', onOut);
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mousedown', onDown, true);
document.removeEventListener('wheel', onWheel, true);
};
}, []);
+59
View File
@@ -897,6 +897,8 @@ export default function WaveformSeek({ trackId }: Props) {
const SEEK_COMMIT_GUARD_MS = 900;
const SEEK_COMMIT_MIN_HOLD_MS = 320;
const SEEK_COMMIT_PROGRESS_EPS = 0.02;
const WHEEL_SEEK_STEP_SECONDS = 10;
const WHEEL_SEEK_DEBOUNCE_MS = 1000;
const canvasRef = useRef<HTMLCanvasElement>(null);
const heightsRef = useRef<Float32Array | null>(null);
const progressRef = useRef(usePlayerStore.getState().progress);
@@ -1005,6 +1007,12 @@ export default function WaveformSeek({ trackId }: Props) {
// While user drags, keep the local preview stable. External progress ticks
// during streaming/recovery would otherwise fight the cursor and flicker.
if (isDragging.current) return;
const now = Date.now();
const wheelPreviewFraction = wheelPreviewFractionRef.current;
if (wheelPreviewFraction != null) {
if (now < wheelPreviewUntilRef.current) return;
wheelPreviewFractionRef.current = null;
}
const pendingCommit = pendingCommittedSeekRef.current;
if (pendingCommit) {
const ageMs = Date.now() - pendingCommit.setAtMs;
@@ -1097,6 +1105,19 @@ export default function WaveformSeek({ trackId }: Props) {
seekRef.current = seek;
const pendingSeekRef = useRef<number | null>(null);
const pendingCommittedSeekRef = useRef<{ fraction: number; setAtMs: number } | null>(null);
const wheelSeekTimerRef = useRef<number | null>(null);
const queuedWheelSeekFractionRef = useRef<number | null>(null);
const wheelPreviewFractionRef = useRef<number | null>(null);
const wheelPreviewUntilRef = useRef(0);
useEffect(() => () => {
if (wheelSeekTimerRef.current != null) {
window.clearTimeout(wheelSeekTimerRef.current);
wheelSeekTimerRef.current = null;
}
wheelPreviewFractionRef.current = null;
wheelPreviewUntilRef.current = 0;
}, []);
// Preview a 01 fraction while dragging: draw immediately for 1:1
// responsiveness; the actual seek is committed on mouseup.
@@ -1151,6 +1172,44 @@ export default function WaveformSeek({ trackId }: Props) {
<canvas
ref={canvasRef}
style={{ width: '100%', height: '24px', cursor: trackId ? 'pointer' : 'default', display: 'block' }}
onWheel={e => {
if (!trackIdRef.current || duration <= 0 || isDragging.current) return;
e.preventDefault();
const wheelSteps = Math.max(1, Math.round(Math.abs(e.deltaY) / 100));
if (wheelSteps <= 0) return;
const now = Date.now();
const currentSeconds = progressRef.current * duration;
const deltaSeconds = (e.deltaY > 0 ? -1 : 1) * WHEEL_SEEK_STEP_SECONDS * wheelSteps;
const nextSeconds = Math.max(0, Math.min(duration, currentSeconds + deltaSeconds));
const nextFraction = Math.max(0, Math.min(1, nextSeconds / duration));
// Preventive UI update: move visual playhead immediately on every wheel event.
progressRef.current = nextFraction;
wheelPreviewFractionRef.current = nextFraction;
wheelPreviewUntilRef.current = now + WHEEL_SEEK_DEBOUNCE_MS;
const canvas = canvasRef.current;
if (canvas && !ANIMATED_STYLES.has(styleRef.current)) {
drawSeekbar(canvas, styleRef.current, heightsRef.current, nextFraction, bufferedRef.current);
}
// Trailing debounce: commit seek only after wheel activity settles.
queuedWheelSeekFractionRef.current = nextFraction;
if (wheelSeekTimerRef.current != null) {
window.clearTimeout(wheelSeekTimerRef.current);
}
wheelSeekTimerRef.current = window.setTimeout(() => {
wheelSeekTimerRef.current = null;
const queuedFraction = queuedWheelSeekFractionRef.current;
queuedWheelSeekFractionRef.current = null;
if (queuedFraction == null) return;
wheelPreviewFractionRef.current = null;
wheelPreviewUntilRef.current = 0;
pendingCommittedSeekRef.current = { fraction: queuedFraction, setAtMs: Date.now() };
seekRef.current(queuedFraction);
}, WHEEL_SEEK_DEBOUNCE_MS);
}}
onMouseDown={e => {
isDragging.current = true;
const rect = e.currentTarget.getBoundingClientRect();
+5
View File
@@ -478,6 +478,7 @@ export const deTranslation = {
delete: 'Löschen',
use: 'Verwenden',
add: 'Hinzufügen',
new: 'Neu',
active: 'Aktiv',
download: 'Herunterladen',
chooseDownloadFolder: 'Download-Ordner wählen',
@@ -1220,6 +1221,10 @@ export const deTranslation = {
progress: 'Songfortschritt',
volume: 'Lautstärke',
toggleQueue: 'Warteschlange umschalten',
collapseQueueResize: 'Warteschlange einklappen, Breite ändern',
moreOptions: 'Weitere Optionen',
equalizer: 'Equalizer',
miniPlayer: 'Mini-Player',
lyrics: 'Lyrics',
fsLyricsToggle: 'Lyrics im Vollbild',
lyricsLoading: 'Lyrics werden geladen…',
+5
View File
@@ -480,6 +480,7 @@ export const enTranslation = {
delete: 'Delete',
use: 'Use',
add: 'Add',
new: 'New',
active: 'Active',
download: 'Download',
chooseDownloadFolder: 'Choose download folder',
@@ -1226,6 +1227,10 @@ export const enTranslation = {
progress: 'Song Progress',
volume: 'Volume',
toggleQueue: 'Toggle Queue',
collapseQueueResize: 'Collapse queue, resize',
moreOptions: 'More options',
equalizer: 'Equalizer',
miniPlayer: 'Mini Player',
lyrics: 'Lyrics',
fsLyricsToggle: 'Lyrics in fullscreen',
lyricsLoading: 'Loading lyrics…',
+5
View File
@@ -479,6 +479,7 @@ export const esTranslation = {
delete: 'Eliminar',
use: 'Usar',
add: 'Agregar',
new: 'Nuevo',
active: 'Activo',
download: 'Descargar',
chooseDownloadFolder: 'Elegir carpeta de descarga',
@@ -1213,6 +1214,10 @@ export const esTranslation = {
progress: 'Progreso de Canción',
volume: 'Volumen',
toggleQueue: 'Alternar Cola',
collapseQueueResize: 'Contraer cola, cambiar ancho',
moreOptions: 'Más opciones',
equalizer: 'Ecualizador',
miniPlayer: 'Mini reproductor',
lyrics: 'Letras',
fsLyricsToggle: 'Letras en pantalla completa',
lyricsLoading: 'Cargando letras…',
+5
View File
@@ -477,6 +477,7 @@ export const frTranslation = {
delete: 'Supprimer',
use: 'Utiliser',
add: 'Ajouter',
new: 'Nouveau',
active: 'Actif',
download: 'Télécharger',
chooseDownloadFolder: 'Choisir le dossier de téléchargement',
@@ -1208,6 +1209,10 @@ export const frTranslation = {
progress: 'Progression',
volume: 'Volume',
toggleQueue: 'Afficher/masquer la file',
collapseQueueResize: 'Réduire la file, redimensionner',
moreOptions: 'Plus doptions',
equalizer: 'Égaliseur',
miniPlayer: 'Mini-lecteur',
lyrics: 'Paroles',
fsLyricsToggle: 'Paroles en plein écran',
lyricsLoading: 'Chargement des paroles…',
+5
View File
@@ -477,6 +477,7 @@ export const nbTranslation = {
delete: 'Slett',
use: 'Bruk',
add: 'Legg til',
new: 'Ny',
active: 'Aktiv',
download: 'Last ned',
chooseDownloadFolder: 'Velg nedlastingsmappe',
@@ -1207,6 +1208,10 @@ export const nbTranslation = {
progress: 'Sangfremdrift',
volume: 'Volum',
toggleQueue: 'Veksle kø',
collapseQueueResize: 'Skjul kø, endre bredde',
moreOptions: 'Flere alternativer',
equalizer: 'Equalizer',
miniPlayer: 'Minispiller',
lyrics: 'Sangtekst',
fsLyricsToggle: 'Sangtekst i fullskjerm',
lyricsLoading: 'Laster sangtekst…',
+5
View File
@@ -476,6 +476,7 @@ export const nlTranslation = {
delete: 'Verwijderen',
use: 'Gebruiken',
add: 'Toevoegen',
new: 'Nieuw',
active: 'Actief',
download: 'Downloaden',
chooseDownloadFolder: 'Downloadmap kiezen',
@@ -1207,6 +1208,10 @@ export const nlTranslation = {
progress: 'Nummervoortgang',
volume: 'Volume',
toggleQueue: 'Wachtrij in-/uitschakelen',
collapseQueueResize: 'Wachtrij inklappen, breedte wijzigen',
moreOptions: 'Meer opties',
equalizer: 'Equalizer',
miniPlayer: 'Minispeler',
lyrics: 'Songtekst',
fsLyricsToggle: 'Songtekst in volledig scherm',
lyricsLoading: 'Songtekst laden…',
+5
View File
@@ -508,6 +508,7 @@ export const ruTranslation = {
delete: 'Удалить',
use: 'Использовать',
add: 'Добавить',
new: 'Новое',
active: 'Активен',
download: 'Скачать',
chooseDownloadFolder: 'Выбрать папку',
@@ -1293,6 +1294,10 @@ export const ruTranslation = {
progress: 'Прогресс',
volume: 'Громкость',
toggleQueue: 'Очередь',
collapseQueueResize: 'Свернуть очередь, изменить ширину',
moreOptions: 'Дополнительно',
equalizer: 'Эквалайзер',
miniPlayer: 'Мини-плеер',
lyrics: 'Текст',
lyricsLoading: 'Загрузка текста…',
lyricsNotFound: 'Текст не найден',
+5
View File
@@ -471,6 +471,7 @@ export const zhTranslation = {
delete: '删除',
use: '使用',
add: '添加',
new: '新',
active: '当前使用',
download: '下载',
chooseDownloadFolder: '选择下载文件夹',
@@ -1202,6 +1203,10 @@ export const zhTranslation = {
progress: '播放进度',
volume: '音量',
toggleQueue: '切换队列',
collapseQueueResize: '收起队列,调整宽度',
moreOptions: '更多选项',
equalizer: '均衡器',
miniPlayer: '迷你播放器',
lyrics: '歌词',
fsLyricsToggle: '全屏歌词',
lyricsLoading: '正在加载歌词…',
+53 -3
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Search, X } from 'lucide-react';
import { Search, X, ListPlus } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
@@ -14,9 +14,11 @@ import { useZipDownloadStore } from '../store/zipDownloadStore';
import AlbumCard from '../components/AlbumCard';
import AlbumHeader from '../components/AlbumHeader';
import AlbumTrackList from '../components/AlbumTrackList';
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
import { useCachedUrl } from '../components/CachedImage';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
import { useSelectionStore } from '../store/selectionStore';
function sanitizeFilename(name: string): string {
return name
@@ -63,6 +65,9 @@ export default function AlbumDetail() {
const [sortKey, setSortKey] = useState<'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration'>('natural');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [sortClickCount, setSortClickCount] = useState(0);
const [showPlPicker, setShowPlPicker] = useState(false);
const selectedCount = useSelectionStore(s => s.selectedIds.size);
const inSelectMode = selectedCount > 0;
// Derive a stable albumId for the selectors below (empty string when not yet loaded).
const albumId = album?.album.id ?? '';
@@ -353,6 +358,19 @@ const handleShuffleAll = () => {
const coverKey = useMemo(() => album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '', [album?.album.coverArt]);
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
useEffect(() => {
if (!showPlPicker) return;
const handler = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
useEffect(() => {
if (!inSelectMode) setShowPlPicker(false);
}, [inSelectMode]);
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
@@ -400,8 +418,8 @@ const handleShuffleAll = () => {
)}
{songs.length > 0 && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '0 16px 8px', flexWrap: 'wrap' }}>
<div style={{ position: 'relative', flex: '1 1 160px', maxWidth: 260 }}>
<div className="album-track-toolbar">
<div className="album-track-toolbar-filter">
<Search size={16} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} />
<input
className="input-search"
@@ -420,6 +438,38 @@ const handleShuffleAll = () => {
</button>
)}
</div>
<div className="album-track-toolbar-actions">
{inSelectMode && (
<>
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedCount })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...useSelectionStore.getState().selectedIds]}
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
dropDown
/>
)}
</div>
<button
className="btn btn-ghost btn-sm"
onClick={() => useSelectionStore.getState().clearAll()}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</>
)}
</div>
</div>
)}
+1 -1
View File
@@ -146,7 +146,7 @@ export default function Help() {
<div className="content-body animate-fade-in">
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('help.title')}</h1>
<div style={{ columns: 2, columnGap: '1.25rem' }}>
<div className="help-columns">
{sections.map((section, si) => (
<section key={si} className="settings-section" style={{ breakInside: 'avoid', marginBottom: '1.25rem' }}>
<div className="settings-section-header">
+16 -2
View File
@@ -24,6 +24,7 @@ import CachedImage from '../components/CachedImage';
import LastfmIcon from '../components/LastfmIcon';
import { useRadioMetadata } from '../hooks/useRadioMetadata';
import { useDragSource, useDragDrop } from '../contexts/DragDropContext';
import OverlayScrollArea from '../components/OverlayScrollArea';
import {
useNpLayoutStore, NP_CARD_IDS,
type NpCardId, type NpColumn,
@@ -1161,7 +1162,20 @@ export default function NowPlaying() {
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="np-page">
<div className="np-main">
<OverlayScrollArea
className="np-main"
viewportClassName="np-main__viewport"
railInset="panel"
measureDeps={[
!!currentTrack,
!!currentRadio,
layoutCards,
enableBandsintown,
tourEvents.length,
discography.length,
topSongs.length,
]}
>
{currentRadio && !currentTrack ? (
<RadioView radioMeta={radioMeta} currentRadio={currentRadio} resolvedCover={resolvedRadioCover} />
) : currentTrack ? (
@@ -1364,7 +1378,7 @@ export default function NowPlaying() {
<p>{t('nowPlaying.nothingPlaying')}</p>
</div>
)}
</div>
</OverlayScrollArea>
</div>
);
}
+7 -4
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Play, HardDriveDownload, Trash2 } from 'lucide-react';
import { Play, HardDriveDownload, Trash2, ListPlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
@@ -89,12 +89,15 @@ export default function OfflineLibrary() {
<button
className="offline-library-enqueue"
onClick={() => handleEnqueue(album.id)}
data-tooltip={t('queue.addToQueue')}
data-tooltip={t('queue.appendToQueue')}
data-tooltip-pos="top"
aria-label={t('queue.appendToQueue')}
>
+ Queue
<ListPlus size={12} />
</button>
<span className="offline-library-tracks">{trackCount} tracks</span>
<span className="offline-library-tracks">
{t('albumDetail.tracksCount', { n: trackCount })}
</span>
<button
className="offline-library-delete"
onClick={() => deleteAlbum(album.id, serverId)}
+34 -3
View File
@@ -23,6 +23,29 @@ import {
passesMixMinRatings,
} from '../utils/mixRatingFilter';
const QUEUE_VISIBILITY_STORAGE_KEY = 'psysonic_queue_visible';
function readInitialQueueVisibility(): boolean {
if (typeof window === 'undefined') return true;
try {
const raw = window.localStorage.getItem(QUEUE_VISIBILITY_STORAGE_KEY);
if (raw === 'true') return true;
if (raw === 'false') return false;
} catch {
// ignore storage access failures and fall back to default
}
return true;
}
function persistQueueVisibility(visible: boolean): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(QUEUE_VISIBILITY_STORAGE_KEY, String(visible));
} catch {
// ignore storage access failures
}
}
export interface Track {
id: string;
title: string;
@@ -2081,7 +2104,7 @@ export const usePlayerStore = create<PlayerState>()(
s.currentTrack?.id === id ? { ...s.currentTrack, userRating: rating } : s.currentTrack,
};
}),
isQueueVisible: true,
isQueueVisible: readInitialQueueVisibility(),
isFullscreenOpen: false,
scheduledPauseAtMs: null,
scheduledPauseStartMs: null,
@@ -2101,8 +2124,16 @@ export const usePlayerStore = create<PlayerState>()(
openSongInfo: (songId) => set({ songInfoModal: { isOpen: true, songId } }),
closeSongInfo: () => set({ songInfoModal: { isOpen: false, songId: null } }),
toggleQueue: () => set(state => ({ isQueueVisible: !state.isQueueVisible })),
setQueueVisible: (v: boolean) => set({ isQueueVisible: v }),
toggleQueue: () =>
set(state => {
const next = !state.isQueueVisible;
persistQueueVisibility(next);
return { isQueueVisible: next };
}),
setQueueVisible: (v: boolean) => {
persistQueueVisibility(v);
set({ isQueueVisible: v });
},
toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })),
toggleLastfmLove: () => {
+263 -14
View File
@@ -452,6 +452,23 @@
pointer-events: none;
}
.album-card-new-badge {
position: absolute;
top: 6px;
left: 6px;
background: var(--accent);
color: var(--ctp-crust);
border: 1px solid color-mix(in srgb, var(--accent) 42%, transparent);
border-radius: var(--radius-sm);
padding: 3px 6px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
z-index: 2;
pointer-events: none;
}
.album-card-play-overlay {
position: absolute;
inset: 0;
@@ -633,9 +650,72 @@
/* ─ Live Search ─ */
.live-search {
position: relative;
min-width: 280px;
min-width: 240px;
max-width: 420px;
flex: 1;
flex: 1 1 320px;
}
/* JS-computed collapse mode (budget-based, with hysteresis) */
.live-search[data-collapsed] {
min-width: 34px;
max-width: 34px;
flex: 0 0 34px;
}
.live-search[data-collapsed]:not([data-active]) .live-search-input-wrap {
width: 34px;
height: 34px;
border-radius: var(--radius-full);
border: 1px solid var(--border-subtle);
background: color-mix(in srgb, var(--bg-card) 84%, transparent);
cursor: text;
}
.live-search[data-collapsed]:not([data-active]) .live-search-icon {
left: 50%;
transform: translateX(-50%);
}
.live-search[data-collapsed]:not([data-active]) .live-search-field {
width: 0 !important;
min-width: 0 !important;
padding: 0 !important;
border: none !important;
opacity: 0;
pointer-events: none;
}
.live-search[data-collapsed][data-active] {
min-width: 34px;
max-width: 34px;
flex: 0 0 34px;
}
.live-search[data-collapsed][data-active] .live-search-input-wrap {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
width: min(420px, calc(100vw - 2 * var(--space-4)));
z-index: 25;
}
.live-search[data-collapsed][data-active] .live-search-field {
width: 100%;
opacity: 1;
pointer-events: auto;
}
.live-search[data-collapsed]:not([data-active]) .live-search-clear,
.live-search[data-collapsed]:not([data-active]) .live-search-adv-btn {
opacity: 0;
pointer-events: none;
}
.live-search[data-collapsed][data-active] .live-search-dropdown {
left: 0;
right: auto;
width: min(420px, calc(100vw - 2 * var(--space-4)));
}
.live-search-input-wrap {
@@ -1183,7 +1263,7 @@
.album-detail-badge {
margin-bottom: 0.5rem;
background: var(--accent);
color: #fff;
color: var(--ctp-crust);
}
.album-detail-back {
@@ -1191,6 +1271,28 @@
gap: 6px;
}
.album-track-toolbar {
display: flex;
gap: 8px;
align-items: center;
padding: 0 16px 8px;
flex-wrap: wrap;
}
.album-track-toolbar-filter {
position: relative;
flex: 1 1 160px;
max-width: 260px;
}
.album-track-toolbar-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 34px;
}
.album-info-dot {
margin: 0 4px;
}
@@ -3565,6 +3667,17 @@ html[data-track-previews-randommix="off"] [data-preview-loc="randomMix"] .pl
}
/* ─ Help Page ─ */
.help-columns {
columns: 2;
column-gap: 1.25rem;
}
@media (max-width: 1200px) {
.help-columns {
columns: 1;
}
}
.help-list {
display: flex;
flex-direction: column;
@@ -3755,11 +3868,13 @@ html[data-track-previews-randommix="off"] [data-preview-loc="randomMix"] .pl
.lyrics-pane {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.lyrics-pane__viewport {
overflow-x: hidden;
/* scroll-behavior intentionally left to auto — JS drives smooth scroll imperatively */
padding: 16px 16px 8px;
scrollbar-width: thin;
}
.lyrics-pane-empty {
@@ -5745,13 +5860,13 @@ html.no-compositing .fs-seekbar-played {
.stats-overview {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: var(--space-4);
}
@media (max-width: 600px) {
.stats-overview {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
}
@@ -5769,10 +5884,12 @@ html.no-compositing .fs-seekbar-played {
.stats-card-value {
font-family: var(--font-display);
font-size: 2rem;
font-size: clamp(1.45rem, 3.4vw, 2rem);
font-weight: 800;
color: var(--accent);
line-height: 1;
overflow-wrap: anywhere;
word-break: break-word;
}
.stats-card-label {
@@ -6004,6 +6121,7 @@ html.no-compositing .fs-seekbar-played {
overflow: hidden;
display: flex;
flex-direction: column;
container-type: inline-size;
background:
radial-gradient(ellipse at 20% 0%, color-mix(in srgb, var(--accent) 10%, var(--bg-main)) 0%, transparent 60%),
radial-gradient(ellipse at 80% 100%, color-mix(in srgb, var(--ctp-blue) 8%, var(--bg-main)) 0%, transparent 60%),
@@ -6013,7 +6131,10 @@ html.no-compositing .fs-seekbar-played {
/* Main scrollable content */
.np-main {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.np-main__viewport {
padding: 28px 28px 40px;
display: flex;
flex-direction: column;
@@ -10942,7 +11063,10 @@ html[data-psy-native-hidden="true"] *::after {
/* ── Now Playing Info panel ──────────────────────────────────────────────── */
.np-info {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.np-info__viewport {
overflow-x: hidden;
padding: 6px 16px 20px;
display: block;
@@ -11076,8 +11200,9 @@ html[data-psy-native-hidden="true"] *::after {
}
.np-info-tour > .np-info-tour-item + .np-info-tour-item { margin-top: 6px; }
.np-info-tour-item {
display: flex;
align-items: center;
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
align-items: start;
gap: 12px;
padding: 10px 12px;
background: var(--bg-elevated);
@@ -11088,6 +11213,7 @@ html[data-psy-native-hidden="true"] *::after {
text-align: left;
width: 100%;
min-width: 0; /* allow children to shrink past their min-content */
max-width: 100%;
box-sizing: border-box;
overflow: hidden;
}
@@ -11126,6 +11252,7 @@ html[data-psy-native-hidden="true"] *::after {
* overflows the parent ul on WebKit. */
flex: 1 1 0;
min-width: 0;
max-width: 100%;
display: flex;
flex-direction: column;
gap: 3px;
@@ -11142,9 +11269,10 @@ html[data-psy-native-hidden="true"] *::after {
.np-info-tour-place {
font-size: 11.5px;
color: var(--text-muted);
white-space: nowrap;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
word-break: break-word;
text-align: left;
}
.np-info-tour-when { color: var(--text-secondary); }
@@ -11831,6 +11959,112 @@ html[data-psy-native-hidden="true"] *::after {
.np-dash-hero-title { font-size: 24px; }
}
/* Prefer container-based adaptation (main-content width can be narrow on desktop). */
@container (max-width: 900px) {
.np-dash-grid {
flex-direction: column;
gap: 14px;
align-items: stretch;
}
.np-dash-col {
flex: 0 0 auto;
width: 100%;
min-height: 0;
gap: 14px;
}
.np-dash-card-wrap {
display: block;
width: 100%;
}
.np-dash-card,
.np-info-card {
min-width: 0;
overflow: hidden;
}
.np-dash-hero {
grid-template-columns: 1fr;
gap: 16px;
padding: 20px;
align-items: stretch;
}
.np-dash-hero-cover {
width: 180px;
height: 180px;
margin: 0 auto;
}
.np-dash-hero-body {
gap: 10px;
}
.np-dash-hero-title {
font-size: 24px;
line-height: 1.2;
overflow-wrap: break-word;
}
.np-dash-hero-sub,
.np-dash-hero-lfm-row {
font-size: 13px;
}
.np-dash-hero-actions {
flex-wrap: wrap;
}
}
@container (max-width: 760px) {
.np-main__viewport {
padding: 16px 14px 24px;
gap: 12px;
}
.np-dash {
gap: 12px;
}
.np-dash-hero {
padding: 14px;
gap: 12px;
}
.np-dash-hero-cover {
width: 150px;
height: 150px;
}
.np-dash-hero-title {
font-size: 21px;
overflow-wrap: anywhere;
}
.np-dash-hero-sub {
gap: 5px;
}
.np-dash-hero-lfm {
padding: 10px 11px;
}
.np-info-card {
padding: 14px 15px;
}
.np-card-header {
flex-wrap: wrap;
align-items: flex-start;
}
.np-dash-col {
gap: 12px;
}
}
.app-shell[data-mobile] .np-dash-grid { flex-direction: column; }
.app-shell[data-mobile] .np-dash-hero { grid-template-columns: 1fr; }
.app-shell[data-mobile] .np-dash-hero-cover { width: 160px; height: 160px; margin: 0 auto; }
@@ -12261,6 +12495,21 @@ html[data-psy-native-hidden="true"] *::after {
}
.orbit-start-trigger:hover .orbit-start-trigger__spin { transform: rotate(45deg); }
/* Collapse Live/Orbit labels in a sticky low-space mode (with hysteresis). */
.content-header[data-live-header-compact] .now-playing-dropdown__trigger,
.content-header[data-live-header-compact] .orbit-start-trigger {
padding-left: 0.55rem !important;
padding-right: 0.55rem !important;
gap: 0.35rem !important;
min-width: 36px;
justify-content: center;
}
.content-header[data-live-header-compact] .now-playing-dropdown__label,
.content-header[data-live-header-compact] .orbit-start-trigger__label {
display: none !important;
}
/* Launch popover — three-option menu anchored below the topbar trigger. */
.orbit-launch-pop {
min-width: 220px;
+132
View File
@@ -196,6 +196,31 @@
}
.resizer-queue { right: calc(var(--queue-width) - 3px); }
.resizer-queue { overflow: visible; }
/* Queue handle has its own visual affordance; don't paint seam stripe on hover. */
.resizer-queue:hover, .resizer-queue:active { background: transparent; }
.resizer-queue-handle {
z-index: 101;
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid var(--border-subtle);
background: var(--bg-card);
color: var(--text-muted);
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
box-shadow: var(--shadow-sm);
}
.resizer-queue-handle:hover {
background: var(--bg-hover);
color: var(--text-primary);
box-shadow: var(--shadow-md);
}
/* While dragging an overlay scrollbar thumb, hide queue resizer so the
pointer does not pick up col-resize / queue resize (thumb sits under the
@@ -568,6 +593,21 @@ body.is-overlay-scrollbar-thumb-drag .resizer {
50% { opacity: 0.4; transform: scale(0.7); }
}
.sidebar-nav-unread-badge {
margin-left: auto;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: var(--ctp-crust, #11111b);
font-size: 10px;
font-weight: 700;
line-height: 18px;
text-align: center;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 38%, transparent);
}
/* Collapsed Sidebar Styles */
.sidebar.collapsed .sidebar-brand {
justify-content: center;
@@ -579,6 +619,18 @@ body.is-overlay-scrollbar-thumb-drag .resizer {
padding: var(--space-3) 0;
}
.sidebar.collapsed .sidebar-nav-unread-badge {
position: absolute;
top: 3px;
right: 7px;
margin-left: 0;
min-width: 16px;
height: 16px;
padding: 0 4px;
line-height: 16px;
font-size: 9px;
}
.collapse-btn {
position: absolute;
top: calc(50% - var(--player-height) / 2);
@@ -1125,6 +1177,16 @@ body.is-overlay-scrollbar-thumb-drag .resizer {
.content-header .spacer { flex: 1; }
.content-header > * {
transition: opacity 0.14s ease, visibility 0.14s ease;
}
.content-header[data-live-search-overlay] > :not(.live-search):not(.spacer) {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.content-body {
flex: 1;
overflow-y: auto;
@@ -1626,6 +1688,72 @@ html[data-platform="windows"] .player-bar.floating {
color: var(--accent);
}
.player-overflow-wrap {
position: relative;
flex-shrink: 0;
}
.player-overflow-menu {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
width: 238px;
padding: 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg-card);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
z-index: 240;
display: flex;
flex-direction: column;
gap: 8px;
}
.player-overflow-menu-row {
display: flex;
gap: 6px;
}
.player-overflow-menu-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 30px;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
background: var(--bg-hover);
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.player-overflow-menu-btn:hover {
background: var(--border);
color: var(--text-primary);
}
.player-overflow-menu-btn.active {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
}
.player-volume-section.player-volume-section--menu {
width: 100%;
}
.player-overflow-menu--volume-only {
padding: 10px;
}
.player-volume-slider-wrap.player-volume-slider-wrap--menu-only {
width: 100%;
min-width: 0;
}
/* ─── EQ Popup ─── */
.eq-popup-backdrop {
position: fixed;
@@ -2419,6 +2547,10 @@ html[data-platform="windows"] .player-bar.floating {
display: none;
}
.app-shell[data-mobile] .resizer-queue-handle {
display: none;
}
/* ─── Player Bar — Mobile ─── */
.app-shell[data-mobile] .player-bar {
gap: var(--space-3);
+8
View File
@@ -0,0 +1,8 @@
const NEW_ALBUM_WINDOW_MS = 2 * 24 * 60 * 60 * 1000;
export function isAlbumRecentlyAdded(created?: string): boolean {
if (!created) return false;
const createdMs = Date.parse(created);
if (!Number.isFinite(createdMs)) return false;
return Date.now() - createdMs <= NEW_ALBUM_WINDOW_MS;
}