mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(mini-player): persistent geometry, queue DnD + context menu, overlay scrollbar, live theme sync
This iteration fills in the pieces that make the mini player usable as a daily standalone window: - Window position persists to <app_config_dir>/mini_player_pos.json on every WindowEvent::Moved (throttled 250 ms); first launch lands in the bottom-right of the main window's monitor. - Queue keeps a 260 px floor when expanded (~2 visible rows). The user-resized expanded height is restored on next toggle via localStorage so reopening doesn't snap back to the default 440 px. - set_mini_player_always_on_top(true) now forces a false-then-true cycle so the WM re-evaluates the layer after a hide/show; the frontend also re-asserts the pin state on mount and on window focus, so the user no longer has to click the pin button twice for it to stick. - Native scrollbar in the queue is hidden in favour of a JS-driven overlay thumb so items use the full width of the queue area. - PsyDnD reorder works inside the mini queue: drag emits 'mini:reorder', the bridge calls reorderQueue on the source-of-truth store in main. - Localized queue-item context menu (MiniContextMenu) with Play now / Remove from queue / Open album / Go to artist / Favorite / Song info. Cross-window actions forward to main via new bridge events (mini:reorder, mini:remove, mini:navigate, mini:song-info); a psy:navigate CustomEvent picked up by AppShell handles routing. - Theme, font and language changes in main propagate live to the mini via a 'storage' event listener that re-hydrates the persisted Zustand stores and calls i18n.changeLanguage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+118
-2
@@ -2603,6 +2603,81 @@ fn is_tiling_wm_cmd() -> bool {
|
|||||||
// "mini". On tiling WMs (Hyprland, Sway, i3, …) always-on-top is ignored, so
|
// "mini". On tiling WMs (Hyprland, Sway, i3, …) always-on-top is ignored, so
|
||||||
// we fall back to a regular window there.
|
// we fall back to a regular window there.
|
||||||
|
|
||||||
|
/// Persisted geometry for the mini player. Stored in
|
||||||
|
/// `<app_config_dir>/mini_player_pos.json` and rewritten (throttled) on
|
||||||
|
/// every `WindowEvent::Moved` so the window reopens where the user last
|
||||||
|
/// left it. Coordinates are physical pixels — that's what `set_position`
|
||||||
|
/// and the move event both report, so we don't need to round-trip through
|
||||||
|
/// scale factors that may differ across monitors.
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug)]
|
||||||
|
struct MiniPlayerPosition {
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mini_pos_file(app: &tauri::AppHandle) -> Option<std::path::PathBuf> {
|
||||||
|
app.path().app_config_dir().ok().map(|p| p.join("mini_player_pos.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_mini_pos(app: &tauri::AppHandle) -> Option<MiniPlayerPosition> {
|
||||||
|
let path = mini_pos_file(app)?;
|
||||||
|
let raw = std::fs::read_to_string(&path).ok()?;
|
||||||
|
serde_json::from_str(&raw).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_mini_pos(app: &tauri::AppHandle, pos: MiniPlayerPosition) {
|
||||||
|
let Some(path) = mini_pos_file(app) else { return };
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Ok(json) = serde_json::to_string(&pos) {
|
||||||
|
let _ = std::fs::write(path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn persist_mini_pos_throttled(app: &tauri::AppHandle, x: i32, y: i32) {
|
||||||
|
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))
|
||||||
|
});
|
||||||
|
{
|
||||||
|
let mut last = mu.lock().unwrap();
|
||||||
|
if last.elapsed() < std::time::Duration::from_millis(250) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*last = std::time::Instant::now();
|
||||||
|
}
|
||||||
|
write_mini_pos(app, MiniPlayerPosition { x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default position when nothing is persisted: bottom-right of the monitor
|
||||||
|
/// the main window sits on (falls back to primary). A 24 px logical margin
|
||||||
|
/// keeps it off the screen edge; +56 px on the bottom margin avoids most
|
||||||
|
/// taskbars/docks since Tauri does not expose work-area rects.
|
||||||
|
fn default_mini_position(app: &tauri::AppHandle) -> Option<tauri::PhysicalPosition<i32>> {
|
||||||
|
let monitor = app
|
||||||
|
.get_webview_window("main")
|
||||||
|
.and_then(|w| w.current_monitor().ok().flatten())
|
||||||
|
.or_else(|| app.primary_monitor().ok().flatten())?;
|
||||||
|
|
||||||
|
let scale = monitor.scale_factor();
|
||||||
|
let m_pos = monitor.position();
|
||||||
|
let m_size = monitor.size();
|
||||||
|
|
||||||
|
let win_w = (340.0 * scale).round() as i32;
|
||||||
|
let win_h = (180.0 * scale).round() as i32;
|
||||||
|
let margin_x = (24.0 * scale).round() as i32;
|
||||||
|
let margin_y = (56.0 * scale).round() as i32;
|
||||||
|
|
||||||
|
Some(tauri::PhysicalPosition::new(
|
||||||
|
m_pos.x + (m_size.width as i32) - win_w - margin_x,
|
||||||
|
m_pos.y + (m_size.height as i32) - win_h - margin_y,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Open (or toggle) the mini player window. Creates it on first call; on
|
/// Open (or toggle) the mini player window. Creates it on first call; on
|
||||||
/// subsequent calls, hides it if visible, shows + focuses it if hidden.
|
/// subsequent calls, hides it if visible, shows + focuses it if hidden.
|
||||||
/// Opening the mini player minimizes the main window; hiding the mini player
|
/// Opening the mini player minimizes the main window; hiding the mini player
|
||||||
@@ -2647,9 +2722,19 @@ fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
.decorations(true)
|
.decorations(true)
|
||||||
.always_on_top(use_always_on_top)
|
.always_on_top(use_always_on_top)
|
||||||
.skip_taskbar(false)
|
.skip_taskbar(false)
|
||||||
|
.visible(false) // place first, then show — avoids the corner flash
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("failed to build mini player window: {e}"))?;
|
.map_err(|e| format!("failed to build mini player window: {e}"))?;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
let _ = win.show();
|
||||||
let _ = win.set_focus();
|
let _ = win.set_focus();
|
||||||
if let Some(main) = app.get_webview_window("main") {
|
if let Some(main) = app.get_webview_window("main") {
|
||||||
let _ = main.minimize();
|
let _ = main.minimize();
|
||||||
@@ -2690,19 +2775,43 @@ fn show_main_window(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle always-on-top on the mini player window.
|
/// Toggle always-on-top on the mini player window.
|
||||||
|
///
|
||||||
|
/// Some window managers (KWin, certain Mutter releases, GNOME-on-Wayland)
|
||||||
|
/// silently ignore `set_always_on_top(true)` when the internal flag is
|
||||||
|
/// already `true` — which happens whenever the window was hidden and
|
||||||
|
/// re-shown, or focus was lost and the WM dropped the constraint. We
|
||||||
|
/// always force a `false → true` cycle so the WM re-evaluates the layer.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn set_mini_player_always_on_top(app: tauri::AppHandle, on_top: bool) -> Result<(), String> {
|
fn set_mini_player_always_on_top(app: tauri::AppHandle, on_top: bool) -> Result<(), String> {
|
||||||
if let Some(win) = app.get_webview_window("mini") {
|
if let Some(win) = app.get_webview_window("mini") {
|
||||||
|
if on_top {
|
||||||
|
let _ = win.set_always_on_top(false);
|
||||||
|
}
|
||||||
win.set_always_on_top(on_top).map_err(|e| e.to_string())?;
|
win.set_always_on_top(on_top).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resize the mini player window (logical pixels). Used when toggling the
|
/// Resize the mini player window (logical pixels). Used when toggling the
|
||||||
/// queue panel to expand/collapse without a capability dance.
|
/// queue panel to expand/collapse without a capability dance. Optional
|
||||||
|
/// `minWidth` / `minHeight` adjust the window's resize floor so the user
|
||||||
|
/// can't shrink past the layout's minimum (e.g. 2 visible queue rows when
|
||||||
|
/// the queue panel is open).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn resize_mini_player(app: tauri::AppHandle, width: f64, height: f64) -> Result<(), String> {
|
fn resize_mini_player(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
width: f64,
|
||||||
|
height: f64,
|
||||||
|
min_width: Option<f64>,
|
||||||
|
min_height: Option<f64>,
|
||||||
|
) -> Result<(), String> {
|
||||||
if let Some(win) = app.get_webview_window("mini") {
|
if let Some(win) = app.get_webview_window("mini") {
|
||||||
|
// Lower the floor first; otherwise set_size to a value below the
|
||||||
|
// existing min would silently clamp.
|
||||||
|
if let (Some(mw), Some(mh)) = (min_width, min_height) {
|
||||||
|
win.set_min_size(Some(tauri::LogicalSize::new(mw, mh)))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
win.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?;
|
win.set_size(tauri::LogicalSize::new(width, height)).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2891,6 +3000,13 @@ pub fn run() {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
|
// Persist mini player position whenever the user drags it.
|
||||||
|
if window.label() == "mini" {
|
||||||
|
if let tauri::WindowEvent::Moved(pos) = event {
|
||||||
|
persist_mini_pos_throttled(window.app_handle(), pos.x, pos.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
if window.label() == "main" {
|
if window.label() == "main" {
|
||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
|
|||||||
+36
-1
@@ -146,6 +146,17 @@ function AppShell() {
|
|||||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||||
|
|
||||||
|
// Mini player → main: route requests dispatched as `psy:navigate`
|
||||||
|
// CustomEvents from the bridge land here so React Router can take over.
|
||||||
|
useEffect(() => {
|
||||||
|
const onPsyNavigate = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
if (detail?.to) navigate(detail.to);
|
||||||
|
};
|
||||||
|
window.addEventListener('psy:navigate', onPsyNavigate);
|
||||||
|
return () => window.removeEventListener('psy:navigate', onPsyNavigate);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
// Sync custom titlebar preference with native decorations on Linux
|
// Sync custom titlebar preference with native decorations on Linux
|
||||||
// On tiling WMs decorations are always off (no native title bar to replace).
|
// On tiling WMs decorations are always off (no native title bar to replace).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -961,8 +972,32 @@ export default function App() {
|
|||||||
return initMiniPlayerBridgeOnMain();
|
return initMiniPlayerBridgeOnMain();
|
||||||
}, [isMiniWindow]);
|
}, [isMiniWindow]);
|
||||||
|
|
||||||
|
// Mini window only: re-hydrate persisted appearance stores when the main
|
||||||
|
// window writes new values. Both webviews share localStorage (same origin),
|
||||||
|
// so the `storage` event fires here whenever main mutates a key — but
|
||||||
|
// Zustand persist only reads localStorage on initial load, hence the
|
||||||
|
// explicit rehydrate.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMiniWindow) return;
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (!e.key) return;
|
||||||
|
if (e.key === 'psysonic_theme') useThemeStore.persist.rehydrate();
|
||||||
|
else if (e.key === 'psysonic_font') useFontStore.persist.rehydrate();
|
||||||
|
else if (e.key === 'psysonic_language' && e.newValue) {
|
||||||
|
import('./i18n').then(m => m.default.changeLanguage(e.newValue!));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
return () => window.removeEventListener('storage', onStorage);
|
||||||
|
}, [isMiniWindow]);
|
||||||
|
|
||||||
if (isMiniWindow) {
|
if (isMiniWindow) {
|
||||||
return <MiniPlayer />;
|
return (
|
||||||
|
<DragDropProvider>
|
||||||
|
<MiniPlayer />
|
||||||
|
<TooltipPortal />
|
||||||
|
</DragDropProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI scaling is scoped to .main-content via an inner wrapper (see <main>
|
// UI scaling is scoped to .main-content via an inner wrapper (see <main>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { emit } from '@tauri-apps/api/event';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Play, Trash2, Disc3, User, Heart, Info } from 'lucide-react';
|
||||||
|
import { star, unstar } from '../api/subsonic';
|
||||||
|
import type { MiniTrackInfo } from '../utils/miniPlayerBridge';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
track: MiniTrackInfo;
|
||||||
|
index: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slim queue-item context menu for the mini player. The mini lives in its
|
||||||
|
* own webview, so all queue mutations forward to the main window via Tauri
|
||||||
|
* events; only the favorite call hits Subsonic directly because it has no
|
||||||
|
* cross-window state to keep in sync (next mini:sync from main reflects the
|
||||||
|
* new starred flag).
|
||||||
|
*/
|
||||||
|
export default function MiniContextMenu({ x, y, track, index, onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [starred, setStarred] = useState(!!track.starred);
|
||||||
|
const [pos, setPos] = useState({ left: x, top: y });
|
||||||
|
|
||||||
|
// Clamp the menu inside the mini window's viewport (it pops near the
|
||||||
|
// cursor and would otherwise overflow at the right/bottom edges of the
|
||||||
|
// small window).
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const left = Math.min(x, Math.max(4, vw - r.width - 4));
|
||||||
|
const top = Math.min(y, Math.max(4, vh - r.height - 4));
|
||||||
|
setPos({ left, top });
|
||||||
|
}, [x, y]);
|
||||||
|
|
||||||
|
// Dismiss on outside click + Escape.
|
||||||
|
useEffect(() => {
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
document.addEventListener('mousedown', onDown);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const run = (fn: () => void | Promise<void>) => {
|
||||||
|
Promise.resolve(fn()).finally(onClose);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleStar = async () => {
|
||||||
|
const next = !starred;
|
||||||
|
setStarred(next);
|
||||||
|
try {
|
||||||
|
if (next) await star(track.id, 'song');
|
||||||
|
else await unstar(track.id, 'song');
|
||||||
|
} catch {
|
||||||
|
setStarred(!next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="context-menu mini-context-menu"
|
||||||
|
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 99998 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div className="context-menu-item" onClick={() => run(() => emit('mini:jump', { index }))}>
|
||||||
|
<Play size={14} /> {t('contextMenu.playNow')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="context-menu-item"
|
||||||
|
style={{ color: 'var(--danger)' }}
|
||||||
|
onClick={() => run(() => emit('mini:remove', { index }))}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> {t('contextMenu.removeFromQueue')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
{track.albumId && (
|
||||||
|
<div
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => run(() => emit('mini:navigate', { to: `/album/${track.albumId}` }))}
|
||||||
|
>
|
||||||
|
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{track.artistId && (
|
||||||
|
<div
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => run(() => emit('mini:navigate', { to: `/artist/${track.artistId}` }))}
|
||||||
|
>
|
||||||
|
<User size={14} /> {t('contextMenu.goToArtist')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="context-menu-item" onClick={() => run(toggleStar)}>
|
||||||
|
<Heart size={14} fill={starred ? 'currentColor' : 'none'} />
|
||||||
|
{starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||||
|
</div>
|
||||||
|
<div className="context-menu-divider" />
|
||||||
|
<div
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => run(() => emit('mini:song-info', { id: track.id }))}
|
||||||
|
>
|
||||||
|
<Info size={14} /> {t('contextMenu.songInfo')}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
+243
-20
@@ -1,14 +1,35 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { emit, listen } from '@tauri-apps/api/event';
|
import { emit, listen } from '@tauri-apps/api/event';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic } from 'lucide-react';
|
import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X, ListMusic } from 'lucide-react';
|
||||||
import CachedImage from './CachedImage';
|
import CachedImage from './CachedImage';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
import { useDragDrop } from '../contexts/DragDropContext';
|
||||||
|
import MiniContextMenu from './MiniContextMenu';
|
||||||
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
|
import type { MiniSyncPayload, MiniControlAction, MiniTrackInfo } from '../utils/miniPlayerBridge';
|
||||||
|
|
||||||
const COLLAPSED_SIZE = { w: 340, h: 180 };
|
const COLLAPSED_SIZE = { w: 340, h: 180 };
|
||||||
const EXPANDED_SIZE = { w: 340, h: 440 };
|
const EXPANDED_SIZE = { w: 340, h: 440 };
|
||||||
|
// Minimum window dimensions per state. When the queue is open the floor must
|
||||||
|
// keep at least two queue rows visible; a stricter min would let the user
|
||||||
|
// collapse the queue area to nothing while it's still toggled on.
|
||||||
|
const COLLAPSED_MIN = { w: 320, h: 180 };
|
||||||
|
const EXPANDED_MIN = { w: 320, h: 260 };
|
||||||
|
|
||||||
|
// Persist the expanded-window height so reopening the queue restores the
|
||||||
|
// user's preferred size instead of snapping back to EXPANDED_SIZE.h.
|
||||||
|
const EXPANDED_H_KEY = 'psysonic_mini_expanded_h';
|
||||||
|
function readStoredExpandedHeight(): number {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(EXPANDED_H_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const n = parseInt(raw, 10);
|
||||||
|
if (Number.isFinite(n) && n >= EXPANDED_MIN.h) return n;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return EXPANDED_SIZE.h;
|
||||||
|
}
|
||||||
|
|
||||||
function toMini(t: any): MiniTrackInfo {
|
function toMini(t: any): MiniTrackInfo {
|
||||||
return {
|
return {
|
||||||
@@ -65,14 +86,73 @@ export default function MiniPlayer() {
|
|||||||
});
|
});
|
||||||
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
|
const [alwaysOnTop, setAlwaysOnTop] = useState(true);
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
|
const [scrollMeta, setScrollMeta] = useState({ thumbH: 0, thumbT: 0, visible: false });
|
||||||
const ticker = useRef<number | null>(null);
|
const ticker = useRef<number | null>(null);
|
||||||
const queueScrollRef = useRef<HTMLDivElement>(null);
|
const queueScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// ── PsyDnD reorder ──
|
||||||
|
// Mirrors QueuePanel's pattern: mousedown threshold → startDrag, mousemove
|
||||||
|
// on the queue computes a drop indicator, psy-drop emits mini:reorder back
|
||||||
|
// to main where the source-of-truth store lives.
|
||||||
|
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
|
||||||
|
const psyDragFromIdxRef = useRef<number | null>(null);
|
||||||
|
const [dropTarget, setDropTarget] = useState<{ idx: number; before: boolean } | null>(null);
|
||||||
|
const dropTargetRef = useRef<{ idx: number; before: boolean } | null>(null);
|
||||||
|
|
||||||
|
const isReorderDrag = isPsyDragging && !!psyPayload && (() => {
|
||||||
|
try { return JSON.parse(psyPayload.data).type === 'queue_reorder'; } catch { return false; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPsyDragging) {
|
||||||
|
dropTargetRef.current = null;
|
||||||
|
setDropTarget(null);
|
||||||
|
}
|
||||||
|
}, [isPsyDragging]);
|
||||||
|
|
||||||
|
// ── Context menu state ──
|
||||||
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; track: MiniTrackInfo; index: number } | null>(null);
|
||||||
|
|
||||||
|
// Compute overlay-scrollbar thumb height + offset from the queue's scroll
|
||||||
|
// metrics. Native scrollbar is hidden via CSS; this thumb floats over the
|
||||||
|
// items so the queue keeps its full width.
|
||||||
|
const recomputeScroll = useCallback(() => {
|
||||||
|
const el = queueScrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||||
|
if (scrollHeight <= clientHeight + 1) {
|
||||||
|
setScrollMeta(prev => (prev.visible ? { thumbH: 0, thumbT: 0, visible: false } : prev));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ratio = clientHeight / scrollHeight;
|
||||||
|
const thumbH = Math.max(24, Math.round(ratio * clientHeight));
|
||||||
|
const range = clientHeight - thumbH;
|
||||||
|
const scrollRange = scrollHeight - clientHeight;
|
||||||
|
const thumbT = scrollRange > 0 ? Math.round((scrollTop / scrollRange) * range) : 0;
|
||||||
|
setScrollMeta({ thumbH, thumbT, visible: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Announce to main window that we're mounted; it replies with a snapshot.
|
// Announce to main window that we're mounted; it replies with a snapshot.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
emit('mini:ready', {}).catch(() => {});
|
emit('mini:ready', {}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
// re-asserting it here means the user no longer has to click the pin
|
||||||
|
// button twice to make it stick.
|
||||||
|
useEffect(() => {
|
||||||
|
invoke('set_mini_player_always_on_top', { onTop: alwaysOnTop }).catch(() => {});
|
||||||
|
const reapply = () => {
|
||||||
|
if (alwaysOnTop) {
|
||||||
|
invoke('set_mini_player_always_on_top', { onTop: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('focus', reapply);
|
||||||
|
return () => window.removeEventListener('focus', reapply);
|
||||||
|
}, [alwaysOnTop]);
|
||||||
|
|
||||||
// Keyboard: Space → toggle, ← / → → prev / next. Ignore when typing.
|
// Keyboard: Space → toggle, ← / → → prev / next. Ignore when typing.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -127,13 +207,65 @@ export default function MiniPlayer() {
|
|||||||
|
|
||||||
const toggleQueue = async () => {
|
const toggleQueue = async () => {
|
||||||
const next = !queueOpen;
|
const next = !queueOpen;
|
||||||
|
// Capture the current expanded height before collapsing so the next
|
||||||
|
// open restores it. Read window.innerHeight directly — it matches the
|
||||||
|
// logical inner size that resize_mini_player set previously.
|
||||||
|
if (!next) {
|
||||||
|
const h = Math.round(window.innerHeight);
|
||||||
|
if (h >= EXPANDED_MIN.h) {
|
||||||
|
try { localStorage.setItem(EXPANDED_H_KEY, String(h)); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
setQueueOpen(next);
|
setQueueOpen(next);
|
||||||
const size = next ? EXPANDED_SIZE : COLLAPSED_SIZE;
|
const targetH = next ? readStoredExpandedHeight() : COLLAPSED_SIZE.h;
|
||||||
try { await invoke('resize_mini_player', { width: size.w, height: size.h }); } catch {}
|
const targetW = next ? EXPANDED_SIZE.w : COLLAPSED_SIZE.w;
|
||||||
|
const min = next ? EXPANDED_MIN : COLLAPSED_MIN;
|
||||||
|
try {
|
||||||
|
await invoke('resize_mini_player', {
|
||||||
|
width: targetW,
|
||||||
|
height: targetH,
|
||||||
|
minWidth: min.w,
|
||||||
|
minHeight: min.h,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const jumpTo = (index: number) => emit('mini:jump', { index }).catch(() => {});
|
const jumpTo = (index: number) => emit('mini:jump', { index }).catch(() => {});
|
||||||
|
|
||||||
|
// Listen for psy-drop on the queue. Only handles `queue_reorder` payloads
|
||||||
|
// since the mini player has no external drag sources. `queueOpen` must be
|
||||||
|
// in deps because the wrap (and thus queueScrollRef.current) only mounts
|
||||||
|
// when the queue is expanded — without it the ref is null on first run
|
||||||
|
// and the listener never attaches.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!queueOpen) return;
|
||||||
|
const el = queueScrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onPsyDrop = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
if (!detail?.data) return;
|
||||||
|
let parsed: any = null;
|
||||||
|
try { parsed = JSON.parse(detail.data); } catch { return; }
|
||||||
|
const tgt = dropTargetRef.current;
|
||||||
|
dropTargetRef.current = null;
|
||||||
|
setDropTarget(null);
|
||||||
|
if (parsed.type !== 'queue_reorder') return;
|
||||||
|
const fromIdx = parsed.index as number;
|
||||||
|
psyDragFromIdxRef.current = null;
|
||||||
|
const queueLen = usePlayerStore.getState().queue.length || state.queue.length;
|
||||||
|
const insertIdx = tgt
|
||||||
|
? (tgt.before ? tgt.idx : tgt.idx + 1)
|
||||||
|
: queueLen;
|
||||||
|
if (fromIdx === insertIdx || fromIdx === insertIdx - 1) return;
|
||||||
|
// Adjust target index if removing the source first shifts later items.
|
||||||
|
const adjusted = fromIdx < insertIdx ? insertIdx - 1 : insertIdx;
|
||||||
|
if (fromIdx === adjusted) return;
|
||||||
|
emit('mini:reorder', { from: fromIdx, to: adjusted }).catch(() => {});
|
||||||
|
};
|
||||||
|
el.addEventListener('psy-drop', onPsyDrop);
|
||||||
|
return () => el.removeEventListener('psy-drop', onPsyDrop);
|
||||||
|
}, [queueOpen, state.queue.length]);
|
||||||
|
|
||||||
// Auto-scroll the current track into view when the queue expands.
|
// Auto-scroll the current track into view when the queue expands.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!queueOpen) return;
|
if (!queueOpen) return;
|
||||||
@@ -141,6 +273,15 @@ export default function MiniPlayer() {
|
|||||||
el?.scrollIntoView({ block: 'nearest' });
|
el?.scrollIntoView({ block: 'nearest' });
|
||||||
}, [queueOpen, state.queueIndex]);
|
}, [queueOpen, state.queueIndex]);
|
||||||
|
|
||||||
|
// Recompute overlay-thumb on open, queue mutations, and window resize.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!queueOpen) return;
|
||||||
|
recomputeScroll();
|
||||||
|
const onResize = () => recomputeScroll();
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => window.removeEventListener('resize', onResize);
|
||||||
|
}, [queueOpen, state.queue.length, recomputeScroll]);
|
||||||
|
|
||||||
const { track, isPlaying } = state;
|
const { track, isPlaying } = state;
|
||||||
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
||||||
|
|
||||||
@@ -213,26 +354,108 @@ export default function MiniPlayer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{queueOpen && (
|
{queueOpen && (
|
||||||
<div className="mini-queue" ref={queueScrollRef}>
|
<div
|
||||||
{state.queue.length === 0 ? (
|
className={`mini-queue-wrap${isReorderDrag ? ' mini-queue-wrap--drop-active' : ''}`}
|
||||||
<div className="mini-queue__empty">Queue is empty</div>
|
onMouseMove={(e) => {
|
||||||
) : (
|
if (!isReorderDrag || !queueScrollRef.current) return;
|
||||||
state.queue.map((t, i) => (
|
const items = queueScrollRef.current.querySelectorAll<HTMLElement>('[data-mq-idx]');
|
||||||
<button
|
for (let i = 0; i < items.length; i++) {
|
||||||
key={`${t.id}-${i}`}
|
const r = items[i].getBoundingClientRect();
|
||||||
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}`}
|
if (e.clientY >= r.top && e.clientY <= r.bottom) {
|
||||||
onClick={() => jumpTo(i)}
|
const before = e.clientY < r.top + r.height / 2;
|
||||||
>
|
const idx = parseInt(items[i].dataset.mqIdx!, 10);
|
||||||
<span className="mini-queue__num">{i + 1}</span>
|
const t = { idx, before };
|
||||||
<div className="mini-queue__meta">
|
dropTargetRef.current = t;
|
||||||
<div className="mini-queue__title">{t.title}</div>
|
setDropTarget(t);
|
||||||
<div className="mini-queue__artist">{t.artist}</div>
|
return;
|
||||||
</div>
|
}
|
||||||
</button>
|
}
|
||||||
))
|
dropTargetRef.current = null;
|
||||||
|
setDropTarget(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mini-queue" ref={queueScrollRef} onScroll={recomputeScroll}>
|
||||||
|
{state.queue.length === 0 ? (
|
||||||
|
<div className="mini-queue__empty">Queue is empty</div>
|
||||||
|
) : (
|
||||||
|
state.queue.map((t, i) => {
|
||||||
|
let dragStyle: React.CSSProperties = {};
|
||||||
|
if (isReorderDrag && psyDragFromIdxRef.current === i) {
|
||||||
|
dragStyle = { opacity: 0.4 };
|
||||||
|
} else if (isReorderDrag && dropTarget?.idx === i) {
|
||||||
|
dragStyle = dropTarget.before
|
||||||
|
? { boxShadow: 'inset 0 2px 0 var(--accent)' }
|
||||||
|
: { boxShadow: 'inset 0 -2px 0 var(--accent)' };
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${t.id}-${i}`}
|
||||||
|
data-mq-idx={i}
|
||||||
|
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxMenu?.index === i ? ' mini-queue__item--ctx' : ''}`}
|
||||||
|
onClick={() => jumpTo(i)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCtxMenu({ x: e.clientX, y: e.clientY, track: t, index: i });
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
// Don't start drag while a click would also be valid —
|
||||||
|
// the threshold check below upgrades to a drag once
|
||||||
|
// the pointer leaves the deadband.
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startY = e.clientY;
|
||||||
|
const onMove = (me: MouseEvent) => {
|
||||||
|
if (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5) {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
psyDragFromIdxRef.current = i;
|
||||||
|
startDrag(
|
||||||
|
{ data: JSON.stringify({ type: 'queue_reorder', index: i }), label: t.title },
|
||||||
|
me.clientX,
|
||||||
|
me.clientY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}}
|
||||||
|
style={dragStyle}
|
||||||
|
>
|
||||||
|
<span className="mini-queue__num">{i + 1}</span>
|
||||||
|
<div className="mini-queue__meta">
|
||||||
|
<div className="mini-queue__title">{t.title}</div>
|
||||||
|
<div className="mini-queue__artist">{t.artist}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{scrollMeta.visible && (
|
||||||
|
<div
|
||||||
|
className="mini-queue__thumb"
|
||||||
|
style={{
|
||||||
|
height: `${scrollMeta.thumbH}px`,
|
||||||
|
transform: `translateY(${scrollMeta.thumbT}px)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ctxMenu && (
|
||||||
|
<MiniContextMenu
|
||||||
|
x={ctxMenu.x}
|
||||||
|
y={ctxMenu.y}
|
||||||
|
track={ctxMenu.track}
|
||||||
|
index={ctxMenu.index}
|
||||||
|
onClose={() => setCtxMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9190,17 +9190,47 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-queue {
|
.mini-queue-wrap {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
overflow-y: auto;
|
position: relative;
|
||||||
overscroll-behavior: contain;
|
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-queue {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Hide native scrollbar — we render an overlay thumb beside it. */
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.mini-queue::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-queue__thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 3px;
|
||||||
|
width: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--text-muted);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.mini-queue-wrap:hover .mini-queue__thumb,
|
||||||
|
.mini-queue.is-scrolling ~ .mini-queue__thumb {
|
||||||
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-queue__empty {
|
.mini-queue__empty {
|
||||||
@@ -9228,6 +9258,14 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active {
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mini-queue__item--ctx {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-queue-wrap--drop-active .mini-queue {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.mini-queue__item--current {
|
.mini-queue__item--current {
|
||||||
background: var(--accent-dim);
|
background: var(--accent-dim);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
|||||||
@@ -120,10 +120,58 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
|||||||
if (track) store.playTrack(track, store.queue, true);
|
if (track) store.playTrack(track, store.queue, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PsyDnD reorder forwarded from the mini queue.
|
||||||
|
const reorderUnlisten = listen<{ from: number; to: number }>('mini:reorder', (e) => {
|
||||||
|
const store = usePlayerStore.getState();
|
||||||
|
const { from, to } = e.payload ?? { from: -1, to: -1 };
|
||||||
|
if (from < 0 || from >= store.queue.length) return;
|
||||||
|
if (to < 0 || to > store.queue.length) return;
|
||||||
|
if (from === to) return;
|
||||||
|
store.reorderQueue(from, to);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove a track at index (context menu → "Remove from queue").
|
||||||
|
const removeUnlisten = listen<{ index: number }>('mini:remove', (e) => {
|
||||||
|
const store = usePlayerStore.getState();
|
||||||
|
const idx = e.payload?.index ?? -1;
|
||||||
|
if (idx < 0 || idx >= store.queue.length) return;
|
||||||
|
store.removeTrack(idx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate the main app to a route. Used by mini context menu actions
|
||||||
|
// like "Open Album" / "Go to Artist" — those need the full main UI.
|
||||||
|
const navigateUnlisten = listen<{ to: string }>('mini:navigate', (e) => {
|
||||||
|
const to = e.payload?.to;
|
||||||
|
if (!to) return;
|
||||||
|
// Surface the main window first so the navigation is visible.
|
||||||
|
const w = getCurrentWindow();
|
||||||
|
w.unminimize().catch(() => {});
|
||||||
|
w.show().catch(() => {});
|
||||||
|
w.setFocus().catch(() => {});
|
||||||
|
// React Router lives in main; route via a custom event the AppShell
|
||||||
|
// picks up (defined in App.tsx).
|
||||||
|
window.dispatchEvent(new CustomEvent('psy:navigate', { detail: { to } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open the SongInfo modal in main for a given track id.
|
||||||
|
const songInfoUnlisten = listen<{ id: string }>('mini:song-info', (e) => {
|
||||||
|
const id = e.payload?.id;
|
||||||
|
if (!id) return;
|
||||||
|
const w = getCurrentWindow();
|
||||||
|
w.unminimize().catch(() => {});
|
||||||
|
w.show().catch(() => {});
|
||||||
|
w.setFocus().catch(() => {});
|
||||||
|
usePlayerStore.getState().openSongInfo(id);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsub();
|
unsub();
|
||||||
readyUnlisten.then(fn => fn()).catch(() => {});
|
readyUnlisten.then(fn => fn()).catch(() => {});
|
||||||
controlUnlisten.then(fn => fn()).catch(() => {});
|
controlUnlisten.then(fn => fn()).catch(() => {});
|
||||||
jumpUnlisten.then(fn => fn()).catch(() => {});
|
jumpUnlisten.then(fn => fn()).catch(() => {});
|
||||||
|
reorderUnlisten.then(fn => fn()).catch(() => {});
|
||||||
|
removeUnlisten.then(fn => fn()).catch(() => {});
|
||||||
|
navigateUnlisten.then(fn => fn()).catch(() => {});
|
||||||
|
songInfoUnlisten.then(fn => fn()).catch(() => {});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user