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 = ( <>