mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(fullscreen): performance fixes + appearance settings
Adds no_compositing_mode Tauri command; frontend adds html.no-compositing on Linux to replace GPU-only CSS effects (backdrop-filter, filter, mask-image) with software-friendly equivalents. Settings → Appearance → Fullscreen Player: toggle for artist portrait visibility + 0–80% dimming slider. Fix: long words in lyric lines now wrap correctly.
This commit is contained in:
@@ -1489,6 +1489,16 @@ fn is_tiling_wm() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Tauri command: returns true when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set.
|
||||
/// The frontend uses this to apply a CSS class that swaps out GPU-only effects
|
||||
/// (backdrop-filter, CSS filter, mask-image) for software-friendly equivalents.
|
||||
#[tauri::command]
|
||||
fn no_compositing_mode() -> bool {
|
||||
std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Tauri command: lets the frontend know whether we're running under a tiling
|
||||
/// WM so it can decide whether to render the custom TitleBar component.
|
||||
#[tauri::command]
|
||||
@@ -1683,6 +1693,7 @@ pub fn run() {
|
||||
greet,
|
||||
exit_app,
|
||||
set_window_decorations,
|
||||
no_compositing_mode,
|
||||
is_tiling_wm_cmd,
|
||||
register_global_shortcut,
|
||||
unregister_global_shortcut,
|
||||
|
||||
@@ -88,6 +88,13 @@ function AppShell() {
|
||||
invoke<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
invoke<boolean>('no_compositing_mode').then(noComp => {
|
||||
if (noComp) document.documentElement.classList.add('no-compositing');
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const win = getCurrentWindow();
|
||||
// Check initial state (e.g. app launched maximised / already fullscreen).
|
||||
|
||||
@@ -355,7 +355,9 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
}, [currentTrack?.artistId]);
|
||||
|
||||
const portraitUrl = artistBgUrl || resolvedCoverUrl;
|
||||
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const showFullscreenLyrics = useAuthStore(s => s.showFullscreenLyrics);
|
||||
const showFsArtistPortrait = useAuthStore(s => s.showFsArtistPortrait);
|
||||
const fsPortraitDim = useAuthStore(s => s.fsPortraitDim);
|
||||
|
||||
// Pre-fetch next track's 300px cover into the IndexedDB cache.
|
||||
// Selector returns only the coverArt id, so it only re-runs on actual changes.
|
||||
@@ -419,7 +421,10 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
aria-label={t('player.fullscreen')}
|
||||
data-idle={isIdle}
|
||||
onMouseMove={handleMouseMove}
|
||||
style={dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } as React.CSSProperties : undefined}
|
||||
style={{
|
||||
...(dynamicAccent ? { '--dynamic-fs-accent': dynamicAccent } : {}),
|
||||
'--fs-portrait-dim': String(fsPortraitDim / 100),
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
|
||||
{/* Layer 0 — animated dark mesh gradient (real divs = will-change possible) */}
|
||||
@@ -429,7 +434,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
</div>
|
||||
|
||||
{/* Layer 1 — artist portrait, right half, object-fit: contain */}
|
||||
<FsPortrait url={portraitUrl} />
|
||||
{showFsArtistPortrait && <FsPortrait url={portraitUrl} />}
|
||||
|
||||
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
|
||||
<div className="fs-scrim" aria-hidden="true" />
|
||||
|
||||
@@ -614,6 +614,10 @@ export const deTranslation = {
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
infiniteQueueDesc: 'Automatisch Zufallstitel anhängen wenn die Warteschlange leer wird',
|
||||
experimental: 'Experimentell',
|
||||
fsPlayerSection: 'Vollbild-Player',
|
||||
fsShowArtistPortrait: 'Künstlerfoto anzeigen',
|
||||
fsShowArtistPortraitDesc: 'Künstlerfoto (oder Albumcover) auf der rechten Seite des Vollbild-Players anzeigen.',
|
||||
fsPortraitDim: 'Abdunkelung des Fotos',
|
||||
seekbarStyle: 'Seekbar-Stil',
|
||||
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
|
||||
seekbarWaveform: 'Wellenform',
|
||||
|
||||
@@ -616,6 +616,10 @@ export const enTranslation = {
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
infiniteQueueDesc: 'Automatically append random tracks when the queue runs out',
|
||||
experimental: 'Experimental',
|
||||
fsPlayerSection: 'Fullscreen Player',
|
||||
fsShowArtistPortrait: 'Show artist photo',
|
||||
fsShowArtistPortraitDesc: 'Display the artist photo (or album art) on the right side of the fullscreen player.',
|
||||
fsPortraitDim: 'Photo dimming',
|
||||
seekbarStyle: 'Seekbar Style',
|
||||
seekbarStyleDesc: 'Choose the look of the player seek bar',
|
||||
seekbarWaveform: 'Waveform',
|
||||
|
||||
@@ -612,6 +612,10 @@ export const frTranslation = {
|
||||
infiniteQueue: 'File infinie',
|
||||
infiniteQueueDesc: 'Ajouter automatiquement des morceaux aléatoires quand la file est épuisée',
|
||||
experimental: 'Expérimental',
|
||||
fsPlayerSection: 'Lecteur plein écran',
|
||||
fsShowArtistPortrait: "Afficher la photo de l'artiste",
|
||||
fsShowArtistPortraitDesc: "Afficher la photo de l'artiste (ou la pochette) sur le côté droit du lecteur plein écran.",
|
||||
fsPortraitDim: "Assombrissement de la photo",
|
||||
seekbarStyle: 'Style de la barre de lecture',
|
||||
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
|
||||
seekbarWaveform: 'Forme d\'onde',
|
||||
|
||||
@@ -611,6 +611,10 @@ export const nbTranslation = {
|
||||
preloadEarly: 'Tidlig (etter 5 s avspilling)',
|
||||
preloadCustom: 'Egendefinert',
|
||||
preloadCustomSeconds: 'Sekunder før slutt: {{n}}',
|
||||
fsPlayerSection: 'Fullskjermspiller',
|
||||
fsShowArtistPortrait: 'Vis artistbilde',
|
||||
fsShowArtistPortraitDesc: 'Vis artistbilde (eller albumomslag) på høyre side av fullskjermspilleren.',
|
||||
fsPortraitDim: 'Mørklegging av bilde',
|
||||
seekbarStyle: 'Søkefelt-stil',
|
||||
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
|
||||
seekbarWaveform: 'Bølgeform',
|
||||
|
||||
@@ -612,6 +612,10 @@ export const nlTranslation = {
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
infiniteQueueDesc: 'Automatisch willekeurige nummers toevoegen als de wachtrij leeg raakt',
|
||||
experimental: 'Experimenteel',
|
||||
fsPlayerSection: 'Volledig scherm speler',
|
||||
fsShowArtistPortrait: 'Artiestfoto tonen',
|
||||
fsShowArtistPortraitDesc: 'Artiestfoto (of albumhoes) weergeven aan de rechterkant van de volledigschermspeler.',
|
||||
fsPortraitDim: 'Verduistering foto',
|
||||
seekbarStyle: 'Zoekbalkstijl',
|
||||
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
|
||||
seekbarWaveform: 'Golfvorm',
|
||||
|
||||
@@ -640,6 +640,10 @@ export const ruTranslation = {
|
||||
infiniteQueue: 'Бесконечная очередь',
|
||||
infiniteQueueDesc: 'Подмешивать случайные треки, когда очередь закончится',
|
||||
experimental: 'Экспериментально',
|
||||
fsPlayerSection: 'Полноэкранный плеер',
|
||||
fsShowArtistPortrait: 'Отображать фото артиста',
|
||||
fsShowArtistPortraitDesc: 'Показывать фото артиста (или обложку альбома) в правой части полноэкранного плеера.',
|
||||
fsPortraitDim: 'Затемнение фото',
|
||||
seekbarStyle: 'Стиль прогресс-бара',
|
||||
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
|
||||
seekbarWaveform: 'Форма волны',
|
||||
|
||||
@@ -608,6 +608,10 @@ export const zhTranslation = {
|
||||
infiniteQueue: '无限队列',
|
||||
infiniteQueueDesc: '队列播完时自动追加随机曲目',
|
||||
experimental: '实验性',
|
||||
fsPlayerSection: '全屏播放器',
|
||||
fsShowArtistPortrait: '显示艺术家照片',
|
||||
fsShowArtistPortraitDesc: '在全屏播放器右侧显示艺术家照片(或专辑封面)。',
|
||||
fsPortraitDim: '照片暗化',
|
||||
seekbarStyle: '进度条样式',
|
||||
seekbarStyleDesc: '选择播放进度条的外观',
|
||||
seekbarWaveform: '波形',
|
||||
|
||||
+37
-1
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2
|
||||
} from 'lucide-react';
|
||||
import i18n from '../i18n';
|
||||
import { exportBackup, importBackup } from '../utils/backup';
|
||||
@@ -1450,6 +1450,42 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Maximize2 size={18} />
|
||||
<h2>{t('settings.fsPlayerSection')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.fsShowArtistPortrait')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.fsShowArtistPortraitDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.fsShowArtistPortrait')}>
|
||||
<input type="checkbox" checked={auth.showFsArtistPortrait} onChange={e => auth.setShowFsArtistPortrait(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.showFsArtistPortrait && (
|
||||
<div style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.fsPortraitDim')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 36, textAlign: 'right' }}>{auth.fsPortraitDim}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={80}
|
||||
step={1}
|
||||
value={auth.fsPortraitDim}
|
||||
onChange={e => auth.setFsPortraitDim(parseInt(e.target.value, 10))}
|
||||
className="ui-scale-slider"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Sliders size={18} />
|
||||
|
||||
@@ -67,6 +67,9 @@ interface AuthState {
|
||||
enableNeteaselyrics: boolean;
|
||||
lyricsSources: LyricsSourceConfig[];
|
||||
showFullscreenLyrics: boolean;
|
||||
showFsArtistPortrait: boolean;
|
||||
/** Portrait dimming 0–100 (percent), applied as CSS rgba alpha */
|
||||
fsPortraitDim: number;
|
||||
showChangelogOnUpdate: boolean;
|
||||
lastSeenChangelogVersion: string;
|
||||
|
||||
@@ -188,6 +191,8 @@ interface AuthState {
|
||||
setEnableNeteaselyrics: (v: boolean) => void;
|
||||
setLyricsSources: (sources: LyricsSourceConfig[]) => void;
|
||||
setShowFullscreenLyrics: (v: boolean) => void;
|
||||
setShowFsArtistPortrait: (v: boolean) => void;
|
||||
setFsPortraitDim: (v: number) => void;
|
||||
setShowChangelogOnUpdate: (v: boolean) => void;
|
||||
setLastSeenChangelogVersion: (v: string) => void;
|
||||
setSeekbarStyle: (v: SeekbarStyle) => void;
|
||||
@@ -278,6 +283,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
enableNeteaselyrics: false,
|
||||
lyricsSources: DEFAULT_LYRICS_SOURCES,
|
||||
showFullscreenLyrics: true,
|
||||
showFsArtistPortrait: true,
|
||||
fsPortraitDim: 28,
|
||||
showChangelogOnUpdate: true,
|
||||
lastSeenChangelogVersion: '',
|
||||
seekbarStyle: 'waveform',
|
||||
@@ -396,6 +403,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }),
|
||||
setLyricsSources: (sources) => set({ lyricsSources: sources }),
|
||||
setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }),
|
||||
setShowFsArtistPortrait: (v: boolean) => set({ showFsArtistPortrait: v }),
|
||||
setFsPortraitDim: (v: number) => set({ fsPortraitDim: v }),
|
||||
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
|
||||
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
|
||||
|
||||
|
||||
@@ -3119,6 +3119,17 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Subtle darkening overlay — plain div paint, no GPU cost.
|
||||
Alpha driven by --fs-portrait-dim (set via inline style on .fs-player). */
|
||||
.fs-portrait-wrap::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background: rgba(0, 0, 0, var(--fs-portrait-dim, 0.28));
|
||||
}
|
||||
|
||||
/* ── Horizontal scrim — dark left edge for text legibility ── */
|
||||
.fs-scrim {
|
||||
position: absolute;
|
||||
@@ -3440,6 +3451,8 @@
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
font-size: 2vh;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, 0.22);
|
||||
@@ -3469,6 +3482,59 @@
|
||||
}
|
||||
|
||||
|
||||
/* ── no-compositing overrides ─────────────────────────────────────────────────
|
||||
Applied only when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set (Arch Linux etc).
|
||||
Replaces GPU-only effects (backdrop-filter, CSS filter, mask-image) with
|
||||
software-friendly equivalents so the fullscreen player stays responsive. */
|
||||
|
||||
/* fs-player: remove will-change — without GPU compositor it only wastes RAM */
|
||||
html.no-compositing .fs-player {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* Close button: replace frosted-glass blur with flat semi-opaque background */
|
||||
html.no-compositing .fs-close {
|
||||
backdrop-filter: none;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* Portrait: remove CSS filter (drop-shadow) and will-change */
|
||||
html.no-compositing .fs-portrait {
|
||||
filter: none;
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* Portrait wrap: replace mask-image fade with a pseudo-element overlay gradient.
|
||||
pointer-events: none on wrap means the overlay never blocks clicks. */
|
||||
html.no-compositing .fs-portrait-wrap {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
html.no-compositing .fs-portrait-wrap::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#06060e 0%,
|
||||
rgba(6, 6, 14, 0.4) 6%,
|
||||
transparent 16%
|
||||
);
|
||||
}
|
||||
|
||||
/* Lyrics overlay: just remove the fade — no replacement needed */
|
||||
html.no-compositing .fs-lyrics-overlay {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
/* Lyrics rail: remove will-change */
|
||||
html.no-compositing .fs-lyrics-rail {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
.chat-popup {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user