mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(ui-scale): re-enable scoped interface scaling
Document-level zoom broke portal positioning and Tauri window measurement,
so the slider had been forced to 100 %. Scope the zoom to a wrapper inside
.main-content instead — sidebar, queue, player bar and the Linux custom
title bar live in sibling grid cells of .app-shell and stay 1:1.
- App.tsx: drop the document-level zoom + auto-reset; wrap main-content
children in <div class="main-content-zoom" style={zoom: uiScale}>.
- layout.css: add .main-content-zoom (flex column, fills parent).
- Settings.tsx: re-enable the slider and snap it to the 6 preset stops
(80/90/100/110/125/150) so the thumb lands directly under each preset
button. Legacy off-preset values snap to the nearest stop on load.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+7
-13
@@ -127,6 +127,7 @@ function AppShell() {
|
|||||||
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
|
const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen);
|
||||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||||
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
const toggleQueue = usePlayerStore(s => s.toggleQueue);
|
||||||
|
const uiScale = useFontStore(s => s.uiScale);
|
||||||
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
|
const initializeFromServerQueue = usePlayerStore(s => s.initializeFromServerQueue);
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
@@ -341,6 +342,7 @@ function AppShell() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
|
<div className="main-content-zoom" style={uiScale !== 1 ? { zoom: uiScale } : undefined}>
|
||||||
<header className="content-header">
|
<header className="content-header">
|
||||||
<LiveSearch />
|
<LiveSearch />
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
@@ -389,6 +391,7 @@ function AppShell() {
|
|||||||
<Route path="/device-sync" element={<DeviceSync />} />
|
<Route path="/device-sync" element={<DeviceSync />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div
|
<div
|
||||||
@@ -919,8 +922,6 @@ export default function App() {
|
|||||||
useThemeStore(s => s.theme); // keep subscription so re-render on manual change
|
useThemeStore(s => s.theme); // keep subscription so re-render on manual change
|
||||||
const effectiveTheme = useThemeScheduler();
|
const effectiveTheme = useThemeScheduler();
|
||||||
const font = useFontStore(s => s.font);
|
const font = useFontStore(s => s.font);
|
||||||
const uiScale = useFontStore(s => s.uiScale);
|
|
||||||
const setUiScale = useFontStore(s => s.setUiScale);
|
|
||||||
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -931,17 +932,10 @@ export default function App() {
|
|||||||
document.documentElement.setAttribute('data-font', font);
|
document.documentElement.setAttribute('data-font', font);
|
||||||
}, [font]);
|
}, [font]);
|
||||||
|
|
||||||
// TODO(ui-scale): UI scaling is disabled pending a cross-platform rework.
|
// UI scaling is scoped to .main-content via an inner wrapper (see <main>
|
||||||
// Reset any stored non-100% value so users aren't stuck at a broken scale.
|
// below). Sidebar, queue, player bar and (Linux) custom title bar stay 1:1
|
||||||
// When re-enabling: remove this effect AND re-enable the slider in Settings.tsx.
|
// because they live in separate grid cells. Document-level zoom is not used
|
||||||
useEffect(() => {
|
// — it broke portal positioning and Tauri window measurement.
|
||||||
if (uiScale !== 1.0) setUiScale(1.0);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.style.zoom = String(uiScale);
|
|
||||||
}, [uiScale]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return initAudioListeners();
|
return initAudioListeners();
|
||||||
|
|||||||
+44
-38
@@ -1739,50 +1739,56 @@ export default function Settings() {
|
|||||||
<h2>{t('settings.uiScaleTitle')}</h2>
|
<h2>{t('settings.uiScaleTitle')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-card">
|
<div className="settings-card">
|
||||||
{/* TODO: UI scaling is being reworked — disabled until fixed */}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', margin: 0 }}>
|
|
||||||
Interface scaling is currently being reworked and will be available in a future update.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', opacity: 0.4, pointerEvents: 'none', marginTop: 12 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
|
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
|
||||||
{Math.round(fontStore.uiScale * 100)}%
|
{Math.round(fontStore.uiScale * 100)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
{(() => {
|
||||||
type="range"
|
const presets = [80, 90, 100, 110, 125, 150];
|
||||||
min={0.8}
|
const currentPct = Math.round(fontStore.uiScale * 100);
|
||||||
max={1.5}
|
let idx = presets.indexOf(currentPct);
|
||||||
step={0.05}
|
if (idx < 0) {
|
||||||
value={fontStore.uiScale}
|
// Snap legacy off-preset values to the closest preset.
|
||||||
onChange={e => fontStore.setUiScale(parseFloat(e.target.value))}
|
idx = presets.reduce((best, p, i) =>
|
||||||
className="ui-scale-slider"
|
Math.abs(p - currentPct) < Math.abs(presets[best] - currentPct) ? i : best, 0);
|
||||||
/>
|
}
|
||||||
<div style={{ position: 'relative', height: 24 }}>
|
return (
|
||||||
{[80, 90, 100, 110, 125, 150].map(p => {
|
<>
|
||||||
const pct = ((p / 100) - 0.8) / (1.5 - 0.8) * 100;
|
<input
|
||||||
const active = Math.round(fontStore.uiScale * 100) === p;
|
type="range"
|
||||||
return (
|
min={0}
|
||||||
<button
|
max={presets.length - 1}
|
||||||
key={p}
|
step={1}
|
||||||
className="btn btn-ghost"
|
value={idx}
|
||||||
style={{
|
onChange={e => fontStore.setUiScale(presets[parseInt(e.target.value, 10)] / 100)}
|
||||||
position: 'absolute',
|
className="ui-scale-slider"
|
||||||
left: `${pct}%`,
|
/>
|
||||||
transform: 'translateX(-50%)',
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
fontSize: 11,
|
{presets.map(p => {
|
||||||
padding: '2px 6px',
|
const active = currentPct === p;
|
||||||
opacity: active ? 1 : 0.5,
|
return (
|
||||||
color: active ? 'var(--accent)' : undefined,
|
<button
|
||||||
}}
|
key={p}
|
||||||
onClick={() => fontStore.setUiScale(p / 100)}
|
className="btn btn-ghost"
|
||||||
>
|
style={{
|
||||||
{p}%
|
fontSize: 11,
|
||||||
</button>
|
padding: '2px 6px',
|
||||||
);
|
opacity: active ? 1 : 0.5,
|
||||||
})}
|
color: active ? 'var(--accent)' : undefined,
|
||||||
</div>
|
}}
|
||||||
|
onClick={() => fontStore.setUiScale(p / 100)}
|
||||||
|
>
|
||||||
|
{p}%
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -940,6 +940,20 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scoped UI scaling target. Sidebar / queue / player / titlebar live in
|
||||||
|
sibling grid cells of .app-shell and stay at 1:1. The wrapper inherits
|
||||||
|
.main-content's flex column layout so .content-header (fixed height) and
|
||||||
|
.content-body (flex: 1, scroll) keep working unchanged.
|
||||||
|
Zoom is applied via inline style only when uiScale !== 1, so the default
|
||||||
|
case has no extra layer or layout cost. */
|
||||||
|
.main-content-zoom {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.content-header {
|
.content-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user