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.

All Platforms

Platforms

ImplementationPlatformsTag / cover reader
src/managers/mediamanager.{h,cpp}AllTagLib 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":

FormatTagLib (desktop)
MP3Title / artist / album / length via FileRef; cover from the first ID3v2 APIC frame
M4A / MP4 / AACTags via FileRef; cover from the first covr item (TagLib::MP4::File)
FLACTags via FileRef; cover from TagLib::FLAC::File::pictureList()
Ogg VorbisTags via FileRef; cover from the Xiph comment's pictureList() (TagLib::Ogg::Vorbis::File)
OpusTags 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
WAVTags 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 name
  • mediaStatusChanged - routed to _handle_media_status(): EndOfMedia advances to the next track, LoadedMedia / BufferedMedia apply a pending seek left by _reseat_source() (if the source still matches) and reset the recovery counters, StalledMedia / InvalidMedia trigger recovery
  • errorOccurred - also triggers recovery (ignored while nothing is playing)
  • playbackStateChanged - forwarded as playbackStateChanged(int) with the raw QMediaPlayer::PlaybackState value (0 stopped, 1 playing, 2 paused); the manager's own playStateChanged(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:

  1. 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 resetDisplayNames is true; internal callers pass false, while the settings-page "Rescan" button and the Android scan that runs once the READ_MEDIA_AUDIO permission is granted use true)
  2. Audio files directly in the root become the "Unsorted" playlist
  3. 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
  4. 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, and m_allMusicFilePaths remembers which directory each name lives in
  5. Playlist names are ordered "All Music", "Unsorted", then the rest case-insensitively
  6. _auto_strip_display_names() cleans the file names that do not yet have a display name
  7. If a playlist was active before the rescan it is re-selected and the playing index restored, then playlistsChanged is 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.

Path Safety
_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 the aboutToQuit handler in main.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

  1. Bump the album's access count (m_accessCount)
  2. Return the URL from the in-memory map m_albumArtCache (albumId → URL) if present
  3. 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
  4. _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
  5. 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
  6. 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

  1. extract_colors_from_album_art() resolves the cover through get_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
  2. The image work runs on a worker: QtConcurrent::run() with a QFutureWatcher<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
  3. _extract_album_colors() converts the cover to RGBA8888 and scales it to 50×50 pixels with a smooth transformation (2500 pixels)
  4. _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
  5. Centroids are sorted by vibrancy (HSV saturation × value, most vibrant first)
  6. The image is classed dark when the average luminance (0.299R + 0.587G + 0.114B) of the first 1000 pixels is below 0.5
  7. _generate_theme_from_colors() builds the theme JSON and _apply_album_colors() (GUI thread) hands it to settingsManager.set_album_art_colors(json). That updates the albumArtColors property, and Main.qml feeds it to Style.updateAlbumArtTheme(). The albumColorsExtracted signal 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 that
  • accent - 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 base
  • sliders.volume, sliders.media, sliders.settings - accents 1-3
  • bottombar.* - 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% brightness
  • obd.boxBackground, obd.barColor, obd.labelColor, obd.valueColor - gauge colors against baseAlt

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:

  1. 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]|_)
  2. 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
  3. 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
  4. setSource() + play(), set the playing flags, re-apply mute (setting a source can reset the output volume), emit playStateChanged(true)
  5. Pre-populate the metadata and cover caches and emit metadataChanged before currentMediaChanged, so the QML bindings that fire on the latter get instant cache hits instead of doing tag I/O during the render phase
  6. Emit durationFormatChanged via get_formatted_duration(), then start the 80 ms pre-cache timer

Track Navigation

SlotBehavior
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)

