A PlexAmp-style desktop Music Assistant client in Zig, drawn with dvui on wio, with full MPRIS support.
  • Zig 87.5%
  • Nix 11.1%
  • Fluent 1.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 649e886cde
All checks were successful
test / test (push) Successful in 20m7s
test / vm (home-manager) (push) Successful in 7m7s
test / vm (libwayland-unstable) (push) Successful in 6m58s
test / vm (libwayland-stable) (push) Successful in 7m55s
test / vm (listen) (push) Successful in 7m15s
test / vm (unstable) (push) Successful in 2m21s
test / vm (stable) (push) Successful in 6m56s
Move to Zig 0.17
Every Zig dependency moves to its 0.17 port: dvui and wio to the heads
of their pipit branches, zf upstream, the others to main --
zig-musicassistant past the commit that asks for tls with no options,
so that it and zig-sendspin share one tls module rather than making two
over the same files. dvui names the renderer's module
dvui_render_backend now.

The install prefix is no longer known while the build is configured, so
the desktop file, D-Bus service and systemd unit are filled in by
tools/data_files.zig, handed the binary directory when the build runs;
the icon goes through it too, rather than reading files at configure
time. zig-dbus-service gives each interface its own context.
@typeInfo hands out parallel slices, so enums are walked with
std.enums.values; @enumFromInt is @fromBackingInt, @intFromEnum
@backingInt, and std.builtin std.lang. zig-noise's KeyPair.fromSecret
no longer fails, and Dir.setTimestampsNow compiles again.

Zig 0.17's test runner needs no patch, so the devshell's patched Zig
goes. zon2nix moves to a commit with --17, nixpkgs to one with
zig_0_17, and dvui's lazy SDL3 is excluded from build.zig.zon.nix, its
manifest being one zon2nix cannot parse. Version 0.2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qFokELp2xiUWz2VHcdBcq
2026-10-07 21:32:50 -05:00
.forgejo/workflows Move to Zig 0.17 2026-10-07 21:32:50 -05:00
data An application icon 2026-09-25 14:17:11 -05:00
LICENSES Initial commit: the Now Playing panel, drawn with dvui on wio 2026-09-24 12:03:45 -05:00
nix The player can wait for the server to find it and dial it 2026-09-29 04:07:41 -05:00
src Move to Zig 0.17 2026-10-07 21:32:50 -05:00
tests/nixos The player can wait for the server to find it and dial it 2026-09-29 04:07:41 -05:00
tools Move to Zig 0.17 2026-10-07 21:32:50 -05:00
.gitignore A VM test: Pipit on a desktop against a real Music Assistant 2026-09-25 14:53:24 -05:00
build.zig Move to Zig 0.17 2026-10-07 21:32:50 -05:00
build.zig.zon Move to Zig 0.17 2026-10-07 21:32:50 -05:00
build.zig.zon.nix Move to Zig 0.17 2026-10-07 21:32:50 -05:00
flake.lock Move to Zig 0.17 2026-10-07 21:32:50 -05:00
flake.nix Move to Zig 0.17 2026-10-07 21:32:50 -05:00
package.nix Move to Zig 0.17 2026-10-07 21:32:50 -05:00
README.md Move to Zig 0.17 2026-10-07 21:32:50 -05:00
REUSE.toml Fetch the Zig dependencies with zon2nix, each a path of its own 2026-09-28 03:23:01 -05:00

Pipit

A desktop music player in Zig that looks like PlexAmp and is a Music Assistant client underneath. The interface is dvui, drawn with Vulkan in a window made by wio.

A pipit is a small songbird, and the name has PipeWire in it, which is where the sound goes.

Where it is

