Repository navigation
feat(linux): add first Avalonia MPRIS desktop app - #7
Merged
Merged
Conversation
Replace the playerctl process bridge with a direct D-Bus client built on Tmds.DBus.Protocol, which Avalonia already ships. Users no longer have to install playerctl, and each one-second poll is a bus round trip rather than a process launch. - DBusMprisBus lists org.mpris.MediaPlayer2.* names and reads every Player property with a single GetAll call, with a 5 s per-call timeout - MprisPropertyMapper tolerates nested variants, string-or-array artists, signed/unsigned/float lengths, and negative "unknown" positions - IMprisBus seam keeps the service testable without a session bus - Update READMEs and the empty-state label to drop the playerctl note Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
The title, level meter, and fallback glyph were baked into layer contents in fixed white, which disappears on a light menu bar. Resolve labelColor against the status item's own effective appearance, thread it through the bitmap builders, tint the SF Symbol glyph with a source-in fill, and redraw from viewDidChangeEffectiveAppearance so the item follows the bar when it darkens over a desktop picture. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
Until the session list message is processed, sessions() synthesises one entry for the selected tab from the snapshot alone, so waiting on tab 7 could break out one message early. Also require the background tab. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
Document every type and member with XML doc comments on the .NET side and DocC comments on the Swift side, explaining intent and edge cases rather than restating signatures. Replace the Xcode template comments in the UI test targets with the same style. No code changes. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
Extend IMediaSessionService with SeekAsync and SetVolumeAsync so the panel can scrub and adjust volume on any platform, and implement them for MPRIS: SetPosition addressed by the player's mpris:trackid, and Properties.Set on Volume. Add PlaybackPosition and PlayerVolume helpers to Core, ported from the macOS app, so position projection, seek confirmation, and mute round-tripping share one definition. D-Bus error replies are reduced to their last line before they reach the UI, since script-based players answer with a full traceback. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
Replace the placeholder window with the PlayerPopoverView layout: a 360-wide borderless panel with 16pt corners, a 112pt card of artwork, scrolling title, artist, player mark, transport, scrubber and time labels, an error banner, and a 30pt footer with version, volume, settings, and quit. The panel opens from the tray, hides on focus loss or Escape, and can be dragged. - Artwork is loaded from file, http(s), and data URIs and decoded at 2x - The scrubber holds a seek target until the player confirms it, and the position is projected between polls so playback moves smoothly - Volume opens a floating slider; drags coalesce into one write in flight - White, Dark, Liquid, and System themes resolve from the desktop's colour scheme and accent; Liquid uses window blur or transparency when granted - Settings live in $XDG_CONFIG_HOME/reprise/preferences.json and are edited from a menu on the gear button - SF Symbols are replaced by hand-drawn geometries, and the Spotify and YouTube Music marks are converted from the macOS vector assets - Custom glyph, button, slider, and marquee controls draw a transparent fill so hit-testing covers their whole bounds Adds Reprise.Desktop.Tests covering the view model's seek, volume, artwork, and error rules, plus time formatting and preference storage. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
Port the macOS LyricsService to Core so every platform can show timed lyrics: SyncedLyrics with binary-searched focused and current lines, a loose LyricsTrackQuery that ignores case, diacritics, width, and punctuation so cosmetic metadata differences do not trigger new lookups, an LRC parser that handles multi-timestamp lines and offset tags, and a LyricsService that tries VIBE first for its line end times and falls back to LRCLIB's exact match and then its search. Every network or parse failure resolves to "no lyrics" rather than an error. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
Replace Avalonia's tray icon on Linux with Reprise's own StatusNotifierItem, exported over a dedicated D-Bus connection together with a com.canonical.dbusmenu context menu. The item publishes the Ubuntu XAyatanaLabel extension, so panels that honour it - Ubuntu's GNOME, Budgie, MATE, Xfce - show the track title or the current lyric line next to the icon, as the macOS menu bar does. The icon itself is the album cover, rendered at 22 and 32 pixels and rounded like the panel's. - StatusItemController composes the label the macOS way (title format, lyrics when enabled) and scrolls long text one character per quarter second, sending a change signal only when the label actually changes - NowPlayingViewModel looks lyrics up once per track and caches misses - The settings menu gains tray title format, lyrics, and icon options - IStatusItem keeps the desktop layer free of D-Bus; Avalonia's tray icon remains the fallback for platforms without their own implementation Verified in a container against a scripted StatusNotifierWatcher: the item registers, serves its properties, pixmaps, and menu, scrolls the label, toggles the panel on Activate, and quits from the menu. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
Port the macOS InlineLyricsView: a 136-point strip under the card that appears when synced lyrics exist, with the line being sung on the second row at full opacity, the previous and following lines dimmed, a fade at both edges, and rows that slide up on a lightly bouncy spring with a cascade delay for the lines below. Lines are drawn directly from the view model's projected position, so they advance between polls. Verified in a container against LRCLIB with a real track: the strip renders the current line and its neighbours, and the tray label shows the same line when lyrics are enabled for it. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
The panel's 16pt corners rely on a see-through window. Without a compositor Avalonia paints the area outside the rounding with its fallback brush, which is white, so the panel showed white corner blocks. Match the fallback to the panel colour and drop the rounding in that case; with a compositor the corners stay rounded as before. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
Replace the gear button's menu with a settings window laid out like RepriseSettingsView: the same 500 by 480 frame, six tabs with glyph headers, grouped-form sections with captions and footnotes, segmented pickers, and live previews of the panel, the tray entry, and the time labels. - General: auto-pause other players (sends MPRIS Pause to the player that was already playing), tray lyrics with width and reserved space, display priority as a drag-ordered list of player kinds with an optional remembered player, and launch at login via an XDG autostart entry - YouTube Music: browser tabs seen over MPRIS, with the active one badged, and a note that Linux browsers need no extension - Theme, Menu bar, Panel: the macOS controls, including marquee speed and restarting the tray marquee when the panel opens - System info: version, a link to releases, distribution and kernel, displays, and the credit line Adds PlaybackCommand.Pause to Core, a label guide to the tray protocol so reserved width reaches the panel, and tests for priority ordering, autostart, auto-pause, the remembered player, and marquee speed. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
The tray entry published a context menu, so a click brought up a two-item menu instead of the panel. Publish no menu at all and treat every click, including the context-menu request a host sends for a right click, as a request to toggle the panel, the way the macOS status item behaves. Quitting stays in the panel footer. Because Tmds.DBus leaves calls to unregistered paths unanswered, the item handler now owns the object root and answers anything below it with UnknownObject, so a host probing for the menu it no longer offers gets a reply instead of waiting out its timeout. Also bundle Pretendard as the default typeface, drop every size a step to suit Linux's 96 DPI baseline, and size the album cover from the measured height of the text column beside it so the two line up exactly whatever the type scale. Tray pixmaps are now rendered at 16, 22, 24, and 32 pixels and inset slightly, which puts the cover at the height of the label text. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
…palette The window leaned on the Fluent theme: its TabControl left-aligned the tabs, tinted every label with the accent colour, and underlined only the selected label, none of which resembles the macOS settings toolbar. Draw the strip instead - evenly sized cells, centred, glyph over label, the selected cell on a rounded card. The rest of the window bound to UWP-era resource keys that Avalonia 12 no longer defines, so those controls fell back to whatever they inherited. Resolve seven colours from the desktop's light or dark setting and publish them as the window's own resources, so every control has a defined brush and a theme change repaints the window without rebuilding it. Also lighten the panel and menu-bar tab glyphs, which were reading as solid black boxes at 17 pixels, and end the credit line with a text heart since the bundled typeface carries no colour emoji. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
Reserving space for lyrics sent XAyatanaLabelGuide as thirty capital Ms, the widest glyph there is, so the tray entry stayed enormous however the width slider was moved. A tray label carries no measurement, only a string, so hold the width by padding the label itself with spaces and publish that as its own guide. The width setting now shows in the panel exactly as it reads, and the default drops from thirty characters to twenty. Also stop the panel dismissing itself on the click that opened it: some shells take focus back as they finish handling a tray click, which hid the panel in the same instant and made the icon look like it needed a second click. Within 600 ms of appearing the panel asks for focus once instead of hiding, and repeated activations closer together than 250 ms count as one click. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
The tray label scrolled by publishing a moving slice of the text, so at every step it showed part of a word and the end of a title was only ever visible in passing. A tray label cannot animate, so drop the imitation: publish the line whole and let the entry be as wide as it needs. The width setting now only ever adds width, padding a short lyric line so neighbouring items hold still, and the carousel settings apply to the panel's own title, which really does scroll. Opening the panel sends that title back to its beginning, which is what the reset setting always meant. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
A tray label is a string the desktop panel renders, so a long title can only ever step a whole character at a time. GNOME lets an extension own its panel actor, so ship one: it reads the current track from Reprise and scrolls the title pixel by pixel, the way the macOS menu bar does, and clicking it opens the panel. Reprise publishes what the extension needs on dev.junx.Reprise: the whole line, the album cover as a PNG, whether a player is running, and the user's scrolling speed and width, with a properties-changed signal that carries only what moved. The tray entry stays for desktops without the extension, so nothing is lost by not installing it. The extension and an installer ride along in the packaged archive. Its D-Bus side is verified against a scripted client - properties, cover bytes, change signals as lyrics advance, and the activation call - but the extension itself is not: GNOME Shell will not start in a container without systemd-logind, so it needs a test on a real desktop. Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA
aodjo
marked this pull request as ready for review
September 6, 2026 12:45
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Local verification
Linux CI
The Ubuntu x64 and ARM64 jobs publish the application, execute reprise --health-check, run applicable tests, and upload tar.gz artifacts.