mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(titlebar): selectable window button styles + minimize toggle (#1083)
* feat(titlebar): selectable window button styles + minimize toggle Custom title bar (Linux) gains a window-button style picker, mirroring the seekbar style picker pattern. Six form-named styles: dots, dotsGlyph, flat, pill, outline, glyph. All buttons now carry minimize/maximize/close glyphs for clear, colour-blind-friendly iconography; dots reveal glyphs on hover, dotsGlyph always shows them. - New authStore state windowButtonStyle (default dots) + showMinimizeButton, with rehydrate validation falling back to dots on unknown values. - WindowButtonPreview reuses the real .titlebar-btn classes for WYSIWYG tiles. - Picker + minimize toggle render under the Custom title bar setting, gated on the toggle being on. - Monochrome styles use --text-primary glyphs and stronger borders for contrast on dark themes. - Dev-build grey marker scoped to the real title bar so previews show true colours. - i18n keys in all 9 locales; setter and rehydrate tests. * docs(changelog): window button styles (#1083)
This commit is contained in:
@@ -131,6 +131,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* Play counts are unchanged — still driven by the existing scrobble path. Servers without the extension keep the previous now-playing behaviour.
|
||||
|
||||
|
||||
|
||||
### Title bar — selectable window button styles
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1083](https://github.com/Psychotoxical/psysonic/pull/1083), suggested by [@PHLAK](https://github.com/PHLAK)**
|
||||
|
||||
* The Linux custom title bar gets a **window button style** picker in **Settings → Appearance → Custom title bar** — choose between dots, dots with icons, flat, pill, outline, and minimal looks.
|
||||
* All styles now carry minimize/maximize/close icons for clear, colour-blind-friendly buttons, and an optional toggle hides the minimize button (maximize and close only).
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
### Dependencies — npm and Rust refresh
|
||||
|
||||
+22
-10
@@ -1,11 +1,15 @@
|
||||
import React from 'react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { Minus, Square, X } from 'lucide-react';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export default function TitleBar() {
|
||||
const win = getCurrentWindow();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const windowButtonStyle = useAuthStore(s => s.windowButtonStyle);
|
||||
const showMinimizeButton = useAuthStore(s => s.showMinimizeButton);
|
||||
|
||||
return (
|
||||
<div className="titlebar" data-tauri-drag-region>
|
||||
@@ -20,28 +24,36 @@ export default function TitleBar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="titlebar-controls">
|
||||
<button
|
||||
className="titlebar-btn titlebar-btn-minimize"
|
||||
onClick={() => win.minimize()}
|
||||
data-tooltip="Minimize"
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label="Minimize"
|
||||
/>
|
||||
<div className="titlebar-controls" data-btnstyle={windowButtonStyle}>
|
||||
{showMinimizeButton && (
|
||||
<button
|
||||
className="titlebar-btn titlebar-btn-minimize"
|
||||
onClick={() => win.minimize()}
|
||||
data-tooltip="Minimize"
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<Minus size={10} strokeWidth={2.5} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="titlebar-btn titlebar-btn-maximize"
|
||||
onClick={() => win.toggleMaximize()}
|
||||
data-tooltip="Maximize"
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label="Maximize"
|
||||
/>
|
||||
>
|
||||
<Square size={9} strokeWidth={2.5} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
className="titlebar-btn titlebar-btn-close"
|
||||
onClick={() => win.close()}
|
||||
data-tooltip="Close"
|
||||
data-tooltip-pos="bottom"
|
||||
aria-label="Close"
|
||||
/>
|
||||
>
|
||||
<X size={10} strokeWidth={2.5} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { Minus, Square, X } from 'lucide-react';
|
||||
import type { WindowButtonStyle } from '../store/authStoreTypes';
|
||||
|
||||
interface Props {
|
||||
style: WindowButtonStyle;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection tile for the custom-title-bar window-button style picker. Renders
|
||||
* the real `.titlebar-controls` / `.titlebar-btn` classes inside a mini title
|
||||
* bar so the preview is exactly what the chosen style produces.
|
||||
*/
|
||||
export default function WindowButtonPreview({ style, label, selected, onClick }: Props) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
style={{
|
||||
border: `2px solid ${selected ? 'var(--accent)' : 'var(--bg-hover)'}`,
|
||||
borderRadius: 8,
|
||||
background: selected
|
||||
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
|
||||
: 'var(--bg-card, var(--bg-app))',
|
||||
padding: '10px 12px 8px',
|
||||
cursor: 'pointer',
|
||||
width: 130,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
alignItems: 'stretch',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
minHeight: 34,
|
||||
background: 'var(--bg-sidebar)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className="titlebar-controls" data-btnstyle={style} aria-hidden>
|
||||
<span className="titlebar-btn titlebar-btn-minimize"><Minus size={10} strokeWidth={2.5} /></span>
|
||||
<span className="titlebar-btn titlebar-btn-maximize"><Square size={9} strokeWidth={2.5} /></span>
|
||||
<span className="titlebar-btn titlebar-btn-close"><X size={10} strokeWidth={2.5} /></span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: selected ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
textAlign: 'center',
|
||||
fontWeight: selected ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,13 @@ import {
|
||||
LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||
LIBRARY_GRID_MAX_COLUMNS_MIN,
|
||||
} from '../../store/authStoreDefaults';
|
||||
import type { SeekbarStyle } from '../../store/authStoreTypes';
|
||||
import type { SeekbarStyle, WindowButtonStyle } from '../../store/authStoreTypes';
|
||||
import { useFontStore, FontId } from '../../store/fontStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { IS_LINUX, IS_WINDOWS } from '../../utils/platform';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SeekbarPreview } from '../WaveformSeekPreview';
|
||||
import WindowButtonPreview from '../WindowButtonPreview';
|
||||
|
||||
export function AppearanceTab() {
|
||||
const { t } = useTranslation();
|
||||
@@ -170,6 +171,39 @@ export function AppearanceTab() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.useCustomTitlebar && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.windowButtonStyle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.windowButtonStyleDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
{(['dots', 'dotsGlyph', 'flat', 'pill', 'outline', 'glyph'] as WindowButtonStyle[]).map(style => (
|
||||
<WindowButtonPreview
|
||||
key={style}
|
||||
style={style}
|
||||
label={t(`settings.windowButtons${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}
|
||||
selected={auth.windowButtonStyle === style}
|
||||
onClick={() => auth.setWindowButtonStyle(style)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.showMinimizeButton')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showMinimizeButtonDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.showMinimizeButton')}>
|
||||
<input type="checkbox" checked={auth.showMinimizeButton} onChange={e => auth.setShowMinimizeButton(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -228,6 +228,16 @@ export const settings = {
|
||||
discordRichPresenceNotice: 'Achtung: Dies ist die in Psysonic integrierte Discord Rich Presence. Wenn du stattdessen das offizielle Navidrome-Discord-Rich-Presence-Plugin nutzen möchtest, lass diese Funktion deaktiviert und aktiviere stattdessen weiter unten auf dieser Seite „Im Livefenster anzeigen".',
|
||||
useCustomTitlebar: 'Eigene Titelleiste',
|
||||
useCustomTitlebarDesc: 'Ersetzt die System-Titelleiste durch eine eingebaute, die zum App-Theme passt. Deaktivieren, um die native GNOME/GTK-Titelleiste zu verwenden.',
|
||||
windowButtonStyle: 'Fenster-Buttons',
|
||||
windowButtonStyleDesc: 'Wähle das Aussehen der Minimieren-, Maximieren- und Schließen-Buttons.',
|
||||
windowButtonsDots: 'Punkte',
|
||||
windowButtonsDotsGlyph: 'Punkte + Symbole',
|
||||
windowButtonsFlat: 'Flach',
|
||||
windowButtonsPill: 'Pille',
|
||||
windowButtonsOutline: 'Umriss',
|
||||
windowButtonsGlyph: 'Minimal',
|
||||
showMinimizeButton: 'Minimieren-Button anzeigen',
|
||||
showMinimizeButtonDesc: 'Ausschalten, um nur Maximieren und Schließen anzuzeigen.',
|
||||
linuxWebkitSmoothScroll: 'Sanftes Mausrad (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'An: mit Nachlauf. Aus: zeilenweise wie in GTK-Apps.',
|
||||
linuxWebkitInputForceRepaint: 'Eingabefelder beim Fokus neu zeichnen (Linux)',
|
||||
|
||||
@@ -231,6 +231,16 @@ export const settings = {
|
||||
discordRichPresenceNotice: 'Heads up: this is Psysonic\'s built-in Discord Rich Presence. If you\'d rather use the official Navidrome Discord Rich Presence plugin, leave this switched off and enable "Show in Now Playing" further down this page instead.',
|
||||
useCustomTitlebar: 'Custom title bar',
|
||||
useCustomTitlebarDesc: 'Replace the system title bar with a built-in one that matches the app theme. Disable to use the native GNOME/GTK title bar.',
|
||||
windowButtonStyle: 'Window buttons',
|
||||
windowButtonStyleDesc: 'Pick the look of the minimize, maximize and close buttons.',
|
||||
windowButtonsDots: 'Dots',
|
||||
windowButtonsDotsGlyph: 'Dots + icons',
|
||||
windowButtonsFlat: 'Flat',
|
||||
windowButtonsPill: 'Pill',
|
||||
windowButtonsOutline: 'Outline',
|
||||
windowButtonsGlyph: 'Minimal',
|
||||
showMinimizeButton: 'Show minimize button',
|
||||
showMinimizeButtonDesc: 'Turn off to show only the maximize and close buttons.',
|
||||
linuxWebkitSmoothScroll: 'Smooth wheel (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'On: inertial scroll. Off: line-by-line, GTK-style.',
|
||||
linuxWebkitInputForceRepaint: 'Repaint inputs on focus (Linux)',
|
||||
|
||||
@@ -227,6 +227,16 @@ export const settings = {
|
||||
discordRichPresenceNotice: 'Aviso: esta es la Discord Rich Presence integrada en Psysonic. Si prefieres usar el plugin oficial de Discord Rich Presence de Navidrome, deja esta función desactivada y activa en su lugar «Mostrar en Reproduciendo Ahora» más abajo en esta página.',
|
||||
useCustomTitlebar: 'Barra de título personalizada',
|
||||
useCustomTitlebarDesc: 'Reemplaza la barra de título del sistema con una integrada que coincide con el tema de la app. Desactiva para usar la barra nativa de GNOME/GTK.',
|
||||
windowButtonStyle: 'Botones de ventana',
|
||||
windowButtonStyleDesc: 'Elige el aspecto de los botones de minimizar, maximizar y cerrar.',
|
||||
windowButtonsDots: 'Puntos',
|
||||
windowButtonsDotsGlyph: 'Puntos + iconos',
|
||||
windowButtonsFlat: 'Plano',
|
||||
windowButtonsPill: 'Píldora',
|
||||
windowButtonsOutline: 'Contorno',
|
||||
windowButtonsGlyph: 'Minimalista',
|
||||
showMinimizeButton: 'Mostrar botón de minimizar',
|
||||
showMinimizeButtonDesc: 'Desactiva para mostrar solo maximizar y cerrar.',
|
||||
linuxWebkitSmoothScroll: 'Rueda suave (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'Activado: inercia. Desactivado: pasos por línea (estilo GTK).',
|
||||
linuxWebkitInputForceRepaint: 'Repintar los campos al enfocar (Linux)',
|
||||
|
||||
@@ -611,6 +611,16 @@ export const settings = {
|
||||
waveformCacheClearFailed: 'Impossible de vider le cache des formes d’onde.',
|
||||
useCustomTitlebar: 'Barre de titre personnalisée',
|
||||
useCustomTitlebarDesc: 'Remplace la barre de titre système par une barre intégrée assortie au thème de l’app. Désactivez pour utiliser la barre GNOME/GTK native.',
|
||||
windowButtonStyle: 'Boutons de fenêtre',
|
||||
windowButtonStyleDesc: 'Choisissez l’apparence des boutons réduire, agrandir et fermer.',
|
||||
windowButtonsDots: 'Points',
|
||||
windowButtonsDotsGlyph: 'Points + icônes',
|
||||
windowButtonsFlat: 'Plat',
|
||||
windowButtonsPill: 'Pilule',
|
||||
windowButtonsOutline: 'Contour',
|
||||
windowButtonsGlyph: 'Minimal',
|
||||
showMinimizeButton: 'Afficher le bouton réduire',
|
||||
showMinimizeButtonDesc: 'Désactivez pour n’afficher qu’agrandir et fermer.',
|
||||
analyticsStrategyTitle: 'Stratégies d’analyse',
|
||||
analyticsStrategyDesc: 'Choisissez le style d’analyse pour la bibliothèque.',
|
||||
analyticsStrategyLabel: 'Stratégie',
|
||||
|
||||
@@ -612,6 +612,16 @@ export const settings = {
|
||||
showTrayIconDesc: 'Vis Psysonic-ikonet i systemstatusfeltet / menylinjen.',
|
||||
useCustomTitlebar: 'Tilpasset tittellinje',
|
||||
useCustomTitlebarDesc: 'Erstatt systemets tittellinje med en innebygd som matcher app-temaet. Slå av for å bruke den native GNOME/GTK-tittellinjen.',
|
||||
windowButtonStyle: 'Vindusknapper',
|
||||
windowButtonStyleDesc: 'Velg utseendet på knappene for minimer, maksimer og lukk.',
|
||||
windowButtonsDots: 'Prikker',
|
||||
windowButtonsDotsGlyph: 'Prikker + ikoner',
|
||||
windowButtonsFlat: 'Flat',
|
||||
windowButtonsPill: 'Pille',
|
||||
windowButtonsOutline: 'Omriss',
|
||||
windowButtonsGlyph: 'Minimal',
|
||||
showMinimizeButton: 'Vis minimer-knapp',
|
||||
showMinimizeButtonDesc: 'Slå av for å vise bare maksimer og lukk.',
|
||||
analyticsStrategyTitle: 'Analysestrategier',
|
||||
analyticsStrategyDesc: 'Velg analysestil for biblioteket.',
|
||||
analyticsStrategyLabel: 'Strategi',
|
||||
|
||||
@@ -611,6 +611,16 @@ export const settings = {
|
||||
waveformCacheClearFailed: 'Waveform-cache wissen mislukt.',
|
||||
useCustomTitlebar: 'Aangepaste titelbalk',
|
||||
useCustomTitlebarDesc: 'Vervang de systeemtitelbalk door een ingebouwde balk die bij het app-thema past. Schakel uit om de native GNOME/GTK-titelbalk te gebruiken.',
|
||||
windowButtonStyle: 'Vensterknoppen',
|
||||
windowButtonStyleDesc: 'Kies het uiterlijk van de knoppen minimaliseren, maximaliseren en sluiten.',
|
||||
windowButtonsDots: 'Stippen',
|
||||
windowButtonsDotsGlyph: 'Stippen + iconen',
|
||||
windowButtonsFlat: 'Plat',
|
||||
windowButtonsPill: 'Pil',
|
||||
windowButtonsOutline: 'Omtrek',
|
||||
windowButtonsGlyph: 'Minimaal',
|
||||
showMinimizeButton: 'Minimaliseerknop tonen',
|
||||
showMinimizeButtonDesc: 'Schakel uit om alleen maximaliseren en sluiten te tonen.',
|
||||
analyticsStrategyTitle: 'Analysestrategieën',
|
||||
analyticsStrategyDesc: 'Kies de analysemethode voor de bibliotheek.',
|
||||
analyticsStrategyLabel: 'Strategie',
|
||||
|
||||
@@ -230,6 +230,16 @@ export const settings = {
|
||||
discordRichPresenceNotice: 'Atenție: aceasta este Discord Rich Presence integrată în Psysonic. Dacă preferi să folosești pluginul oficial Discord Rich Presence de la Navidrome, lasă această funcție dezactivată și activează în schimb „Se afișează în Now Playing" mai jos pe această pagină.',
|
||||
useCustomTitlebar: 'Bară de titlu personalizată',
|
||||
useCustomTitlebarDesc: 'Înlocuiește bara de titlu a sistemului cu una care corespunde cu tema aplicației. Dezactivează pentru a folosi bara de titlu nativ GNOME/GTK.',
|
||||
windowButtonStyle: 'Butoane de fereastră',
|
||||
windowButtonStyleDesc: 'Alege aspectul butoanelor de minimizare, maximizare și închidere.',
|
||||
windowButtonsDots: 'Puncte',
|
||||
windowButtonsDotsGlyph: 'Puncte + pictograme',
|
||||
windowButtonsFlat: 'Plat',
|
||||
windowButtonsPill: 'Pastilă',
|
||||
windowButtonsOutline: 'Contur',
|
||||
windowButtonsGlyph: 'Minimal',
|
||||
showMinimizeButton: 'Afișează butonul de minimizare',
|
||||
showMinimizeButtonDesc: 'Dezactivează pentru a afișa doar maximizare și închidere.',
|
||||
linuxWebkitSmoothScroll: 'Rotiță lină (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'Pornit: scroll inert. Oprit: linie cu linie, în stil GTK.',
|
||||
linuxWebkitInputForceRepaint: 'Redesenare câmpuri la focus (Linux)',
|
||||
|
||||
@@ -230,6 +230,16 @@ export const settings = {
|
||||
useCustomTitlebar: 'Своя строка заголовка',
|
||||
useCustomTitlebarDesc:
|
||||
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK и т.д.',
|
||||
windowButtonStyle: 'Кнопки окна',
|
||||
windowButtonStyleDesc: 'Выберите вид кнопок свернуть, развернуть и закрыть.',
|
||||
windowButtonsDots: 'Кружки',
|
||||
windowButtonsDotsGlyph: 'Кружки + значки',
|
||||
windowButtonsFlat: 'Плоские',
|
||||
windowButtonsPill: 'Пилюля',
|
||||
windowButtonsOutline: 'Контур',
|
||||
windowButtonsGlyph: 'Минимал',
|
||||
showMinimizeButton: 'Показывать кнопку «Свернуть»',
|
||||
showMinimizeButtonDesc: 'Выключите, чтобы показывать только «Развернуть» и «Закрыть».',
|
||||
linuxWebkitSmoothScroll: 'Плавное колесо (Linux)',
|
||||
linuxWebkitSmoothScrollDesc: 'Вкл — инерция. Выкл — по шагам, как в GTK.',
|
||||
linuxWebkitInputForceRepaint: 'Перерисовывать поля ввода при фокусе (Linux)',
|
||||
|
||||
@@ -610,6 +610,16 @@ export const settings = {
|
||||
waveformCacheClearFailed: '清除波形缓存失败。',
|
||||
useCustomTitlebar: '自定义标题栏',
|
||||
useCustomTitlebarDesc: '用与应用主题匹配的内置标题栏替换系统标题栏。关闭后将使用原生 GNOME/GTK 标题栏。',
|
||||
windowButtonStyle: '窗口按钮',
|
||||
windowButtonStyleDesc: '选择最小化、最大化和关闭按钮的外观。',
|
||||
windowButtonsDots: '圆点',
|
||||
windowButtonsDotsGlyph: '圆点 + 图标',
|
||||
windowButtonsFlat: '扁平',
|
||||
windowButtonsPill: '胶囊',
|
||||
windowButtonsOutline: '描边',
|
||||
windowButtonsGlyph: '极简',
|
||||
showMinimizeButton: '显示最小化按钮',
|
||||
showMinimizeButtonDesc: '关闭后仅显示最大化和关闭按钮。',
|
||||
analyticsStrategyTitle: '分析策略',
|
||||
analyticsStrategyDesc: '选择库的分析方式。',
|
||||
analyticsStrategyLabel: '策略',
|
||||
|
||||
@@ -131,6 +131,28 @@ describe('onRehydrate migrations', () => {
|
||||
expect(useAuthStore.getState().seekbarStyle).toBe('neon');
|
||||
});
|
||||
|
||||
it('falls back an invalid windowButtonStyle to `dots`', async () => {
|
||||
writePersistedState({
|
||||
servers: [],
|
||||
activeServerId: null,
|
||||
windowButtonStyle: 'bogus', // not in VALID_WINDOW_BUTTON_STYLES
|
||||
});
|
||||
|
||||
await useAuthStore.persist.rehydrate();
|
||||
expect(useAuthStore.getState().windowButtonStyle).toBe('dots');
|
||||
});
|
||||
|
||||
it('keeps a valid windowButtonStyle unchanged', async () => {
|
||||
writePersistedState({
|
||||
servers: [],
|
||||
activeServerId: null,
|
||||
windowButtonStyle: 'glyph',
|
||||
});
|
||||
|
||||
await useAuthStore.persist.rehydrate();
|
||||
expect(useAuthStore.getState().windowButtonStyle).toBe('glyph');
|
||||
});
|
||||
|
||||
it('strips the removed `animationMode` and `reducedAnimations` legacy fields', async () => {
|
||||
writePersistedState({
|
||||
servers: [],
|
||||
|
||||
@@ -58,6 +58,8 @@ describe('trivial pass-through setters', () => {
|
||||
['setDiscordRichPresence', 'discordRichPresence', true],
|
||||
['setEnableBandsintown', 'enableBandsintown', true],
|
||||
['setUseCustomTitlebar', 'useCustomTitlebar', true],
|
||||
['setWindowButtonStyle', 'windowButtonStyle', 'flat'],
|
||||
['setShowMinimizeButton', 'showMinimizeButton', false],
|
||||
['setPreloadMiniPlayer', 'preloadMiniPlayer', true],
|
||||
['setLinuxWebkitKineticScroll', 'linuxWebkitKineticScroll', false],
|
||||
['setLinuxWaylandTextRenderProfile', 'linuxWaylandTextRenderProfile', 'gpu'],
|
||||
|
||||
@@ -74,6 +74,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
discordTemplateLargeText: '{album}',
|
||||
discordTemplateName: '{title}',
|
||||
useCustomTitlebar: false,
|
||||
windowButtonStyle: 'dots',
|
||||
showMinimizeButton: true,
|
||||
preloadMiniPlayer: false,
|
||||
linuxWebkitKineticScroll: true,
|
||||
linuxWaylandTextRenderProfile: 'sharp',
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
LyricsSourceConfig,
|
||||
QueueDisplayMode,
|
||||
SeekbarStyle,
|
||||
WindowButtonStyle,
|
||||
} from './authStoreTypes';
|
||||
import { migrateLegacyLastfm, sanitizeAccounts } from '../music-network';
|
||||
|
||||
@@ -93,6 +94,17 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
|
||||
? {}
|
||||
: { seekbarStyle: 'truewave' as SeekbarStyle };
|
||||
|
||||
// Unknown / missing / tampered window-button style falls back to the
|
||||
// default 'dots' so the title bar never renders an unstyled data-attr.
|
||||
const VALID_WINDOW_BUTTON_STYLES = new Set<string>([
|
||||
'dots', 'dotsGlyph', 'flat', 'pill', 'outline', 'glyph',
|
||||
]);
|
||||
const windowButtonStyleMigrated = VALID_WINDOW_BUTTON_STYLES.has(
|
||||
(state as { windowButtonStyle?: unknown }).windowButtonStyle as string,
|
||||
)
|
||||
? {}
|
||||
: { windowButtonStyle: 'dots' as WindowButtonStyle };
|
||||
|
||||
// Garbage / null / undefined / missing key from a legacy or tampered persist
|
||||
// payload maps back to 'total' so the duration chip never receives an
|
||||
// unknown mode (would render an empty label).
|
||||
@@ -236,6 +248,7 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
|
||||
...youLyPlusMigrated,
|
||||
...wheelSmoothOneTime,
|
||||
...seekbarStyleMigrated,
|
||||
...windowButtonStyleMigrated,
|
||||
...queueDurationDisplayModeMigrated,
|
||||
...queueDisplayModeMigrated,
|
||||
...linuxWaylandTextRenderProfileMigrated,
|
||||
|
||||
@@ -33,6 +33,17 @@ export interface ServerProfile {
|
||||
}
|
||||
|
||||
export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
|
||||
/**
|
||||
* Look of the custom-title-bar window buttons (minimize/maximize/close).
|
||||
* Form-descriptive names, not OS brands:
|
||||
* - `dots`: coloured traffic-light circles, glyphs appear on hover (default).
|
||||
* - `dotsGlyph`: traffic-light circles with always-visible glyphs (colour + shape).
|
||||
* - `flat`: full-height rectangular buttons with line glyphs, red close hover.
|
||||
* - `pill`: soft circular monochrome buttons with glyphs.
|
||||
* - `outline`: square bordered buttons with thin glyphs, accent hover.
|
||||
* - `glyph`: themed monochrome glyphs only, no background — blends with the app.
|
||||
*/
|
||||
export type WindowButtonStyle = 'dots' | 'dotsGlyph' | 'flat' | 'pill' | 'outline' | 'glyph';
|
||||
/** Queue header duration chip: total duration / time left / ETA finish clock. */
|
||||
export type DurationMode = 'total' | 'remaining' | 'eta';
|
||||
|
||||
@@ -143,6 +154,10 @@ export interface AuthState {
|
||||
* Empty string falls back to "Psysonic". */
|
||||
discordTemplateName: string;
|
||||
useCustomTitlebar: boolean;
|
||||
/** Look of the custom-title-bar window buttons (Linux custom title bar only). */
|
||||
windowButtonStyle: WindowButtonStyle;
|
||||
/** Show the minimize button in the custom title bar. Off = only maximize + close. */
|
||||
showMinimizeButton: boolean;
|
||||
/** Pre-build the mini-player webview at app start on Linux/macOS so content is available instantly
|
||||
* on first open. Ignored on Windows — that platform always pre-creates as a hang workaround. */
|
||||
preloadMiniPlayer: boolean;
|
||||
@@ -344,6 +359,8 @@ export interface AuthState {
|
||||
setDiscordTemplateLargeText: (v: string) => void;
|
||||
setDiscordTemplateName: (v: string) => void;
|
||||
setUseCustomTitlebar: (v: boolean) => void;
|
||||
setWindowButtonStyle: (v: WindowButtonStyle) => void;
|
||||
setShowMinimizeButton: (v: boolean) => void;
|
||||
setPreloadMiniPlayer: (v: boolean) => void;
|
||||
setLinuxWebkitKineticScroll: (v: boolean) => void;
|
||||
setLinuxWaylandTextRenderProfile: (v: LinuxWaylandTextRenderProfile) => void;
|
||||
|
||||
@@ -19,6 +19,8 @@ export function createUiAppearanceActions(set: SetState): Pick<
|
||||
| 'setClockFormat'
|
||||
| 'setShowOrbitTrigger'
|
||||
| 'setUseCustomTitlebar'
|
||||
| 'setWindowButtonStyle'
|
||||
| 'setShowMinimizeButton'
|
||||
| 'setPreloadMiniPlayer'
|
||||
| 'setLinuxWebkitKineticScroll'
|
||||
| 'setLinuxWaylandTextRenderProfile'
|
||||
@@ -41,6 +43,8 @@ export function createUiAppearanceActions(set: SetState): Pick<
|
||||
setClockFormat: (v) => set({ clockFormat: v }),
|
||||
setShowOrbitTrigger: (v) => set({ showOrbitTrigger: v }),
|
||||
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
|
||||
setWindowButtonStyle: (v) => set({ windowButtonStyle: v }),
|
||||
setShowMinimizeButton: (v) => set({ showMinimizeButton: v }),
|
||||
setPreloadMiniPlayer: (v) => set({ preloadMiniPlayer: v }),
|
||||
setLinuxWebkitKineticScroll: (v) => set({ linuxWebkitKineticScroll: v }),
|
||||
setLinuxWaylandTextRenderProfile: (v) => set({ linuxWaylandTextRenderProfile: v }),
|
||||
|
||||
@@ -89,49 +89,117 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Shared base — each style below overrides shape, fill and glyph colour. */
|
||||
.titlebar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18);
|
||||
transition: filter var(--transition-fast), box-shadow var(--transition-fast);
|
||||
transition: filter var(--transition-fast), box-shadow var(--transition-fast),
|
||||
background var(--transition-fast), color var(--transition-fast),
|
||||
border-color var(--transition-fast), opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.titlebar-btn-close {
|
||||
background: #ff5f57;
|
||||
.titlebar-btn svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.titlebar-btn-minimize {
|
||||
background: #febc2e;
|
||||
}
|
||||
|
||||
.titlebar-btn-maximize {
|
||||
background: #28c840;
|
||||
}
|
||||
|
||||
.titlebar-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.titlebar-btn-close:hover { box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18), 0 0 6px rgba(255, 95, 87, 0.75); }
|
||||
.titlebar-btn-minimize:hover { box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18), 0 0 6px rgba(254, 188, 46, 0.75); }
|
||||
.titlebar-btn-maximize:hover { box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18), 0 0 6px rgba(40, 200, 64, 0.75); }
|
||||
|
||||
.titlebar-btn:active {
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
|
||||
.titlebar-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18), 0 0 0 2px rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
/* ── dots / dotsGlyph — colour-circle window buttons ── */
|
||||
[data-btnstyle="dots"] .titlebar-btn,
|
||||
[data-btnstyle="dotsGlyph"] .titlebar-btn {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
[data-btnstyle="dots"] .titlebar-btn svg,
|
||||
[data-btnstyle="dotsGlyph"] .titlebar-btn svg {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
[data-btnstyle="dots"] .titlebar-btn-close,
|
||||
[data-btnstyle="dotsGlyph"] .titlebar-btn-close { background: #ff5f57; }
|
||||
[data-btnstyle="dots"] .titlebar-btn-minimize,
|
||||
[data-btnstyle="dotsGlyph"] .titlebar-btn-minimize { background: #febc2e; }
|
||||
[data-btnstyle="dots"] .titlebar-btn-maximize,
|
||||
[data-btnstyle="dotsGlyph"] .titlebar-btn-maximize { background: #28c840; }
|
||||
[data-btnstyle="dots"] .titlebar-btn:hover,
|
||||
[data-btnstyle="dotsGlyph"] .titlebar-btn:hover { filter: brightness(1.1); }
|
||||
[data-btnstyle="dots"] .titlebar-btn-close:hover,
|
||||
[data-btnstyle="dotsGlyph"] .titlebar-btn-close:hover { box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18), 0 0 6px rgba(255, 95, 87, 0.75); }
|
||||
[data-btnstyle="dots"] .titlebar-btn-minimize:hover,
|
||||
[data-btnstyle="dotsGlyph"] .titlebar-btn-minimize:hover { box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18), 0 0 6px rgba(254, 188, 46, 0.75); }
|
||||
[data-btnstyle="dots"] .titlebar-btn-maximize:hover,
|
||||
[data-btnstyle="dotsGlyph"] .titlebar-btn-maximize:hover { box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18), 0 0 6px rgba(40, 200, 64, 0.75); }
|
||||
/* dots: glyphs reveal on hover (authentic); dotsGlyph: always shown */
|
||||
[data-btnstyle="dots"] .titlebar-btn svg { opacity: 0; }
|
||||
[data-btnstyle="dots"] .titlebar-controls:hover .titlebar-btn svg,
|
||||
[data-btnstyle="dots"]:hover .titlebar-btn svg { opacity: 1; }
|
||||
[data-btnstyle="dotsGlyph"] .titlebar-btn svg { opacity: 0.85; }
|
||||
|
||||
/* ── flat — full-height rectangular buttons, red close hover ── */
|
||||
.titlebar-controls[data-btnstyle="flat"] { gap: 0; padding: 0; margin-right: -6px; }
|
||||
[data-btnstyle="flat"] .titlebar-btn {
|
||||
width: 30px;
|
||||
height: var(--titlebar-height);
|
||||
border-radius: 0;
|
||||
}
|
||||
[data-btnstyle="flat"] .titlebar-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
[data-btnstyle="flat"] .titlebar-btn-close:hover { background: #e81123; color: #fff; }
|
||||
|
||||
/* ── pill — soft circular monochrome buttons ── */
|
||||
[data-btnstyle="pill"] .titlebar-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
[data-btnstyle="pill"] .titlebar-btn:hover { filter: brightness(1.18); color: var(--text-primary); }
|
||||
[data-btnstyle="pill"] .titlebar-btn-close:hover { background: #e01b24; color: #fff; filter: none; }
|
||||
|
||||
/* ── outline — square bordered buttons, accent hover ── */
|
||||
[data-btnstyle="outline"] .titlebar-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
[data-btnstyle="outline"] .titlebar-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
[data-btnstyle="outline"] .titlebar-btn-close:hover {
|
||||
border-color: #e01b24;
|
||||
color: #e01b24;
|
||||
background: color-mix(in srgb, #e01b24 12%, transparent);
|
||||
}
|
||||
|
||||
/* ── glyph — themed monochrome, no fill, blends with the app ── */
|
||||
[data-btnstyle="glyph"] .titlebar-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
[data-btnstyle="glyph"] .titlebar-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
[data-btnstyle="glyph"] .titlebar-btn-close:hover { background: var(--bg-hover); color: #ff5f57; }
|
||||
|
||||
/* Resizer handles must start below the titlebar */
|
||||
.app-shell[data-titlebar] .resizer {
|
||||
top: var(--titlebar-height);
|
||||
|
||||
@@ -9,26 +9,23 @@ html[data-dev-build] .sidebar-brand svg {
|
||||
--logo-color-end: #fecaca;
|
||||
}
|
||||
|
||||
html[data-dev-build] .titlebar-btn-close,
|
||||
html[data-dev-build] .titlebar-btn-minimize,
|
||||
html[data-dev-build] .titlebar-btn-maximize {
|
||||
/* Scope the dev-grey to the real title bar only — the Settings window-button
|
||||
* style previews reuse `.titlebar-btn` classes and must show true colours. */
|
||||
html[data-dev-build] .titlebar .titlebar-btn-close,
|
||||
html[data-dev-build] .titlebar .titlebar-btn-minimize,
|
||||
html[data-dev-build] .titlebar .titlebar-btn-maximize {
|
||||
background: #8b8b8b;
|
||||
}
|
||||
|
||||
html[data-dev-build] .titlebar-btn-close:hover,
|
||||
html[data-dev-build] .titlebar-btn-minimize:hover,
|
||||
html[data-dev-build] .titlebar-btn-maximize:hover {
|
||||
html[data-dev-build] .titlebar .titlebar-btn-close:hover,
|
||||
html[data-dev-build] .titlebar .titlebar-btn-minimize:hover,
|
||||
html[data-dev-build] .titlebar .titlebar-btn-maximize:hover {
|
||||
background: #a3a3a3;
|
||||
box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
html[data-dev-build] .titlebar-btn-close:hover,
|
||||
html[data-dev-build] .titlebar-btn-minimize:hover,
|
||||
html[data-dev-build] .titlebar-btn-maximize:hover {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
html[data-dev-build] .titlebar-btn:focus-visible {
|
||||
html[data-dev-build] .titlebar .titlebar-btn:focus-visible {
|
||||
box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.18), 0 0 0 2px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user