Pipit controls a Music Assistant player. What exists is the Now Playing panel, laid out the way PlexAmp lays out its own and drawn from a live connection:

  • the cover, as wide as the window, fetched through the server's image proxy at the size it is drawn;
  • the scrubber, with elapsed and total time at either end, moving between the server's reports and seeking where it is let go. For a track Music Assistant has analyzed (its Smart Fades provider does, working through the library and around whatever is playing), it is drawn as the track's waveform, lit up to where the track has got to. The server keeps a waveform under the copy of the track it analyzed -- the one it streams, from whichever provider -- so that is the copy Pipit asks about. For a track not analyzed yet it is a plain bar, and Pipit asks again every minute while it plays;
  • artist, title, album and year, where the artist and the album open their pages in the library;
  • the format line — where the track is coming from, codec, sample rate and bit depth — with a favorite toggle where PlexAmp puts its stars (Music Assistant has favorites, not ratings). Beside it is a quality badge, green for lossless or Hi-Res and amber for lossy, rating the worst step between the source and the player showing. Clicking it opens the signal path as Music Assistant 2.10 reports it: the input and its format, the server's processing (headroom, loudness normalization with the measured and applied figures, crossfade, speed), and each output's format, DSP, whether it is bit-perfect, and which players it went to;
  • previous, play/pause and next; mute and volume;
  • the player picker, with this computer first and a Play here beside every other player with something queued, which moves that queue — its items, where it was, shuffle and repeat — to this computer and carries on playing. Players play together in Music Assistant's groups, led by the player showing: every player that could join it has a link at its end, lit while it is in the group, which a click joins or leaves; each player says who it is playing with; and while the one showing leads a group, it and each member have a slider for their own volume, the one under the transport setting the whole group's. The picker's button shows two speakers while the player showing leads a group; shuffle and repeat;
  • the queue beneath, as Back To and Up Next, where a track is played by clicking it, and Related: tracks like the one playing, as Music Assistant's providers suggest them, looked up the first time the tab shows for each track, any of which clicked plays now without losing the queue. Every row has a menu, from the kebab at its end or a right click: play now, play next, and remove from the queue or add to it;
  • the "more" button, whose menu starts a radio from the track playing — Music Assistant's radio mode, which keeps going with tracks like it — adds it to a playlist, and turns crossfade on or off for the player showing (how it fades, plainly or matched to the music, is that player's own setting in Music Assistant);
  • the whole panel tinted from the cover: the average of each quarter of it, darkened and muted, one per corner, with the most vivid color it has lighting the controls that are on.

It connects to the server when it starts and shows the player it was showing when it last quit, following it through Music Assistant's events. That includes this computer, whose player only comes back a moment after the rest. The server goes on listing a player that has gone, as unavailable, and refuses anything sent to it, so the remembered player is shown only once it is available; until then, or when there is nothing remembered, Pipit shows whatever is playing, and a player that is there before one that is not. Ctrl+Q quits.

The chevron in the action row turns the window into the mini player: a strip with the cover, the title and artist, previous, play and next, a line along the bottom for how far into the track it is, and a chevron back up. Dragging the strip moves the window and a right click opens the window's menu, as the cover does in the whole panel. Each mode keeps a size of its own, and Pipit opens in whichever it was left in. Each has a minimum too, which the window system holds a resize to -- 280×560 for the whole panel, the least cover and everything below it, and 340×64 for the mini player, room for a few words of title beside its buttons.

The bookshelf in the action row opens the browsing screens, with the mini player along their bottom to get back:

  • Home: Music Assistant's recommendations — recently played, recently added, mixes and picks from each provider — as rows of covers scrolled sideways, each row fetched once the list of them has come;
  • Library: albums and artists as a grid of covers, as many across as the window has room for, and playlists, tracks and radio as lists, fetched a hundred at a time as the end of what is loaded scrolls into view;
  • Search: across the library and every provider, asked once typing pauses, the artists, albums, tracks, playlists and radio found each under a heading of its own.

An artist, album or playlist opens a page of its own, with its cover, Play, which replaces the queue with it, and Radio. An album's page leads to its artist, by the line under its name and by an Artist button, and lists its tracks by number, a disc heading between discs, and an artist's has their albums and tracks. A playlist's tracks can be sorted — by title, artist, album, year or length, and back to the playlist's own order — and a track in a playlist that can be edited has Remove from playlist in its menu; the tracks after it are renumbered on the spot as the server renumbers them, so that taking out another names the right one. A page with no picture of its own shows its first track's. Every track's menu, everywhere — the queue, Related, the browsing screens and Now Playing's "more" — has Add to playlist…, which opens a dialog listing the playlists that can be added to. Typing narrows the list, fuzzily, with zf — chva finds "Christmas Vacation", the letters in order — best match first, and Enter adds to that one; when no playlist has the name typed, New playlist makes one with it, kept by Music Assistant, and adds the track to it. Escape, or a click outside, closes it. The back arrow returns to the page before, scrolled as it was left. A track clicked plays now without losing the queue, and a radio station plays. Everything has a menu, from a right click: play, play next, add to the queue, start a radio, and for a track, go to its album or artist.

