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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ the obvious answer, and each one cost real effort to establish.
- **A diagnostics view** showing sync error, correction band, playback rate in ppm, buffer depth,
clock offset and drift, and — the most useful single field — whether the timing source is
actually the audio hardware clock.
- **Resizable, native chrome, follows the system light/dark theme.** Deliberately not a fixed-size
dark-only custom-chrome window.
- **Native shell, system theme and accent, platform font.** Resizable, with the OS's own
decorations; follows the desktop's light/dark setting and accent live. The layout inside the
window comes from Sendspin for Windows; the colours do not.

## Requirements

Expand Down
53 changes: 41 additions & 12 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -597,8 +597,9 @@ dotnet run --project scripts/spike/ShellSpike -- theme | font | chrome | clock |
```

Every mode prints `[spike]` lines; the numbers below are those lines. `SENDSPIN_X11=1` selects the X11
head exactly as it does for the player. **GNOME and Windows 11 were not available and are marked
unmeasured**, each with the procedure — which is the same probe, run there.
head exactly as it does for the player. **GNOME was not available and is marked unmeasured** with the
procedure — the same probe, run there. Windows 11 was measured on a separate box for theme and
font (below); its Mica and client-area rows are still marked unmeasured until that run is written up.

The macOS figures were taken after two defects in the probe were fixed, and no macOS number here
predates those fixes:
Expand Down Expand Up @@ -747,11 +748,15 @@ it, and on Plasma it is a variant-dependent derivative from the start. Anything
desktop accent needs a platform-specific source, and anything that merely wants to be tasteful should
carry its own colour.

**Windows 11 — unmeasured.** Same property set (`RequestedThemeVariant="Default"`, and
`SystemAccentColor` for the accent). Procedure: `dotnet run --project scripts/spike/ShellSpike --
theme --seconds 60`, flip Settings › Personalization › Colors and read the `ColorValuesChanged`
lines. That backend does not go through a portal either, so the no-portal fallback above is
Linux-only.
**Windows 11 — measured** (10.0.26200, Avalonia 12.1.1), same property set. The theme and the accent
are correct at `Opened` with no asynchronous settle, unlike the portal read above. The accent reported
is the OS `AccentPalette` base slot (`#0076E4` on that box), not `AccentColorMenu`. And
`PlatformSettings.ColorValuesChanged` is a storm there: one accent change produced about **20
duplicate events in 600 ms**, one of them with completely unchanged values, and each variant flip
raises it twice. Anything hung off that event goes through `Core/Theme/SystemColorChangeFilter.cs`,
which drops a report whose accent and variant match the last one accepted; `Player/Theme/
PlatformColorChanges.cs` is the one subscription in the app and re-raises only what passes.


### System font — Inter wins only because Fluent asks for it first

Expand Down Expand Up @@ -859,11 +864,35 @@ installed, and nothing in `~/Library/Fonts` or `/Library/Fonts` supplies one. A
`DefaultFamilyName` therefore needs a resolve-check at start-up rather than a hardcoded name trusted
to exist. **Acting on this in the player is a separate task; this section measures and records.**

**Windows — unmeasured.** The intent is Segoe UI Variable. What `$Default` resolves to there is
whatever `SKTypeface.Default.FamilyName` says — that may well be plain `Segoe UI` rather than the
Variable face, in which case `DefaultFamilyName` has to be named per platform as it does on macOS.
Procedure: `dotnet run --project scripts/spike/ShellSpike -- font` and read the
`FontManager.DefaultFontFamily` line.
**Windows — measured** (Windows 11 10.0.26200, Avalonia 12.1.1): `FontManager.DefaultFontFamily`
resolves to plain `Segoe UI`, not Segoe UI Variable. That is the face the WPF reference app uses, so
it is the right answer and `DefaultFamilyName` stays unset there. Glyph fallback through the composite
works: 日 → `Yu Gothic UI`.

**Decided and shipped (reskin phase 1).** The shape above is what `Program.cs` and `App.axaml` do,
with one addition for the Flatpak row: the Settings portal serves the desktop's interface font as
`org.gnome.desktop.interface` / `font-name`, and the KDE backend answers it as well as the GNOME
one. Measured on this box, host and sandbox alike:

