fix(updater): rebuild the update modal on a reusable Modal (#1142)

* feat(ui): add reusable Modal component

Portal + dimmed backdrop (no backdrop-filter — on WebKitGTK the blur bleeds
onto modal content on some GPU stacks), Escape/backdrop/X to close, header
(icon/title/subtitle) + scrollable body + footer slot. First consumer is the
update modal; the other hand-rolled modals migrate onto it over time.

* fix(updater): rebuild the update modal on the reusable Modal

Fixes a user report (Manjaro / AMD / X11): blurry modal text (the eq-popup
backdrop-filter bled onto the content), the version arrow sitting between the
two header rows, and the unclear Skip / Remind buttons. Now uses the no-blur
Modal shell; the header icon aligns with the title; Skip is a clear button and
Remind me later is the accent action when there is no in-app install. Drops the
now-dead update-modal shell CSS.

* docs(changelog): update notification popup fix (PR #1142)
This commit is contained in:
Psychotoxical
2026-06-21 00:48:26 +02:00
committed by GitHub
parent 538dbe3db1
commit 30ccaf51a8
6 changed files with 382 additions and 263 deletions
+6
View File
@@ -146,6 +146,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Track-advance queue pushes no longer suspend idle auto-pull, so the connection LED does not flash yellow on every song change. Yellow sync still appears after a local queue edit while paused; it clears while audio is playing.
### Update notification — clearer popup on Linux
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1142](https://github.com/Psychotoxical/psysonic/pull/1142)**, reported by zunoz on Discord
* The "new version available" popup no longer shows blurry, unfocused text on some Linux setups (the background blur could bleed onto the dialog). The version arrow now lines up with the heading, and the Skip / Remind me later buttons read clearly — Remind me later is the highlighted action when there's no in-app installer.
## [1.48.1] - 2026-06-15
+198 -211
View File
@@ -1,10 +1,11 @@
import { createPortal } from 'react-dom';
import { type ReactNode } from 'react';
import { open } from '@tauri-apps/plugin-shell';
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck, X } from 'lucide-react';
import { ArrowUpCircle, CheckCircle2, ChevronDown, Download, FolderOpen, RefreshCw, ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { version as currentVersion } from '../../package.json';
import { formatBytes } from '../utils/format/formatBytes';
import { useAppUpdater } from '../hooks/useAppUpdater';
import Modal from './Modal';
import Changelog from './appUpdater/Changelog';
export default function AppUpdater() {
@@ -18,222 +19,208 @@ export default function AppUpdater() {
if (!release || dismissed) return null;
return createPortal(
<>
<div className="eq-popup-backdrop" onClick={() => setDismissed(true)} style={{ zIndex: 3000 }} />
<div
className="eq-popup update-modal"
style={{ zIndex: 3001 }}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="eq-popup-header update-modal-header">
<ArrowUpCircle size={16} style={{ color: 'var(--accent)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<span className="eq-popup-title">{t('common.updaterModalTitle')}</span>
<span className="update-modal-versions">
v{currentVersion} <strong>v{release.version}</strong>
</span>
</div>
<button
className="app-updater-dismiss"
onClick={() => setDismissed(true)}
data-tooltip={t('common.updaterRemindBtn')}
data-tooltip-pos="bottom"
>
<X size={14} />
// Footer actions — state-dependent. Downloading has no actions (no footer).
// When there is no in-app install (AUR / from-source), "Remind me later" is the
// primary action, so it gets the accent button; Skip stays a clear button.
let footer: ReactNode = null;
if (dlState === 'idle') {
footer = (
<>
<button className="btn btn-surface" onClick={handleSkip}>
{t('common.updaterSkipBtn')}
</button>
<div style={{ flex: 1 }} />
<button
className={`btn ${showInstallBtn ? 'btn-surface' : 'btn-primary'}`}
onClick={() => setDismissed(true)}
>
{t('common.updaterRemindBtn')}
</button>
{showInstallBtn && (
<button className="btn btn-primary" onClick={handleDownload}>
<Download size={14} />
{useTauriUpdater
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
: t('common.updaterDownloadBtn')}
</button>
</div>
)}
</>
);
} else if (dlState === 'done' && useTauriUpdater) {
footer = (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={handleRestartNow}>
<RefreshCw size={14} />
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
</button>
</>
);
} else if (dlState === 'done') {
footer = (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
</>
);
} else if (dlState === 'error') {
footer = (
<>
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={handleDownload}>
{t('common.updaterRetryBtn')}
</button>
</>
);
}
{/* Scrollable body: changelog + download area — single overflow container */}
<div className="update-modal-body">
{/* Collapsible Changelog */}
{release.body && (
<div className="update-modal-changelog">
<button
type="button"
className="update-modal-changelog-toggle"
onClick={() => setChangelogOpen(v => !v)}
>
<ChevronDown
size={13}
style={{
transform: changelogOpen ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s',
flexShrink: 0,
}}
/>
{t('common.updaterChangelog')}
</button>
{changelogOpen && (
<div className="update-modal-changelog-body">
<Changelog body={release.body} />
</div>
)}
</div>
)}
{/* Download / AUR area */}
<div className="update-modal-download-area">
{showAurHint ? (
<div className="update-modal-aur">
<div className="update-modal-aur-title">{t('common.updaterAurHint')}</div>
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
</div>
) : useTauriUpdater ? (
<>
{dlState === 'idle' && (
<div className="update-modal-mac-info">
<div className="update-modal-mac-info-main">
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
</div>
<div className="update-modal-mac-info-sub">
{t('common.updaterMacReady', {
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
})}
</div>
<div className="update-modal-trust-badges">
<span className="update-modal-trust-badge">
<ShieldCheck size={12} />
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
</span>
<span className="update-modal-trust-badge">
<CheckCircle2 size={12} />
{t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
</span>
</div>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{formatBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<CheckCircle2 size={32} className="update-modal-done-icon" />
<div className="update-modal-done-title">
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
</div>
<div className="update-modal-done-countdown">
{countdown !== null
? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
: t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
</div>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : asset ? (
<>
{dlState === 'idle' && (
<div className="update-modal-asset">
<span className="update-modal-asset-name">{asset.name}</span>
<span className="update-modal-asset-size">{formatBytes(asset.size)}</span>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{formatBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<div className="update-modal-done-title">{t('common.updaterDone')}</div>
<div className="update-modal-done-hint">{t('common.updaterInstallHint')}</div>
<button className="btn btn-surface update-modal-folder-btn" onClick={handleShowFolder}>
<FolderOpen size={14} />
{t('common.updaterShowFolder')}
</button>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : (
<div className="update-modal-asset-none">
<button
className="app-updater-btn-primary"
onClick={() => open(`https://github.com/Psychotoxical/psysonic/releases/tag/${release.tag}`)}
>
{t('common.updaterOpenGitHub')}
</button>
return (
<Modal
open
onClose={() => setDismissed(true)}
icon={<ArrowUpCircle size={18} />}
title={t('common.updaterModalTitle')}
subtitle={<>v{currentVersion} <strong>v{release.version}</strong></>}
closeLabel={t('common.updaterRemindBtn')}
footer={footer}
>
{/* Collapsible changelog */}
{release.body && (
<div className="update-modal-changelog">
<button
type="button"
className="update-modal-changelog-toggle"
onClick={() => setChangelogOpen(v => !v)}
>
<ChevronDown
size={13}
style={{
transform: changelogOpen ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s',
flexShrink: 0,
}}
/>
{t('common.updaterChangelog')}
</button>
{changelogOpen && (
<div className="update-modal-changelog-body">
<Changelog body={release.body} />
</div>
)}
</div>
</div>{/* end update-modal-body */}
)}
{/* Footer buttons — state-dependent to avoid redundant/jumping buttons */}
<div className="update-modal-footer">
{dlState === 'idle' && (
<>
<button className="btn btn-ghost update-modal-skip" onClick={handleSkip}>
{t('common.updaterSkipBtn')}
</button>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
{showInstallBtn && (
<button className="btn btn-primary" onClick={handleDownload}>
<Download size={14} />
{useTauriUpdater
? t('common.updaterInstallNow', { defaultValue: 'Install now' })
: t('common.updaterDownloadBtn')}
{/* Download / AUR area */}
<div className="update-modal-download-area">
{showAurHint ? (
<div className="update-modal-aur">
<div className="update-modal-aur-title">{t('common.updaterAurHint')}</div>
<code className="update-modal-aur-cmd">yay -S psysonic-bin</code>
<code className="update-modal-aur-cmd update-modal-aur-alt">sudo pacman -Syu psysonic-bin</code>
</div>
) : useTauriUpdater ? (
<>
{dlState === 'idle' && (
<div className="update-modal-mac-info">
<div className="update-modal-mac-info-main">
{t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })}
</div>
<div className="update-modal-mac-info-sub">
{t('common.updaterMacReady', {
defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.',
})}
</div>
<div className="update-modal-trust-badges">
<span className="update-modal-trust-badge">
<ShieldCheck size={12} />
{t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })}
</span>
<span className="update-modal-trust-badge">
<CheckCircle2 size={12} />
{t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })}
</span>
</div>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{formatBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<CheckCircle2 size={32} className="update-modal-done-icon" />
<div className="update-modal-done-title">
{t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })}
</div>
<div className="update-modal-done-countdown">
{countdown !== null
? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown })
: t('common.updaterRestarting', { defaultValue: 'Restarting…' })}
</div>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : asset ? (
<>
{dlState === 'idle' && (
<div className="update-modal-asset">
<span className="update-modal-asset-name">{asset.name}</span>
<span className="update-modal-asset-size">{formatBytes(asset.size)}</span>
</div>
)}
{dlState === 'downloading' && (
<div className="update-modal-progress">
<div className="app-updater-progress-bar">
<div className="app-updater-progress-fill" style={{ width: `${pct}%` }} />
</div>
<span className="app-updater-pct">{pct}%</span>
<span className="update-modal-dl-bytes">
{formatBytes(dlProgress.bytes)}
{dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`}
</span>
</div>
)}
{dlState === 'done' && (
<div className="update-modal-done">
<div className="update-modal-done-title">{t('common.updaterDone')}</div>
<div className="update-modal-done-hint">{t('common.updaterInstallHint')}</div>
<button className="btn btn-surface update-modal-folder-btn" onClick={handleShowFolder}>
<FolderOpen size={14} />
{t('common.updaterShowFolder')}
</button>
)}
</>
)}
{dlState === 'downloading' && <div style={{ flex: 1 }} />}
{dlState === 'done' && useTauriUpdater && (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-primary" onClick={handleRestartNow}>
<RefreshCw size={14} />
{t('common.updaterRestartNow', { defaultValue: 'Restart now' })}
</button>
</>
)}
{dlState === 'done' && !useTauriUpdater && (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
</>
)}
{dlState === 'error' && (
<>
<div style={{ flex: 1 }} />
<button className="btn btn-surface" onClick={() => setDismissed(true)}>
{t('common.updaterRemindBtn')}
</button>
<button className="btn btn-primary" onClick={handleDownload}>
{t('common.updaterRetryBtn')}
</button>
</>
)}
</div>
</div>
)}
{dlState === 'error' && (
<div className="app-updater-error">{dlError || t('common.updaterErrorMsg')}</div>
)}
</>
) : (
<div className="update-modal-asset-none">
<button
className="app-updater-btn-primary"
onClick={() => open(`https://github.com/Psychotoxical/psysonic/releases/tag/${release.tag}`)}
>
{t('common.updaterOpenGitHub')}
</button>
</div>
)}
</div>
</>,
document.body
</Modal>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { type ReactNode, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
interface ModalProps {
open: boolean;
/** Called on Escape, backdrop click, and the header close button. */
onClose: () => void;
title: ReactNode;
/** Smaller line under the title (e.g. a version range). */
subtitle?: ReactNode;
/** Leading icon, aligned with the title line. */
icon?: ReactNode;
/** Action row pinned to the bottom, above the modal edge. */
footer?: ReactNode;
size?: 'sm' | 'md' | 'lg';
/** Hide the header close (X) button. */
hideClose?: boolean;
/** Accessible label / tooltip for the close button. */
closeLabel?: string;
children: ReactNode;
}
/**
* Reusable modal shell: a portal to `document.body` with an opaque, centered
* card over a dimmed backdrop. Closes on Escape, backdrop click and the header
* X. Deliberately uses **no `backdrop-filter`** — on WebKitGTK (some GPU stacks)
* the blur bleeds onto the modal content, making text look unfocused. The other
* hand-rolled modals migrate onto this over time.
*/
export default function Modal({
open, onClose, title, subtitle, icon, footer, size = 'md', hideClose, closeLabel, children,
}: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="ui-modal-backdrop" onClick={onClose}>
<div
className={`ui-modal ui-modal--${size}`}
role="dialog"
aria-modal="true"
onClick={e => e.stopPropagation()}
>
<div className="ui-modal-header">
{icon && <span className="ui-modal-icon">{icon}</span>}
<div className="ui-modal-titles">
<span className="ui-modal-title">{title}</span>
{subtitle != null && <span className="ui-modal-subtitle">{subtitle}</span>}
</div>
{!hideClose && (
<button
type="button"
className="ui-modal-close"
onClick={onClose}
aria-label={closeLabel ?? 'Close'}
data-tooltip={closeLabel}
data-tooltip-pos="bottom"
>
<X size={16} />
</button>
)}
</div>
<div className="ui-modal-body">{children}</div>
{footer && <div className="ui-modal-footer">{footer}</div>}
</div>
</div>,
document.body,
);
}
+1
View File
@@ -17,6 +17,7 @@
@import './share-queue-preview-modal.css';
@import './tracklist.css';
@import './modal.css';
@import './ui-modal.css';
@import './playback-delay-sleep-delayed-start-modal.css';
@import './lucky-mix-pips-shared-by-the-inline-queue-lucky-cube-indicator.css';
@import './playback-buffering-overlay.css';
+98
View File
@@ -0,0 +1,98 @@
/* ─── Reusable Modal shell (src/components/Modal.tsx) ─── */
@keyframes ui-modal-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.ui-modal-backdrop {
position: fixed;
inset: 0;
z-index: 3000;
background: var(--modal-scrim, rgba(0, 0, 0, 0.5));
display: flex;
align-items: center;
justify-content: center;
padding: 6vh 16px;
animation: fadeIn 150ms ease both;
/* No backdrop-filter: on WebKitGTK (some GPU stacks) the blur bleeds onto the
modal content and makes its text look unfocused. */
}
.ui-modal {
position: relative;
display: flex;
flex-direction: column;
width: min(520px, 94vw);
max-height: 88vh;
background: var(--bg-sidebar);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
animation: ui-modal-in 200ms ease both;
}
.ui-modal--sm { width: min(400px, 94vw); }
.ui-modal--lg { width: min(720px, 94vw); }
.ui-modal-header {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px 16px 12px 18px;
}
.ui-modal-icon {
display: flex;
align-items: center;
margin-top: 1px;
color: var(--accent);
flex-shrink: 0;
}
.ui-modal-titles {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.ui-modal-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
}
.ui-modal-subtitle {
font-size: 12px;
color: var(--text-muted);
}
.ui-modal-close {
flex-shrink: 0;
display: flex;
align-items: center;
padding: 2px;
background: none;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-muted);
opacity: 0.7;
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
}
.ui-modal-close:hover {
opacity: 1;
color: var(--text-primary);
background: var(--bg-glass);
}
.ui-modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.ui-modal-footer {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 12px 18px;
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
+1 -52
View File
@@ -34,18 +34,6 @@
text-transform: uppercase;
flex: 1;
}
.app-updater-dismiss {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
padding: 0;
display: flex;
align-items: center;
opacity: 0.6;
transition: opacity var(--transition-fast);
}
.app-updater-dismiss:hover { opacity: 1; }
.app-updater-version {
font-size: 12px;
color: var(--text-secondary);
@@ -136,30 +124,7 @@
font-style: italic;
}
/* ── Update Modal ────────────────────────────────────────────────────────── */
.update-modal {
width: min(520px, 94vw);
gap: 0;
padding: 0;
max-height: 85vh;
display: flex;
flex-direction: column;
}
.update-modal-header {
padding: 14px 16px 14px 18px;
}
.update-modal-versions {
font-size: 12px;
color: var(--text-muted);
display: block;
margin-top: 2px;
}
/* Scrollable middle section (changelog + download area combined) */
.update-modal-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* ── Update Modal — content styles (the shell is the reusable Modal) ── */
/* Changelog accordion */
.update-modal-changelog {
border-top: 1px solid var(--border-subtle);
@@ -318,22 +283,6 @@
.update-modal-asset-none {
padding: 4px 0;
}
/* Footer */
.update-modal-footer {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 12px 18px;
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.update-modal-skip {
font-size: 12px;
color: var(--text-muted);
padding: var(--space-1) var(--space-2);
}
.update-modal-skip:hover { color: var(--text-secondary); }
.update-toast {
margin: 0 var(--space-1) var(--space-2);
padding: var(--space-3) var(--space-3);