Skip to content

Commit 36aa1be

Browse files
authored
Merge pull request #18 from chrisuthe/chrisuthe/task/build-the-settings-card-and-the-stats-window-and
Build the settings card and the Stats window, retire the toolbar stats toggle and the footer's Disconnect
2 parents 867650c + ffd607a commit 36aa1be

32 files changed

Lines changed: 2171 additions & 369 deletions

docs/ARCHITECTURE.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,3 +1219,80 @@ to `Never` before it connects, and the server it connects to has already been as
12191219
`Sendspin.Ui.Tests/AutoConnectPromptTests` walks each answer and the once-per-server rule.
12201220
Screenshots: `docs/screenshots/reskin/phase3-{narrow,wide}-{light,dark}.png` and
12211221
`phase3-welcome-{advertise,discover}.png`.
1222+
1223+
### As shipped (reskin phase 4) — the settings card and the Stats window
1224+
1225+
**The settings card** is the Phase 2 `SettingsOverlay` border with, inside it, a header row (the
1226+
gear glyph and "Settings" at `subtitle`), a `ScrollViewer` over `Views/SettingsView.axaml`, and a
1227+
footer row with the version at `caption` on the left and a **Done** button on the right. Done only
1228+
closes the card (`MainViewModel.CloseSettingsCommand`); there is no Save and no Cancel, because every
1229+
control writes through `SettingsService.Update` the moment it changes, as it did before. The view's
1230+
context is `MainViewModel`, exactly as Welcome's is: the Connection section binds `DisconnectCommand`
1231+
and `ConnectionStatus` straight off it, and every setting binds through `Settings.` to
1232+
`SettingsViewModel`, so Disconnect and the connection line sit inside the card's section order
1233+
without any state being duplicated. Now Playing and Welcome step aside while the card is open
1234+
(`MainViewModel.ShowsNowPlaying` / `ShowsWelcome`): the card's surface is the 60 % translucent
1235+
token, which reads as a tint over the blurred backdrop and the veil, and read as nothing over the
1236+
art tile and the title — the first screenshot had the rows over a Garth Brooks cover. The backdrop
1237+
layers stay, so the card is still on the music. The version is read from the assembly once, in `AppInfo`
1238+
(`Version` for the protocol's `device_info`, `DisplayVersion` — the same string without its
1239+
`+commit` metadata — for the footer). Five sections, each a `sectionCaption` over rows: General
1240+
(the four notification events, Discord Rich Presence, start hidden in the tray, closing keeps
1241+
playing, **show the Switch Group button**, player name), Connection (Disconnect with the connection
1242+
line beside it, connection mode with its restart note, auto-connect), Audio (output device with
1243+
Refresh, preferred codec), Audio sync (the two calibration steppers), Diagnostics (the Stats for
1244+
Nerds row). A row is a title at `body`, an optional caption beneath it, and the control; a boolean
1245+
row is a `ToggleSwitch` right-aligned on the title line with `OnContent` and `OffContent` null (the
1246+
`setting` style), so the title is its label. The `ComboBoxWheelGuard` style still covers the whole
1247+
panel, and `EverySettingsComboBoxIsGuarded` still counts four.
1248+
1249+
**The stepper row** (`Controls/StepperRow.axaml`) is a step-down button, a slider, a step-up button,
1250+
the value in a `TextBox` and the unit. The buttons move `Value` by `Step` (10 for both rows) and clamp
1251+
to `[Minimum, Maximum]`; a typed value is clamped the same way on Enter or on leaving the box, and
1252+
text that is not a number puts the value back. The slider is deliberately *not* bound two-way to
1253+
`Value`: a slider clamps to its own range, and a two-way binding writes the clamped value back, so a
1254+
value that arrived before the range did — attribute order in the XAML — would be rewritten into the
1255+
setting on load (a 500 ms static delay becoming 100). The code-behind pushes `Value` into the slider
1256+
while ignoring what the slider says, and takes the slider's value only when the user moves it;
1257+
`Value` itself is never coerced, so an out-of-range persisted value is shown as it is and the first
1258+
step brings it inside. `Sendspin.Ui.Tests/StepperRowTests` pins each of these, including the
1259+
value-before-range case.
1260+
1261+
**The Stats window** (`Views/StatsWindow.axaml`) replaces the inline diagnostics panel, which is
1262+
gone. A plain 480×640 resizable `Window` with native decorations, titled "Stats for Nerds", on the
1263+
same `SystemControlBackgroundAltHighBrush` root as the main window, following the theme as every
1264+
window does. Five cards in this order: Timing (the timing source with its "(not a hardware clock)"
1265+
warning, first because it decides whether anything below it means anything), Sync status (error and
1266+
band, correction mode, playback rate and ppm), Buffer (buffered, static delay), Clock sync (offset,
1267+
drift, uncertainty, converged, post-anchor, round trip), Output (measured + manual = total latency,
1268+
stream format, output device, platform). Every value the inline panel showed is here and nothing is
1269+
new. It is over the same `DiagnosticsViewModel`, whose `IsVisible` is the one fact everything
1270+
follows: `MainViewModel.SetStatsVisible` sets it and writes `PlayerSettings.ShowDiagnostics` in the
1271+
same call, the view model's `SetVisible` starts and stops the 500 ms `UiClock`, and
1272+
`MainWindow` shows the window while `IsVisible` is set and hides it otherwise. `MainWindow` keeps the
1273+
one instance for its own lifetime: the Diagnostics row's `OpenStatsCommand` sets the flag and raises
1274+
`StatsRequested`, which activates the window if it was already open; the user's close is cancelled
1275+
by `StatsWindow.OnClosing` and turned into a hide, with `MainWindow` recording it through
1276+
`SetStatsVisible(false)`. A user's close is a non-programmatic close whose reason is
1277+
`WindowClosing`, and only that: an OS-initiated quit (Cmd+Q, a session end) also reaches every
1278+
unowned window as a non-programmatic close, with a shutdown reason, and that one is let through with
1279+
the flag untouched, or the app would never quit and the window would never come back. The
1280+
window is deliberately **not** an owned window: Avalonia hides owned windows with their owner and
1281+
never re-shows them, so instead the rule is *visible iff the main window is visible and `IsVisible`
1282+
is set* — hiding to the tray takes it along, showing again brings it back, and a start hidden in the
1283+
tray does not leave a stray stats window on the desktop. **`ShowDiagnostics` now means "open at
1284+
exit"**: it is written on every open and close, and the start-up path's `ReopenStatsIfLeftOpen`
1285+
reads it after the platform is up, so the window comes back on the next start if it was up on the
1286+
last. `Sendspin.Ui.Tests/StatsWindowTests` drives the desktop's close through the headless impl's
1287+
`Closing` callback, which is the only non-programmatic path.
1288+
1289+
**Toolbar and footer.** The Phase 2 stats toggle is gone from the toolbar; the gear is alone beside
1290+
the connection line. Disconnect is gone from the footer, which now shows the volume row while
1291+
connected, the status message while disconnected with one to show, and collapses to nothing
1292+
otherwise (`MainViewModel.HasFooter`). The Switch Group button binds its visibility to the new
1293+
`PlayerSettings.ShowSwitchGroupButton` (default true) through `SettingsViewModel`. The legacy text
1294+
classes (`h1`/`h2`/`h3`/`subtle`/`fieldLabel`/`metricLabel`/`metricValue`) had no users left and are
1295+
gone; `PlayerStyles.axaml` is the one scale plus `warning`. Screenshots:
1296+
`docs/screenshots/reskin/phase4-settings-{light,dark}.png` and `phase4-stats-{light,dark}.png`,
1297+
Wayland head, plus `phase4-settings-tall-dark.png`, the window stretched so the whole card is
1298+
in one frame.