Every track in a list — the queue, Related, and the browsing screens — shows where it comes from, as a badge between its length and its menu: the provider's own icon, as the server sends it, for YouTube Music, Plex and the rest, with the provider's name in a tooltip for anyone who does not know the icon. For a track in the library that is the first provider that can play it; for the one playing, the provider actually streaming it. The icons are SVG from the server, drawn once by zig-svg in its sandbox, pictures embedded in them and all; one that still cannot be drawn gets its monochrome icon if that can be, and otherwise a folder for files on disk or a globe.

Every picture from the server — covers, thumbnails, providers' icons — is decoded out of Pipit's own process: covers and thumbnails in z2dimg's sandbox, icons in zig-svg's, each a forked child locked down by seccomp that can do nothing but decode and hands the pixels back through shared memory. Image decoders are where memory-safety bugs live, and these images are the server's, not Pipit's. dvui is given the pixels, never a file, so it is built without its own image code, the C libraries stb_image and stb_image_write. The dvui fork makes that build work; upstream dvui still calls them when they are left out.

Covers on these screens are fetched only once they are in view, the most recently drawn first, through the image proxy at 256 pixels, and the last two hundred decoded are kept.

Pipit is a player as well. Once it has a token it joins the server over Sendspin, through zig-sendspin, and appears in Music Assistant's list of players as "Pipit on host", so music can be sent to this computer like any speaker. The connection is encrypted: a Noise handshake, then every message sealed, as Music Assistant 2.10 expects. Pipit is admitted as a guest, which Music Assistant approves by itself, since a player needs no pairing. Its identity is a Curve25519 key, made the first time and kept in ~/.local/state/pipit/sendspin-key, readable by nobody else. The public half is the client id the server knows this player by, and keeps its settings under, so the file is what makes it the same player from one run to the next.

Music Assistant can also pair with this computer, from the player's settings there. Pipit then shows a PIN, in its window and as a notification, and once it has been typed into Music Assistant the two agree a key of their own over CPace, kept in ~/.local/state/pipit/sendspin-pairings beside the identity and as private. The PIN goes as soon as the pairing is done or given up; a wrong one leaves Pipit connected, for Music Assistant to try again. From then on this computer connects with that key, and each side knows the other is the one it paired with, where a guest proves nothing.

The player can also be the one that is dialed. With Wait for the server to find this computer on the settings screen, it connects to nothing: it waits on port 8928 for a Sendspin server to connect, and says so on the local network by mDNS, as _sendspin._tcp under its client id with path=/sendspin and its name, which is how Music Assistant finds players nobody told it about. The advertisement goes through the system's Avahi when Avahi publishes for its users, and otherwise Pipit answers mDNS itself, through zig-sendspin's sendspin_discovery. The connection, the identity and the pairings are the same either way; the token is not sent, since it is for Music Assistant's proxy, which a player being dialed never passes through. Port 8928 over TCP has to be open in the firewall for a server on another machine to reach it, which on NixOS is

networking.firewall.allowedTCPPorts = [ 8928 ];

and Avahi publishing for users, if Pipit is to go through it rather than answer mDNS itself, is

services.avahi = {
  enable = true;
  publish = {
    enable = true;
    userServices = true;
  };
};

