From 9ad0f8af6df06bedab84b99f2fbbc0ea0a4c4824 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 1 May 2026 15:42:40 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20UI=20refinements=20=E2=80=94=20side?= =?UTF-8?q?bar=20indicators,=20adaptive=20header,=20and=20interaction=20po?= =?UTF-8?q?lish=20(#397)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- CHANGELOG.md | 23 ++ src-tauri/src/lib.rs | 82 ++++-- src/App.tsx | 208 +++++++++++++-- src/components/AlbumCard.tsx | 7 + src/components/AlbumHeader.tsx | 6 + src/components/AlbumTrackList.tsx | 46 +--- src/components/LiveSearch.tsx | 142 +++++++++- src/components/LyricsPane.tsx | 26 +- src/components/NowPlayingDropdown.tsx | 60 +++-- src/components/NowPlayingInfo.tsx | 22 +- src/components/OrbitStartTrigger.tsx | 2 +- src/components/OverlayScrollArea.tsx | 8 + src/components/PlayerBar.tsx | 368 +++++++++++++++++++++----- src/components/Sidebar.tsx | 192 +++++++++++++- src/components/TooltipPortal.tsx | 7 + src/components/WaveformSeek.tsx | 59 +++++ src/locales/de.ts | 5 + src/locales/en.ts | 5 + src/locales/es.ts | 5 + src/locales/fr.ts | 5 + src/locales/nb.ts | 5 + src/locales/nl.ts | 5 + src/locales/ru.ts | 5 + src/locales/zh.ts | 5 + src/pages/AlbumDetail.tsx | 56 +++- src/pages/Help.tsx | 2 +- src/pages/NowPlaying.tsx | 18 +- src/pages/OfflineLibrary.tsx | 11 +- src/store/playerStore.ts | 37 ++- src/styles/components.css | 277 ++++++++++++++++++- src/styles/layout.css | 132 +++++++++ src/utils/albumRecency.ts | 8 + 32 files changed, 1642 insertions(+), 197 deletions(-) create mode 100644 src/utils/albumRecency.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 64e3bff4..4d18ac59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fd8819f1..ec7d3edc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -490,6 +490,17 @@ type TrayState = Mutex>; /// Empty string means "use the default `Psysonic` tooltip". type TrayTooltip = Mutex; +#[derive(Default)] +struct TrayPlaybackState(Mutex); + +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, show_hide: tauri::menu::MenuItem, quit: tauri::menu::MenuItem, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] now_playing: Option>, } @@ -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 { if g.is_empty() { None } else { Some(g.clone()) } }) .unwrap_or_else(|| "Psysonic".to_string()); + let playback_state = app + .try_state::() + .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 { }); } - 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, tooltip_cache: tauri::State, + playback_state_cache: tauri::State, tooltip: String, + playback_state: Option, ) -> Result<(), String> { - let truncated = if tooltip.chars().count() > 127 { - tooltip.chars().take(124).collect::() + "..." + 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::() + "..." } 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::() + let nothing = app.try_state::() .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::() + .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()) diff --git a/src/App.tsx b/src/App.tsx index d79922f7..0f3d0702 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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(); + const appViewport = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID); + if (appViewport) viewports.add(appViewport); + const nowPlayingViewport = document.querySelector('.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) => { + 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 && ( setIsSidebarCollapsed(!isSidebarCollapsed)} + toggleCollapse={() => setSidebarCollapsed(!isSidebarCollapsed)} /> )}
@@ -480,14 +613,16 @@ function AppShell() { - + {!isMobile && !isQueueVisible && ( + + )} {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 && ( + + )} {!isMobile && } {isMobile && !isMobilePlayer && } {!isMobilePlayer && } diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index aee9a1f7..e9a699fb 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -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 )} + {isNewAlbum && ( +
+ {t('common.new', 'New')} +
+ )} {selectionMode && (
{selected && } diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index 20c45a87..4321febd 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -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({
)}
+ {isNewAlbum && ( + {t('common.new', 'New')} + )}

{info.name}

- {showPlPicker && ( - { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }} - dropDown - /> - )} -

- -
- )} - {/* ── Header ── */}
diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index b33b2604..4ad9fcbe 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -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(null); const dropdownRef = useRef(null); + const inputRef = useRef(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 ( -
-
+
+
{ + if (isSearchActive) return; + if (!isCollapsed) return; + e.preventDefault(); + setIsFocused(true); + requestAnimationFrame(() => inputRef.current?.focus()); + }} + > {loading ? (
@@ -113,13 +237,18 @@ export default function LiveSearch() { )} 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() {
, + document.body )}
); diff --git a/src/components/NowPlayingInfo.tsx b/src/components/NowPlayingInfo.tsx index 79709625..306c4746 100644 --- a/src/components/NowPlayingInfo.tsx +++ b/src/components/NowPlayingInfo.tsx @@ -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 ( -
+ {/* Artist card */}
{heroImage && heroCacheKey && ( @@ -306,6 +324,6 @@ export default function NowPlayingInfo() {
)} -
+ ); } diff --git a/src/components/OrbitStartTrigger.tsx b/src/components/OrbitStartTrigger.tsx index 061156e9..dbb4cd7e 100644 --- a/src/components/OrbitStartTrigger.tsx +++ b/src/components/OrbitStartTrigger.tsx @@ -76,7 +76,7 @@ export default function OrbitStartTrigger() { style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem' }} > - + diff --git a/src/components/OverlayScrollArea.tsx b/src/components/OverlayScrollArea.tsx index c91b395a..d48e6dfc 100644 --- a/src/components/OverlayScrollArea.tsx +++ b/src/components/OverlayScrollArea.tsx @@ -22,6 +22,10 @@ export type OverlayScrollAreaProps = { viewportRef?: React.Ref; /** 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; + /** Optional touch-move handler on the scrollable viewport. */ + viewportOnTouchMove?: React.TouchEventHandler; }; const RAIL_INSET_CLASS: Record = { @@ -46,6 +50,8 @@ export default function OverlayScrollArea({ viewportScrollBehaviorAuto = false, viewportRef: viewportRefProp, viewportId, + viewportOnWheel, + viewportOnTouchMove, }: OverlayScrollAreaProps) { const wrapRef = useRef(null); const viewportRef = useRef(null); @@ -121,6 +127,8 @@ export default function OverlayScrollArea({ ref={setViewportNode} className={viewportClass} onScroll={recompute} + onWheel={viewportOnWheel} + onTouchMove={viewportOnTouchMove} > {children}
diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 0a21307e..e8066ad1 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -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({}); + const playerBarRef = useRef(null); + const [utilityOverflow, setUtilityOverflow] = useState(false); + const [utilityMenuOpen, setUtilityMenuOpen] = useState(false); + const [utilityMenuMode, setUtilityMenuMode] = useState<'full' | 'volume'>('full'); + const utilityMenuRef = useRef(null); + const utilityBtnRef = useRef(null); + const [utilityMenuStyle, setUtilityMenuStyle] = useState({}); + const volumeWheelMenuTimerRef = useRef(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(null); const playSlotRef = useRef(null); @@ -193,11 +284,25 @@ export default function PlayerBar() { setVolume(parseFloat(e.target.value)); }, [setVolume]); - const handleVolumeWheel = useCallback((e: React.WheelEvent) => { + const handleVolumeWheel = useCallback((e: React.WheelEvent) => { 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 = ( <>