docs/NEXT_STEPS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ Reading them by reflection was considered and rejected: a shipping diagnostics p
172172
another package's private members fails silently and invisibly the first time a name moves.
173173

174174
**First action:** when the pin moves to 10.x (item 1), check whether these are public there and add
175-
them to `PlayerDiagnosticsSnapshot` and `DiagnosticsView.axaml`. If 10.x also keeps them internal,
175+
them to `PlayerDiagnosticsSnapshot` and `StatsWindow.axaml`. If 10.x also keeps them internal,
176176
that is worth an upstream issue alongside item 2.
177177

178178
---
230 KB
Loading
226 KB
Loading
410 KB
Loading
107 KB
Loading
105 KB
Loading

src/Sendspin.Core/Configuration/PlayerSettings.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,20 @@ public sealed class PlayerSettings
155155
/// <summary>Gets or sets whether Discord Rich Presence is published. Off by default.</summary>
156156
public bool DiscordRichPresence { get; set; }
157157

158-
/// <summary>Gets or sets whether the diagnostics view is shown on startup.</summary>
158+
/// <summary>
159+
/// Gets or sets whether the footer shows the Switch Group button. On by default; off is for a
160+
/// single-room setup with nothing to switch to.
161+
/// </summary>
162+
public bool ShowSwitchGroupButton { get; set; } = true;
163+
164+
/// <summary>
165+
/// Gets or sets whether the Stats window was open when the player last exited, so the next
166+
/// start reopens it.
167+
/// </summary>
168+
/// <remarks>
169+
/// Written on every open and close of the window rather than at exit, so a crash leaves the
170+
/// same answer a clean exit would.
171+
/// </remarks>
159172
public bool ShowDiagnostics { get; set; }
160173