| Where | `ReadOne org.gnome.desktop.interface font-name` | `fc-match "Noto Sans"` | `fc-match sans-serif` |
|---|---|---|---|
| Host | `"Noto Sans 10"` (the KDE backend's double space) | `NotoSans-Regular.ttf` | `NotoSans-Regular.ttf` |
| `org.freedesktop.Platform//25.08` sandbox | `"Noto Sans 10"` | `NotoSans-Regular.ttf`, from `/run/host/fonts` | `DejaVuSans.ttf` |

So the Linux head reads that key once, before the app builder runs, and hands the family to
`FontManagerOptions.DefaultFamilyName` (`PlatformSelection.Linux.cs` →
`Platform.Linux/Portals/SettingsPortal.cs`; the Pango description is reduced to a family by
`Core/Platform/DesktopFontName.cs`, which strips the size and style words because fontconfig turns an
unknown family back into its default). Inside the Flatpak the same call reaches the portal — the
Settings portal needs no permission — and the family resolves against the host fonts the sandbox
already sees, which is what takes the Flatpak from DejaVu Sans to the desktop's face. No bus, no
portal or no key leaves `DefaultFamilyName` null, which is fontconfig's answer as before. The app logs
what it ended up with at start-up (`UI font: $Default is …, glyphs from …, fallback face …`), which
is the `font` probe's report without a second binary. Windows leaves the name null on the measurement
above; macOS leaves it null on the Helvetica measurement; the table above shows `.AppleSystemUIFont` is the
name that resolves to the system face and that an unresolvable name kills the process, so the macOS
override is a follow-up that ships with a resolve check (`PlatformSelection.MacOS.cs`).


### Decorations and client-area extension — the hint does nothing under KWin

Expand Down
Binary file added docs/screenshots/reskin/phase1-wayland-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/reskin/phase1-wayland-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions packaging/flatpak/io.sendspin.client.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ finish-args:
- --talk-name=org.freedesktop.Notifications
- --talk-name=org.kde.StatusNotifierWatcher

# Fonts: nothing to declare, and nothing bundled. The runtime's own fontconfig default is
# DejaVu Sans, not the desktop's font, so the app asks the Settings portal (reachable from
# every sandbox, no permission needed) for org.gnome.desktop.interface/font-name at start-up
# and names that family as its default; it resolves against the host fonts the sandbox already
# sees under /run/host/fonts. Measured on Plasma 6.7 — "Noto Sans 10" from the portal, and
# fc-match "Noto Sans" answering NotoSans-Regular.ttf inside org.freedesktop.Platform//25.08.
# The embedded Inter stays as the glyph fallback. See the "System font" section of
# docs/ARCHITECTURE.md.

# Deliberately NOT declared: --own-name=org.mpris.MediaPlayer2.*
# Flatpak auto-grants org.mpris.MediaPlayer2.<app_id>.* and declaring it is a Flathub linter
# error. The bus name the app claims is org.mpris.MediaPlayer2.io.sendspin.client, which falls
Expand Down
Binary file modified packaging/flatpak/sendspin-256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified packaging/icons/io.sendspin.client.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 69 additions & 0 deletions src/Sendspin.Core/Platform/DesktopFontName.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
namespace Sendspin.Core.Platform;

/// <summary>
/// Turns a desktop's interface font setting into a family name a font manager can be handed.
/// </summary>
/// <remarks>
/// <para>
/// The Settings portal serves <c>org.gnome.desktop.interface/font-name</c> as a Pango font
/// description — <c>"Cantarell 11"</c>, <c>"Ubuntu Bold 11"</c> — and the KDE backend writes it
/// with a double space (<c>"Noto Sans 10"</c>). Skia asks fontconfig for the family by name, and
/// fontconfig treats a family it does not know as a request for the default face, so a style word
/// or a size left on the end turns "the user's font" back into "DejaVu Sans". This strips the
/// size and the style words Pango recognises, and keeps the first family of a comma list.
/// </para>
/// </remarks>
public static class DesktopFontName
{
/// <summary>
/// Pango's style keywords, which can trail the family in a font description.
/// </summary>
private static readonly HashSet<string> StyleWords = new(StringComparer.OrdinalIgnoreCase)
{
"Thin", "Ultra-Light", "Extra-Light", "Light", "Semi-Light", "Demi-Light", "Book", "Regular",
"Medium", "Semi-Bold", "Demi-Bold", "Bold", "Ultra-Bold", "Extra-Bold", "Heavy", "Black",
"Ultra-Black", "Extra-Black", "Italic", "Oblique", "Roman",
"Ultra-Condensed", "Extra-Condensed", "Condensed", "Semi-Condensed",
"Semi-Expanded", "Expanded", "Extra-Expanded", "Ultra-Expanded",
"Normal", "Small-Caps",
// Pango accepts the same words without the hyphen.
"Ultralight", "Extralight", "Semilight", "Demilight", "Semibold", "Demibold", "Ultrabold",
"Extrabold", "Ultrablack", "Extrablack", "Ultracondensed", "Extracondensed", "Semicondensed",
"Semiexpanded", "Extraexpanded", "Ultraexpanded",
};

/// <summary>
/// Extracts the family name, or null when nothing usable is left.
/// </summary>
public static string? ParseFamily(string? description)
{
if (string.IsNullOrWhiteSpace(description))
{
return null;
}

var firstFamily = description.Split(',', 2)[0];
var words = new List<string>(firstFamily.Split(' ', StringSplitOptions.RemoveEmptyEntries));

while (words.Count > 0 && IsSizeOrStyle(words[^1]))
{
words.RemoveAt(words.Count - 1);
}

return words.Count == 0 ? null : string.Join(' ', words);
}

private static bool IsSizeOrStyle(string word) =>
StyleWords.Contains(word) || IsSize(word);

/// <summary>
/// A Pango size is a number in points, or a number followed by <c>px</c>.
/// </summary>
private static bool IsSize(string word)
{
var digits = word.EndsWith("px", StringComparison.OrdinalIgnoreCase) ? word[..^2] : word;
return digits.Length > 0
&& double.TryParse(digits, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out _);
}
}
48 changes: 48 additions & 0 deletions src/Sendspin.Core/Theme/AccentContrast.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
namespace Sendspin.Core.Theme;

/// <summary>
/// Picks black or white for text drawn over the system accent.
/// </summary>
/// <remarks>
/// <para>
/// The accent is not one kind of colour. GNOME and Windows serve the saturated pick the user
/// made; Plasma serves the colour scheme's <em>highlight</em>, which in a light scheme is a light
/// derivative of the pick (see the "Theme and accent on Linux" section of
/// <c>docs/ARCHITECTURE.md</c>). Fluent's text-on-accent brush is white regardless, and white on
/// Plasma's light highlight is unreadable. So the glyph colour is computed from the accent's
/// relative luminance (WCAG 2.x, sRGB) rather than assumed.
/// </para>
/// <para>
/// The threshold is deliberately not the pure WCAG crossover. Black has the higher contrast ratio
/// from a luminance of 0.179 upward, which would put black glyphs on Windows blue (#0078D7,
/// 0.18) and on GNOME's blue (#3584E4, 0.23) — accents every user has only ever seen white on.
/// 0.3 sits between those saturated picks and the lightest measured highlights on Plasma
/// (#3DAEE9 at 0.37, #EF9277 at 0.40, Nordic's #8FBCBB at 0.45), so both kinds of accent get
/// the glyph their desktop would draw.
/// </para>
/// </remarks>
public static class AccentContrast
{
/// <summary>
/// The relative luminance above which black glyphs are used over the accent.
/// </summary>
public const double BlackTextLuminanceThreshold = 0.3;

/// <summary>
/// Returns true when black glyphs belong over the given accent, false when white does.
/// </summary>
public static bool PrefersBlackText(byte red, byte green, byte blue) =>
RelativeLuminance(red, green, blue) > BlackTextLuminanceThreshold;

/// <summary>
/// The WCAG 2.x relative luminance of an sRGB colour, 0 for black through 1 for white.
/// </summary>
public static double RelativeLuminance(byte red, byte green, byte blue) =>
(0.2126 * Linearize(red)) + (0.7152 * Linearize(green)) + (0.0722 * Linearize(blue));

private static double Linearize(byte channel)
{
var c = channel / 255.0;
return c <= 0.03928 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4);
}
}
35 changes: 35 additions & 0 deletions src/Sendspin.Core/Theme/SystemColorChangeFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace Sendspin.Core.Theme;

