diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f65b134..0c7188ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/components/AppUpdater.tsx b/src/components/AppUpdater.tsx index 30383772..42e3ec5d 100644 --- a/src/components/AppUpdater.tsx +++ b/src/components/AppUpdater.tsx @@ -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( - <> -
setDismissed(true)} style={{ zIndex: 3000 }} /> -
e.stopPropagation()} - > - {/* Header */} -
- -
- {t('common.updaterModalTitle')} - - v{currentVersion} → v{release.version} - -
- +
+ + {showInstallBtn && ( + -
+ )} + + ); + } else if (dlState === 'done' && useTauriUpdater) { + footer = ( + <> +
+ + + ); + } else if (dlState === 'done') { + footer = ( + <> +
+ + + ); + } else if (dlState === 'error') { + footer = ( + <> + +
+ + + ); + } - {/* Scrollable body: changelog + download area — single overflow container */} -
- {/* Collapsible Changelog */} - {release.body && ( -
- - {changelogOpen && ( -
- -
- )} -
- )} - - {/* Download / AUR area */} -
- {showAurHint ? ( -
-
{t('common.updaterAurHint')}
- yay -S psysonic-bin - sudo pacman -Syu psysonic-bin -
- ) : useTauriUpdater ? ( - <> - {dlState === 'idle' && ( -
-
- {t('common.updaterMacReadyTitle', { defaultValue: 'Ready to install' })} -
-
- {t('common.updaterMacReady', { - defaultValue: 'The update downloads, verifies and applies in place — no DMG needed. The app restarts automatically when done.', - })} -
-
- - - {t('common.updaterTrustNotarized', { defaultValue: 'Notarized by Apple' })} - - - - {t('common.updaterTrustSignature', { defaultValue: 'Signature verified' })} - -
-
- )} - {dlState === 'downloading' && ( -
-
-
-
- {pct}% - - {formatBytes(dlProgress.bytes)} - {dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`} - -
- )} - {dlState === 'done' && ( -
- -
- {t('common.updaterMacDoneTitle', { defaultValue: 'Update installed' })} -
-
- {countdown !== null - ? t('common.updaterRestartingIn', { defaultValue: 'Restarting in {{n}}s…', n: countdown }) - : t('common.updaterRestarting', { defaultValue: 'Restarting…' })} -
-
- )} - {dlState === 'error' && ( -
{dlError || t('common.updaterErrorMsg')}
- )} - - ) : asset ? ( - <> - {dlState === 'idle' && ( -
- {asset.name} - {formatBytes(asset.size)} -
- )} - {dlState === 'downloading' && ( -
-
-
-
- {pct}% - - {formatBytes(dlProgress.bytes)} - {dlProgress.total > 0 && ` / ${formatBytes(dlProgress.total)}`} - -
- )} - {dlState === 'done' && ( -
-
{t('common.updaterDone')}
-
{t('common.updaterInstallHint')}
- -
- )} - {dlState === 'error' && ( -
{dlError || t('common.updaterErrorMsg')}
- )} - - ) : ( -
- + return ( + setDismissed(true)} + icon={} + title={t('common.updaterModalTitle')} + subtitle={<>v{currentVersion} → v{release.version}} + closeLabel={t('common.updaterRemindBtn')} + footer={footer} + > + {/* Collapsible changelog */} + {release.body && ( +
+ + {changelogOpen && ( +
+
)}
-
{/* end update-modal-body */} + )} - {/* Footer buttons — state-dependent to avoid redundant/jumping buttons */} -
- {dlState === 'idle' && ( - <> - -
- - {showInstallBtn && ( - - )} - - )} - {dlState === 'downloading' &&
} - {dlState === 'done' && useTauriUpdater && ( - <> -
- - - )} - {dlState === 'done' && !useTauriUpdater && ( - <> -
- - - )} - {dlState === 'error' && ( - <> -
- - - - )} -
+
+ )} + {dlState === 'error' && ( +
{dlError || t('common.updaterErrorMsg')}
+ )} + + ) : ( +
+ +
+ )}
- , - document.body + ); } diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 00000000..bf1a78fe --- /dev/null +++ b/src/components/Modal.tsx @@ -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( +
+
e.stopPropagation()} + > +
+ {icon && {icon}} +
+ {title} + {subtitle != null && {subtitle}} +
+ {!hideClose && ( + + )} +
+
{children}
+ {footer &&
{footer}
} +
+
, + document.body, + ); +} diff --git a/src/styles/components/index.css b/src/styles/components/index.css index 582c13aa..b29817e5 100644 --- a/src/styles/components/index.css +++ b/src/styles/components/index.css @@ -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'; diff --git a/src/styles/components/ui-modal.css b/src/styles/components/ui-modal.css new file mode 100644 index 00000000..1f673ab9 --- /dev/null +++ b/src/styles/components/ui-modal.css @@ -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; +} diff --git a/src/styles/layout/update-toast.css b/src/styles/layout/update-toast.css index e66403ac..a1a00e1f 100644 --- a/src/styles/layout/update-toast.css +++ b/src/styles/layout/update-toast.css @@ -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);