SlotReturns
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::shuffle with a std::random_device-seeded mt19937 / 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 shuffleStateChanged and mediaListChanged(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_currentIndex
  • m_libraryRoot - the resolved mediaFolder

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

SlotWhat 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() emits volumeApplied(percent, linear) and knows no manager; main.cpp connects it to mediaManager.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: QtConcurrent workers, results delivered through QFutureWatcher. 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 (commits 2884c65, 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

SignalTypeDescription
playbackStateChangedintRaw QMediaPlayer playback state, forwarded on every change: 0 stopped, 1 playing, 2 paused
playStateChangedboolTrue when playing, False when paused/stopped; also drives the position timer (whose positionChanged reports also drive the waveform analyzer, through main.cpp)
currentMediaChangedstrFilename of the now-playing (or restored) track (nowPlaying follows it, and through its art the Album Art Capture colours)
mediaListChangedlistNew ordered file list after a playlist selection, shuffle toggle, strip or scan
metadataChangedstr, str, strTitle, artist, album for the current track
durationChangedintTrack duration in milliseconds (from QMediaPlayer)
durationFormatChangedstrDuration formatted as "M:SS" (emitted by get_formatted_duration)
positionChangedintPlayback position in milliseconds (QMediaPlayer plus the 100 ms timer while playing)
volumeChangedfloatUser volume level (0.0-1.0, linear)
shuffleStateChangedboolShuffle toggled on/off (or restored at startup)
albumColorsExtractedstrTheme JSON; only used when no SettingsManager is wired (normally the colors go through settingsManager.albumArtColors)
playlistsChanged(none)Playlist list rebuilt by a scan
currentPlaylistChangedstrActive playlist name changed
scanProgressstrTerminal-style feedback during library scan and filename stripping
totalDurationChangedstrFormatted "H:MM:SS" total duration of the selected playlist
albumCountChangedintNumber of unique albums in the selected playlist
artistCountChangedintNumber of unique artists in the selected playlist
songDeletedstrA file was removed via delete_song()
audioOutputsChanged(none)The output list or the output in use changed (notify of audioOutputs / currentAudioOutput)

Slots

SlotSignatureDescription
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() -> floatPosition / duration in ms
is_playing / is_paused / is_muted / is_shuffled() -> boolState 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() -> floatUser'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() -> strCurrent filename (initializes the order if needed, never plays)
get_next_track_name / get_previous_track_name() -> strPeek at neighbors
get_next_track_album_art / get_previous_track_album_art / get_neighbor_album_arts() -> str / listNeighbor cover URLs; the list form returns [prevArt, nextArt]
get_media_files(bool emitSignal=true) -> listFlat listing of the current media directory (emits mediaListChanged by default); callable with or without the bool from QML
get_album_art(str) -> strCover file:// URL for a filename, or ""
get_band / get_album(str) -> strArtist / album from metadata
get_formatted_duration(str) -> str"M:SS" duration string (also emits durationFormatChanged)
get_display_name(str) -> strCleaned display name
get_total_duration / get_album_count / get_artist_count() -> str / intLibrary statistics for the selected playlist (cached)
invalidate_stats_cache()Force recalculation of library statistics
sort_media_files(str column, bool ascending) -> listCurrent 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 / strPlaylist queries
create_playlist / delete_playlist(str) -> boolCreate or remove a playlist folder
move_song_to_playlist(str, str) -> boolMove a file into another playlist folder
delete_song(str) -> boolDelete a file from disk (emits songDeleted)
get_song_playlist_name(str) -> strPlaylist folder a file lives in
play_file_at_path(str) -> boolPlay a file by absolute path (selects its playlist)
get_full_file_path / get_song_file_path(str) -> strAbsolute path for a filename (the former returns "" for a missing file; used by the audio analyzer)
get_default_media_dir / get_media_folder_name() -> strBuilt-in default directory / name of the current media directory
browse_music_folders(path) -> mapFolder 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() -> boolBrowser 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

ChoiceWhy (from code comments and commit history)
QMediaPlayer + QAudioOutput rather than a custom decoderQt 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 playlistsThe 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 runsThe 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 threadk-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 seedThe 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 currentMediaChangedQML 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 cachesThe 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 placePer 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 stateBefore 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 stopQt 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 writesSpamming 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 playingA 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

  1. Add the extension to s_audioExtensions and the file-local s_audioExts list in mediamanager.cpp
  2. Tags already come from TagLib::FileRef; add a picture reader to _extract_art_for() if the container embeds art
  3. Update the format table above

Adding a New Playback Feature

  1. Add the signal, state variable and slot to MediaManager (Q_INVOKABLE or a public slot, plus the signal) in mediamanager.h
  2. Emit the signal when state changes; grep the QML for the new symbol and confirm the manager defines it
  3. If the feature should persist, add a corresponding setting to SettingsManager
  4. Update Signals & Slots Reference and this page