The sound goes out through PipeWire, as a stream called Pipit, in step with any other Sendspin player grouped with it. It asks for FLAC, which is lossless at about half the bytes of PCM, and for PCM if the server will not send that, both at the rate the PipeWire graph runs at, so nothing is resampled on this side. Stream as Opus on the settings screen asks for Opus first instead, which is lossy but about a tenth of FLAC's bytes, for a slow link to the server. Opus is only offered when the graph runs at a rate Opus codes at, such as 48 kHz; at any other rate Pipit asks for FLAC. If the graph changes rate while Pipit runs, it offers the server formats at the new rate, which takes a moment's reconnection. Each sample is scheduled for the moment PipeWire says it will be heard: the start of the graph's cycle and however far downstream the sink is, which for a sound card is a cycle and a little headroom and for Bluetooth headphones is the codec's delay on top, so the player stays in step with the rest of its group whatever it plays through. The log says what that latency is when a stream starts. Its volume and mute are the ones Music Assistant sets for that player, and are remembered between runs: a Sendspin player tells the server its volume when it joins, rather than being told.

A skip, a seek or a pause of this computer's player — from the window, the media keys or anything else on MPRIS — fades what is playing out at once, since Music Assistant takes about a second to replace the stream and the old track carrying on for that second sounds like the button did nothing. When nothing has played to this computer for three seconds the stream is paused, out of the graph's schedule, so the sound card can suspend as it would with Pipit closed. Coming back from that takes PipeWire longer than a skip leaves, which is why it waits three seconds rather than pausing at every stream's end, and why Pipit asks the server for 300 ms of lead before the first audio of each stream. Without PipeWire there is no local playback, and the rest of Pipit works as before.

Pipit is an MPRIS player. It owns org.mpris.MediaPlayer2.pipit on the session bus, so GNOME's media controls, the keyboard's media keys and playerctl --player=pipit see and control whichever player the window is showing: status, the track and its cover, a position that moves, seeking, next and previous, shuffle, repeat and volume. The queue is its track list, and the library's playlists are its playlists, which can be started from there. A favorite is reported as a rating of 1. Commands from the bus go the same way the window's buttons do, so the two never disagree.

What is shown lives in the app module (src/app/), which knows nothing about the window: a Store the connection writes and the window reads, and a Controller whose tasks keep it up to date and carry out what the buttons ask. zig build test covers it without opening one.

Setting it up

The first time Pipit starts it has no server, and shows a setup screen instead: the Music Assistant servers it found on the local network — found over Multicast DNS, as instances of _mass._tcp — an address field that clicking one of them fills in, and a username and password. Signing in trades the password for a token, and both are kept, the token readable by nobody else, so the password is never asked for again. If the token stops working, the setup screen comes back, saying why.

The settings screen — the gear on the browsing screens, Settings in Now Playing's "more" menu, or the button on the screen shown while connecting — says which server Pipit is signed in to and which Music Assistant it runs, and Sign out forgets the token and returns to the setup screen, which is also how to change servers. Play music here turns this computer's own player on and off. Announce each new song turns off the desktop notifications described below. Both are on by default and remembered, in $XDG_CONFIG_HOME/pipit/settings. The screen also says how much the art cache holds, with a button to empty it, and which version of Pipit this is.

When the player showing moves on to a new song, Pipit sends a desktop notification with the title, the artist and album, and the cover. It goes through the XDG desktop portal's Notification interface:

  • Pipit first registers as dev.jcollie.pipit with the portal's host registry, so the notification is filed under Pipit even though it is not sandboxed.
  • Every notification has the same id, so each one replaces the last rather than piling up. From version 2 of the interface it is also marked transient.
  • Clicking it brings Pipit forward, through the same org.freedesktop.Application.Activate the desktop launches it with.
  • A song that was already playing when Pipit started, or when another player was chosen, is shown in the window but not announced.

The same two things can be given without the screen:

What Environment File
the server PIPIT_SERVER $XDG_CONFIG_HOME/pipit/server
a token PIPIT_TOKEN, or a file PIPIT_TOKEN_FILE names $XDG_STATE_HOME/pipit/token

PIPIT_TOKEN_FILE is for a token a secret manager keeps: agenix, sops-nix or systemd credentials. It is read each time Pipit starts and never written, and signing out does not remove it.

