Skip to content

Commit 43e7392

Browse files
authored
Merge pull request #20 from chrisuthe/chrisuthe/task/finish-the-reskin-tray-parity-friendly-setting
Finish the reskin: tray parity, friendly picker labels, guarded macOS font, docs sweep
2 parents 91ad882 + 12e0cc7 commit 43e7392

18 files changed

Lines changed: 576 additions & 65 deletions

README.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,34 @@ the obvious answer, and each one cost real effort to establish.
2929
- **Native OS media integration** — the Windows 11 media flyout, MPRIS on Plasma 6 and GNOME, and
3030
Now Playing / Control Center on macOS — so hardware media keys work without the app being
3131
focused.
32-
- **Tray / status item**, start-minimized, single-instance, and notifications with per-event
33-
toggles.
32+
- **Tray / status item** with the transport, Switch Group, mute and a volume readout in its menu;
33+
start-minimized, single-instance, and notifications with per-event toggles.
3434
- **Real measured output latency** per platform, not a constant, plus a per-device manual offset
3535
for the Bluetooth and AirPlay tail that no API reports.
36-
- **A diagnostics view** showing sync error, correction band, playback rate in ppm, buffer depth,
37-
clock offset and drift, and — the most useful single field — whether the timing source is
38-
actually the audio hardware clock.
36+
- **Stats for Nerds**, a separate window showing sync error, correction band, playback rate in
37+
ppm, buffer depth, clock offset and drift, and — the most useful single field — whether the
38+
timing source is actually the audio hardware clock. It reopens where it was left.
3939
- **Native shell, system theme and accent, platform font.** Resizable, with the OS's own
4040
decorations; follows the desktop's light/dark setting and accent live. The layout inside the
4141
window comes from Sendspin for Windows; the colours do not.
42+
- **A living backdrop.** The player advertises the `color@v1` and `visualizer@v1` roles, and the
43+
server's palette and loudness/beat frames drive the window: **Ambient Glow** (colour blobs
44+
behind the veil), **Breathing Art** (the album art breathes with the music) or **Off**, with an
45+
intensity slider. A box drawing without a GPU gets Off automatically, because the effect costs
46+
too much in software rendering.
47+
- **A settings card** over the backdrop rather than a dialog: every control writes through the
48+
moment it changes, so there is no Save. Pickers show plain names, not enum members.
49+
50+
## Screenshots
51+
52+
Taken on Fedora / KDE Plasma under Wayland, following the desktop's theme and accent.
53+
54+
| | |
55+
|---|---|
56+
| ![Now Playing, narrow, dark](docs/screenshots/reskin/phase3-narrow-dark.png) | ![Now Playing, wide, light](docs/screenshots/reskin/phase3-wide-light.png) |
57+
| Now Playing, the narrow composition, dark | Now Playing, the wide composition, light |
58+
| ![Ambient Glow](docs/screenshots/reskin/phase5-glow-dark.png) | ![The settings card](docs/screenshots/reskin/phase4-settings-dark.png) |
59+
| Ambient Glow, driven by the server's palette and loudness | The settings card |
4260

4361
## Requirements
4462

docs/ARCHITECTURE.md

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,28 @@ the host's and the shell cannot follow a path into it.
582582

583583
## UI shell
584584

