mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
fix(mini-player): restore window position + queue-open state across launches
Two regressions in the mini player's persistence story:
1. Window position was lost on every reopen. set_position() called on a
hidden window is unreliable on Linux WMs (Mutter, KWin re-centre on
show). Worse, the WM-induced re-centre fired WindowEvent::Moved with
the centre coords, which the throttled persister happily wrote to
disk — so the saved position turned into "centre" within seconds.
Fix:
- Initial open uses WebviewWindowBuilder::position() (logical pixels,
scaled via the primary monitor) so the window is created at the
right spot.
- Re-show path re-applies the saved position with set_position AFTER
show().
- Both paths call mark_mini_pos_programmatic() before triggering the
move; persist_mini_pos_throttled ignores Moved events within 1 s of
a programmatic mark, so WM-induced and self-induced moves no longer
overwrite the user's position.
2. The queue panel always opened collapsed regardless of the previous
session. Persist queueOpen to localStorage (psysonic_mini_queue_open)
and resize the window to the stored expanded height on mount when
the saved state was 'open'. Brief jump from 180 px to expanded is
unavoidable since localStorage only lives in the JS layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+59
-13
@@ -2635,10 +2635,33 @@ fn write_mini_pos(app: &tauri::AppHandle, pos: MiniPlayerPosition) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks when we last set the mini player position programmatically.
|
||||
/// `WindowEvent::Moved` fires for both user drags AND our own `show()` /
|
||||
/// `set_position` calls — without this guard the WM's "centre on show"
|
||||
/// behaviour would silently overwrite the user's saved position.
|
||||
fn last_programmatic_pos_set() -> &'static Mutex<std::time::Instant> {
|
||||
static LAST: OnceLock<Mutex<std::time::Instant>> = OnceLock::new();
|
||||
LAST.get_or_init(|| Mutex::new(std::time::Instant::now() - std::time::Duration::from_secs(10)))
|
||||
}
|
||||
|
||||
fn mark_mini_pos_programmatic() {
|
||||
*last_programmatic_pos_set().lock().unwrap() = std::time::Instant::now();
|
||||
}
|
||||
|
||||
fn is_mini_pos_programmatic() -> bool {
|
||||
last_programmatic_pos_set().lock().unwrap().elapsed()
|
||||
< std::time::Duration::from_millis(1000)
|
||||
}
|
||||
|
||||
/// Throttle disk writes during a drag — `WindowEvent::Moved` fires on
|
||||
/// every pointer step. 250 ms keeps the file fresh enough that any close
|
||||
/// or release lands a recent position, without hammering the disk.
|
||||
/// Programmatic moves (during `show()` / `set_position`) are skipped so
|
||||
/// WM re-centring on re-show doesn't clobber the saved position.
|
||||
fn persist_mini_pos_throttled(app: &tauri::AppHandle, x: i32, y: i32) {
|
||||
if is_mini_pos_programmatic() {
|
||||
return;
|
||||
}
|
||||
static LAST_WRITE: OnceLock<Mutex<std::time::Instant>> = OnceLock::new();
|
||||
let mu = LAST_WRITE.get_or_init(|| {
|
||||
Mutex::new(std::time::Instant::now() - std::time::Duration::from_secs(10))
|
||||
@@ -2694,8 +2717,18 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let _ = main.set_focus();
|
||||
}
|
||||
} else {
|
||||
// Re-applying the saved position after show() — many Linux WMs
|
||||
// (Mutter, KWin) re-centre hidden windows when they're shown
|
||||
// again, ignoring any earlier set_position. Mark the move as
|
||||
// programmatic so the Moved-event handler doesn't echo the
|
||||
// intermediate centre coords back to disk.
|
||||
let target = read_mini_pos(&app);
|
||||
mark_mini_pos_programmatic();
|
||||
win.show().map_err(|e| e.to_string())?;
|
||||
let _ = win.set_focus();
|
||||
if let Some(p) = target {
|
||||
let _ = win.set_position(tauri::PhysicalPosition::new(p.x, p.y));
|
||||
}
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
let _ = main.minimize();
|
||||
}
|
||||
@@ -2710,7 +2743,20 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
|
||||
{ true }
|
||||
};
|
||||
|
||||
let win = tauri::WebviewWindowBuilder::new(
|
||||
// Resolve target position BEFORE building so the WM places the window
|
||||
// correctly from creation. Calling `set_position` after `build()` is
|
||||
// unreliable on several Linux WMs which re-centre hidden windows.
|
||||
let target_physical = read_mini_pos(&app)
|
||||
.map(|p| tauri::PhysicalPosition::new(p.x, p.y))
|
||||
.or_else(|| default_mini_position(&app));
|
||||
let scale = app
|
||||
.primary_monitor()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|m| m.scale_factor())
|
||||
.unwrap_or(1.0);
|
||||
|
||||
let mut builder = tauri::WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"mini",
|
||||
tauri::WebviewUrl::App("index.html".into()),
|
||||
@@ -2721,20 +2767,20 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
|
||||
.resizable(true)
|
||||
.decorations(true)
|
||||
.always_on_top(use_always_on_top)
|
||||
.skip_taskbar(false)
|
||||
.visible(false) // place first, then show — avoids the corner flash
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build mini player window: {e}"))?;
|
||||
.skip_taskbar(false);
|
||||
|
||||
// Restore last-known position, otherwise drop into the bottom-right of
|
||||
// the main window's monitor.
|
||||
let target = read_mini_pos(&app)
|
||||
.map(|p| tauri::PhysicalPosition::new(p.x, p.y))
|
||||
.or_else(|| default_mini_position(&app));
|
||||
if let Some(pos) = target {
|
||||
let _ = win.set_position(pos);
|
||||
if let Some(pos) = target_physical {
|
||||
builder = builder.position(pos.x as f64 / scale, pos.y as f64 / scale);
|
||||
}
|
||||
let _ = win.show();
|
||||
|
||||
// Suppress Moved-event echo for the initial show — Linux WMs sometimes
|
||||
// fire stray Moved events with default coords during the first paint.
|
||||
mark_mini_pos_programmatic();
|
||||
|
||||
let win = builder
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build mini player window: {e}"))?;
|
||||
|
||||
let _ = win.set_focus();
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
let _ = main.minimize();
|
||||
|
||||
@@ -31,6 +31,14 @@ function readStoredExpandedHeight(): number {
|
||||
return EXPANDED_SIZE.h;
|
||||
}
|
||||
|
||||
// Persist whether the queue panel was open so the next launch restores
|
||||
// the same state. Same scope as the height: localStorage of the mini
|
||||
// webview (shared across mini sessions, separate from the main store).
|
||||
const QUEUE_OPEN_KEY = 'psysonic_mini_queue_open';
|
||||
function readQueueOpen(): boolean {
|
||||
try { return localStorage.getItem(QUEUE_OPEN_KEY) === '1'; } catch { return false; }
|
||||
}
|
||||
|
||||
function toMini(t: any): MiniTrackInfo {
|
||||
return {
|
||||
id: t.id,
|
||||
@@ -85,7 +93,7 @@ export default function MiniPlayer() {
|
||||
return initial.track?.duration ?? 0;
|
||||
});
|
||||
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const [queueOpen, setQueueOpen] = useState(readQueueOpen);
|
||||
const [scrollMeta, setScrollMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
|
||||
const ticker = useRef<number | null>(null);
|
||||
const queueScrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -137,6 +145,22 @@ export default function MiniPlayer() {
|
||||
emit('mini:ready', {}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Restore the expanded window size on initial mount when the queue was
|
||||
// open at the previous app close. Rust always builds the window at the
|
||||
// collapsed size; without this we'd render queueOpen=true into a 180 px
|
||||
// window. Brief jump from collapsed to expanded is unavoidable since
|
||||
// localStorage only lives in JS.
|
||||
useEffect(() => {
|
||||
if (!queueOpen) return;
|
||||
invoke('resize_mini_player', {
|
||||
width: EXPANDED_SIZE.w,
|
||||
height: readStoredExpandedHeight(),
|
||||
minWidth: EXPANDED_MIN.w,
|
||||
minHeight: EXPANDED_MIN.h,
|
||||
}).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Re-apply pin state on mount and whenever the window regains focus.
|
||||
// After a Hide → Show cycle (which is what `open_mini_player` does on
|
||||
// re-toggle) the WM often drops the always-on-top constraint silently;
|
||||
@@ -217,6 +241,7 @@ export default function MiniPlayer() {
|
||||
}
|
||||
}
|
||||
setQueueOpen(next);
|
||||
try { localStorage.setItem(QUEUE_OPEN_KEY, next ? '1' : '0'); } catch {}
|
||||
const targetH = next ? readStoredExpandedHeight() : COLLAPSED_SIZE.h;
|
||||
const targetW = next ? EXPANDED_SIZE.w : COLLAPSED_SIZE.w;
|
||||
const min = next ? EXPANDED_MIN : COLLAPSED_MIN;
|
||||
|
||||
Reference in New Issue
Block a user