What a person chooses is configuration, in $XDG_CONFIG_HOME (~/.config); what Pipit was handed or worked out for itself is state, in $XDG_STATE_HOME (~/.local/state) — the token, the local player's key in sendspin-key, its volume in volume, the player last shown in selected, and the window's size -- the whole panel's and the mini player's -- in window. Covers and thumbnails are kept between runs in $XDG_CACHE_HOME/pipit/art (~/.cache), as the server sent them and named by the SHA-256 of their URL, so that a cover is fetched once rather than once a run; the cache is kept to 256 MB by removing the least recently used at each start, and anything in it can be deleted at any time. The directories are found with known-folders. A token or player id left in ~/.config/pipit/ by an earlier version is moved the first time it is read.

The server is written the way a person would write it — media01, media01:8095, [fd00::5]:8095, https://music.example — and read as a URI by zig-uri, as every URI Pipit takes apart is, with http:// supposed when no scheme is given. A long-lived token can be made in the Music Assistant web interface, under the profile page.

With Home Manager

The flake has a Home Manager module, homeManagerModules.default (also homeModules.default):

{
  inputs.pipit.url = "git+https://git.jcollie.dev/jeff/pipit.git";

  # In the Home Manager configuration:
  imports = [ inputs.pipit.homeManagerModules.default ];
  programs.pipit = {
    enable = true;
    server = "http://media01:8095";
    tokenFile = "/run/agenix/pipit-token";
    settings.notify = false;
    autostart = true;
  };
}
Option What it does
enable Installs Pipit, and links its systemd user unit and D-Bus activation file into the user's own directories, so that they are found on any system
package The package; this flake's by default
server PIPIT_SERVER in the unit's environment, over what the setup screen saved. Null leaves the server to the setup screen
tokenFile PIPIT_TOKEN_FILE in the unit's environment. A path as a string, so the secret never lands in the Nix store. Null leaves the token to signing in
settings play_here, notify, stream_opus and player_mode ("dial" or "listen"), written to ~/.config/pipit/settings. A change made on the settings screen lasts until the next activation puts these back. Empty leaves the file to Pipit
autostart Starts Pipit with the graphical session

The server and the token file go in a drop-in on the dev.jcollie.pipit unit, because the desktop file and D-Bus activation both start Pipit through that unit, so every way of starting it sees them. Pipit's state, including a token left by signing in, is not the module's, and it leaves it alone. The virtual machine test runs once with the user set up through the module (vm-home-manager), and that run turns on stream_opus, so that between them the tests play a FLAC stream and an Opus one.

What it is for

Pipit is two things to a Music Assistant server:

  1. A controller for any of its players — speakers, groups, anything Music Assistant can play to — over the server's WebSocket API.
  2. A player itself, so that this computer is one of the places Music Assistant can send music, over Sendspin, with the sound going out through PipeWire.

Whichever player is selected, Pipit reports it over MPRIS, so desktop media controls, media keys and playerctl drive it like any other player.

The Music Assistant client is zig-musicassistant, and MPRIS is zig-mpris, on zig-dbus-service and mdbus, and servers are found with zig-dns-client's Multicast DNS. Its words are Fluent messages, through zig-fluent. Sendspin is zig-sendspin, and the audio goes out through zig-pipewire. Opus is decoded by zig-opus. Vorbis and AAC decoders are to be libraries of their own.

Building it

git clone https://git.jcollie.dev/jeff/pipit.git
cd pipit
nix build           # the package, in result/
nix develop -c zig build run

It builds with Zig 0.17, which the devshell provides. Version 0.1.0, tagged v0.1.0 and kept on the zig-0.16 branch, is the last to build with Zig 0.16.

Pipit is also tested whole, in a NixOS virtual machine, against a real Music Assistant. The machine is a desktop with no screen: a user's own dbus-broker session bus, PipeWire with a sink that plays into nothing, headless sway, and Mesa's software Vulkan. Pipit starts from its own systemd user unit, told the server and given a token, as the setup screen would leave it. The test checks that:

  • its window is on the compositor;
  • it is a player of the server, over Sendspin;
  • it is an MPRIS player;
  • a track played to it shows in MPRIS, with its title, and has a PipeWire stream;
  • a pause from playerctl stops the server's queue where it was;
  • the compositor's screen shows what Pipit drew, which is the check that its frames arrived.

