feat(ui): surface BitPerfectMode in QualityBadge (#288)

QualityBadge now has three new visual states wired off the
bit_perfect_mode field the backend emits in PlaybackEvent:

- DirectHardware → subtle green check next to the tier label,
  border tinted green. Reassurance that audiophile listeners are
  getting the stream untouched.
- PluginFallback → purple down-arrow + new tooltip explaining the
  DAC doesn't support the source rate so ALSA (plughw) is doing
  software resampling. The tooltip's suggested fix is explicit
  about the mitigation (pick lower-rate source or a broader DAC)
  since this is a hardware-capability constraint, not a user
  setting issue.
- Disabled / null → no change.

Plumbed through the existing playerStore subscription pattern so
the indicator reflects the stream the moment it opens. i18n keys
added for all 5 locales (en/es/de/fr/pt).

cc
This commit is contained in:
vicrodh committed 2026-04-13 19:38:06 -06:00
1 parent 8311cef821
commit 33761c6462
8 files changed
+110 -7

No files matched your search

+17 -2
View File
@@ -36,7 +36,12 @@
refreshStatus,
type OfflineReason
} from '$lib/stores/offlineStore';
import { toggleMute } from '$lib/stores/playerStore';
import {
toggleMute,
getBitPerfectMode,
subscribe as subscribePlayer,
type BitPerfectMode,
} from '$lib/stores/playerStore';
import {
subscribe as subscribeDegraded,
isDegraded
@@ -182,6 +187,16 @@
return unsubDegraded;
});
// Bit-perfect mode reported by the audio backend (null until first stream).
let bitPerfectMode = $state<BitPerfectMode | null>(getBitPerfectMode());
$effect(() => {
const unsubPlayer = subscribePlayer(() => {
bitPerfectMode = getBitPerfectMode();
});
return unsubPlayer;
});
// Get human-readable offline reason
function getOfflineReasonText(reason: OfflineReason | null): string {
switch (reason) {
@@ -440,7 +455,7 @@
<div class="badges-group">
<div class="quality-indicator">
<QualityBadge {quality} {bitDepth} {samplingRate} {originalBitDepth} {originalSamplingRate} {format} />
<QualityBadge {quality} {bitDepth} {samplingRate} {originalBitDepth} {originalSamplingRate} {format} {bitPerfectMode} />
<div class="audio-badges-row">
<AudioOutputBadges {samplingRate} />
</div>
+64 -4
View File
@@ -8,6 +8,8 @@
is_active: boolean;
}
type BitPerfectMode = 'DirectHardware' | 'PluginFallback' | 'Disabled';
interface Props {
quality?: string;
bitDepth?: number;
@@ -16,6 +18,9 @@
originalSamplingRate?: number;
format?: string;
compact?: boolean;
/** Reported by the audio backend when a stream is opened. Lets us distinguish
* ALSA plughw software resample (PluginFallback) from system resampling. */
bitPerfectMode?: BitPerfectMode | null;
}
let {
@@ -25,7 +30,8 @@
originalBitDepth,
originalSamplingRate,
format,
compact = false
compact = false,
bitPerfectMode = null,
}: Props = $props();
// Hardware sample rate from /proc/asound (sub-ms read)
@@ -69,6 +75,14 @@
return false;
});
// ALSA plughw fallback is its own failure mode: the stream is being resampled
// inside the kernel/ALSA space because the DAC does not accept the source
// rate (see issue #288). Distinct from isDowngraded which is about the
// pipewire/system path — both get surfaced in the UI but with different
// explanations so users know where to look for the fix.
const isPluginFallback = $derived(bitPerfectMode === 'PluginFallback');
const isBitPerfect = $derived(bitPerfectMode === 'DirectHardware');
// Quality adjustment detection: QBZ fetched lower quality for hardware compatibility.
// Decoded rate < original metadata rate, but hardware plays it natively (no resampling).
const isQualityAdjusted = $derived.by(() => {
@@ -198,7 +212,9 @@
class="quality-badge"
class:downgraded={isDowngraded}
class:adjusted={isQualityAdjusted}
title={isDowngraded || isQualityAdjusted ? undefined : `${tierLabel}: ${displayText}`}
class:plughw={isPluginFallback}
class:bitperfect={isBitPerfect}
title={isDowngraded || isQualityAdjusted || isPluginFallback ? undefined : `${tierLabel}: ${displayText}`}
onmouseenter={() => { isHovering = true; }}
onmouseleave={() => { isHovering = false; }}
>
@@ -216,13 +232,34 @@
<div class="badge-text">
<span class="tier-label">
{tierLabel}
<span class="downgrade-indicator" class:visible={isDowngraded || isQualityAdjusted} class:adjusted={isQualityAdjusted}>↓</span>
<span
class="downgrade-indicator"
class:visible={isDowngraded || isQualityAdjusted || isPluginFallback}
class:adjusted={isQualityAdjusted}
class:plughw={isPluginFallback}
>↓</span>
<span class="bitperfect-indicator" class:visible={isBitPerfect} title={$t('quality.tooltip.bitPerfect')}>✓</span>
</span>
<span class="quality-info">{displayText}</span>
</div>
<!-- Custom tooltip for ALSA plughw fallback (software resample inside ALSA) -->
{#if isPluginFallback && !isDowngraded && !isQualityAdjusted && isHovering}
<div class="quality-tooltip">
<div class="tooltip-section">
<div class="tooltip-label">{$t('quality.tooltip.source')}</div>
<div class="tooltip-value">{displayText}</div>
</div>
<div class="tooltip-section">
<div class="tooltip-label">{$t('quality.tooltip.output')}</div>
<div class="tooltip-value">{effectiveOutputRateKHz ? `${effectiveOutputRateKHz} kHz` : displayText}</div>
</div>
<div class="tooltip-divider"></div>
<div class="tooltip-warning">{$t('quality.tooltip.plughwFallback')}</div>
<div class="tooltip-fix">{$t('quality.tooltip.plughwFix')}</div>
</div>
<!-- Custom tooltip for quality-adjusted state (QBZ chose lower quality for hw compat) -->
{#if isQualityAdjusted && isHovering}
{:else if isQualityAdjusted && isHovering}
<div class="quality-tooltip">
<div class="tooltip-section">
<div class="tooltip-label">{$t('quality.tooltip.source')}</div>
@@ -332,6 +369,14 @@
border-color: rgba(234, 179, 8, 0.3);
}
.quality-badge.plughw {
border-color: rgba(168, 85, 247, 0.3);
}
.quality-badge.bitperfect {
border-color: rgba(34, 197, 94, 0.25);
}
.downgrade-indicator {
color: #eab308;
font-weight: 600;
@@ -347,6 +392,21 @@
color: #60a5fa;
}
.downgrade-indicator.plughw {
color: #a855f7;
}
.bitperfect-indicator {
color: #22c55e;
font-weight: 600;
margin-left: 2px;
visibility: hidden;
}
.bitperfect-indicator.visible {
visibility: visible;
}
/* Custom tooltip for degraded quality */
.quality-tooltip {
position: absolute;
+3
View File
@@ -687,6 +687,9 @@
"output": "Ausgabe",
"resampled": "Vom System resampled",
"qualityAdjusted": "Qualität für Gerätekompatibilität angepasst — bit-perfekt, kein Resampling",
"plughwFallback": "Von ALSA resampelt (plughw)",
"plughwFix": "Der DAC unterstützt diese Abtastrate nicht nativ. Wählen Sie eine Quelle mit niedrigerer Rate oder einen DAC mit breiterer Unterstützung.",
"bitPerfect": "Bit-perfekt: direkte Hardware-Ausgabe",
"fix": "Aktiviere DAC Passthrough oder Exklusivmodus in den Einstellungen"
},
"stereo": "Stereo",
+4 -1
View File
@@ -687,7 +687,10 @@
"output": "Output",
"resampled": "Resampled by system",
"qualityAdjusted": "Quality adjusted for device compatibility — bit-perfect, no resampling",
"fix": "Enable DAC Passthrough or Exclusive Mode in Settings"
"fix": "Enable DAC Passthrough or Exclusive Mode in Settings",
"plughwFallback": "Resampled by ALSA (plughw)",
"plughwFix": "DAC doesn't support this sample rate natively. Pick a lower-rate source or a DAC with broader rate support.",
"bitPerfect": "Bit-perfect: direct hardware output"
},
"stereo": "Stereo",
"mono": "Mono"
+3
View File
@@ -687,6 +687,9 @@
"output": "Salida",
"resampled": "Remuestreado por el sistema",
"qualityAdjusted": "Calidad ajustada por compatibilidad — bit-perfect, sin remuestreo",
"plughwFallback": "Remuestreado por ALSA (plughw)",
"plughwFix": "El DAC no soporta esta tasa de muestreo nativamente. Elige una fuente de menor tasa o un DAC con mayor soporte.",
"bitPerfect": "Bit-perfect: salida directa al hardware",
"fix": "Activa DAC Passthrough o Modo Exclusivo en Ajustes"
},
"stereo": "Estéreo",
+3
View File
@@ -687,6 +687,9 @@
"output": "Sortie",
"resampled": "Rééchantillonné par le système",
"qualityAdjusted": "Qualité ajustée pour compatibilité — bit-perfect, sans rééchantillonnage",
"plughwFallback": "Rééchantillonné par ALSA (plughw)",
"plughwFix": "Le DAC ne prend pas en charge cette fréquence nativement. Choisissez une source à fréquence plus basse ou un DAC avec un support plus large.",
"bitPerfect": "Bit-perfect : sortie matérielle directe",
"fix": "Activez DAC Passthrough ou Mode Exclusif dans les Paramètres"
},
"stereo": "Stéréo",
+3
View File
@@ -687,6 +687,9 @@
"output": "Saída",
"resampled": "Reamostrado pelo sistema",
"qualityAdjusted": "Qualidade ajustada para compatibilidade do dispositivo — bit-perfect, sem reamostragem",
"plughwFallback": "Reamostrado pelo ALSA (plughw)",
"plughwFix": "O DAC não suporta esta taxa de amostragem nativamente. Escolha uma fonte de menor taxa ou um DAC com suporte mais amplo.",
"bitPerfect": "Bit-perfect: saída direta de hardware",
"fix": "Ative DAC Passthrough ou Modo Exclusivo nas Configurações"
},
"stereo": "Estéreo",
+13
View File
@@ -70,6 +70,12 @@ interface BackendPlaybackState {
volume: number;
}
/**
* Bit-perfect mode of the active audio stream, reported by the Rust backend.
* Matches the `qbz_audio::BitPerfectMode` enum via serde.
*/
export type BitPerfectMode = 'DirectHardware' | 'PluginFallback' | 'Disabled';
// Event payload from backend
interface PlaybackEvent {
is_playing: boolean;
@@ -83,6 +89,7 @@ interface PlaybackEvent {
gapless_ready: boolean; // Backend wants next track queued for gapless
gapless_next_track_id: number; // Track ID queued for gapless (0 = none)
buffer_progress?: number | null; // Streaming buffer progress (0-1, null = fully cached)
bit_perfect_mode?: BitPerfectMode | null; // None until first stream opens
}
// Queue track from backend (for external track sync)
@@ -147,6 +154,7 @@ let isSkipping = false;
let queueEnded = false;
let normalizationGain: number | null = null; // Current normalization gain (null = not active)
let bufferProgress: number | null = null; // Streaming buffer progress (0-1, null = fully cached)
let bitPerfectMode: BitPerfectMode | null = null; // Reported by backend when stream opens
let pendingSeekPosition: number | null = null;
let seekRequestInFlight = false;
let seekTargetPosition: number | null = null;
@@ -227,6 +235,10 @@ export function getNormalizationGain(): number | null {
return normalizationGain;
}
export function getBitPerfectMode(): BitPerfectMode | null {
return bitPerfectMode;
}
async function flushPendingSeek(): Promise<void> {
if (seekRequestInFlight) return;
if (pendingSeekPosition === null) return;
@@ -766,6 +778,7 @@ async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
// Update normalization gain state
normalizationGain = event.normalization_gain;
bufferProgress = event.buffer_progress ?? null;
bitPerfectMode = event.bit_perfect_mode ?? null;
notifyListeners();