585+
**What shipped, by paragraph.** The measurements below are long; these are the "As shipped"
586+
paragraphs that say what the player does with them, so a reader after the shell rather than the
587+
numbers can go straight there:
588+
589+
- **The shell**[reskin phase 1](#system-font--inter-wins-only-because-fluent-asks-for-it-first)
590+
(system theme, accent and font, the "Decided and shipped" paragraph at the end of the font section)
591+
and [reskin phase 2](#as-shipped-reskin-phase-2--the-window-shell): the 440×700 native-decorated
592+
window, the layer stack, the opaque-root and toolbar-inset rules.
593+
- **The clock** — the "one timer" paragraph under
594+
[reskin phase 2](#as-shipped-reskin-phase-2--the-window-shell): `UiClock`, the hygiene test that
595+
keeps `DispatcherTimer` out, and the elapsed-time progress bar.
596+
- **The compositions**[reskin phase 3](#as-shipped-reskin-phase-3--now-playing-welcome-the-backdrop-the-prompt):
597+
Now Playing's narrow and wide layouts, Welcome, the blurred-art backdrop and the auto-connect prompt.
598+
- **The settings card**[reskin phase 4](#as-shipped-reskin-phase-4--the-settings-card-and-the-stats-window),
599+
first paragraph: the overlay card, its sections, and write-through with no Save.
600+
- **The Stats window**[reskin phase 4](#as-shipped-reskin-phase-4--the-settings-card-and-the-stats-window),
601+
"The Stats window": the separate diagnostics window and its refresh clock.
602+
- **The backdrop**[reskin phase 5](#as-shipped-reskin-phase-5--the-living-backdrop): the two
603+
advertised roles, Ambient Glow, Breathing Art, the software-rendering guard and the measured cost.
604+
- **The loose ends**[reskin phase 6](#as-shipped-reskin-phase-6--tray-parity-friendly-labels-the-macos-font):
605+
the tray menu at parity with the reference, the friendly picker labels, and the guarded macOS font.
606+
585607
What the windowing layer actually does when asked to follow the desktop, and what the living-backdrop
586608
effect loop costs. Measured on two dev machines with a throwaway probe kept under
587609
`scripts/spike/ShellSpike/` so any of it can be re-run:
@@ -889,9 +911,14 @@ already sees, which is what takes the Flatpak from DejaVu Sans to the desktop's
889911
portal or no key leaves `DefaultFamilyName` null, which is fontconfig's answer as before. The app logs
890912
what it ended up with at start-up (`UI font: $Default is …, glyphs from …, fallback face …`), which
891913
is the `font` probe's report without a second binary. Windows leaves the name null on the measurement
892-
above; macOS leaves it null on the Helvetica measurement; the table above shows `.AppleSystemUIFont` is the
893-
name that resolves to the system face and that an unresolvable name kills the process, so the macOS
894-
override is a follow-up that ships with a resolve check (`PlatformSelection.MacOS.cs`).
914+
above. **macOS: shipped, guarded** (reskin phase 6). The table above shows `.AppleSystemUIFont` is the
915+
name that resolves to the system face and that an unresolvable name kills the process, so
916+
`PlatformSelection.MacOS.cs` names it only after `SKFontManager.Default.MatchFamily` has returned a
917+
typeface for it — the same font manager Avalonia will ask — and leaves the name null, with a line on
918+
standard error, when it does not. The rule is `Core/Platform/MacSystemFont.Select`, a pure function
919+
over a resolver, pinned by `Sendspin.Tests/MacSystemFontTests` on both answers without a Mac. The
920+
start-up `UI font:` line is how the result is read on the machine: `$Default is .AppleSystemUIFont,
921+
glyphs from System Font` is the guard passing.
895922

896923

897924
### Decorations and client-area extension — the hint does nothing under KWin
@@ -1430,3 +1457,29 @@ spike's, and is exactly why the guard exists. The loop itself held 62.5 fps in e
14301457
`docs/screenshots/reskin/phase5-glow-{light,dark}.png` (the window as the user had it, wide) and
14311458
`phase5-breathing-dark.png`; the settings-card shot `phase5-settings-backdrop.png` needs a hand on
14321459
the gear and is not in this commit.
1460+
1461+
### As shipped (reskin phase 6) — tray parity, friendly labels, the macOS font
1462+
1463+
**The tray menu** (`Services/TrayIconController.cs`) carries the Sendspin for Windows items in its
1464+
order, under this app's status line: Play / Pause, Next, Previous, Switch Group, a separator, Mute /
1465+
Unmute, a disabled `Volume: NN %` readout, a separator, Show Sendspin Player, Quit. Switch Group is
1466+
there whether or not the footer shows its button, because the menu is where a hidden button's
1467+
function still lives. The two Avalonia constraints in the file's remarks still hold, so state is
1468+
carried in labels — Play / Pause and Mute / Unmute follow the view model, the readout follows
1469+
`Volume` — and never in `IsChecked`. The transport items bind the view model's commands rather than
1470+
click handlers, which is what greys them all out together while there is no connection
1471+
(`NativeMenuItem` follows `CanExecute`); Mute goes through a new `ToggleMuteCommand` so the footer's
1472+
speaker button and the tray drive the one property. `Sendspin.Ui.Tests/TrayMenuTests` builds the
1473+
menu over the headless platform's null tray and reads the headers in order, then flips mute and
1474+
volume on the view model and reads them back.
1475+
1476+
**The friendly labels.** `Converters/ConnectionModeLabel.cs` and `AutoConnectPolicyLabel.cs` follow
1477+
`BackdropModeLabel`: the picker binds the enum through to the setting and only the item template
1478+
goes through the converter, so "AdvertiseOnly" reads as "Advertise to servers, and let a server
1479+
connect" and "JustOnce" as "Just once". `Sendspin.Ui.Tests/SettingLabelTests` pins every member of
1480+
both enums, so a new member fails a test rather than showing raw; `ConnectionMode.Auto`, obsolete
1481+
on this SDK line and removed on the next, is the one member allowed to fall through, and the test
1482+
says so. `EverySettingsComboBoxIsGuarded` still counts five.
1483+
1484+
**The macOS font** is the "shipped, guarded" paragraph at the end of the System font section
1485+
above. The one thing this phase could not verify from Linux is that line on a Mac.

docs/NEXT_STEPS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,19 @@ Recorded so the reasoning is not relitigated from scratch:
261261
complete first-party Swift SDK; SwiftUI plus SendspinKit is the answer there.
262262
- **No native C audio shim yet.** See `docs/ARCHITECTURE.md` for the per-platform realtime story and
263263
where the macOS one is knowingly short.
264+
- **The native shell.** The window follows the system theme, accent and font, with the OS's own
265+
decorations; the composition — the layer stack, Now Playing's two layouts, the settings card, the
266+
Stats window, the living backdrop — comes from Sendspin for Windows, and the colours do not. Every
267+
colour is a theme or accent token; the hygiene test in `Sendspin.Ui.Tests` keeps literal colours
268+
out of the axaml. The reasoning and the measurements are the *UI shell* section of
269+
`docs/ARCHITECTURE.md`.
270+
- **The `UiClock` rule.** No `DispatcherTimer`, `RequestAnimationFrame` or Avalonia `Animation` in
271+
the app: the Wayland backend quantises the first and runs the other two at tens of kilohertz with
272+
no frame between (item 8). Everything periodic goes through `Player/Threading/UiClock.cs`, and a
273+
hygiene test keeps `DispatcherTimer` out of every other file. Re-measure the backend on every
274+
`Avalonia.Wayland` bump before relaxing this; do not relax it on the strength of a reading.
275+
- **The two backdrop roles are advertised.** `client/hello` lists `color@v1` and `visualizer@v1`
276+
with a `visualizer@v1_support` object, verified live against Music Assistant; the roles and the
277+
support object are one pin. Taking them out again to slim the hello would put the living backdrop
278+
back on the theme accent alone — the fallback exists for a server that lacks the roles, not as a
279+
mode to prefer.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
namespace Sendspin.Core.Platform;
2+
3+
/// <summary>
4+
/// The family name the macOS head hands Avalonia as <c>$Default</c>, and the rule for when it
5+
/// may.
6+
/// </summary>
7+
/// <remarks>
8+
/// <para>
9+
/// Left to itself, Avalonia's <c>DefaultFontFamily</c> resolves to Helvetica on macOS, not the
10+
/// system face. <c>.AppleSystemUIFont</c> is the name that resolves to it (measured in the
11+
/// "System font" section of <c>docs/ARCHITECTURE.md</c>). The same measurement found that an
12+
/// unresolvable <c>DefaultFamilyName</c> does not degrade: it throws out of the first layout pass
13+
/// and the process dies before a window appears, with an exception that names only
14+
/// <c>$Default</c>. So the name is only ever used after the font manager has said it resolves.
15+
/// </para>
16+
/// <para>
17+
/// The resolve check is handed in so the rule is testable without a Mac; the head passes
18+
/// Skia's font manager.
19+
/// </para>
20+
/// </remarks>
21+
public static class MacSystemFont
22+
{
23+
/// <summary>The name macOS exposes its system UI font under.</summary>
24+
public const string FamilyName = ".AppleSystemUIFont";
25+
26+
/// <summary>
27+
/// The family to hand Avalonia: <see cref="FamilyName"/> when <paramref name="resolves"/>
28+
/// says the font manager can match it, otherwise null, which leaves the platform default.
29+
/// </summary>
30+
public static string? Select(Func<string, bool> resolves)
31+
{
32+
ArgumentNullException.ThrowIfNull(resolves);
33+
return resolves(FamilyName) ? FamilyName : null;
34+
}
35+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System.Globalization;
2+
using Avalonia.Data.Converters;
3+
using Sendspin.Core.Configuration;
4+
5+
namespace Sendspin.Player.Converters;
6+
7+
/// <summary>
8+
/// The auto-connect policies as the settings row names them. The picker binds the enum straight
9+
/// through to the setting; only what it shows goes through here.
10+
/// </summary>
11+
public sealed class AutoConnectPolicyLabel : IValueConverter
12+
{
13+
/// <summary>The one instance, for <c>x:Static</c>.</summary>
14+
public static AutoConnectPolicyLabel Instance { get; } = new();
15+
16+
/// <summary>The row's name for a policy.</summary>
17+
public static string For(AutoConnectPolicy policy) => policy switch
18+
{
19+
AutoConnectPolicy.Never => "Never",
20+
AutoConnectPolicy.JustOnce => "Just once",
21+
AutoConnectPolicy.Always => "Always",
22+
_ => policy.ToString(),
23+
};
24+
25+
/// <inheritdoc/>
26+
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
27+
value is AutoConnectPolicy policy ? For(policy) : value?.ToString();
28+
29+
/// <inheritdoc/>
30+
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
31+
throw new NotSupportedException("The label is display-only; the picker binds the value itself.");
32+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System.Globalization;
2+
using Avalonia.Data.Converters;
3+
using Sendspin.SDK.Client;
4+
5+
namespace Sendspin.Player.Converters;
6+
7+
/// <summary>
8+
/// The connection modes as the settings row names them. The picker binds the enum straight
9+
/// through to the setting; only what it shows goes through here.
10+
/// </summary>
11+
public sealed class ConnectionModeLabel : IValueConverter
12+
{
13+
/// <summary>The one instance, for <c>x:Static</c>.</summary>
14+
public static ConnectionModeLabel Instance { get; } = new();
15+
16+
/// <summary>The row's name for a mode.</summary>
17+
/// <remarks>
18+
/// <c>ConnectionMode.Auto</c> is deliberately not named: it is obsolete, the picker does
19+
/// not offer it, and a persisted one is rewritten on load.
20+
/// </remarks>
21+
public static string For(ConnectionMode mode) => mode switch
22+
{
23+
ConnectionMode.AdvertiseOnly => "Advertise to servers, and let a server connect",
24+
ConnectionMode.DiscoverOnly => "Discover servers, and connect from here",
25+
_ => mode.ToString(),
26+
};
27+
28+
/// <inheritdoc/>
29+
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
30+
value is ConnectionMode mode ? For(mode) : value?.ToString();
31+
32+
/// <inheritdoc/>
33+
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
34+
throw new NotSupportedException("The label is display-only; the picker binds the value itself.");
35+
}

src/Sendspin.Player/PlatformSelection/PlatformSelection.MacOS.cs

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Avalonia;
22
using Sendspin.Core.Platform;
33
using Sendspin.Platform.MacOS.Platform;
4+
using SkiaSharp;
45

56
namespace Sendspin.Player;
67

@@ -40,14 +41,36 @@ internal static class PlatformSelection
4041
/// answer.
4142
/// </summary>
4243
/// <remarks>
43-
/// Null for now, and measured: <c>FontManager.DefaultFontFamily</c> resolves to
44-
/// <c>Helvetica</c> on macOS 26 (Avalonia 12.1.1), not the system UI font (SF Pro). So
45-
/// <c>$Default</c> is the wrong face here and a per-platform name will be needed; which
46-
/// family name Skia resolves to SF is still being measured, and guessing one would be worse
47-
/// than Helvetica. The SF override is a follow-up once the resolvable name is known
48-
/// (<c>dotnet run --project scripts/spike/ShellSpike -- font</c>, with the candidate names).
44+
/// <para>
45+
/// The platform's own answer is Helvetica (measured on macOS 26, Avalonia 12.1.1), so the
46+
/// system face is named: <c>.AppleSystemUIFont</c>, the one name that resolved to it. The
47+
/// name is guarded, because an unresolvable <c>DefaultFamilyName</c> kills the process in
48+
/// the first layout pass — the rule and the measurement are on <see cref="MacSystemFont"/>.
49+
/// Skia's font manager is the resolver: it is what Avalonia asks at start-up, and it is
50+
/// already loaded through Avalonia.Skia.
51+
/// </para>
52+
/// <para>
53+
/// This runs before the container exists, so a miss goes to standard error rather than the
54+
/// logger; the start-up <c>UI font:</c> log line then shows what <c>$Default</c> became.
55+
/// </para>
4956
/// </remarks>
50-
public static string? ReadDesktopFontFamily() => null;
57+
public static string? ReadDesktopFontFamily()
58+
{
59+
var family = MacSystemFont.Select(Resolves);
60+
61+
if (family is null)
62+
{
63+
Console.Error.WriteLine($"{MacSystemFont.FamilyName} does not resolve here; leaving the UI font to the platform.");
64+
}
65+
66+
return family;
67+
68+
static bool Resolves(string name)
69+
{
70+
using var typeface = SKFontManager.Default.MatchFamily(name);
71+
return typeface is not null;
72+
}
73+
}
5174

5275
/// <summary>
5376
/// Selects the windowing backend.

src/Sendspin.Player/Program.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,14 @@ public static int Main(string[] args)
6262
/// and App.axaml overrides that resource to <c>$Default</c>. <c>DefaultFamilyName</c> is what
6363
/// <c>$Default</c> resolves to: on Linux it is the desktop's interface font read from the
6464
/// Settings portal (fontconfig's own default is DejaVu Sans inside the Flatpak, which is not
65-
/// the desktop's font); on Windows and macOS it is left null, which means "ask the platform".
66-
/// On Windows that answer is measured: plain Segoe UI, the face the WPF reference app uses.
67-
/// On macOS it is measured too, and wrong: Helvetica rather than SF Pro. The SF override is
68-
/// a follow-up once the family name Skia resolves to SF is known — see
69-
/// <see cref="PlatformSelection.ReadDesktopFontFamily"/> on each head. The pair was measured
70-
/// in the "System font" section of docs/ARCHITECTURE.md; macOS also confirmed that the
71-
/// embedded <c>fonts:Inter#Inter</c> is the only way Inter resolves on a box without it
65+
/// the desktop's font); on Windows it is left null, which means "ask the platform", and that
66+
/// answer is measured: plain Segoe UI, the face the WPF reference app uses. On macOS the
67+
/// platform's answer is Helvetica, so the head names the system face,
68+
/// <c>.AppleSystemUIFont</c>, guarded by a resolve check because an unresolvable name kills
69+
/// the process before a window appears — see
70+
/// <see cref="PlatformSelection.ReadDesktopFontFamily"/> on each head. All three were
71+
/// measured in the "System font" section of docs/ARCHITECTURE.md; macOS also confirmed that
72+
/// the embedded <c>fonts:Inter#Inter</c> is the only way Inter resolves on a box without it
7273
/// installed, which is why <c>WithInterFont()</c> stays.
7374
/// </para>
7475
/// </remarks>

0 commit comments

Comments
 (0)