zig-musicassistant's test helper sets up the server and its music. The frame Pipit drew last is kept as result/pipit.png, and the compositor's screen, taken with grim, as result/screen.png. The test runs once for each NixOS release the flake names, and again for each with the libwayland build (see below):

nix build -L .#checks.x86_64-linux.vm-stable                # NixOS 26.05, Music Assistant 2.8.7
nix build -L .#checks.x86_64-linux.vm-unstable              # NixOS unstable, Music Assistant 2.10.3
nix build -L .#checks.x86_64-linux.vm-libwayland-stable
nix build -L .#checks.x86_64-linux.vm-libwayland-unstable
nix build -L .#checks.x86_64-linux.vm-home-manager         # set up through the Home Manager module
nix build -L .#checks.x86_64-linux.vm-listen                # found by mDNS and dialed by the server

vm-listen sets the player to wait to be dialed, with the machine's Avahi publishing for users: Pipit's advertisement has to be seen by avahi-browse, and Music Assistant 2.10 has to find it and dial it, after which the rest of the test plays over that connection.

Against 2.8.7, whose Sendspin does not seal connections, Pipit's player falls back to the unsealed protocol.

Pipit runs on Wayland alone: wio is built without its X11 backend. It speaks the Wayland protocol itself, through zig-wayland-native, with bindings generated in the build from the protocols' XML, so the binary links glibc alone. The one library it opens is the Vulkan loader, by soname at run time, so nothing puts it on the library path; the package wraps the binary to set LD_LIBRARY_PATH, and the devshell sets it for zig build run. The GPU driver is the host's: the Vulkan loader finds Mesa's under /run/opengl-driver on NixOS. There is no libdecor, and none of the GTK, cairo or pango that a libdecor plugin loads, and no EGL or libglvnd.

The keyboard is zig-xkb's, in Zig, rather than libxkbcommon's. It builds the X locale directory, which holds the Compose sequences for dead keys and the Compose key, from libX11's release, and zig build installs it as share/X11/locale beside bin. zig-xkb looks there when XLOCALEDIR is not set, so neither the package nor zig build run has to set anything, and nothing of libX11 is in the package.

What C there is in the running process is glibc, the Vulkan loader, and the drivers the loader opens while it looks for a GPU. Those are Mesa's to choose, and they bring libxcb, LLVM and libwayland with them even on Wayland: Pipit never calls the last.

How frames reach the compositor

Vulkan's Wayland surface is made from libwayland's own wl_display and wl_surface, and that client has neither, so Pipit cannot have a swapchain. dvui's Vulkan renderer draws each frame into an image of its own instead, and hands it to the compositor itself, through zig-wayland-native's present module. Each buffer is used again only once the compositor has let go of it, and frames are paced by frame callbacks. There are two ways to hand it over:

  • dma-buf. Where the compositor offers linux-dmabuf and names its GPU, and Vulkan has that GPU, the frame is copied on the GPU into a dma-buf the compositor reads directly, with no copy through the CPU. The buffer is an image with a modifier both sides support, or a linear buffer where the GPU has no modifiers. radv on GFX8 cards such as the RX 480 is one of those, and exports no image at all. The two are kept in step the way Mesa's own swapchain does it:

    • Explicit sync, where the compositor offers linux-drm-syncobj, as mutter and sway do: each buffer has a timeline of its own, the copy signals its acquire point, and the buffer is used again once the compositor has signalled the release point.
    • Implicit sync otherwise: the compositor's reads of a buffer are waited on before copying into it, and the copy is attached to the buffer as a fence before it is presented.

    The journal says which was chosen, as presenting through dma-bufs, with the kind of sync.

  • Shared memory, everywhere else: the frame is copied into host memory in the same submission and written into shared-memory buffers. That is a copy through the CPU each frame. The virtual machine test takes this path, since its sway draws with pixman and offers no dma-buf.

With libwayland

-Dwayland_native=false, or the pipit-libwayland package, builds wio's Wayland backend on libwayland instead, with bindings zig-wayland generates, and presents through an ordinary Vulkan swapchain. libwayland-client is linked, which is why the build wants pkg-config and wayland, and put on the library path beside the Vulkan loader.