161174
/// <summary>

src/Sendspin.Player/App.axaml.cs

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.Reflection;
21
using Avalonia;
32
using Avalonia.Controls;
43
using Avalonia.Controls.ApplicationLifetimes;
@@ -76,7 +75,7 @@ public override void OnFrameworkInitializationCompleted()
7675

7776
var logger = _services.GetRequiredService<ILogger<App>>();
7877
logger.LogInformation("Sendspin Player {Version} starting on {Platform}",
79-
AppVersion, platform.PlatformName);
78+
AppInfo.Version, platform.PlatformName);
8079

8180
_services.GetRequiredService<IPlatformPaths>().EnsureDirectoriesExist();
8281

@@ -164,14 +163,6 @@ internal void RequestShutdown()
164163
}
165164
}
166165

167-
/// <summary>
168-
/// Gets this build's informational version, for the protocol's <c>device_info</c>.
169-
/// </summary>
170-
private static string AppVersion =>
171-
typeof(App).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
172-
?? typeof(App).Assembly.GetName().Version?.ToString()
173-
?? "1.0.0";
174-
175166
/// <summary>
176167
/// Builds the service container.
177168
/// </summary>
@@ -213,7 +204,7 @@ private static ServiceProvider BuildServices(IPlatformInitializer platform)
213204
provider.GetRequiredService<Sendspin.SDK.Audio.IAudioPlayer>,
214205
provider.GetRequiredService<ArtworkCache>(),
215206
provider.GetRequiredService<SyncCorrectionPolicy>(),
216-
AppVersion));
207+
AppInfo.Version));
217208

218209
services.AddSingleton<IPlayerCommandSink>(p => p.GetRequiredService<SendspinPlayerService>());
219210
services.AddSingleton<IDiagnosticsProvider>(p => p.GetRequiredService<SendspinPlayerService>());

src/Sendspin.Player/AppInfo.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using System.Reflection;
2+
3+
namespace Sendspin.Player;
4+
5+
/// <summary>
6+
/// Facts about this build, read from the assembly in this one place.
7+
/// </summary>
8+
internal static class AppInfo
9+
{
10+
/// <summary>
11+
/// Gets the informational version, which is what the protocol's <c>device_info</c> sends.
12+
/// </summary>
13+
public static string Version { get; } =
14+
typeof(AppInfo).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
15+
?? typeof(AppInfo).Assembly.GetName().Version?.ToString()
16+
?? "1.0.0";
17+
18+
/// <summary>
19+
/// Gets the version as the settings card shows it: <see cref="Version"/> without the
20+
/// <c>+commit</c> build metadata the SDK appends, which is for a bug report, not a footer.
21+
/// </summary>
22+
public static string DisplayVersion { get; } = Version.Split('+', 2)[0];
23+
}

0 commit comments

Comments
 (0)