feat(ui): add normalization toggle to player bar

Adds 3-state normalization icon between fullscreen and volume controls.
Uses mask-image SVGs for theme-compatible coloring. Reduces song card
min-width by 72px to accommodate the new control.
This commit is contained in:
vicrodh committed 2026-02-07 22:55:18 -06:00
1 parent 717eb6fed2
commit 00d0ce7835
9 files changed
+168 -7

No files matched your search

+52 -2
View File
@@ -69,6 +69,9 @@
onTrackClick?: () => void;
onContextClick?: () => void;
queueOpen?: boolean;
normalizationEnabled?: boolean;
normalizationGain?: number | null;
onToggleNormalization?: () => void;
}
let {
@@ -110,7 +113,10 @@
onAlbumClick,
onTrackClick,
onContextClick,
queueOpen = false
queueOpen = false,
normalizationEnabled = false,
normalizationGain = null,
onToggleNormalization
}: Props = $props();
let progressRef: HTMLDivElement;
@@ -382,6 +388,25 @@
<Maximize2 size={16} />
</button>
<!-- Normalization Toggle -->
<button
class="control-btn"
class:active={normalizationEnabled && normalizationGain !== null && normalizationGain !== 1.0}
class:norm-enabled={normalizationEnabled && (normalizationGain === null || normalizationGain === 1.0)}
onclick={onToggleNormalization}
title={!normalizationEnabled
? $t('player.normalizationOff')
: normalizationGain !== null && normalizationGain !== 1.0
? $t('player.normalizationApplied')
: $t('player.normalizationOn')}
>
<span
class="norm-icon"
class:norm-on={normalizationEnabled}
aria-hidden="true"
></span>
</button>
<!-- Volume Control -->
<div class="volume-control">
<div class="volume-value" class:visible={isDraggingVolume}>{volume}</div>
@@ -651,6 +676,31 @@
opacity: 0.9;
}
/* Normalization Icon (mask-image for currentColor inheritance) */
.norm-icon {
display: block;
width: 16px;
height: 16px;
background-color: currentColor;
mask-image: url('/bars-disorder-outlined.svg');
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-image: url('/bars-disorder-outlined.svg');
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}
.norm-icon.norm-on {
mask-image: url('/bars-normal.svg');
-webkit-mask-image: url('/bars-normal.svg');
}
.control-btn.norm-enabled {
color: var(--text-primary);
}
.play-btn {
width: 34px;
height: 34px;
@@ -670,7 +720,7 @@
padding: 2px;
background: var(--bg-tertiary);
border-radius: 8px;
min-width: 580px;
min-width: 508px;
flex: 1;
max-width: 800px;
}
+4 -1
View File
@@ -394,7 +394,10 @@
"maximize": "Maximieren",
"restore": "Wiederherstellen",
"minimize": "Minimieren",
"exitImmersive": "Immersiv-Modus beenden"
"exitImmersive": "Immersiv-Modus beenden",
"normalizationOff": "Lautstärkenormalisierung: Aus",
"normalizationOn": "Lautstärkenormalisierung: Ein",
"normalizationApplied": "Lautstärkenormalisierung: Aktiv"
},
"audioBadges": {
"castingTo": "Übertragung an",
+4 -1
View File
@@ -408,7 +408,10 @@
"maximize": "Maximize",
"restore": "Restore",
"minimize": "Minimize",
"exitImmersive": "Exit Immersive"
"exitImmersive": "Exit Immersive",
"normalizationOff": "Volume Normalization: Off",
"normalizationOn": "Volume Normalization: On",
"normalizationApplied": "Volume Normalization: Active"
},
"audioBadges": {
"castingTo": "Casting to",
+4 -1
View File
@@ -408,7 +408,10 @@
"maximize": "Maximizar",
"restore": "Restaurar",
"minimize": "Minimizar",
"exitImmersive": "Salir del modo inmersivo"
"exitImmersive": "Salir del modo inmersivo",
"normalizationOff": "Normalización de volumen: Desactivada",
"normalizationOn": "Normalización de volumen: Activada",
"normalizationApplied": "Normalización de volumen: Activa"
},
"audioBadges": {
"castingTo": "Transmitiendo a",
+4 -1
View File
@@ -394,7 +394,10 @@
"maximize": "Agrandir",
"restore": "Restaurer",
"minimize": "Réduire",
"exitImmersive": "Quitter le mode immersif"
"exitImmersive": "Quitter le mode immersif",
"normalizationOff": "Normalisation du volume : Désactivée",
"normalizationOn": "Normalisation du volume : Activée",
"normalizationApplied": "Normalisation du volume : Active"
},
"audioBadges": {
"castingTo": "Diffusion vers",
+12 -1
View File
@@ -76,6 +76,7 @@ interface PlaybackEvent {
volume: number;
sample_rate: number | null; // Actual stream sample rate in Hz
bit_depth: number | null; // Actual stream bit depth
normalization_gain: number | null; // Active normalization gain factor (null = not applied)
}
// Queue track from backend (for external track sync)
@@ -132,6 +133,7 @@ let castUnsubscribe: (() => void) | null = null;
let isAdvancingTrack = false;
let isSkipping = false;
let queueEnded = false;
let normalizationGain: number | null = null; // Current normalization gain (null = not active)
// Callbacks for track advancement (set by consumer)
let onTrackEnded: (() => Promise<void>) | null = null;
@@ -188,6 +190,10 @@ export function getIsSkipping(): boolean {
return isSkipping;
}
export function getNormalizationGain(): number | null {
return normalizationGain;
}
// ============ State Setter ============
export interface PlayerState {
@@ -198,6 +204,7 @@ export interface PlayerState {
volume: number;
isFavorite: boolean;
isSkipping: boolean;
normalizationGain: number | null;
}
export function getPlayerState(): PlayerState {
@@ -208,7 +215,8 @@ export function getPlayerState(): PlayerState {
duration,
volume,
isFavorite,
isSkipping
isSkipping,
normalizationGain
};
}
@@ -547,6 +555,9 @@ async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
currentTrack.bitDepth = event.bit_depth;
}
// Update normalization gain state
normalizationGain = event.normalization_gain;
notifyListeners();
// Check if track ended - auto-advance to next
+26
View File
@@ -531,6 +531,8 @@
let duration = $state(0);
let volume = $state(getVolume()); // Load persisted volume from localStorage
let isFavorite = $state(false);
let normalizationEnabled = $state(false);
let normalizationGain = $state<number | null>(null);
// Queue/Shuffle State (from queueStore subscription)
let isShuffle = $state(false);
@@ -1382,6 +1384,16 @@
}
}
async function toggleNormalization() {
const newState = !normalizationEnabled;
try {
await invoke('set_audio_normalization_enabled', { enabled: newState });
normalizationEnabled = newState;
} catch (err) {
console.error('Failed to toggle normalization:', err);
}
}
// Add to Playlist handler for Now Playing track
function openAddToPlaylistModal() {
if (!currentTrack) return;
@@ -2530,6 +2542,13 @@
// Ignore storage errors
}
// Load normalization enabled state from audio settings
invoke<{ normalization_enabled: boolean }>('get_audio_settings').then((settings) => {
normalizationEnabled = settings.normalization_enabled;
}).catch((err) => {
console.error('Failed to load normalization setting:', err);
});
// Set up callback for cast disconnect handoff
setOnAskContinueLocally(async (track, position) => {
// Ask user if they want to continue locally
@@ -2635,6 +2654,7 @@
duration = playerState.duration;
volume = playerState.volume;
isFavorite = playerState.isFavorite;
normalizationGain = playerState.normalizationGain;
// Save position during playback (debounced to every 5s)
if (isPlaying && currentTrack && currentTime > 0) {
@@ -3353,6 +3373,9 @@
}}
onContextClick={handleContextNavigation}
queueOpen={isQueueOpen}
{normalizationEnabled}
{normalizationGain}
onToggleNormalization={toggleNormalization}
onTrackClick={() => {
if (currentTrack && !currentTrack.isLocal) {
trackInfoTrackId = currentTrack.id;
@@ -3370,6 +3393,9 @@
queueOpen={isQueueOpen}
{volume}
onVolumeChange={handleVolumeChange}
{normalizationEnabled}
{normalizationGain}
onToggleNormalization={toggleNormalization}
/>
{/if}
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 592.988 592.988"
xml:space="preserve">
<g>
<g>
<g>
<path d="M122.388,285.984H70.373c-32.058,0-58.14,26.083-58.14,58.139v190.225c0,32.059,26.082,58.139,58.14,58.139h52.016
c32.059,0,58.14-26.08,58.14-58.139V344.123C180.528,312.066,154.447,285.984,122.388,285.984z M137.688,534.348
c0,8.449-6.85,15.299-15.3,15.299H70.373c-8.45,0-15.3-6.85-15.3-15.299V344.123c0-8.449,6.85-15.299,15.3-15.299h52.016
c8.45,0,15.3,6.85,15.3,15.299V534.348z"/>
<path d="M122.388,592.986H70.373c-32.334,0-58.64-26.306-58.64-58.639V344.123c0-32.334,26.306-58.639,58.64-58.639h52.016
c32.334,0,58.64,26.305,58.64,58.639v190.225C181.028,566.681,154.723,592.986,122.388,592.986z M70.373,286.484
c-31.783,0-57.64,25.857-57.64,57.639v190.225c0,31.782,25.857,57.639,57.64,57.639h52.016c31.783,0,57.64-25.856,57.64-57.639
V344.123c0-31.782-25.857-57.639-57.64-57.639H70.373z M122.388,550.146H70.373c-8.712,0-15.8-7.087-15.8-15.799V344.123
c0-8.712,7.088-15.799,15.8-15.799h52.016c8.712,0,15.8,7.087,15.8,15.799v190.225
C138.188,543.06,131.101,550.146,122.388,550.146z M70.373,329.324c-8.161,0-14.8,6.639-14.8,14.799v190.225
c0,8.16,6.639,14.799,14.8,14.799h52.016c8.161,0,14.8-6.639,14.8-14.799V344.123c0-8.16-6.639-14.799-14.8-14.799H70.373z"/>
</g>
<g>
<path d="M322.502,592.486c32.059,0,58.141-26.08,58.141-58.139V58.64c0-32.059-26.082-58.14-58.141-58.14h-52.016
c-32.059,0-58.14,26.082-58.14,58.14v475.708c0,32.059,26.082,58.139,58.14,58.139H322.502z M255.186,534.348V58.64
c0-8.45,6.85-15.3,15.3-15.3h52.016c8.449,0,15.301,6.85,15.301,15.3v475.708c0,8.449-6.852,15.299-15.301,15.299h-52.016
C262.036,549.646,255.186,542.797,255.186,534.348z"/>
<path d="M322.502,592.986h-52.016c-32.334,0-58.64-26.306-58.64-58.639V58.64c0-32.334,26.306-58.64,58.64-58.64h52.016
c32.335,0,58.641,26.306,58.641,58.64v475.708C381.143,566.681,354.837,592.986,322.502,592.986z M270.486,1
c-31.783,0-57.64,25.857-57.64,57.64v475.708c0,31.782,25.857,57.639,57.64,57.639h52.016c31.783,0,57.641-25.856,57.641-57.639
V58.64c0-31.783-25.857-57.64-57.641-57.64H270.486z M322.502,550.146h-52.016c-8.712,0-15.8-7.087-15.8-15.799V58.64
c0-8.712,7.088-15.8,15.8-15.8h52.016c8.713,0,15.801,7.088,15.801,15.8v475.708C338.303,543.06,331.215,550.146,322.502,550.146
z M270.486,43.84c-8.161,0-14.8,6.639-14.8,14.8v475.708c0,8.16,6.64,14.799,14.8,14.799h52.016
c8.161,0,14.801-6.639,14.801-14.799V58.64c0-8.161-6.64-14.8-14.801-14.8H270.486z"/>
</g>
<g>
<path d="M522.615,592.486c32.059,0,58.141-26.08,58.141-58.139V207.961c0-32.059-26.082-58.14-58.141-58.14H470.6
c-32.057,0-58.139,26.082-58.139,58.14v326.386c0,32.059,26.082,58.141,58.139,58.141L522.615,592.486L522.615,592.486z
M455.301,534.348V207.961c0-8.45,6.85-15.3,15.299-15.3h52.016c8.451,0,15.301,6.85,15.301,15.3v326.386
c0,8.449-6.85,15.301-15.301,15.301H470.6C462.15,549.646,455.301,542.797,455.301,534.348z"/>
<path d="M470.6,592.988c-32.333,0-58.639-26.306-58.639-58.641V207.961c0-32.334,26.306-58.64,58.639-58.64h52.016
c32.335,0,58.641,26.306,58.641,58.64v326.386c0,32.333-26.306,58.639-58.641,58.639L470.6,592.988z M470.6,150.321
c-31.782,0-57.639,25.857-57.639,57.64v326.386c0,31.783,25.856,57.641,57.639,57.641l52.016-0.002
c31.783,0,57.641-25.856,57.641-57.639V207.961c0-31.783-25.857-57.64-57.641-57.64H470.6z M522.615,550.148H470.6
c-8.712-0.002-15.799-7.09-15.799-15.801V207.961c0-8.712,7.087-15.8,15.799-15.8h52.016c8.713,0,15.801,7.088,15.801,15.8
v326.386C538.416,543.061,531.328,550.148,522.615,550.148z M470.6,193.161c-8.16,0-14.799,6.639-14.799,14.8v326.386
c0,8.159,6.639,14.799,14.799,14.801h52.016c8.161,0,14.801-6.64,14.801-14.801V207.961c0-8.161-6.64-14.8-14.801-14.8H470.6z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 978 978" xml:space="preserve">
<g>
<path d="M0,903c0,33.1,26.9,60,60,60h160c33.1,0,60-26.9,60-60V75c0-33.1-26.9-60-60-60H60C26.9,15,0,41.9,0,75V903z"/>
<path d="M409,15c-33.1,0-60,26.9-60,60v828c0,33.1,26.9,60,60,60h160c33.1,0,60-26.9,60-60V75c0-33.1-26.9-60-60-60H409z"/>
<path d="M758,963h160c33.1,0,60-26.9,60-60V75c0-33.1-26.9-60-60-60H758c-33.1,0-60,26.9-60,60v828C698,936.1,724.9,963,758,963z"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 823 B