nix build .#pipit-libwayland
nix develop -c zig build run -Dwayland_native=false

Mesa's Vulkan drivers link libwayland themselves, so it is still loaded into the process. Pipit just never calls it.

Nix fetches the package's Zig dependencies, each as a path of its own, so a bump of one fetches only that one and every project using the same package shares it through the cache. build.zig.zon.nix lists them. It is generated by zon2nix and committed, and has to be regenerated whenever build.zig.zon changes:

nix develop -c zon2nix --17 --exclude tree_sitter --exclude tree_sitter_json \
  --exclude tree_sitter_zig --exclude sdl3 --nix=build.zig.zon.nix build.zig.zon

The exclusions are dvui's lazy tree-sitter and SDL3 dependencies, which Pipit never asks for. One beneath the tree-sitter packages declares a hash Zig 0.17 refuses to fetch at all, and SDL3's manifest gives its dependencies a version field, which zon2nix cannot parse. The build takes the set with --system, which forbids fetching, so a package missing from it is an error naming the package. --system also switches on every system integration, and -fno-sys=accesskit, -fno-sys=freetype and -fno-sys=wio switch them off again, so dvui builds its own freetype as it does without it. The set is also a flake output, for a zig build in the devshell to use the same way, as CI does:

nix develop -c zig build test --system "$(nix build --print-out-paths .#zig-deps)" \
  -fno-sys=accesskit -fno-sys=freetype -fno-sys=wio

One Pipit, and launching it

Pipit owns dev.jcollie.pipit on the session bus and serves org.freedesktop.Application at /dev/jcollie/pipit. It claims the name before it opens a window. If another Pipit already has the name, it asks that one to come forward, passing on its launcher's activation token, and exits. Two copies would register the same local player and take it from each other.

That is also how the desktop launches it. The package installs:

File What it is for
share/applications/dev.jcollie.pipit.desktop the launcher, DBusActivatable, so the desktop calls Activate on the name rather than running a command
share/dbus-1/services/dev.jcollie.pipit.service tells the bus how to start Pipit when the name is asked for
share/systemd/user/dev.jcollie.pipit.service the user unit the bus starts it as, Type=dbus, in app.slice
share/icons/hicolor/scalable/apps/dev.jcollie.pipit.svg the application icon, which shell media controls and notifications show: the Material Design "bird" glyph, taken from the icon package at build time, on a rounded square

So a launch from the desktop starts Pipit as a systemd user service the first time, and brings the window forward every time after that. systemctl --user start dev.jcollie.pipit starts it the same way. On Wayland, bringing the window forward uses the token the launcher issued, which is the only kind of request a compositor raises a window for; the wio fork's Window.activate passes it on.

With NixOS, the package goes in environment.systemPackages and in systemd.packages, and the desktop, the bus and systemd all find their files; with Home Manager, the module above puts them where they are found.

To try a build without installing it, link the three files into the same places under ~/.local/share, then run systemctl --user daemon-reload — and remove the links when done. They are looked at before anything installed, so they go on hiding the installed files afterwards; and once result points at some other build, they dangle, and GNOME, finding a desktop file by Pipit's name that it cannot read, no longer recognizes Pipit's window and shows it with neither its name nor its icon.

The window

Pipit has no title bar, as PlexAmp has none: the cover is flush with the top of the window, dragging it moves the window, right-clicking it opens the system's window menu, a close button and the settings gear show in its corner while the pointer is over it, and the window's edges resize it. The system's title bar and borders are turned off, which dvui's wio backend cannot ask for and wio itself could not do, so:

  • src/backend/wio.zig is dvui's wio backend, carried here and built through dvui's custom backend, creating the window without decorations and with app_id set to dev.jcollie.pipit, which the desktop file is named after;
  • wio is a fork, jeff/wio, whose pipit branch adds the option to leave decorations off — a window without them being a plain xdg-shell toplevel rather than an invisible libdecor frame — a Wayland backend of its own on zig-wayland-native, the calls that hand a move or resize to the system, the one that opens the window menu, the one that brings the window forward for another launch, a minimum size, and Wayland's scrolling counted in clicks of the wheel, as on X11, rather than in surface distance, which scrolled ten times as far.