/// <summary>
/// The two platform colour facts the app reacts to: the accent, and whether the variant is dark.
/// </summary>
public readonly record struct SystemColors(uint AccentArgb, bool IsDark);

/// <summary>
/// Drops platform colour reports that change nothing.
/// </summary>
/// <remarks>
/// Windows raises its colour-changed event as a storm: one accent change measured as about twenty
/// events in 600 ms, one of them with values identical to the last, and every variant flip raises
/// it twice (Windows 11 10.0.26200, Avalonia 12.1.1). Everything hung off that event — the
/// on-accent brush today, the backdrop palette in a later phase — goes through one of these, so
/// the work happens once per real change. Cheap on purpose: one struct compare per report.
/// </remarks>
public sealed class SystemColorChangeFilter
{
private SystemColors? _last;

/// <summary>
/// Returns true when the values differ from the last accepted ones, and records them.
/// </summary>
public bool Accept(SystemColors colors)
{
if (_last == colors)
{
return false;
}

_last = colors;
return true;
}
}
36 changes: 18 additions & 18 deletions src/Sendspin.Platform.Linux/MediaSession/MprisMediaSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -481,30 +481,30 @@ private void HandleProperties(MethodContext context)
switch (context.Request.MemberAsString)
{
case "GetAll":
{
using var writer = context.CreateReplyWriter("a{sv}");
writer.WriteDictionary(PropertiesFor(reader.ReadString()));
context.Reply(writer.CreateMessage());
break;
}
{
using var writer = context.CreateReplyWriter("a{sv}");
writer.WriteDictionary(PropertiesFor(reader.ReadString()));
context.Reply(writer.CreateMessage());
break;
}

case "Get":
{
var interfaceName = reader.ReadString();
var propertyName = reader.ReadString();

if (!PropertiesFor(interfaceName).TryGetValue(propertyName, out var value))
{
context.ReplyError(ErrorInvalidArgs, $"No such property {interfaceName}.{propertyName}");
var interfaceName = reader.ReadString();
var propertyName = reader.ReadString();

if (!PropertiesFor(interfaceName).TryGetValue(propertyName, out var value))
{
context.ReplyError(ErrorInvalidArgs, $"No such property {interfaceName}.{propertyName}");
break;
}

using var writer = context.CreateReplyWriter("v");
writer.WriteVariant(value);
context.Reply(writer.CreateMessage());
break;
}

using var writer = context.CreateReplyWriter("v");
writer.WriteVariant(value);
context.Reply(writer.CreateMessage());
break;
}

case "Set":
SetProperty(context, ref reader);
break;
Expand Down
87 changes: 87 additions & 0 deletions src/Sendspin.Platform.Linux/Portals/SettingsPortal.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using Sendspin.Core.Platform;
using Tmds.DBus.Protocol;

namespace Sendspin.Platform.Linux.Portals;

/// <summary>
/// One-shot reads from <c>org.freedesktop.portal.Settings</c> that have to happen before Avalonia
/// exists.
/// </summary>
/// <remarks>
/// <para>
/// Avalonia reads the portal itself for the colour scheme and the accent, live. It does not read
/// the interface font: <c>$Default</c> is fontconfig's answer to an empty pattern, which on the
/// host is usually the desktop font by coincidence and inside the Flatpak is DejaVu Sans. The
/// portal's <c>org.gnome.desktop.interface/font-name</c> is the desktop's actual setting, and the
/// KDE backend serves it as well as the GNOME one (measured on Plasma 6.7: <c>"Noto Sans 10"</c>).
/// </para>
/// <para>
/// Synchronous and bounded on purpose. <c>FontManagerOptions</c> is fixed when the app builder
/// runs, before the dispatcher or the service container exist, so this opens its own short-lived
/// connection rather than going through <see cref="DBus.SessionBus"/>, and gives up after the
/// timeout so a hung portal cannot hold the window back. No bus, no portal, no key: null, and the
/// caller leaves the default alone.
/// </para>
/// </remarks>
public static class SettingsPortal
{
private const string SettingsInterface = "org.freedesktop.portal.Settings";
private const string InterfaceNamespace = "org.gnome.desktop.interface";
private const string FontNameKey = "font-name";

/// <summary>
/// Reads the desktop's interface font family, or null when the portal does not serve one.
/// </summary>
public static string? TryReadInterfaceFontFamily(TimeSpan timeout)
{
var address = DBusAddress.Session;
if (string.IsNullOrEmpty(address))
{
return null;
}

try
{
using var connection = new DBusConnection(address);
var read = ReadFontNameAsync(connection);

if (read.Wait(timeout))
{
return DesktopFontName.ParseFamily(read.Result);
}

// Disposing the connection faults the pending read; observe it so it does not
// surface as an unobserved task exception later.
read.ContinueWith(static t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
return null;
}
catch (Exception)
{
// Best effort, at start-up, before a logger exists: a portal that errors, a bus that
// refuses, a variant that is not a string — none of them may stop the app starting.
return null;
}
}

private static async Task<string> ReadFontNameAsync(DBusConnection connection)
{
await connection.ConnectAsync().ConfigureAwait(false);

return await connection.CallMethodAsync(
CreateReadOneMessage(connection),
static (Message message, object? state) => message.GetBodyReader().ReadVariantValue().GetString(),
null).ConfigureAwait(false);
}

private static MessageBuffer CreateReadOneMessage(DBusConnection connection)
{
using var writer = connection.GetMessageWriter();
writer.WriteMethodCallHeader(
PortalRequest.Destination, PortalRequest.ObjectPath, SettingsInterface,
"ReadOne", "ss", MessageFlags.None);
writer.WriteString(InterfaceNamespace);
writer.WriteString(FontNameKey);

return writer.CreateMessage();
}
}
Loading