Media Manager
Local media playback engine with QMediaPlayer, folder-based playlists, a persistent tag store, cover-art caching, album-art colour theming, volume routing and playback recovery
Overview
The MediaManager class (src/managers/mediamanager.{h,cpp}) handles all
local audio file playback for OCTAVE. It wraps Qt's QMediaPlayer and QAudioOutput,
turns the sub-folders of the music folder into playlists, keeps a metadata and album-art cache backed by a
persistent tag store on disk, extracts dominant colors from album art for the "Album Art Capture" theme, and
provides shuffle, skip, playback resume and automatic recovery from unplayable files.
It is registered as a QML context property named mediaManager in
src/main.cpp. All playback controls
are Slots and every state change is broadcast with a Signal, so the QML in
frontend/MediaRoom.qml, MediaPlayer.qml,
BottomBar.qml, MainMenu.qml and the
dashboards/widgets/NowPlayingWidget.qml binds to the same API.
Platforms
| Implementation | Platforms | Tag / cover reader |
|---|---|---|
| src/managers/mediamanager.{h,cpp} | All | TagLib on desktop (TagLib::FileRef for tags, format-specific readers for pictures). On Android the CMake define Q_OS_MOBILE (set for ANDROID builds in CMakeLists.txt) compiles TagLib out; tags and covers come from OctaveAndroid::readMetadata() / extractAlbumArt() in src/platform/androidmediabridge.h (JNI). Any other mobile target falls back to the file name with "Unknown Artist" / "Unknown Album". Which reader is used is OCTAVE_TAGLIB (mediamanager.h): desktop and the browser demo (WebAssembly), which reads tags and covers with TagLib from the parts of each file read in the browser (openTags() gives TagLib a WebMusicLibrary::tagStream() there) and whose library is a folder picked in the browser and played from blob: URLs (pick_browser_library, Building > WebAssembly). |
Supported Audio Formats
The following file extensions are recognized during library scanning (MediaManager::s_audioExtensions, with a file-local copy s_audioExts used by hasAudioExtension()):
// src/managers/mediamanager.cpp
".mp3", ".m4a", ".mp4", ".flac", ".ogg", ".opus", ".wav"
The list accepts .mp4: an audio-only MP4 file is the same container as .m4a with a
different extension. The
display-name and metadata fallbacks strip any of these extensions (and only these).
The desktop TagLib readers handle the formats below; none extracts WAV art. Actual playback also depends on the codecs available to Qt Multimedia. The dispatch lives in _read_tags(filePath, baseName) (tags) and _extract_art_for(filePath, albumId) (cover); a file that fails to open yields the file-name title with "Unknown Artist" / "Unknown Album":
| Format | TagLib (desktop) |
|---|---|
| MP3 | Title / artist / album / length via FileRef; cover from the first ID3v2 APIC frame |
| M4A / MP4 / AAC | Tags via FileRef; cover from the first covr item (TagLib::MP4::File) |
| FLAC | Tags via FileRef; cover from TagLib::FLAC::File::pictureList() |
| Ogg Vorbis | Tags via FileRef; cover from the Xiph comment's pictureList() (TagLib::Ogg::Vorbis::File) |
| Opus | Tags via FileRef; cover from the Xiph comment's pictureList(). _extract_album_art_ogg picks the reader by extension -- TagLib::Ogg::Opus::File for .opus, since the Vorbis reader rejects an Opus stream -- and shares the picture handling |
| WAV | Tags and length via FileRef; no cover |
QMediaPlayer Integration
The constructor creates one QMediaPlayer with one QAudioOutput and starts it at
volume 0.5 (the real startup volume is applied a moment later by VolumeController, see
Volume).
Output device. A QAudioOutput stays bound to the device that was the system
default when it was created, so an output that disappears and returns (HDMI audio across a display sleep,
a USB DAC) used to leave playback silently on the old device. The constructor therefore creates a
QMediaDevices and connects audioOutputsChanged to _apply_output_device(),
which is also called when settingsManager.audioOutputDevice changes. It plays to the output picked
in Settings > Media > Audio Output (a QAudioDevice id) while that device is
plugged in, and to QMediaDevices::defaultAudioOutput() otherwise or when nothing is picked
(src/util/audiooutput.h, shared with the phone mirror's audio). It switches only
when the device differs (a null device is ignored), logs "Audio output changed, now using: ...", and emits
audioOutputsChanged(). QML binds to audioOutputs (every output as
{id, name, isDefault}) and currentAudioOutput (the name of the one in use).
// src/managers/mediamanager.cpp — constructor
m_player = new QMediaPlayer(this);
m_audioOutput = new QAudioOutput(this);
m_player->setAudioOutput(m_audioOutput);
m_audioOutput->setVolume(0.5f);
// Re-pick the output (picked one, else the system default) when outputs change
m_mediaDevices = new QMediaDevices(this);
connect(m_mediaDevices, &QMediaDevices::audioOutputsChanged, this, &MediaManager::_apply_output_device);
connect(m_player, &QMediaPlayer::durationChanged, this, &MediaManager::durationChanged);
connect(m_player, &QMediaPlayer::positionChanged, this, &MediaManager::positionChanged);
connect(m_player, &QMediaPlayer::mediaStatusChanged, this, &MediaManager::_handle_media_status);
connect(m_player, &QMediaPlayer::errorOccurred, this, &MediaManager::_handle_player_error);
connect(m_player, &QMediaPlayer::playbackStateChanged, this,
[this](QMediaPlayer::PlaybackState state) { emit playbackStateChanged(static_cast<int>(state)); });
// Position timer (100 ms) as a safety net for backends whose
// QMediaPlayer::positionChanged is coarse. It only runs while playing.
m_positionTimer.setInterval(100);
connect(&m_positionTimer, &QTimer::timeout, this, &MediaManager::_update_position);
connect(this, &MediaManager::playStateChanged, this, [this](bool playing) {
if (playing) m_positionTimer.start(); else m_positionTimer.stop();
});
Connected signals from QMediaPlayer:
durationChanged/positionChanged- forwarded directly to QML as the manager's own signals of the same namemediaStatusChanged- routed to_handle_media_status():EndOfMediaadvances to the next track,LoadedMedia/BufferedMediaapply a pending seek left by_reseat_source()(if the source still matches) and reset the recovery counters,StalledMedia/InvalidMediatrigger recoveryerrorOccurred- also triggers recovery (ignored while nothing is playing)playbackStateChanged- forwarded asplaybackStateChanged(int)with the rawQMediaPlayer::PlaybackStatevalue (0 stopped, 1 playing, 2 paused); the manager's ownplayStateChanged(bool)remains the signal the QML actually binds
The 100 ms QTimer re-emits positionChanged(position) while the player's
playbackState() is PlayingState, as a safety net for multimedia backends whose native
positionChanged is coarse. It is started and stopped from playStateChanged, so a
paused or idle app does not wake ten times a second (commit ee5a23e).
Library Scanning
The library root comes from SettingsManager's mediaFolder setting. The stored default is the
empty string, which resolves to the user's Music folder
(QStandardPaths::MusicLocation; on Android
/storage/emulated/0/Music) and is created if missing. The manager's own built-in
<app dir>/media directory is therefore only the
initial value of m_mediaDir before settings are wired, and what get_default_media_dir()
reports. Wiring happens in setSettingsManager() (called from main.cpp; the
connect_settings_manager() slot is a no-op kept for Main.qml), which calls set_library_root(),
connects mediaFolderChanged to it, and schedules _restore_playback_state() 500 ms later.
scan_library(resetDisplayNames) is synchronous on the GUI thread, guarded by a re-entrancy flag, and emits scanProgress lines ([SCAN], [PATH], [FOUND], [DONE], ...) that the Media settings page shows terminal-style. The algorithm:
- Clear the playlists, the in-memory metadata and album-art maps, the All Music path map and the stats cache (and the display names if
resetDisplayNamesis true; internal callers passfalse, while the settings-page "Rescan" button and the Android scan that runs once theREAD_MEDIA_AUDIOpermission is granted usetrue) - Audio files directly in the root become the "Unsorted" playlist
- Every immediate, non-hidden sub-folder becomes a playlist named after the folder, including empty folders, so a user-created playlist survives a rescan after its last song was moved out
- Every file is also appended to the combined "All Music" playlist (
isCombined). A file name already seen in another folder is renamed"<folder> - <file>"for that playlist, andm_allMusicFilePathsremembers which directory each name lives in - Playlist names are ordered "All Music", "Unsorted", then the rest case-insensitively
_auto_strip_display_names()cleans the file names that do not yet have a display name- If a playlist was active before the rescan it is re-selected and the playing index restored, then
playlistsChangedis emitted once, after all state is settled
Android: after the root scan the manager also walks the app's own external-files
Music directory (OctaveAndroid::getAppMusicDir()), the place files can be put under scoped
storage without extra permissions, and merges each of its sub-folders into the playlist of the same name (or adds it as a new one).
_get_file_path() accepts that directory as a second valid root, and
play_file_at_path() has a filename-only fallback for the same reason.
get_media_files(emitSignal) is the older flat listing of the current m_mediaDir, used as a fallback by shuffle and sorting
when no playlist is selected. QML no longer calls it: for "All Music" m_mediaDir is the library
root, so over a library of subfolders it emitted an empty mediaListChanged that emptied the music
list and put Home back to "No music yet"; MediaPlayer.qml loads
get_current_song_list() on each visit instead. Both it and scan_library(resetDisplayNames) can be called from QML
with or without their boolean: the methods have default arguments.
_get_file_path() rejects names containing a .. path component or starting with a
separator, resolves All Music names through m_allMusicFilePaths, and then requires the result to
be inside the library root (_is_safe_path(), which compares canonical
paths). create_playlist(), move_song_to_playlist(), delete_song() and
delete_playlist() apply the same check to every path they touch, and the two special playlists
"All Music" and "Unsorted" cannot be deleted.
Metadata Caching and the Tag Store
Every lookup (get_band, get_album, get_formatted_duration, the
"now playing" metadata, library statistics) goes through _cache_metadata(filename), which fills an
in-memory map (m_metadataCache, filename → title, artist,
album, duration in seconds) capped at 1000 entries. When the cap is hit the QHash drops whichever entry begin() points at. The map is
cleared on every select_playlist() and scan_library().
Behind it sits the persistent tag store <app data>/metadata_cache.json
(the app-data directory from SettingsManager::getAppDataDir(),
e.g. ~/.config/OCTAVE/ on Linux), introduced in commit 1232538:
{
"version": 1,
"files": {
"Artist - Song.mp3": {
"title": "Song", "artist": "Artist", "album": "Album",
"duration": 245, // seconds
"size": 6021384, "mtime": 1757000000 // validation: bytes, seconds since epoch
}
}
}
How the store is used:
_cache_metadata()first asks_meta_from_store(). An entry is served without opening the audio file if the file still exists and its size and integer mtime match. After the first run, scrolling a list or opening the media page therefore no longer opens every file with TagLib.- A stale entry is removed when that filename is looked up and its size/mtime check fails. There is no full-library pruning pass for the tag store, so entries for files no longer requested can remain on disk.
- Only on a miss does
_cache_metadata_from_file()read the tags; the result is written back with_meta_to_store(). - Writes are coalesced: a single-shot 2 s timer is restarted on every change and
_save_meta_store()writes the compact JSON once, atomically (QSaveFile), so a power cut leaves the old store or the new one. The dirty flag is only cleared when the write succeeded, so a failed write is retried on the next change or at shutdown. - Shutdown flushes it explicitly:
flush_metadata_store()is called from theaboutToQuithandler inmain.cpp(the destructor also saves).
Tag strings are sanitized (control characters replaced by spaces, whitespace collapsed, truncated to 200
characters with "...") by _sanitize_metadata(). It works per Unicode code point, so emoji
(surrogate pairs) and zero-width joiners in joined emoji are kept, a lone surrogate becomes a space, and the
cut never splits an emoji; _read_tags() sanitizes every
format. Missing tags fall back
to the file's base name for the title and to "Unknown Artist" / "Unknown Album".
Album Art Extraction
get_album_art(filename) returns a file:// URL to a cover image written into the temp
directory: <app dir>/temp on desktop,
the app-data temp/ folder on Android. Cover filenames are derived from the album identifier:
cover_<sha256(albumId + "_0")[:16]>.<jpg|png|gif|img>. The extension follows the
picture's MIME type; only the first embedded picture is used.
Album ID
_get_album_id() keys covers by "{album}_{artist}" so every track of an album shares
one file. When the album tag is missing the key becomes "Unknown Album_{artist}_{filename}",
because a folder of loose single tracks usually has a different cover per track and they must not all share the
first one. If metadata cannot be read at all, a 16-hex-digit SHA-256 of the file name is used.
Lookup Order
- Bump the album's access count (
m_accessCount) - Return the URL from the in-memory map
m_albumArtCache(albumId → URL) if present - Run
_manage_cache(): when the map already holds 500 entries (m_maxCacheFiles), the entry with the lowest access count (other than the one being added) is removed from the map and its file deleted _cached_art_url_for(albumId): if a cover file with that hash already exists in the temp dir from any earlier run, use it without opening the audio file- Otherwise open the audio file and extract the first picture (
_extract_art_for()dispatches on extension to the TagLib readers in the format table; on Android the JNI bridge supplies the picture) and write it if no non-empty cover file exists yet. Cover files are written atomically (QSaveFile); a zero-byte cover left by an older build is treated as missing and rewritten - Store the URL in the map and return it (empty string if the file has no picture)
Covers Survive Restarts
Until commit 1232538 the temp directory was wiped at startup and at quit, so every cover was a
fresh tag read plus JPEG write on each boot. Now the directory is kept and step 4 above finds a previous run's
cover by hash. Because the in-memory map starts empty on every launch, _manage_cache() can only
bound the covers the current run has seen; commit 43432c3 therefore added
_prune_cover_dir(), which runs once in the constructor and deletes the least recently
read cover_* files beyond the 500 limit (QFileInfo::lastRead()), so covers of removed tracks do not accumulate forever.
_clear_temp_files() still exists as a slot but nothing calls it any more.
Populate Tags and Covers Together
When _cache_metadata_from_file() has to open a file for its tags it also extracts the cover in
the same cache-population operation (_extract_art_for() after _read_tags(); these may open the audio file separately, and cover extraction is skipped when
the album's cover is already known or found by hash), so the get_album_art() call that follows in
a QML binding is an instant map hit. Entries served from the tag store skip this step on purpose; their cover is found by the hash fast
path.
Color Extraction and Theming
When the "Album Art Capture" theme is active, MediaManager extracts dominant colors from the current cover
and generates a complete theme palette. It uses Qt's QImage only. MediaManager does the
extraction; NowPlaying decides when (the
live source's art changed, or the theme was chosen) and calls extract_colors_from_album_art(filename)
for a local track or extract_colors_from_image(url) for the phone's art.
Extraction Pipeline
extract_colors_from_album_art()resolves the cover throughget_album_art(). A repeat request for the same file within 2 s is dropped: restoring playback and the 1 s startup timer both ask for the same file and one extraction is enough- The image work runs on a worker:
QtConcurrent::run()with aQFutureWatcher<QString>. Each request increments a job counter; when a job finishes it is applied only if no newer job was started, so a fast skip through tracks applies only the last theme _extract_album_colors()converts the cover to RGBA8888 and scales it to 50×50 pixels with a smooth transformation (2500 pixels)_kmeans_colors()finds k=5 centroids with at most 10 iterations, seeded with a fixed generator (std::mt19937(42)) so the same cover always yields the same palette- Centroids are sorted by vibrancy (HSV saturation × value, most vibrant first)
- The image is classed dark when the average luminance (
0.299R + 0.587G + 0.114B) of the first 1000 pixels is below 0.5 _generate_theme_from_colors()builds the theme JSON and_apply_album_colors()(GUI thread) hands it tosettingsManager.set_album_art_colors(json). That updates thealbumArtColorsproperty, and Main.qml feeds it toStyle.updateAlbumArtTheme(). ThealbumColorsExtractedsignal is only emitted when no SettingsManager is wired
Theme Generation
_generate_theme_from_colors() takes the sorted palette (primary, secondary, tertiary, quaternary;
missing entries repeat the previous one) and derives:
base,baseAlt- dark images: primary at 15% / 22% brightness; light images: primary at 250% brightness desaturated to 30%, then 92% of thataccent- primary with saturation ×1.4 and brightness ×1.2; three further accents from the other centroids with progressively smaller boosts, and two dimmer "nav" variants (70% / 75%)text.primary,text.secondary- near-white (245 / 210) on backgrounds whose WCAG relative luminance is below 0.3, otherwise near-black (25 / 60)states.hover,states.paused,states.playing- brightness steps of the basesliders.volume,sliders.media,sliders.settings- accents 1-3bottombar.*- 14 keys (previous,play,pause,next,volume,shuffle,toggleShade,homeButton,obdButton,mediaButton,settingsButton,sensorButton,androidAutoButton,phoneMirrorButton)mediaroom.*- player control colors (play/pause use the accent at 110% brightness)mainmenu.mediaContainer- second accent at 60% brightnessobd.boxBackground,obd.barColor,obd.labelColor,obd.valueColor- gauge colors againstbaseAlt
Every icon/accent color is passed through ensureIconContrast(), which targets a contrast
ratio of 3.0 against the background, trying brightness factors 1.3 to 3.0 (dark base) or 0.7 to 0.2
(light base) and falling back to a muted gold (200, 180, 130) or a dark brown
(50, 40, 30). Colors that already pass are capped at 85% brightness by
capBrightness() (with saturation nudged ×1.1) to avoid washed-out pastels. The final capped or fallback colour is not rechecked, so this is a heuristic rather than a guaranteed minimum contrast.
Playback Control
Playing a File
play_file(filename) does the following:
- If no playlist order exists yet, build one: shuffled when shuffle is on, otherwise alphabetical by the sort key (lower-cased, punctuation and underscores stripped:
[^\w\s]|_) - Locate the file in the playlist and set
m_currentIndex; if it is not there and shuffle is off, rebuild the alphabetical list; if it is still missing, play the first entry instead - Resolve the path with
_get_file_path()(All Music aware); a missing file (deleted, moved, unmounted USB stick) is logged and skipped via_skip_bad_track(advance=true), the same path as an unplayable track setSource()+play(), set the playing flags, re-apply mute (setting a source can reset the output volume), emitplayStateChanged(true)- Pre-populate the metadata and cover caches and emit
metadataChangedbeforecurrentMediaChanged, so the QML bindings that fire on the latter get instant cache hits instead of doing tag I/O during the render phase - Emit
durationFormatChangedviaget_formatted_duration(), then start the 80 ms pre-cache timer
Track Navigation
| Slot | Behavior |
|---|---|
next_track() | Advances the index by 1 (wraps around), calls play_file(), restarts the debounced state save. Also called on EndOfMedia |
previous_track() | Decrements the index by 1 (wraps around), calls play_file(), restarts the debounced state save |
toggle_play() | If no source is set, plays the current file. If playing, pauses and saves state. If paused, checks for a bad media status (InvalidMedia, NoMedia, StalledMedia) and forces recovery instead of resuming. If the pause lasted at least 30 s (kLongPauseMs / LONG_PAUSE_SECONDS) and the file exists, it reloads the track at its current position with _reseat_source() before play(), re-applies mute and emits playStateChanged(true) without the state check (the source is still loading). Otherwise, after play() it verifies playbackState() is PlayingState and forces recovery if not |
pause() | Pauses the player, emits playStateChanged(false), starts the debounced state save, remembers the volume for unmute |
set_position(ms) | Seeks to a position in milliseconds |
Peek Methods (No Side Effects on Playback)
| Slot | Returns |
|---|---|
get_current_file() | Current filename. If no playlist order exists yet it builds the alphabetical one and returns its first entry without playing it |
get_next_track_name() / get_previous_track_name() | Neighbor filenames (wrap around) |
get_next_track_album_art() / get_previous_track_album_art() | Cover URL of a neighbor |
get_neighbor_album_arts() | [prevArtUrl, nextArtUrl] in a single call; MediaRoom.qml uses it for the side cards of the carousel to halve the QML-bridge crossings |
is_playing(), is_paused(), get_duration(), get_position() | Playback flags and the player's duration / position in ms |
Shuffle Logic
Shuffle is a toggle (m_shuffle / _shuffle); there is no repeat mode, playback simply
wraps around the playlist. toggle_shuffle() works on the selected playlist's file list
(_get_current_playlist_files()), not on a directory scan, so shuffling "All Music" shuffles
every folder:
- On: the files are shuffled (
std::shufflewith astd::random_device-seededmt19937/random.shuffle), the currently playing song is swapped to position 0 and the index reset to 0, so playback continues from the current song into a random order - Off: the alphabetical order is rebuilt and the index set to the current song's position in it
- Afterwards
shuffleStateChangedandmediaListChanged(newOrder)are emitted, in that order, so QML reads the new order
Every toggle is saved through settingsManager.save_last_shuffle_state(). On startup
_restore_playback_state() re-enables shuffle only when the persistShuffleState
setting is on and lastShuffleState was true; it then shuffles the restored playlist with the last
played song at the front (see Settings Reference).
Playlist Management
Playlists are folders. The manager keeps:
m_playlists/_playlists- name →{name, path, files, songCount, isCombined}m_playlistNames- ordered names ("All Music", "Unsorted", then alphabetical)m_currentPlaylistName,m_currentPlaylist(the ordered or shuffled file list actually played),m_currentIndexm_libraryRoot- the resolvedmediaFolder
select_playlist(name) switches m_mediaDir to the folder (or the root for All Music),
sorts the files alphabetically (or, when shuffle is on, shuffles them with the current song first, so shuffle
survives playlist selection and library rescans), resets the index, clears the in-memory metadata map, recalculates the library
statistics (totalDurationChanged, albumCountChanged, artistCountChanged,
computed from cached durations and distinct non-"Unknown" album / artist tags) and emits
currentPlaylistChanged and mediaListChanged.
"All Music" Playlist
The combined playlist spans every folder, so _get_file_path() looks the directory up in
m_allMusicFilePaths first. A duplicate that was renamed "<folder> - <file>"
during the scan is mapped back to its real name when the prefixed file does not exist on disk;
_get_original_filename() does the same for display purposes and for moves.
Editing
| Slot | What it does |
|---|---|
create_playlist(name) | Creates <root>/<name> (rejects separators, .., hidden names; reuses an existing empty folder), then rescans |
move_song_to_playlist(file, playlist) | Renames the file into the target folder, appending " (n)" on collision, stops playback if it was the loaded song (playing or paused), then rescans. Returns false if the file already lives in that folder |
delete_song(file) | Stops playback if it was the loaded song (playing or paused), deletes the file and its display name, rescans, emits songDeleted(file) |
delete_playlist(name) | Recursively deletes the folder (never "All Music" / "Unsorted"), rescans, and selects the first playlist if the deleted one was active |
get_movable_playlist_names() | All names except "All Music" (valid move targets) |
get_song_playlist_name(file) | The playlist folder the file actually lives in, even when viewed through "All Music"; empty if unknown |
play_file_at_path(path) | Rescan, find the file by directory (or by filename in any playlist), select its playlist and play it. Nothing in the bundled UI calls it; it is there for service clients |
Display Names
get_display_name(filename) returns a cleaned title for lists and the now-playing views. The map
m_displayNames is persisted as <app data>/display_names.json (written atomically
with QSaveFile) so cleaned names survive restarts. _strip_single_filename() removes the extension, the artist name(s) from the
tags (split on / and ;), leading track numbers (^\d{1,3}\s*[-._)\s]\s*),
a list of common upload junk such as (Official Audio), [HD], (feat. ...),
and leftover separators. _auto_strip_display_names() runs at the end of every scan for files that
have no entry yet; the settings page's "Strip filenames" button (strip_filenames()) re-strips every
file and reports each change through scanProgress. Without an entry, the fallback strips any recognised audio
extension.
Volume, Mute and Ducking
The Volume Curve and VolumeController
The UI works in percent (0-100); QAudioOutput takes a linear 0.0-1.0. OCTAVE maps between them
with a quadratic curve, linear = (percent / 100)², implemented once in
src/managers/volumecontroller.{h,cpp} (VolumeController::toLinearStatic()). The VolumeController
QObject, registered as the QML context property volumeController, is the single entry point
for changing the volume: the BottomBar and MediaRoom sliders, the gesture sensor and the ESP32 knob all call
volumeController.applyVolume(percent), which clamps, stores the percent as the
in-memory currentVolume (not saved; a restart starts at startUpVolume), computes the linear value and fans it out. toLinear(percent)
exposes the pure math to QML.
// src/managers/volumecontroller.cpp
float VolumeController::toLinearStatic(float percent)
{
const float pct = std::clamp(percent, 0.0f, 100.0f);
const float normalized = pct / 100.0f;
return normalized * normalized; // (pct / 100)^2.0
}
The fan-out:
applyVolume()emitsvolumeApplied(percent, linear)and knows no manager; main.cpp connects it tomediaManager.setVolume(linear),phoneMirrorManager.setVolume(linear),esp32VolumeManager.send_volume_update(percent)(desktop builds).
main.cpp calls applyVolume(settingsManager.currentVolume) once at startup after every manager
exists, which is what replaces the constructor's 0.5 placeholder.
Volume State Inside MediaManager
The user's volume (m_volume), the mute flag and a ducking factor
are kept separately; one helper (applyOutputVolume()) computes
what the QAudioOutput actually gets: 0 while muted, otherwise
volume × duck. getVolume() reports the user's volume, never the ducked or muted
output level.
// src/managers/mediamanager.cpp
void MediaManager::setVolume(float volume)
{
volume = qBound(0.0f, volume, 1.0f);
m_volume = volume;
applyOutputVolume();
emit volumeChanged(volume);
}
void MediaManager::applyOutputVolume()
{
m_audioOutput->setVolume(m_isMuted ? 0.0f : m_volume * m_duckCurrent);
}
Ducking
setDucking(factor) (commit 5834d9d) temporarily attenuates the output
while another source needs to be heard: today the mirrored phone's audio (a navigation prompt, a call). The
PhoneMirrorManager measures each PCM chunk's peak, sets
audioPlaying with a 1.5 s release hold, and emits duckingChanged(factor) (the
scrcpyAudioDuckLevel setting, default 0.1 = -20 dB, or 1.0 when the phone is silent or
scrcpyAudioDuckEnabled is off); main.cpp connects that signal to mediaManager.setDucking.
The factor is ramped with a QVariantAnimation (OutCubic): 80 ms attack so a
prompt is not stepped on, 500 ms release so music does not jump back up between sentences. Volume changes
and mute keep working while ducked.
Mute
MediaManager does not own mute. The rules (toggle, the 150 ms debounce, "raising the volume unmutes",
"unmute at 0 restores the startup volume") live in
VolumeController; main.cpp
connects VolumeController::mutedChanged to setMuted(bool), which only stores the flag
and calls applyOutputVolume(). is_muted() reports that mirrored flag. Because mute is
applied by applyOutputVolume() rather than by overwriting the volume, a volume change while muted
updates m_volume silently and unmuting plays at exactly the level every slider shows.
play_file(), toggle_play() and recovery re-apply setVolume(0) on the
output after setSource(), which can reset the output level.
Playback State Resume
The current song, position (ms) and playlist name are saved through
settingsManager.save_playback_state(). To avoid a disk write on every rapid track change, saves go
through a 1 s single-shot debounce timer (m_saveStateTimer): next / previous
restart it, pause and play-toggle restart it, only the last call wins.
// src/managers/mediamanager.cpp — constructor
m_saveStateTimer.setInterval(1000);
m_saveStateTimer.setSingleShot(true);
connect(&m_saveStateTimer, &QTimer::timeout, this, &MediaManager::_save_playback_state_now);
At shutdown the state is flushed immediately: Main.qml calls
mediaManager._save_playback_state() on close, main.cpp's aboutToQuit
handler calls it again before settingsManager.flushPendingSave(), and the destructor flushes a
pending debounced save. _save_playback_state() is a Q_INVOKABLE alias of
_save_playback_state_now() that saves immediately.
_restore_playback_state() runs 500 ms after the settings manager is wired: it restores
shuffle if persisted, selects the last playlist, checks the song is still in it, sets the source
without playing, emits currentMediaChanged and the metadata so the UI shows the track,
seeks to the saved position after 100 ms, and only if the autoPlayOnStartup setting is on
schedules playback 500 ms after restoration (the delay lets the audio system initialize and avoids crackling).
Neighbor Pre-caching
80 ms after a track change (m_precacheTimer, single-shot),
_precache_neighbors_start() warms the metadata and cover caches for the tracks at offsets
-3 to +3 around the current index, so the carousel's side cards and the next skip are instant.
The index uses Euclidean modulo, skips the current track and de-duplicates: commit aa2dacb fixed a
crash when spamming "next" on a two-track playlist, where the truncating % produced a negative
index (-1 % 2 == -1) and QList::at() asserted; a playlist shorter than the window now
queues each neighbor once instead of six redundant jobs. It also bails on the "no current track"
sentinel (-1) or an index that outlived a playlist shrink.
The desktop build uses a three-phase, race-free model (since commit 1232538): phase 1 on a worker reads tags (_read_tags()) for neighbors not already in the map or the tag store; phase 2 on the GUI thread inserts them (and writes them to the store), then hands the neighbors still lacking a cover to a second worker that only writes cover files (_extract_art_for()); phase 3 on the GUI thread records the URLs. The GUI thread is therefore the sole owner of the metadata and album-art maps, and the workers never touch them.
- Desktop:
QtConcurrentworkers, results delivered throughQFutureWatcher. The earlier version mutated the hashes from the worker while list delegates were reading them. - Mobile:
_precache_tracks()runs synchronously on the GUI thread, using the platform tag reader.
Error Recovery
MediaManager recovers automatically from playback failures:
- EndOfMedia - calls
next_track() - StalledMedia / InvalidMedia and player errors - call
_attempt_playback_recovery(), but only while something is playing; a stopped player keeps reporting its last failure, so those events are ignored (commits2884c65,1e5f585) - LoadedMedia / BufferedMedia - the file decodes, which is what "recovered" actually means: any pending seek from
_reseat_source()is applied, the attempt counters are reset and, if there were attempts, "Playback recovered" is logged
// src/managers/mediamanager.cpp
static constexpr int kMaxRecoveryAttempts = 2; // per file, then skip it
static constexpr int kMaxConsecutiveBadTracks = 10; // then stop instead of looping the library
void MediaManager::_attempt_playback_recovery(bool force)
{
if (!m_isPlaying && !force)
return;
const QString currentFile = get_current_file();
// ... reset the attempt counter when the file changed ...
if (++m_recoveryAttempts > kMaxRecoveryAttempts) {
_skip_bad_track();
return;
}
const qint64 position = m_player->position();
// Clear then re-set the source: Qt does not reload (or re-report an
// error for) a source identical to the current one.
_reseat_source(filePath, position); // seek deferred to LoadedMedia
if (wasPlaying)
m_player->play();
if (m_isMuted)
m_audioOutput->setVolume(0.0f);
emit playStateChanged(m_isPlaying);
}
Recovery is capped (commit 2884c65, seen on the Pi with 14 tag-only MP3 stubs). After
2 attempts on the same file the track is skipped (_skip_bad_track() →
next_track()): a tag-only stub or a truncated MP3 never becomes playable. A missing file skips
immediately and counts toward the same limit. After 10 unplayable tracks in a row (or the whole playlist, if shorter) playback
stops, the source is cleared so no further status events arrive, and playStateChanged(false) is
emitted instead of looping the library. toggle_play() passes force so a user pressing
play on a bad state still gets a recovery attempt. The limits are the constants
kMaxRecoveryAttempts / kMaxConsecutiveBadTracks in mediamanager.cpp.
Reseating the source
_reseat_source(file, ms) reloads a track at a position, which gives the player a fresh audio
sink. It clears the source first (Qt does not reload a source identical to the current one), then sets it
again. The seek is deferred: a setPosition() issued straight after
setSource() is dropped and playback restarts at 0, which also affected recovery before this
change. The pending (file, ms) (m_pendingSeekFile / m_pendingSeekMs) is stored before the second setSource(), because
LoadedMedia can arrive during that call, and _handle_media_status() applies it on
LoadedMedia / BufferedMedia only if the player's source is still that file. A
position of 0 sets no pending seek.
Both recovery and the long-pause resume in toggle_play() use it. The long-pause path exists
because on the Orange Pi, resuming after a long pause sometimes produced no sound while the player reported
PlayingState; only skipping to another track brought audio back. pause() and
toggle_play() record when playback paused (m_pausedSince); a resume after 30 s or more reloads the track instead of calling a plain
play().
Signals
| Signal | Type | Description |
|---|---|---|
playbackStateChanged | int | Raw QMediaPlayer playback state, forwarded on every change: 0 stopped, 1 playing, 2 paused |
playStateChanged | bool | True when playing, False when paused/stopped; also drives the position timer (whose positionChanged reports also drive the waveform analyzer, through main.cpp) |
currentMediaChanged | str | Filename of the now-playing (or restored) track (nowPlaying follows it, and through its art the Album Art Capture colours) |
mediaListChanged | list | New ordered file list after a playlist selection, shuffle toggle, strip or scan |
metadataChanged | str, str, str | Title, artist, album for the current track |
durationChanged | int | Track duration in milliseconds (from QMediaPlayer) |
durationFormatChanged | str | Duration formatted as "M:SS" (emitted by get_formatted_duration) |
positionChanged | int | Playback position in milliseconds (QMediaPlayer plus the 100 ms timer while playing) |
volumeChanged | float | User volume level (0.0-1.0, linear) |
shuffleStateChanged | bool | Shuffle toggled on/off (or restored at startup) |
albumColorsExtracted | str | Theme JSON; only used when no SettingsManager is wired (normally the colors go through settingsManager.albumArtColors) |
playlistsChanged | (none) | Playlist list rebuilt by a scan |
currentPlaylistChanged | str | Active playlist name changed |
scanProgress | str | Terminal-style feedback during library scan and filename stripping |
totalDurationChanged | str | Formatted "H:MM:SS" total duration of the selected playlist |
albumCountChanged | int | Number of unique albums in the selected playlist |
artistCountChanged | int | Number of unique artists in the selected playlist |
songDeleted | str | A file was removed via delete_song() |
audioOutputsChanged | (none) | The output list or the output in use changed (notify of audioOutputs / currentAudioOutput) |
Slots
| Slot | Signature | Description |
|---|---|---|
play_file | (str) | Play a specific file by filename |
next_track / previous_track | () | Skip within the current order (wraps) |
toggle_play | () | Toggle play/pause with bad-state recovery |
pause | () | Pause playback |
set_position | (int) | Seek to position in ms |
get_position / get_duration | () -> float | Position / duration in ms |
is_playing / is_paused / is_muted / is_shuffled | () -> bool | State flags |
setMuted | (bool) | Follow VolumeController::mutedChanged (wired in src/appcore.cpp); call volumeController.toggleMute() instead from QML and handlers |
toggle_shuffle | () | Toggle shuffle, persist via SettingsManager |
setVolume | (float) | Set the user volume (0.0-1.0 linear); call volumeController.applyVolume(percent) instead from QML and handlers |
getVolume | () -> float | User's volume (ignores mute and ducking) |
extract_colors_from_image | (str) | Extract the Album Art Capture palette from an image path or file:// URL, used for mirrored-phone artwork |
setDucking | (float) | Temporarily attenuate output (1.0 = none); ramped 80 ms in / 500 ms out |
get_current_file | () -> str | Current filename (initializes the order if needed, never plays) |
get_next_track_name / get_previous_track_name | () -> str | Peek at neighbors |
get_next_track_album_art / get_previous_track_album_art / get_neighbor_album_arts | () -> str / list | Neighbor cover URLs; the list form returns [prevArt, nextArt] |
get_media_files | (bool emitSignal=true) -> list | Flat listing of the current media directory (emits mediaListChanged by default); callable with or without the bool from QML |
get_album_art | (str) -> str | Cover file:// URL for a filename, or "" |
get_band / get_album | (str) -> str | Artist / album from metadata |
get_formatted_duration | (str) -> str | "M:SS" duration string (also emits durationFormatChanged) |
get_display_name | (str) -> str | Cleaned display name |
get_total_duration / get_album_count / get_artist_count | () -> str / int | Library statistics for the selected playlist (cached) |
invalidate_stats_cache | () | Force recalculation of library statistics |
sort_media_files | (str column, bool ascending) -> list | Current list sorted by "title", "album" or "artist" |
extract_colors_from_album_art | (str) | Run the k-means theme extraction for a file's cover |
scan_library | (bool resetDisplayNames=true) | Rescan the library root into playlists (emits scanProgress, playlistsChanged); callable with or without the bool from QML |
set_library_root | (str) | Set and scan a new root, select its first playlist (connected to mediaFolderChanged) |
select_playlist | (str) | Switch the active playlist ("All Music" spans every folder) |
get_playlist_names / get_current_playlist_name / get_current_song_list / get_movable_playlist_names | () -> list / str | Playlist queries |
create_playlist / delete_playlist | (str) -> bool | Create or remove a playlist folder |
move_song_to_playlist | (str, str) -> bool | Move a file into another playlist folder |
delete_song | (str) -> bool | Delete a file from disk (emits songDeleted) |
get_song_playlist_name | (str) -> str | Playlist folder a file lives in |
play_file_at_path | (str) -> bool | Play a file by absolute path (selects its playlist) |
get_full_file_path / get_song_file_path | (str) -> str | Absolute path for a filename (the former returns "" for a missing file; used by the audio analyzer) |
get_default_media_dir / get_media_folder_name | () -> str | Built-in default directory / name of the current media directory |
browse_music_folders | (path) -> map | Folder picker for remote UIs (src/util/folderbrowser.{h,cpp}): "" = the browse roots (~/Music, not the home folder, /media, /mnt, /run/media, /storage, the current music folder unless it is /); inside them the sub-folders and audio files by name; anything whose canonical path is outside them an error |
pick_browser_library | () -> bool | Browser demo only: opens the browser's folder picker (call from a tap) and makes the folder picked the library for the visit, then returns true (src/platform/wasm/weblibrary.{h,cpp}, Building > WebAssembly). Everywhere else does nothing and returns false, and the QML "Browse" buttons open their FolderDialog instead. Scope files |
strip_filenames | () | Re-clean every display name, reporting via scanProgress |
flush_metadata_store | () | Write the tag store now if dirty (called at shutdown) |
_save_playback_state | () | Save song / position / playlist immediately; called from Main.qml on close |
connect_settings_manager | (QObject) | No-op kept for Main.qml; the wiring (library root, restore, theme) is done by setSettingsManager() in main.cpp |
_clear_temp_files | () | Delete every file in the temp dir; retained for compatibility, no longer called |
Threading
MediaManager lives on the GUI thread and all of its slots are called there. Work that leaves the thread:
- Neighbor pre-cache - QtConcurrent workers, results marshalled back through
QFutureWatcher(desktop; synchronous on mobile); the caches are written only on the GUI thread, see above - Colour extraction - QtConcurrent +
QFutureWatcher<QString>; only the newest job is applied - Library scanning and tag reads on demand stay synchronous on the GUI thread; the tag store makes a warm scan cheap
The QMediaPlayer backend decodes on its own threads and reports back through the signals listed
under QMediaPlayer Integration.
Design Decisions
| Choice | Why (from code comments and commit history) |
|---|---|
| QMediaPlayer + QAudioOutput rather than a custom decoder | Qt Multimedia provides the playback interface across targets; the selected backend and supported codecs depend on the Qt build and platform. One manager class serves the build targets through the same signals. The manager only adds what Qt lacks: the position timer as a safety net for backends whose positionChanged is coarse, and the recovery logic for files Qt cannot decode. |
| Folders as playlists | The music folder's sub-folders are the only source of truth: there is no playlist database to keep in sync, every edit (create_playlist, move_song_to_playlist, delete_*) is a plain filesystem operation followed by a rescan, and "All Music" is the union. The scanner keeps empty folders on purpose "so user-created playlists survive a rescan (e.g. after moving the last song out)". |
Persistent tag store on disk (metadata_cache.json) | Reading tags is file I/O per track; on the Pi's SD card, scrolling a list or opening the media page opened every file with TagLib on every run. Tags rarely change, and size + mtime is a cheap, sufficient validity check (commit 1232538). |
| Content-addressed covers kept across runs | The cover of an album is the same bytes every time; wiping the temp dir at start and quit made every cover a fresh tag read + JPEG write on each boot. Hashing the album id makes the file name derivable without opening the audio file, and the startup prune bounds the directory once the in-memory LRU can no longer see earlier runs (43432c3). |
| Colour extraction off the GUI thread | k-means over a 50×50 cover takes ~0.3 s on a desktop and closer to a second on the Pi; doing it in the currentMediaChanged handler froze the track-change animation. A job counter makes a fast skip through tracks apply only the last theme, and the 2 s dedupe removes the startup double-fire (restore + the 1 s timer). |
| Fixed k-means seed | The same cover must produce the same theme every time it is played. The seed lives in a local std::mt19937, so it does not affect the shuffle order. |
Caches populated before currentMediaChanged | QML bindings on the now-playing track run during the render phase; pre-filling the metadata and cover caches moves the disk I/O out of binding evaluation so the carousel animation starts without blocking the scene graph. |
| GUI thread as sole owner of the caches | The first QtConcurrent pre-cache mutated QHashes from the worker while list delegates were reading them. Splitting the work into "read files on the worker, insert on the GUI thread" keeps the speed without a mutex around every lookup. |
| Quadratic volume curve in one place | Per the VolumeController header, (percent/100)² "gives a more natural perceptual response than linear at low volumes", and "every audio output in the app must use this same curve or they will drift out of sync when the user moves the slider". Before VolumeController existed the curve was hard-coded at several call sites and the outputs drifted out of sync whenever one site changed; centralizing math and dispatch means changing the curve is a one-line edit in toLinearStatic(). |
| Volume, mute and ducking as separate state | Before ducking, mute read the output level back as "the volume to restore", so a ducked or muted output could be persisted as the user's volume. Computing the output from three independent values in one function removed that class of bug (5834d9d). |
| Recovery cap, skip, then stop | Qt neither reloads nor re-reports an error for a source identical to the current one, so the old "re-set the same URL" recovery left a broken file silently stuck at 0:00 with the pause icon, logging "recovery successful" unconditionally. A library of tag-only stubs must be skipped, not retried forever, and if every track is bad the player must stop rather than cycle the library (2884c65). "Recovered" is only claimed when the player actually reaches LoadedMedia. |
| Debounced state and store writes | Spamming next/previous or scrolling a list must not turn into a disk write per event on an SD card; a 1 s (playback state) and 2 s (tag store) single-shot timer coalesce bursts, and shutdown flushes both explicitly. |
| Position timer only while playing | A 100 ms timer that runs forever keeps an idle head unit waking ten times a second for nothing; tying it to playStateChanged costs nothing while playing (ee5a23e). |
How to Modify / Extend
Adding a New Audio Format
- Add the extension to
s_audioExtensionsand the file-locals_audioExtslist in mediamanager.cpp - Tags already come from
TagLib::FileRef; add a picture reader to_extract_art_for()if the container embeds art - Update the format table above
Adding a New Playback Feature
- Add the signal, state variable and slot to
MediaManager(Q_INVOKABLEor a public slot, plus the signal) in mediamanager.h - Emit the signal when state changes; grep the QML for the new symbol and confirm the manager defines it
- If the feature should persist, add a corresponding setting to
SettingsManager - Update Signals & Slots Reference and this page