mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(updater): professional update modal with skip, changelog, and OS-aware downloader
Replaces the small corner toast with a centered modal that appears on startup when a newer GitHub release is detected (4 s delay). Features: - Skip this Version: stores skipped tag in localStorage, reappears only for newer releases - Collapsible changelog: renders GitHub release body as markdown accordion - OS-aware download: Windows → .exe installer, macOS → .dmg (aarch64 preferred), Linux Arch → AUR hint (yay/pacman), Linux other → .AppImage/.deb - In-app downloader: Rust download_update command streams to ~/Downloads/, emits update:download:progress every 250 ms for a real-time progress bar - Post-download: Show in Folder button opens Downloads dir via shell.open - Buttons: Download Now / Skip this Version / Remind me Later Rust: check_arch_linux() reads /etc/arch-release + /etc/os-release platform.ts: adds IS_MACOS, IS_WINDOWS alongside existing IS_LINUX Settings About: Preview Update Modal button for testing Fixes: renderInline regex had nested capture group causing undefined entries in split() result → TypeError → React crash → WebKit white-screen freeze Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -611,6 +611,108 @@ fn resolve_hot_cache_root(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the current Linux system is Arch-based
|
||||
/// (checks /etc/arch-release and /etc/os-release).
|
||||
#[tauri::command]
|
||||
fn check_arch_linux() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::path::Path::new("/etc/arch-release").exists() {
|
||||
return true;
|
||||
}
|
||||
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
|
||||
for line in content.lines() {
|
||||
let lower = line.to_lowercase();
|
||||
if lower.starts_with("id=arch") { return true; }
|
||||
if lower.starts_with("id_like=") && lower.contains("arch") { return true; }
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{ false }
|
||||
}
|
||||
|
||||
/// Progress payload emitted during an update binary download.
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateDownloadProgress {
|
||||
bytes: u64,
|
||||
total: Option<u64>,
|
||||
}
|
||||
|
||||
/// Downloads an update installer/package to the user's Downloads folder.
|
||||
/// Emits `update:download:progress` events with `{ bytes, total }` every 250 ms.
|
||||
/// Returns the final absolute file path on success.
|
||||
#[tauri::command]
|
||||
async fn download_update(url: String, filename: String, app: tauri::AppHandle) -> Result<String, String> {
|
||||
use futures_util::StreamExt;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
const EMIT_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
let dest_dir = app.path().download_dir().map_err(|e| e.to_string())?;
|
||||
let dest_path = dest_dir.join(&filename);
|
||||
let part_path = dest_dir.join(format!("{}.part", filename));
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.timeout(Duration::from_secs(3600))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
|
||||
let total = response.content_length();
|
||||
|
||||
let result: Result<u64, String> = async {
|
||||
let mut file = tokio::fs::File::create(&part_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut bytes_done: u64 = 0;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut last_emit = Instant::now();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||
file.write_all(&chunk).await.map_err(|e| e.to_string())?;
|
||||
bytes_done += chunk.len() as u64;
|
||||
|
||||
if last_emit.elapsed() >= EMIT_INTERVAL {
|
||||
let _ = app.emit("update:download:progress", UpdateDownloadProgress {
|
||||
bytes: bytes_done,
|
||||
total,
|
||||
});
|
||||
last_emit = Instant::now();
|
||||
}
|
||||
}
|
||||
file.flush().await.map_err(|e| e.to_string())?;
|
||||
Ok(bytes_done)
|
||||
}.await;
|
||||
|
||||
match result {
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
Err(e)
|
||||
}
|
||||
Ok(bytes_done) => {
|
||||
let _ = app.emit("update:download:progress", UpdateDownloadProgress {
|
||||
bytes: bytes_done,
|
||||
total: Some(bytes_done),
|
||||
});
|
||||
tokio::fs::rename(&part_path, &dest_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(dest_path.to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress payload emitted to the frontend during a ZIP download.
|
||||
/// `total` is `None` when the server doesn't send a `Content-Length` header
|
||||
/// (Navidrome on-the-fly ZIPs).
|
||||
@@ -1206,6 +1308,8 @@ pub fn run() {
|
||||
toggle_tray_icon,
|
||||
check_dir_accessible,
|
||||
download_zip,
|
||||
check_arch_linux,
|
||||
download_update,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Psysonic");
|
||||
|
||||
+321
-42
@@ -1,9 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { RefreshCw, X } from 'lucide-react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { dirname } from '@tauri-apps/api/path';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ArrowUpCircle, ChevronDown, Download, FolderOpen, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { version as currentVersion } from '../../package.json';
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
||||
|
||||
const SKIP_KEY = 'psysonic_skipped_update_version';
|
||||
|
||||
// Semver comparison: returns true if `a` is newer than `b`
|
||||
function isNewer(a: string, b: string): boolean {
|
||||
@@ -16,57 +22,330 @@ function isNewer(a: string, b: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// Minimal inline-markdown renderer (bold, italic, code)
|
||||
// IMPORTANT: regex must have NO nested capture groups — split() includes captured
|
||||
// groups in the result, and nested groups produce undefined entries that crash on .startsWith()
|
||||
function renderInline(text: string): React.ReactNode[] {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (!part) return null;
|
||||
if (part.startsWith('**') && part.endsWith('**'))
|
||||
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
||||
if (part.startsWith('*') && part.endsWith('*'))
|
||||
return <em key={i}>{part.slice(1, -1)}</em>;
|
||||
if (part.startsWith('`') && part.endsWith('`'))
|
||||
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
function renderChangelog(body: string) {
|
||||
return body.split('\n').map((line, i) => {
|
||||
if (line.startsWith('### '))
|
||||
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
|
||||
if (line.startsWith('#### '))
|
||||
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
|
||||
if (line.startsWith('## '))
|
||||
return null; // skip nested release headers in body
|
||||
if (line.startsWith('- '))
|
||||
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
|
||||
if (line.trim() === '') return null;
|
||||
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
|
||||
});
|
||||
}
|
||||
|
||||
interface GithubAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ReleaseData {
|
||||
version: string;
|
||||
tag: string;
|
||||
body: string;
|
||||
assets: GithubAsset[];
|
||||
}
|
||||
|
||||
function pickAsset(assets: GithubAsset[]): GithubAsset | undefined {
|
||||
if (IS_WINDOWS) {
|
||||
return assets.find(a => a.name.endsWith('-setup.exe'))
|
||||
?? assets.find(a => a.name.endsWith('.exe'));
|
||||
}
|
||||
if (IS_MACOS) {
|
||||
// Prefer Apple Silicon, fall back to Intel
|
||||
return assets.find(a => a.name.endsWith('.dmg') && a.name.includes('aarch64'))
|
||||
?? assets.find(a => a.name.endsWith('.dmg'));
|
||||
}
|
||||
if (IS_LINUX) {
|
||||
// AppImage > deb > rpm
|
||||
return assets.find(a => a.name.endsWith('.AppImage'))
|
||||
?? assets.find(a => a.name.endsWith('.deb'))
|
||||
?? assets.find(a => a.name.endsWith('.rpm'));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type DlState = 'idle' | 'downloading' | 'done' | 'error';
|
||||
|
||||
export default function AppUpdater() {
|
||||
const { t } = useTranslation();
|
||||
const [newVersion, setNewVersion] = useState<string | null>(null);
|
||||
const [release, setRelease] = useState<ReleaseData | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [changelogOpen, setChangelogOpen] = useState(false);
|
||||
const [isArch, setIsArch] = useState(false);
|
||||
const [dlState, setDlState] = useState<DlState>('idle');
|
||||
const [dlProgress, setDlProgress] = useState({ bytes: 0, total: 0 });
|
||||
const [dlPath, setDlPath] = useState('');
|
||||
const [dlError, setDlError] = useState('');
|
||||
const unlistenRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const fetchRelease = async (preview = false) => {
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
const version = tag.replace(/^[^0-9]*/, '');
|
||||
if (!version) return;
|
||||
if (!preview) {
|
||||
if (!isNewer(version, currentVersion)) return;
|
||||
const skipped = localStorage.getItem(SKIP_KEY);
|
||||
if (skipped === version) return;
|
||||
}
|
||||
setDismissed(false);
|
||||
setDlState('idle');
|
||||
setRelease({
|
||||
version,
|
||||
tag,
|
||||
body: (data.body ?? '').trim(),
|
||||
assets: data.assets ?? [],
|
||||
});
|
||||
if (IS_LINUX) {
|
||||
const arch = await invoke<boolean>('check_arch_linux');
|
||||
setIsArch(arch);
|
||||
}
|
||||
} catch {
|
||||
// No network or rate-limited — stay idle
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok || cancelled) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
if (!cancelled && tag && isNewer(tag, currentVersion)) {
|
||||
setNewVersion(tag.replace(/^[^0-9]*/, ''));
|
||||
}
|
||||
} catch {
|
||||
// No network or rate-limited — stay idle
|
||||
}
|
||||
}, 4000);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
const timer = setTimeout(() => { if (!cancelled) fetchRelease(); }, 4000);
|
||||
|
||||
const handler = () => fetchRelease(true);
|
||||
window.addEventListener('psysonic:preview-update', handler);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('psysonic:preview-update', handler);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!newVersion || dismissed) return null;
|
||||
// Clean up download listener when component unmounts
|
||||
useEffect(() => {
|
||||
return () => { unlistenRef.current?.(); };
|
||||
}, []);
|
||||
|
||||
if (!release || dismissed) return null;
|
||||
|
||||
const asset = pickAsset(release.assets);
|
||||
const showAurHint = IS_LINUX && isArch;
|
||||
|
||||
const handleSkip = () => {
|
||||
localStorage.setItem(SKIP_KEY, release.version);
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!asset) return;
|
||||
setDlState('downloading');
|
||||
setDlProgress({ bytes: 0, total: asset.size });
|
||||
setDlError('');
|
||||
|
||||
const unlisten = await listen<{ bytes: number; total: number | null }>(
|
||||
'update:download:progress',
|
||||
e => {
|
||||
setDlProgress({
|
||||
bytes: e.payload.bytes,
|
||||
total: e.payload.total ?? asset.size,
|
||||
});
|
||||
}
|
||||
);
|
||||
unlistenRef.current = unlisten;
|
||||
|
||||
try {
|
||||
const finalPath = await invoke<string>('download_update', {
|
||||
url: asset.browser_download_url,
|
||||
filename: asset.name,
|
||||
});
|
||||
unlisten();
|
||||
unlistenRef.current = null;
|
||||
setDlPath(finalPath);
|
||||
setDlState('done');
|
||||
} catch (e) {
|
||||
unlisten();
|
||||
unlistenRef.current = null;
|
||||
setDlError(String(e));
|
||||
setDlState('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowFolder = async () => {
|
||||
try {
|
||||
const dir = await dirname(dlPath);
|
||||
await open(dir);
|
||||
} catch {
|
||||
// fallback: try opening the file path directly
|
||||
await open(dlPath).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const pct = dlProgress.total > 0
|
||||
? Math.min(100, Math.round((dlProgress.bytes / dlProgress.total) * 100))
|
||||
: 0;
|
||||
|
||||
return createPortal(
|
||||
<div className="app-updater-toast">
|
||||
<div className="app-updater-header">
|
||||
<RefreshCw size={13} />
|
||||
<span className="app-updater-label">{t('common.updaterAvailable')}</span>
|
||||
<button className="app-updater-dismiss" onClick={() => setDismissed(true)} aria-label="Dismiss">
|
||||
<X size={12} />
|
||||
</button>
|
||||
<>
|
||||
<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} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{renderChangelog(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>
|
||||
) : asset ? (
|
||||
<>
|
||||
{dlState === 'idle' && (
|
||||
<div className="update-modal-asset">
|
||||
<span className="update-modal-asset-name">{asset.name}</span>
|
||||
<span className="update-modal-asset-size">{fmtBytes(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">
|
||||
{fmtBytes(dlProgress.bytes)}
|
||||
{dlProgress.total > 0 && ` / ${fmtBytes(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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>{/* end update-modal-body */}
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div className="update-modal-footer">
|
||||
<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>
|
||||
{!showAurHint && asset && dlState === 'idle' && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
<Download size={14} />
|
||||
{t('common.updaterDownloadBtn')}
|
||||
</button>
|
||||
)}
|
||||
{dlState === 'error' && (
|
||||
<button className="btn btn-primary" onClick={handleDownload}>
|
||||
{t('common.updaterRetryBtn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="app-updater-version">{t('common.updaterVersion', { version: newVersion })}</div>
|
||||
<div className="app-updater-actions">
|
||||
<button
|
||||
className="app-updater-btn-primary"
|
||||
onClick={() => open('https://github.com/Psychotoxical/psysonic/releases/latest')}
|
||||
>
|
||||
GitHub
|
||||
</button>
|
||||
<button
|
||||
className="app-updater-btn-secondary"
|
||||
onClick={() => open('https://psysonic.psychotoxic.eu/#downloads')}
|
||||
>
|
||||
{t('common.updaterWebsite')}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -350,6 +350,18 @@ export const deTranslation = {
|
||||
updaterAvailable: 'Update verfügbar',
|
||||
updaterVersion: 'v{{version}} verfügbar',
|
||||
updaterWebsite: 'Website',
|
||||
updaterModalTitle: 'Neue Version verfügbar',
|
||||
updaterChangelog: 'Was ist neu',
|
||||
updaterDownloadBtn: 'Jetzt herunterladen',
|
||||
updaterSkipBtn: 'Version überspringen',
|
||||
updaterRemindBtn: 'Später erinnern',
|
||||
updaterDone: 'Download abgeschlossen',
|
||||
updaterShowFolder: 'Im Ordner anzeigen',
|
||||
updaterInstallHint: 'Psysonic schließen und das Installationsprogramm manuell ausführen.',
|
||||
updaterAurHint: 'Update über AUR installieren:',
|
||||
updaterErrorMsg: 'Download fehlgeschlagen',
|
||||
updaterRetryBtn: 'Erneut versuchen',
|
||||
updaterOpenGitHub: 'Auf GitHub öffnen',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
|
||||
@@ -351,6 +351,18 @@ export const enTranslation = {
|
||||
updaterAvailable: 'Update available',
|
||||
updaterVersion: 'v{{version}} is available',
|
||||
updaterWebsite: 'Website',
|
||||
updaterModalTitle: 'New Version Available',
|
||||
updaterChangelog: "What's New",
|
||||
updaterDownloadBtn: 'Download Now',
|
||||
updaterSkipBtn: 'Skip this Version',
|
||||
updaterRemindBtn: 'Remind me Later',
|
||||
updaterDone: 'Download complete',
|
||||
updaterShowFolder: 'Show in Folder',
|
||||
updaterInstallHint: 'Close Psysonic and run the installer manually.',
|
||||
updaterAurHint: 'Install the update via AUR:',
|
||||
updaterErrorMsg: 'Download failed',
|
||||
updaterRetryBtn: 'Retry',
|
||||
updaterOpenGitHub: 'Open on GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
@@ -479,6 +491,7 @@ export const enTranslation = {
|
||||
aboutLicense: 'License',
|
||||
aboutLicenseText: 'GNU GPL v3 — free to use, modify, and distribute under the same license.',
|
||||
aboutRepo: 'Source Code on GitHub',
|
||||
aboutPreviewUpdate: 'Preview Update Modal',
|
||||
aboutVersion: 'Version',
|
||||
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio',
|
||||
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic',
|
||||
|
||||
@@ -350,6 +350,18 @@ export const frTranslation = {
|
||||
updaterAvailable: 'Mise à jour disponible',
|
||||
updaterVersion: 'v{{version}} est disponible',
|
||||
updaterWebsite: 'Site Web',
|
||||
updaterModalTitle: 'Nouvelle version disponible',
|
||||
updaterChangelog: 'Nouveautés',
|
||||
updaterDownloadBtn: 'Télécharger maintenant',
|
||||
updaterSkipBtn: 'Ignorer cette version',
|
||||
updaterRemindBtn: 'Me rappeler plus tard',
|
||||
updaterDone: 'Téléchargement terminé',
|
||||
updaterShowFolder: 'Afficher dans le dossier',
|
||||
updaterInstallHint: 'Fermez Psysonic et lancez le programme d\'installation manuellement.',
|
||||
updaterAurHint: 'Installer la mise à jour via AUR :',
|
||||
updaterErrorMsg: 'Échec du téléchargement',
|
||||
updaterRetryBtn: 'Réessayer',
|
||||
updaterOpenGitHub: 'Ouvrir sur GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Paramètres',
|
||||
|
||||
@@ -350,6 +350,18 @@ export const nbTranslation = {
|
||||
updaterAvailable: 'Ny versjon er tilgjengelig',
|
||||
updaterVersion: 'v{{version}} er tilgjengelig',
|
||||
updaterWebsite: 'Nettsted',
|
||||
updaterModalTitle: 'Ny versjon tilgjengelig',
|
||||
updaterChangelog: 'Hva er nytt',
|
||||
updaterDownloadBtn: 'Last ned nå',
|
||||
updaterSkipBtn: 'Hopp over denne versjonen',
|
||||
updaterRemindBtn: 'Påminn meg senere',
|
||||
updaterDone: 'Nedlasting fullført',
|
||||
updaterShowFolder: 'Vis i mappe',
|
||||
updaterInstallHint: 'Lukk Psysonic og kjør installasjonsprogrammet manuelt.',
|
||||
updaterAurHint: 'Installer oppdateringen via AUR:',
|
||||
updaterErrorMsg: 'Nedlasting mislyktes',
|
||||
updaterRetryBtn: 'Prøv igjen',
|
||||
updaterOpenGitHub: 'Åpne på GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Innstillinger',
|
||||
|
||||
@@ -350,6 +350,18 @@ export const nlTranslation = {
|
||||
updaterAvailable: 'Update beschikbaar',
|
||||
updaterVersion: 'v{{version}} beschikbaar',
|
||||
updaterWebsite: 'Website',
|
||||
updaterModalTitle: 'Nieuwe versie beschikbaar',
|
||||
updaterChangelog: 'Wat is er nieuw',
|
||||
updaterDownloadBtn: 'Nu downloaden',
|
||||
updaterSkipBtn: 'Deze versie overslaan',
|
||||
updaterRemindBtn: 'Later herinneren',
|
||||
updaterDone: 'Download voltooid',
|
||||
updaterShowFolder: 'Tonen in map',
|
||||
updaterInstallHint: 'Sluit Psysonic en voer het installatieprogramma handmatig uit.',
|
||||
updaterAurHint: 'Update installeren via AUR:',
|
||||
updaterErrorMsg: 'Downloaden mislukt',
|
||||
updaterRetryBtn: 'Opnieuw proberen',
|
||||
updaterOpenGitHub: 'Openen op GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Instellingen',
|
||||
|
||||
@@ -364,6 +364,18 @@ export const ruTranslation = {
|
||||
updaterAvailable: 'Доступно обновление',
|
||||
updaterVersion: 'Версия {{version}} доступна',
|
||||
updaterWebsite: 'Сайт',
|
||||
updaterModalTitle: 'Доступна новая версия',
|
||||
updaterChangelog: 'Что нового',
|
||||
updaterDownloadBtn: 'Скачать сейчас',
|
||||
updaterSkipBtn: 'Пропустить эту версию',
|
||||
updaterRemindBtn: 'Напомнить позже',
|
||||
updaterDone: 'Загрузка завершена',
|
||||
updaterShowFolder: 'Показать в папке',
|
||||
updaterInstallHint: 'Закройте Psysonic и запустите установщик вручную.',
|
||||
updaterAurHint: 'Установить обновление через AUR:',
|
||||
updaterErrorMsg: 'Ошибка загрузки',
|
||||
updaterRetryBtn: 'Повторить',
|
||||
updaterOpenGitHub: 'Открыть на GitHub',
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
|
||||
@@ -346,6 +346,18 @@ export const zhTranslation = {
|
||||
updaterAvailable: '有可用更新',
|
||||
updaterVersion: 'v{{version}} 已发布',
|
||||
updaterWebsite: '官网',
|
||||
updaterModalTitle: '有新版本可用',
|
||||
updaterChangelog: '更新内容',
|
||||
updaterDownloadBtn: '立即下载',
|
||||
updaterSkipBtn: '跳过此版本',
|
||||
updaterRemindBtn: '稍后提醒',
|
||||
updaterDone: '下载完成',
|
||||
updaterShowFolder: '在文件夹中显示',
|
||||
updaterInstallHint: '请关闭 Psysonic 并手动运行安装程序。',
|
||||
updaterAurHint: '通过 AUR 安装更新:',
|
||||
updaterErrorMsg: '下载失败',
|
||||
updaterRetryBtn: '重试',
|
||||
updaterOpenGitHub: '在 GitHub 上打开',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
|
||||
+18
-8
@@ -96,6 +96,7 @@ const CONTRIBUTORS = [
|
||||
'Hot playback cache — queue prefetch (PR #123)',
|
||||
'Per-server music folder filter and sidebar library picker (PR #124, PR #125)',
|
||||
'Richer star ratings, skip threshold, and library filtering (PR #130)',
|
||||
'Statistics: scope album and song totals to selected music library (PR #138)',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1861,14 +1862,23 @@ export default function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginTop: '1.25rem', alignSelf: 'flex-start' }}
|
||||
onClick={() => openUrl('https://github.com/Psychotoxical/psysonic')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('settings.aboutRepo')}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.25rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
onClick={() => openUrl('https://github.com/Psychotoxical/psysonic')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{t('settings.aboutRepo')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('psysonic:preview-update'))}
|
||||
>
|
||||
{t('settings.aboutPreviewUpdate')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -154,6 +154,10 @@
|
||||
|
||||
.resizer-queue { right: calc(var(--queue-width) - 3px); }
|
||||
|
||||
/* In native fullscreen the resizer sits flush against the screen edge and
|
||||
would interfere with OS edge-swipe gestures — hide it entirely. */
|
||||
.app-shell[data-fullscreen] .resizer { display: none; }
|
||||
|
||||
/* ─── Sidebar ─── */
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
@@ -651,6 +655,157 @@
|
||||
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;
|
||||
}
|
||||
/* Changelog accordion */
|
||||
.update-modal-changelog {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.update-modal-changelog-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 9px 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
letter-spacing: 0.03em;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
.update-modal-changelog-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-glass);
|
||||
}
|
||||
.update-modal-changelog-body {
|
||||
padding: 4px 18px 12px;
|
||||
}
|
||||
/* Download area */
|
||||
.update-modal-download-area {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.update-modal-asset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.update-modal-asset-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
.update-modal-asset-size {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.update-modal-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.update-modal-progress .app-updater-progress-bar { flex: 1; height: 4px; }
|
||||
.update-modal-dl-bytes {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.update-modal-done {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.update-modal-done-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
.update-modal-done-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.update-modal-folder-btn {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* AUR hint */
|
||||
.update-modal-aur {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.update-modal-aur-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.update-modal-aur-cmd {
|
||||
display: block;
|
||||
background: var(--bg-glass);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-family: monospace;
|
||||
user-select: all;
|
||||
}
|
||||
.update-modal-aur-alt {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.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);
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
/** True when running on Linux (WebKitGTK). Used to show the custom title bar. */
|
||||
export const IS_LINUX = navigator.platform.toLowerCase().includes('linux');
|
||||
/** True when running on macOS (WKWebView). */
|
||||
export const IS_MACOS = navigator.platform.toLowerCase().includes('mac');
|
||||
/** True when running on Windows (WebView2). */
|
||||
export const IS_WINDOWS = navigator.platform.toLowerCase().includes('win');
|
||||
|
||||
Reference in New Issue
Block a user