The window opens as it was left: its size and whether it was maximized. Wayland keeps windows' places to the compositor and tells an application nothing about them, so the size is all that comes back. The size is written a second after the window stops changing, not only on quit, because a logout ends Pipit without warning.

The text is set in Adwaita Sans, GNOME's typeface, with Adwaita Mono for anything monospaced, taken from GNOME's release tarball as a Zig dependency and embedded. dvui's own Vera Sans has 256 characters, and album titles use more of them than that: ★, ♥, arrows and Cyrillic are all in Adwaita Sans. No Chinese, Japanese or Korean, which it does not cover either. Adwaita Sans is a variable font, every weight in one file, and dvui opened a font only at its default instance, so dvui is a fork as well, jeff/dvui, whose pipit branch lets a font source name a face within its file; Bold is the seventh named instance. The fonts are under the SIL Open Font License, which permits embedding them.

The icons are Material Design Icons, fetched as SVG from the MaterialDesign-SVG package as a Zig dependency. build.zig embeds only the ones it names, and zig-svg draws each at the size in pixels it is shown at and in the colour it is shown in. Each is rendered once and kept, rather than scaled from one size. Repeat has an icon for each of its three states, and shuffle one for each of its two.

The renderer is dvui's Vulkan one, with bindings vulkan-zig generates from Khronos' registry. dvui's build gave it dvui's own copy of wio, which only its wio backend has; the dvui fork lets a custom backend give it its own instead, and Pipit's build.zig adds its wio fork to the renderer.

nix develop -c zig build test --summary all   # the app module, headless
nix develop -c zig build check                # compile the window without opening it
nix develop -c zig fmt --check --exclude zig-pkg .
nix develop -c reuse lint

PIPIT_CAPTURE=shot.png makes Pipit write what its window shows to that file, as a PNG, every frame from two seconds in: for checking a layout without a person to look at the screen. The frame is drawn into a texture as well as the window -- dvui's Picture, the way its own tests capture one -- and read back from it, whatever the renderer, then encoded by z2dimg. With XDG_STATE_HOME pointed at an empty directory, the server and token given as PIPIT_SERVER and PIPIT_TOKEN, PIPEWIRE_REMOTE pointed at nothing so that it does not join as a player, and dbus-run-session so that it does not meet the Pipit already running, it can be run beside the real one.

--exclude zig-pkg is not optional: that is where Zig 0.17 unpacks fetched dependencies, inside the checkout, and zig fmt . would otherwise report on dvui's formatting.

Its language

Everything Pipit says is a Project Fluent message, formatted by zig-fluent. The messages are in src/app/locales/<tag>.ftl, embedded in the program, so there is nothing to install beside it. en-US.ftl is the source: it has every message, with comments saying what the less obvious ones are for, and it is what Pipit shows when nothing the user reads is shipped. The words and phrases that join names together, "With Kitchen & Den" and the like, are messages as well, so a translation decides how its lists read.

Which translation is used is the environment's to say, as for any other program: LANGUAGE, then LC_ALL, LC_MESSAGES and LANG. The first of those that Pipit has wins, matched by the whole tag, then by language and script, then by language alone. Numbers follow LC_NUMERIC, so someone who reads English in Germany sees "1.234,6 MB". The journal says which it chose, as speaking en-US.

Names that come from Music Assistant, such as players, tracks, artists and albums, arrive as arguments and are never translated.

To add a translation:

  1. copy src/app/locales/en-US.ftl to src/app/locales/<tag>.ftl, de or pt-BR say, and translate the right-hand sides;
  2. add it to catalog in src/app/L10n.zig.

zig build test fails if a shipped translation does not parse or lacks a message the source has. That makes a translation that is out of date after a new message was added into a failed test, not English appearing partway through a screen.

Fluent's bidirectional isolation marks are off. They are for text laid out by a renderer that implements the Unicode bidirectional algorithm, which dvui's does not, and it would draw them as boxes around every name.

Where this lives

Three homes, with the same history in each.

License

MIT, following the REUSE specification; reuse lint checks it.

References cited

Kept in the pipit Zotero collection.