@@ -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.
12201220Screenshots: ` 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.
0 commit comments