Skip to content

fix(desktop): slide the lyric sheet on the macOS spring - #8

Closed
aodjo wants to merge 29 commits into
developfrom
feature/linux-app
Closed

aodjo wants to merge 29 commits into
developfrom
feature/linux-app

Conversation

@aodjo

@aodjo aodjo commented Sep 6, 2026

Copy link
Copy Markdown
Owner

The scrolling lyrics were reaching 96% of their travel a quarter of the way
through the time they were given, so a line change read as a jump rather
than a glide, and the cascade delay tore the rows apart to 42px gaps before
snapping them back to 29px.

The spring constants did not match SwiftUI's spring(duration:bounce:):
that spring's duration is one period of the underlying oscillator, so the
normalised frequency is a full turn of the circle, not 11.5, and the damping
ratio is one minus the bounce.

Measured on a headless render of the view, sampling frames every 40ms:

before after
travel complete ~180ms ~470ms
widest row gap mid-slide 42px 37px

Also here: the highlight now follows the line being sung rather than the
focused row, so an instrumental gap dims the sheet the way macOS does
instead of leaving a line lit; the crossfade eases at both ends; and a slide
draws the rows it is travelling from, so a seek no longer pops them in.

https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA

aodjo added 29 commits August 5, 2026 17:36
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
Copilot AI lite review requested due to automatic review settings September 6, 2026 13:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@aodjo

aodjo commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Superseded by a branch cut from develop; feature/linux-app diverged when #7 was squash-merged.

@aodjo aodjo closed this Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants