Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/features/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,18 @@ The overflow popover itself is capped at `max-h-[calc(100dvh-7rem)]` with `overf

The opt-in audio-quality footer ([`AudioQualityFooter`](../../src/components/player/AudioQualityFooter.tsx), pinned via Settings → Appearance → Player bar layout) is a thin strip below the player bar that surfaces the source file specs in compact form (`48 kHz · 256 kb/s · 6 Mo` on the left, `AAC · 24bit · 48kHz` on the right; bitrates ≥ 1000 kbps render as `Mb/s`). When the engine is resampling — source rate ≠ output device rate — the left chunk renders an arrow instead: `48 kHz → 44.1 kHz · …`, so the user can spot the conversion at a glance without opening the popover. The arrow is gated on the device rate being known (the engine reports `0` before the first stream opens); otherwise we fall back to the source rate alone rather than printing a misleading `48 kHz → null`. The Hi-Res pill surfaces when [`isHiRes`](../../src/lib/hiRes.ts) accepts the source bit depth / sample rate combination.

### Hi-Res / DSD badge

[`HiResBadge`](../../src/components/common/HiResBadge.tsx) is the green pill that decorates track rows, album grid tiles, and the player-bar metadata when the source qualifies as Hi-Res (`isHiRes` — ≥ 24-bit, ≥ 44.1 kHz) or as DSD (`dsdLabel` returns `DSD64` / `DSD128` / …). Three variants:

| Variant | Used in | Style |
| --------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `overlay` | Album / artist grid covers (default). | Absolute-positioned pill in the cover's top-left corner with a drop shadow. |
| `inline` | TrackTable rows, sidebar lists. | Inline rounded pill next to the title. |
| `text` | Player bar — under the artist name. | Spotify-style minimal green uppercase text, no pill background, blends into the metadata stack. |

All variants are gated by [`useHiResBadgeVisibility`](../../src/hooks/useHiResBadgeVisibility.ts), which reads `profile_setting['ui.show_hi_res_badge']` (default `true`) and re-reads on the `waveflow:hi-res-badge-visibility` window event. Settings → Appearance ships [`HiResBadgeCard`](../../src/components/views/settings/HiResBadgeCard.tsx) to flip the flag — when off, every mounted `HiResBadge` returns `null` in one render, including the player-bar text label. Per-profile so a kid's profile can hide the audiophile chrome while the audiophile profile keeps it.

Hovering (or keyboard-focusing) the footer opens [`AudioPipelinePopover`](../../src/components/player/AudioPipelinePopover.tsx) — an audiophile-grade breakdown of what the engine is actually doing.

#### Sections displayed
Expand Down
26 changes: 21 additions & 5 deletions src/components/common/HiResBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import { dsdLabel, isHiRes } from "../../lib/hiRes";
import { useHiResBadgeVisibility } from "../../hooks/useHiResBadgeVisibility";

/**
* Hi-Res Audio badge — shown when the source file is delivered at a
* better-than-CD spec (≥ 24-bit, ≥ 44.1 kHz) OR when the codec is
* DSD (in which case the badge says "DSD64", "DSD128", etc. instead
* of "Hi-Res 24-bit").
*
* Two visual variants:
* - `overlay` is intended to sit on top of an album cover (top-left
* Three visual variants:
* - `overlay` (default) sits on top of an album cover (top-left
* corner, drop shadow);
* - `inline` is for sidebar / row contexts where the badge sits next
* to text.
* to text;
* - `text` is the minimal Spotify-style green text label used under
* the artist name in the player bar — no background pill so it
* nests cleanly inside dense metadata.
*
* Globally hidden by the per-profile
* `profile_setting['ui.show_hi_res_badge']` toggle — flipping that off
* returns `null` everywhere this component is mounted.
*/
interface HiResBadgeProps {
bitDepth: number | null;
sampleRate: number | null;
/** Codec label from the scanner (e.g. "FLAC", "DSD128"). */
codec?: string | null;
variant?: "overlay" | "inline";
variant?: "overlay" | "inline" | "text";
/** Override the visible text. Default is "Hi-Res {bitDepth}-bit". */
label?: string;
}
Expand All @@ -29,13 +37,21 @@ export function HiResBadge({
variant = "overlay",
label,
}: HiResBadgeProps) {
const { visible } = useHiResBadgeVisibility();
// DSD wins over the generic Hi-Res check — a DSF/DFF file reports
// bit_depth=1 but is anything but lossy, and the user expects the
// rate label (DSD64/128/...) rather than "Hi-Res 1-bit".
const dsd = dsdLabel(codec);
const isVisible = dsd !== null || isHiRes(bitDepth, sampleRate);
if (!isVisible) return null;
if (!isVisible || !visible) return null;
const text = label ?? dsd ?? `Hi-Res ${bitDepth}-bit`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (variant === "text") {
return (
<span className="text-[10px] font-bold uppercase tracking-wider text-emerald-500 dark:text-emerald-400">
{text}
</span>
);
}
if (variant === "inline") {
return (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500 text-white tracking-wide">
Expand Down
14 changes: 14 additions & 0 deletions src/components/player/PlayerBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useSleepTimer } from "../../hooks/useSleepTimer";
import { usePlayerBarLayout } from "../../hooks/usePlayerBarLayout";
import { Artwork } from "../common/Artwork";
import { ArtistLink } from "../common/ArtistLink";
import { HiResBadge } from "../common/HiResBadge";
import { PlaybackControls } from "./PlaybackControls";
import { ProgressBar } from "./ProgressBar";
import { SleepTimerMenu } from "./SleepTimerMenu";
Expand Down Expand Up @@ -166,6 +167,19 @@ export function PlayerBar({ onNavigateToArtist }: PlayerBarProps) {
(currentTrack?.album_title ?? t("player.inactive"))
)}
</span>
{/* Spotify-style minimal quality label under the artist.
HiResBadge renders null when the track isn't Hi-Res /
DSD OR when the user has hidden the badge from
Settings → Appearance, so the row collapses naturally
for lossy / lossless-16-bit content. */}
{currentTrack && (
<HiResBadge
bitDepth={currentTrack.bit_depth}
sampleRate={currentTrack.sample_rate}
codec={currentTrack.codec}
variant="text"
/>
)}
</div>
{currentTrack && !isSpotify && (
<button
Expand Down
3 changes: 3 additions & 0 deletions src/components/views/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ import { ExclusiveModeCard } from "./settings/ExclusiveModeCard";
import { PlayerBarLayoutCard } from "./settings/PlayerBarLayoutCard";
import { ShortcutsCard } from "./settings/ShortcutsCard";
import { WrappedBannerCard } from "./settings/WrappedBannerCard";
import { HiResBadgeCard } from "./settings/HiResBadgeCard";

interface SettingsViewProps {
onNavigate: (view: ViewId) => void;
Expand Down Expand Up @@ -2533,6 +2534,8 @@ export function SettingsView({ onNavigate }: SettingsViewProps) {

<PlayerBarLayoutCard />

<HiResBadgeCard />

<WrappedBannerCard />
</section>
)}
Expand Down
49 changes: 49 additions & 0 deletions src/components/views/settings/HiResBadgeCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useTranslation } from "react-i18next";
import { Sparkles } from "lucide-react";
import { useHiResBadgeVisibility } from "../../../hooks/useHiResBadgeVisibility";

/**
* Settings → Appearance row toggling the Hi-Res / DSD pill that
* decorates track lists, album grids, and the compact label under
* the artist name in the player bar. Default ON because the pill is
* part of WaveFlow's audiophile identity; the toggle lets users who
* find it noisy turn it off without touching every list view.
*/
export function HiResBadgeCard() {
const { t } = useTranslation();
const { visible, setVisible } = useHiResBadgeVisibility();

return (
<section
aria-label={t("settings.hiResBadge.title")}
className="px-4 py-3"
>
<label className="flex items-start justify-between gap-3 cursor-pointer">
<span className="flex items-start gap-3 min-w-0">
<Sparkles
size={20}
className="text-zinc-400 mt-0.5 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-zinc-900 dark:text-white">
{t("settings.hiResBadge.title")}
</span>
<span className="block text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed mt-0.5">
{t("settings.hiResBadge.subtitle")}
</span>
</span>
</span>
<input
type="checkbox"
checked={visible}
onChange={(e) => {
void setVisible(e.target.checked);
}}
className="mt-1.5 w-4 h-4 accent-emerald-500 cursor-pointer shrink-0"
aria-label={t("settings.hiResBadge.title")}
/>
</label>
</section>
);
}
151 changes: 151 additions & 0 deletions src/hooks/useHiResBadgeVisibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { useCallback, useSyncExternalStore } from "react";
import { getProfileSetting, setProfileSetting } from "../lib/tauri/profile";

/** Per-profile `profile_setting` key gating every Hi-Res / DSD pill. */
const KEY = "ui.show_hi_res_badge";

/**
* Window event broadcast by the Settings toggle after a write so every
* mounted `HiResBadge` + the player-bar quality label re-read in one
* go. Also re-fired by other callers (tests, future profile-switch
* code) when the underlying setting changes outside this module.
*/
export const HI_RES_BADGE_EVENT = "waveflow:hi-res-badge-visibility";

const DEFAULT_VISIBLE = true;

function parseVisible(raw: string | null): boolean {
if (raw == null) return DEFAULT_VISIBLE;
return raw !== "false" && raw !== "0";
}

// ─── Module-level store ───────────────────────────────────────────────
//
// The previous implementation called `useEffect` with its own window
// listener + Tauri fetch inside every `HiResBadge` instance. On a
// virtualised library view ~20-50 badges are mounted at once → 20-50
// `getProfileSetting` calls plus 20-50 listeners on the window event
// for what is really a single boolean. The shared store collapses that
// to one Tauri fetch on the first subscriber + one window listener
// attached lazily, with React-level subscriptions handled through
// `useSyncExternalStore` (cheap — just adds a callback to a Set).

let currentVisible: boolean = DEFAULT_VISIBLE;
let hydrated = false;
let windowListenerAttached = false;
// Monotonic token for in-flight backend reads. Bumped before each
// fetch; the resolver only commits if its captured token still
// matches — protects against an older async response overwriting a
// newer one if two `hydrateFromBackend` calls race (e.g. settings
// toggle dispatching the window event right next to a profile-switch
// `refreshHiResBadgeVisibility`).
let hydrateToken = 0;
const listeners: Set<() => void> = new Set();

function notify(): void {
for (const listener of listeners) listener();
}

async function hydrateFromBackend(): Promise<void> {
const token = ++hydrateToken;
try {
const raw = await getProfileSetting(KEY);
if (token !== hydrateToken) return; // a newer read superseded us
const next = parseVisible(raw);
if (next !== currentVisible) {
currentVisible = next;
notify();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (err) {
console.error("[useHiResBadgeVisibility] read failed", err);
}
}

function ensureWindowListener(): void {
if (windowListenerAttached || typeof window === "undefined") return;
windowListenerAttached = true;
window.addEventListener(HI_RES_BADGE_EVENT, () => {
void hydrateFromBackend();
});
}

function subscribe(listener: () => void): () => void {
ensureWindowListener();
if (!hydrated) {
// First subscriber kicks off the one-time backend read. Marked
// hydrated even before the fetch resolves so subsequent
// subscribers don't fire a duplicate request — they'll get the
// value via `notify` once the first fetch lands.
hydrated = true;
void hydrateFromBackend();
}
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}

function getSnapshot(): boolean {
return currentVisible;
}

// ─── Test / profile-switch hook ───────────────────────────────────────
//
// Exported so that `ProfileContext` (or unit tests) can force a re-
// hydrate when the active profile changes — `profile_setting` is
// scoped per profile, so the cached `currentVisible` becomes stale
// the moment the user switches.

/** Force the module-level store to re-read from the backend. */
export function refreshHiResBadgeVisibility(): void {
hydrated = true;
void hydrateFromBackend();
}

export interface HiResBadgeVisibility {
visible: boolean;
setVisible: (next: boolean) => Promise<void>;
}

/**
* Hook returning the user's preference for the Hi-Res / DSD pill that
* decorates track lists, album grids, and the player bar. Default
* **on** because the badge is part of WaveFlow's audiophile identity;
* the toggle lets users who find it noisy turn it off in one click.
*
* Backed by `profile_setting['ui.show_hi_res_badge']` (per-profile so
* a kid's profile can stay clean while the audiophile profile keeps
* the pills) and a module-level cache so a virtualised view with
* dozens of mounted badges only triggers one Tauri fetch + one window
* listener.
*/
export function useHiResBadgeVisibility(): HiResBadgeVisibility {
const visible = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);

const setVisible = useCallback(async (next: boolean) => {
const previous = currentVisible;
if (next === previous) return;
// Optimistic update — flips every mounted consumer immediately.
currentVisible = next;
notify();
try {
await setProfileSetting(KEY, next ? "true" : "false", "bool");
// Broadcast for other tabs / mini-player webview so they
// re-hydrate from the same store. Guarded so callers running
// in a non-DOM environment (Node tests, future SSR) don't
// crash here — `ensureWindowListener` applies the same guard.
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(HI_RES_BADGE_EVENT));
}
} catch (err) {
console.error("[useHiResBadgeVisibility] write failed", err);
// Roll back so the UI stays consistent with the persisted
// setting on failure (Tauri command rejected, profile pool
// unavailable, etc.).
currentVisible = previous;
notify();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}, []);

return { visible, setVisible };
}
4 changes: 4 additions & 0 deletions src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -1601,6 +1601,10 @@
"description": "لا تُظهر الشعار في الشاشة الرئيسية أبدًا. تظل صفحة Wrapped متاحة."
}
}
},
"hiResBadge": {
"title": "شارة Hi-Res / DSD",
"subtitle": "عرض ملصق «Hi-Res 24-bit» أو «DSD128» بجانب المسارات المؤهلة وأسفل اسم الفنان في شريط المشغل. مفعّل افتراضيًا."
}
},
"spotify": {
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1597,6 +1597,10 @@
"description": "Banner nie auf dem Startbildschirm anzeigen. Die Wrapped-Ansicht bleibt erreichbar."
}
}
},
"hiResBadge": {
"title": "Hi-Res / DSD-Badge",
"subtitle": "Zeigt die Plakette „Hi-Res 24-bit“ oder „DSD128“ neben qualifizierenden Titeln und unter dem Künstlernamen in der Wiedergabeleiste. Standardmäßig aktiviert."
}
},
"spotify": {
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1597,6 +1597,10 @@
"description": "Never show the banner on the home screen. The Wrapped view itself stays reachable."
}
}
},
"hiResBadge": {
"title": "Hi-Res / DSD badge",
"subtitle": "Show the “Hi-Res 24-bit” or “DSD128” pill next to qualifying tracks and under the artist name in the player bar. On by default."
}
},
"spotify": {
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1597,6 +1597,10 @@
"description": "Nunca mostrar el banner en el inicio. La vista Wrapped sigue siendo accesible."
}
}
},
"hiResBadge": {
"title": "Insignia Hi-Res / DSD",
"subtitle": "Muestra la etiqueta «Hi-Res 24-bit» o «DSD128» junto a las pistas que califican y bajo el nombre del artista en la barra del reproductor. Activado por defecto."
}
},
"spotify": {
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1597,6 +1597,10 @@
"description": "Ne jamais afficher le bandeau sur l'accueil. La page Wrapped reste accessible."
}
}
},
"hiResBadge": {
"title": "Badge Hi-Res / DSD",
"subtitle": "Affiche la pastille « Hi-Res 24-bit » ou « DSD128 » à côté des morceaux concernés et sous l'artiste dans la barre de lecture. Activé par défaut."
}
},
"spotify": {
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1597,6 +1597,10 @@
"description": "होम स्क्रीन पर बैनर कभी न दिखाएँ। Wrapped दृश्य फिर भी सुलभ रहता है।"
}
}
},
"hiResBadge": {
"title": "Hi-Res / DSD बैज",
"subtitle": "योग्य ट्रैक के बगल में और प्लेयर बार में कलाकार के नाम के नीचे «Hi-Res 24-bit» या «DSD128» पिल दिखाएँ। डिफ़ॉल्ट रूप से चालू।"
}
},
"spotify": {
Expand Down
Loading
Loading