Author(s): Renato Sanchez
Published on Thu Apr 10 2025
Summary: In order to explore more deeply GTK, a new music player has join the battle.
URL: https://github.com/renatosanz/riplay
A fast, native, lyrics-aware music player for the GNOME desktop.
Home

Player
![[riplay_player.png]]
- Version: 0.1
- License: GPL-3.0-or-later
- Language: C++ (gtkmm-4.0)
- UI Toolkit: GTK4 + Libadwaita
What is it?
riplay is a GTK4 music player that reads a file’s tags and plays it, and the
feature it is built around is lyrics — both kinds. A file with synchronized
lyrics (SYLT, or the [mm:ss.xx] LRC convention) is played back line by line
against the audio position; a file with only unsynchronized lyrics gets the whole
text as a block. On top of that it exposes an extra metadata view for the fields
players normally hide, and a recents list that survives a restart. Everything runs
locally: no account, no network, no service.
It is not a web app in a webview: tags are parsed in-process by a statically linked
TagLib, the lyrics parser is hand-written, and the whole interface is declared in
Blueprint and compiled into the binary. Opening a file you have never seen touches
nothing but that file.
Why?
dec2bin was the warm-up — a C program, one screen, mostly an excuse to learn what
a GTK window actually is. This is the same exercise with real data. Lyrics live in
the most inconsistent tags imaginable: three container formats, two incompatible
lyric kinds, timestamps that are sometimes off by a hundred milliseconds and
sometimes absent entirely. That mess is the point. I wanted something small enough
to finish and ugly enough to teach me how a desktop app is put together, and
openness is non-negotiable for me.
Some key points
LyricsManager::extractLyrics (lyrics.cpp:120) never asks the file what it is;
it dynamic_casts the concrete TagLib type and reads whichever field that format
uses — ID3v2 USLT first, falling back to SYLT (lyrics.cpp:132), FLAC Xiph
LYRICS (lyrics.cpp:150), MP4 ©lyr (lyrics.cpp:161). Untimed text is
returned as a plain string, so the caller never has to ask which kind it got: the
return type is a std::variant<bool, std::string> and the string arrives with its
timestamps already stripped.
parser_lyrics (lyrics.cpp:222) turns whatever came back into
std::vector<LyricBar>, each entry a timestamp in microseconds plus the line.
[by:...] and [offset:...] headers are recognised and collected as properties
rather than being mistaken for lyrics, and a malformed line throws inside
format_lyric (lyrics.cpp:176) and is skipped — one bad line never costs you
the rest of the song.
2. Sync That Never Blocks The Main Loop -> src/metadata/lyrics.cpp:69
The synchronized mode runs off a plain Glib::signal_timeout at 100 ms
(lyrics.cpp:28, lyrics.cpp:110) that reads the pipeline position and advances
the label. The trick is that it walks the lyric list forward only, never
rescanning from the top, so a track is O(1) per tick regardless of length:
for (size_t i = lyrics_index; i < sync_lyrics.size(); i++) {
if (current_time < sync_lyrics[i].timestamp) {
break;
}
if (i != lyrics_index) {
show_lyric(i);
}
}
LyricsManager also owns its timer, which is the part that bites: a sigc::connection
does not disconnect itself, so a manager destroyed with the timer still armed will
fire into freed memory. ~LyricsManager (metadata.h:34) disconnects it, which is
the whole lifetime rule for this class.
3. One Long-Lived Player, Released On Purpose -> src/audio/audio_manager.cpp
AppState owns a single AudioManager for the whole process and swaps songs
through it, so audio state has exactly one home. Each loadFile
(audio_manager.cpp:28) tears the old playbin down first, converts the path with
gst_filename_to_uri, attaches a bus watch for EOS, and pre-rolls to PAUSED so
duration and stream metadata are queryable before the first frame plays.
The class is non-copyable and has a reset() (audio_manager.cpp:26) that releases
the pipeline in place. Assigning a fresh manager instead would be shorter to write
and quietly leak a still-playing pipeline, so the copy and move operations are
deleted rather than left to do the wrong thing.
4. Declarative UI Via Blueprint -> data/ui/*.blp
Every window is authored in Blueprint and batch-compiled to .ui at build time
(meson.build:48), then embedded as a GResource through
gnome.compile_resources(). Views are Gtk::Builder lookups by name, so the C++
never mentions a widget’s position, styling or hierarchy — the running binary has
no runtime file dependencies and nothing to install next to it.
5. Every Action Is A Real D-Bus Action -> src/models/state.cpp:82
Playback, opening files, recents, keymaps and quit are six Gio::SimpleActions
registered on the application rather than callbacks hung off menu items. That
single decision buys three things at once: the same code path serves the menus, the
keyboard and anything else on the bus; GApplication gets single-instance
behaviour for free, so a second riplay song.mp3 forwards the path to the running
instance instead of starting a rival one; and the player becomes scriptable.
Architecture Map
- main.cpp — 12 lines. Init GStreamer, init libadwaita, hand off to
AppState.
- models/models.h —
AppState, SongInstance, PlaybackInfo, and the three
view owners. The declaration of what exists.
- models/state.cpp — the application: actions, window lifecycle,
on_activate
and on_open, and the one function that swaps songs.
- views/home.cpp — landing window and the
Gtk::FileDialog open flow.
- views/recents.cpp — the recents sheet.
- views/player.cpp — transport, seek bar, lyrics toggle, metadata side panel.
- metadata/metadata.cpp — tags to
FileMetadata.
- metadata/albumart.cpp — cover art from ID3v2 attached pictures (
albumart.cpp:30),
FLAC picture blocks (albumart.cpp:44) or MP4 cover art (albumart.cpp:59).
- metadata/lyrics.cpp — the container probe, the LRC parser, the sync timer.
- audio/audio_manager.cpp — the
playbin wrapper: load, play, seek, position, EOS.
- logic/file_history.cpp — recents persistence as a
GKeyFile under
$XDG_CONFIG_HOME/riplay (file_history.cpp:12). No database, no daemon.
- types.h —
FileMetadata, which owns its AudioProps and cover-art buffer
(types.h:19); the one place in the codebase that has to think about who frees
what.
Build pipeline: Blueprint (.blp) → Greenfield XML → GResource → single native binary
orchestrated by Meson + Ninja, with TagLib linked statically.