| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | /* | ||
| 2 | * This file is part of the Falcon Player (FPP) and is Copyright (C) | ||
| 3 | * 2013-2022 by the Falcon Player Developers. | ||
| 4 | * | ||
| 5 | * The Falcon Player (FPP) is free software, and is covered under | ||
| 6 | * multiple Open Source licenses. Please see the included 'LICENSES' | ||
| 7 | * file for descriptions of what files are covered by each license. | ||
| 8 | * | ||
| 9 | * This source file is covered under the LGPL v2.1 as described in the | ||
| 10 | * included LICENSE.LGPL file. | ||
| 11 | */ | ||
| 12 | |||
| 13 | #include "fpp-pch.h" | ||
| 14 | |||
| 15 | #include "fpp-json.h" | ||
| 16 | |||
| 17 | #include "Warnings.h" // WarningHolder -- needed directly for NOPCH builds | ||
| 18 | #include "Timers.h" // periodic audio-device presence monitor | ||
| 19 | |||
| 20 | #include <pthread.h> | ||
| 21 | #include <stdlib.h> | ||
| 22 | #include <string.h> | ||
| 23 | |||
| 24 | #include <cstdio> // popen/pclose for the amixer control probe | ||
| 25 | #include <algorithm> | ||
| 26 | #include <atomic> | ||
| 27 | #include <condition_variable> | ||
| 28 | #include <map> | ||
| 29 | #include <mutex> | ||
| 30 | #include <set> | ||
| 31 | #include <sstream> | ||
| 32 | #include <string> | ||
| 33 | #include <thread> | ||
| 34 | #include <vector> | ||
| 35 | |||
| 36 | #include "MultiSync.h" | ||
| 37 | #include "common.h" | ||
| 38 | #include "log.h" | ||
| 39 | #include "mediaoutput.h" | ||
| 40 | #include "commands/Commands.h" | ||
| 41 | |||
| 42 | #include "Plugins.h" | ||
| 43 | #include "Sequence.h" | ||
| 44 | #include "AES67Manager.h" | ||
| 45 | #include "GStreamerOut.h" | ||
| 46 | #include "OpusRTPManager.h" | ||
| 47 | #include "PipeWireOutputStream.h" | ||
| 48 | #include "StreamSlotManager.h" | ||
| 49 | #include "VideoOutputManager.h" | ||
| 50 | #include "mediadetails.h" | ||
| 51 | #include "settings.h" | ||
| 52 | #include "../config.h" | ||
| 53 | #ifdef PLATFORM_OSX | ||
| 54 | #include "../MacOSApp.h" | ||
| 55 | #endif | ||
| 56 | |||
| 57 | ///////////////////////////////////////////////////////////////////////////// | ||
| 58 | MediaOutputBase* mediaOutput = 0; | ||
| 59 | float masterMediaPosition = 0.0; | ||
| 60 | std::mutex mediaOutputLock; | ||
| 61 | std::mutex drmMasterLock; | ||
| 62 | |||
| 63 | static bool firstOutCreate = true; | ||
| 64 | |||
| 65 | // AudioOutput is persisted as a stable ALSA card ID string (e.g. "S3", | ||
| 66 | // "bcm2835ALSA"); setupAudio() in boot/FPPINIT_Audio.cpp migrates legacy numeric | ||
| 67 | // values on boot. Resolve it to the current card number for amixer. Read | ||
| 68 | // defensively: an all-numeric value is treated as a legacy card index, otherwise | ||
| 69 | // it is matched against /proc/asound/cards by ID. Mirrors getAlsaCardNumForId() | ||
| 70 | // in boot/FPPINIT_Audio.cpp (fppd and fppinit are separate binaries and cannot | ||
| 71 | // share the static helper). | ||
| 72 | 6445 | std::string GetEffectiveAudioOutput() { | |
| 73 |
1/2✓ Branch 0 taken 6445 times.
✗ Branch 1 not taken.
|
6445 | std::string selected = getSetting("AudioOutput"); |
| 74 |
1/2✓ Branch 0 taken 6445 times.
✗ Branch 1 not taken.
|
6445 | TrimWhiteSpace(selected); |
| 75 |
1/2✓ Branch 0 taken 6445 times.
✗ Branch 1 not taken.
|
6445 | Json::Value eff; |
| 76 | 19335 | if (FileExists("/run/fppd/audio-output.json") && | |
| 77 |
4/20✓ Branch 0 taken 6445 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 6445 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✓ Branch 13 taken 6445 times.
✗ Branch 14 not taken.
✓ Branch 15 taken 6445 times.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
✗ Branch 18 not taken.
✗ Branch 19 not taken.
|
12890 | LoadJsonFromFile("/run/fppd/audio-output.json", eff, JsonRoot::Object) && |
| 78 |
4/20✗ Branch 0 not taken.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 6445 times.
✗ Branch 8 not taken.
✓ Branch 9 taken 6445 times.
✗ Branch 10 not taken.
✓ Branch 11 taken 6445 times.
✗ Branch 12 not taken.
✓ Branch 13 taken 6445 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
✗ Branch 18 not taken.
✗ Branch 19 not taken.
|
6445 | eff.get("selected", "").asString() == selected) { |
| 79 | ✗ | std::string effective = eff.get("effective", "").asString(); | |
| 80 | ✗ | if (!effective.empty()) { | |
| 81 | ✗ | return effective; | |
| 82 | } | ||
| 83 | ✗ | } | |
| 84 | 6445 | return selected; | |
| 85 | 6445 | } | |
| 86 | |||
| 87 | 3115 | static int resolveAudioOutputCardNum() { | |
| 88 |
1/2✓ Branch 0 taken 3115 times.
✗ Branch 1 not taken.
|
3115 | std::string id = GetEffectiveAudioOutput(); |
| 89 |
1/2✓ Branch 0 taken 3115 times.
✗ Branch 1 not taken.
|
3115 | TrimWhiteSpace(id); |
| 90 |
2/2✓ Branch 0 taken 3113 times.
✓ Branch 1 taken 2 times.
|
3115 | if (id.empty()) { |
| 91 | 3113 | return 0; | |
| 92 | } | ||
| 93 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
|
2 | if (id.find_first_not_of("0123456789") == std::string::npos) { |
| 94 | try { | ||
| 95 | ✗ | return std::stoi(id); | |
| 96 | ✗ | } catch (...) { | |
| 97 | ✗ | return 0; | |
| 98 | ✗ | } | |
| 99 | } | ||
| 100 |
3/6✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
|
2 | std::istringstream iss(GetFileContents("/proc/asound/cards")); |
| 101 | 2 | std::string line; | |
| 102 |
4/6✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 2 times.
|
4 | while (std::getline(iss, line)) { |
| 103 | 2 | auto b = line.find('['); | |
| 104 | 2 | auto e = line.find(']'); | |
| 105 |
4/6✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
|
2 | if (b != std::string::npos && e != std::string::npos && e > b) { |
| 106 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | std::string cid = line.substr(b + 1, e - b - 1); |
| 107 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | TrimWhiteSpace(cid); |
| 108 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (cid == id) { |
| 109 | ✗ | std::string num = line.substr(0, b); | |
| 110 | ✗ | TrimWhiteSpace(num); | |
| 111 | try { | ||
| 112 | ✗ | return std::stoi(num); | |
| 113 | ✗ | } catch (...) {} | |
| 114 | ✗ | } | |
| 115 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | } |
| 116 | } | ||
| 117 | 2 | return 0; // selected card not present -> default to card 0 | |
| 118 | 3115 | } | |
| 119 | |||
| 120 | #ifndef PLATFORM_OSX | ||
| 121 | // Warning ID for "configured audio output card is missing". Kept distinct so | ||
| 122 | // the warning can be added/removed idempotently as the card comes and goes. | ||
| 123 | static constexpr int AUDIO_CARD_MISSING_WARNING_ID = 58; | ||
| 124 | |||
| 125 | // Is the given stable ALSA card ID currently listed in /proc/asound/cards? | ||
| 126 | 7 | static bool alsaCardIdPresent(const std::string& id) { | |
| 127 |
3/6✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 7 times.
✗ Branch 5 not taken.
|
7 | std::istringstream iss(GetFileContents("/proc/asound/cards")); |
| 128 | 7 | std::string line; | |
| 129 |
4/6✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 15 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 9 times.
✓ Branch 5 taken 6 times.
|
15 | while (std::getline(iss, line)) { |
| 130 | 9 | auto b = line.find('['); | |
| 131 | 9 | auto e = line.find(']'); | |
| 132 |
4/6✓ Branch 0 taken 5 times.
✓ Branch 1 taken 4 times.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 5 times.
✗ Branch 5 not taken.
|
9 | if (b != std::string::npos && e != std::string::npos && e > b) { |
| 133 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | std::string cid = line.substr(b + 1, e - b - 1); |
| 134 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | TrimWhiteSpace(cid); |
| 135 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 4 times.
|
5 | if (cid == id) { |
| 136 | 1 | return true; | |
| 137 | } | ||
| 138 |
2/2✓ Branch 0 taken 4 times.
✓ Branch 1 taken 1 times.
|
5 | } |
| 139 | } | ||
| 140 | 6 | return false; | |
| 141 | 7 | } | |
| 142 | |||
| 143 | // Periodically verify the configured audio output card is still present. USB | ||
| 144 | // sound cards can be unplugged (or drop off the bus) while fppd is running; when | ||
| 145 | // that happens PipeWire/ALSA fall back to the default device and audio silently | ||
| 146 | // plays somewhere unexpected -- and, historically, PipeWire's ALSA layer would | ||
| 147 | // also spam syslog retrying the dead device. Surface a UI warning so the user | ||
| 148 | // knows, and clear it automatically once the card returns. | ||
| 149 | // | ||
| 150 | // Only named cards (stable IDs like "S3", "ICUSBAUDIO7D") are monitored: an | ||
| 151 | // empty or all-numeric setting means the default/legacy card index, which we | ||
| 152 | // can't meaningfully test for presence by ID. A short debounce avoids flapping | ||
| 153 | // on transient enumeration gaps. | ||
| 154 | 648 | static void CheckAudioOutputCardPresence() { | |
| 155 | static int missCount = 0; | ||
| 156 | static bool warningActive = false; | ||
| 157 | // RemoveWarning() matches the exact text, and the text names the card, so | ||
| 158 | // keep what was added rather than retyping it at the removal sites. | ||
| 159 |
3/4✓ Branch 0 taken 216 times.
✓ Branch 1 taken 432 times.
✓ Branch 2 taken 216 times.
✗ Branch 3 not taken.
|
648 | static std::string warningText; |
| 160 | |||
| 161 |
1/2✓ Branch 0 taken 648 times.
✗ Branch 1 not taken.
|
648 | std::string id = getSetting("AudioOutput"); |
| 162 |
1/2✓ Branch 0 taken 648 times.
✗ Branch 1 not taken.
|
648 | TrimWhiteSpace(id); |
| 163 |
5/6✓ Branch 0 taken 7 times.
✓ Branch 1 taken 641 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 7 times.
✓ Branch 4 taken 641 times.
✓ Branch 5 taken 7 times.
|
648 | if (id.empty() || id.find_first_not_of("0123456789") == std::string::npos) { |
| 164 | // Nothing monitorable by ID; make sure any stale warning is cleared. | ||
| 165 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 640 times.
|
641 | if (warningActive) { |
| 166 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
1 | WarningHolder::RemoveWarning(AUDIO_CARD_MISSING_WARNING_ID, warningText); |
| 167 | 1 | warningActive = false; | |
| 168 | } | ||
| 169 | 641 | missCount = 0; | |
| 170 | 641 | return; | |
| 171 | } | ||
| 172 | |||
| 173 |
3/4✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 6 times.
|
7 | if (alsaCardIdPresent(id)) { |
| 174 | 1 | missCount = 0; | |
| 175 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (warningActive) { |
| 176 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, "Audio output card '%s' is present again; clearing warning.\n", id.c_str()); |
| 177 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
1 | WarningHolder::RemoveWarning(AUDIO_CARD_MISSING_WARNING_ID, warningText); |
| 178 | 1 | warningActive = false; | |
| 179 | } | ||
| 180 | 1 | return; | |
| 181 | } | ||
| 182 | |||
| 183 | // Absent. Require two consecutive misses (~20s) before warning to ride out | ||
| 184 | // brief re-enumeration during resume/hotplug. | ||
| 185 |
2/2✓ Branch 0 taken 4 times.
✓ Branch 1 taken 2 times.
|
6 | if (missCount < 2) { |
| 186 | 4 | ++missCount; | |
| 187 | 4 | return; | |
| 188 | } | ||
| 189 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | if (!warningActive) { |
| 190 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | LogWarn(VB_MEDIAOUT, "Configured audio output card '%s' is no longer present.\n", id.c_str()); |
| 191 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
4 | warningText = "Audio output card '" + id + "' is not present - it may have been unplugged. " |
| 192 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | "Audio will fall back to the default output device until it is reconnected."; |
| 193 |
3/6✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
|
6 | WarningHolder::AddWarning(AUDIO_CARD_MISSING_WARNING_ID, warningText, |
| 194 | "pipewire-audio.php", "Audio Settings"); | ||
| 195 | 2 | warningActive = true; | |
| 196 | } | ||
| 197 |
2/2✓ Branch 0 taken 2 times.
✓ Branch 1 taken 646 times.
|
648 | } |
| 198 | #endif | ||
| 199 | |||
| 200 | // Starts idle: status defaults to MEDIAOUTPUTSTATUS_IDLE (0). | ||
| 201 | MediaOutputStatus mediaOutputStatus; | ||
| 202 | |||
| 203 | /* | ||
| 204 | * | ||
| 205 | */ | ||
| 206 | 1887 | void InitMediaOutput(void) { | |
| 207 | #ifndef PLATFORM_OSX | ||
| 208 | 1887 | int vol = getSettingInt("volume", -1); | |
| 209 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1887 times.
|
1887 | if (vol < 0) { |
| 210 | ✗ | vol = 70; | |
| 211 | } | ||
| 212 | 1887 | setVolume(vol); | |
| 213 | |||
| 214 | // Watch for the configured audio output card disappearing at runtime | ||
| 215 | // (e.g. a USB sound card unplugged mid-show) and surface a UI warning. | ||
| 216 |
2/4✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1887 times.
✗ Branch 3 not taken.
|
5341 | Timers::INSTANCE.addPeriodicTimer("AudioCardPresence", 10000, []() { |
| 217 | 648 | CheckAudioOutputCardPresence(); | |
| 218 | 648 | }); | |
| 219 | #endif | ||
| 220 | #ifdef HAS_GSTREAMER | ||
| 221 | 1887 | GStreamerOutput::PreloadAsync(); | |
| 222 | 1887 | PipeWireOutputStream::SetKeepOpen(getSettingInt("PipeWireKeepOutputOpen") != 0); | |
| 223 |
3/6✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1887 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1887 times.
✗ Branch 5 not taken.
|
9115 | registerSettingsListener("PipeWireOutputStream", "PipeWireKeepOutputOpen", [](const std::string& value) { |
| 224 | ✗ | PipeWireOutputStream::SetKeepOpen(!value.empty() && value != "0"); | |
| 225 | ✗ | }); | |
| 226 | #endif | ||
| 227 | 1887 | } | |
| 228 | |||
| 229 | /* | ||
| 230 | * | ||
| 231 | */ | ||
| 232 | 1867 | void CleanupMediaOutput(void) { | |
| 233 | 1867 | CloseMediaOutput(); | |
| 234 | #ifdef HAS_GSTREAMER | ||
| 235 | 1867 | PipeWireOutputStream::ShutdownAll(); | |
| 236 | #endif | ||
| 237 | 1867 | } | |
| 238 | |||
| 239 | 247 | void PrewarmMediaOutput(int holdMs) { | |
| 240 | #ifdef HAS_GSTREAMER | ||
| 241 | 247 | PipeWireOutputStream::Prewarm(1, holdMs); | |
| 242 | // The playlist may open with a video; slot 1's overlay and RTP members | ||
| 243 | // (if it has any) are built and waiting. | ||
| 244 | 247 | VideoOutputManager::Instance().Prewarm(1, holdMs); | |
| 245 | #endif | ||
| 246 | 247 | } | |
| 247 | |||
| 248 | // The volume last asked for: what getVolume() reports, at once, while the | ||
| 249 | // worker below catches the mixers up. Bumped with each request, and copied to | ||
| 250 | // s_volumeApplied once the worker has applied that request. | ||
| 251 | static std::atomic<int> volume{ 70 }; | ||
| 252 | static std::atomic<uint64_t> s_volumeRequested{ 0 }; | ||
| 253 | static std::atomic<uint64_t> s_volumeApplied{ 0 }; | ||
| 254 | #ifndef PLATFORM_OSX | ||
| 255 | static int lastPipeWireVolume = -1; // Track last volume to avoid redundant mute toggles (volume worker only) | ||
| 256 | 257921 | int getVolume() { | |
| 257 | 257921 | return volume; | |
| 258 | } | ||
| 259 | |||
| 260 | // Read a command's stdout. Local to the mixer handling below, which has to parse | ||
| 261 | // amixer's output; libfpp has no shared helper for this. | ||
| 262 | 1884 | static std::string readCommandOutput(const std::string& cmd) { | |
| 263 | 1884 | std::string out; | |
| 264 |
1/2✓ Branch 0 taken 1884 times.
✗ Branch 1 not taken.
|
1884 | FILE* f = popen(cmd.c_str(), "r"); |
| 265 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1884 times.
|
1884 | if (!f) { |
| 266 | ✗ | return out; | |
| 267 | } | ||
| 268 | char buf[512]; | ||
| 269 |
3/4✓ Branch 0 taken 1897 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 13 times.
✓ Branch 3 taken 1884 times.
|
1897 | while (fgets(buf, sizeof(buf), f) != nullptr) { |
| 270 |
1/2✓ Branch 0 taken 13 times.
✗ Branch 1 not taken.
|
13 | out += buf; |
| 271 | } | ||
| 272 |
1/2✓ Branch 0 taken 1884 times.
✗ Branch 1 not taken.
|
1884 | pclose(f); |
| 273 | 1884 | return out; | |
| 274 | ✗ | } | |
| 275 | |||
| 276 | // A playback volume control, and whether amixer reports a dB scale for it. | ||
| 277 | struct PlaybackVolumeControl { | ||
| 278 | std::string name; | ||
| 279 | bool hasDbScale = false; | ||
| 280 | }; | ||
| 281 | |||
| 282 | // The playback volume controls on a card, cached: enumerating them costs an | ||
| 283 | // amixer per control, and setVolume() is called repeatedly while a slider moves. | ||
| 284 | // Most FPP capes are bare I2S DACs and return an empty list, so this is normally | ||
| 285 | // one amixer at startup and nothing thereafter. | ||
| 286 | 3108 | static const std::vector<PlaybackVolumeControl>& playbackVolumeControls(int card) { | |
| 287 | static std::mutex cacheLock; | ||
| 288 |
3/4✓ Branch 0 taken 1881 times.
✓ Branch 1 taken 1227 times.
✓ Branch 2 taken 1881 times.
✗ Branch 3 not taken.
|
3108 | static std::map<int, std::vector<PlaybackVolumeControl>> cache; |
| 289 |
1/2✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
|
3108 | std::unique_lock<std::mutex> lk(cacheLock); |
| 290 |
1/2✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
|
3108 | auto it = cache.find(card); |
| 291 |
2/2✓ Branch 0 taken 1227 times.
✓ Branch 1 taken 1881 times.
|
3108 | if (it != cache.end()) { |
| 292 | 1227 | return it->second; | |
| 293 | } | ||
| 294 | 1881 | std::vector<PlaybackVolumeControl> ctls; | |
| 295 |
3/6✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1881 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1881 times.
✗ Branch 5 not taken.
|
1881 | std::string list = readCommandOutput("amixer -c " + std::to_string(card) + " scontrols 2>/dev/null"); |
| 296 |
1/2✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
|
1881 | std::istringstream iss(list); |
| 297 | 1881 | std::string line; | |
| 298 |
4/6✓ Branch 0 taken 1884 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1884 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✓ Branch 5 taken 1881 times.
|
1884 | while (std::getline(iss, line)) { |
| 299 | // "Simple mixer control 'Main Digital',0" | ||
| 300 | 3 | std::size_t b = line.find('\''); | |
| 301 | 3 | std::size_t e = line.rfind('\''); | |
| 302 |
2/4✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
|
3 | if (b == std::string::npos || e <= b) { |
| 303 | ✗ | continue; | |
| 304 | } | ||
| 305 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | std::string name = line.substr(b + 1, e - b - 1); |
| 306 | // The name is interpolated into a shell command. Control names are plain | ||
| 307 | // text, so anything that could escape the quoting is a reason to skip it | ||
| 308 | // rather than to quote harder. | ||
| 309 |
3/6✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 3 times.
|
3 | if (name.empty() || name.find_first_of("\"'`$\\") != std::string::npos) { |
| 310 | ✗ | continue; | |
| 311 | } | ||
| 312 | // Only controls that actually carry a playback volume. A card like a | ||
| 313 | // PCM512x cape also exposes switches and tuning enums (Deemphasis, DSP | ||
| 314 | // Program, Auto Mute, Overclock...) that must not be written. | ||
| 315 |
2/4✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
|
6 | std::string info = readCommandOutput("amixer -c " + std::to_string(card) + " sget \"" + |
| 316 |
3/6✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
|
3 | name + "\" 2>/dev/null"); |
| 317 |
2/2✓ Branch 0 taken 2 times.
✓ Branch 1 taken 1 times.
|
3 | if (info.find("pvolume") != std::string::npos) { |
| 318 | // A control with a dB scale prints its level as "[0.00dB]". | ||
| 319 | 2 | ctls.push_back({ name, info.find("dB]") != std::string::npos }); | |
| 320 | } | ||
| 321 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | } |
| 322 |
1/2✓ Branch 0 taken 1881 times.
✗ Branch 1 not taken.
|
1881 | return cache.emplace(card, std::move(ctls)).first->second; |
| 323 |
2/6✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
|
3110 | } |
| 324 | |||
| 325 | // Output sinks the master volume has to attenuate that are NOT FPP-owned. | ||
| 326 | // | ||
| 327 | // The master targets the terminal output sinks so it multiplies with the group | ||
| 328 | // and member volumes instead of fighting them, and finds them by prefix | ||
| 329 | // (fpp_alsa_*, aes67_*). But a group member does not always target an FPP | ||
| 330 | // adapter: when WirePlumber already publishes a node for the card, the member | ||
| 331 | // targets that instead -- an HDMI output comes through as | ||
| 332 | // alsa_output.platform-<addr>.hdmi.hdmi-stereo. Those match no prefix, so the | ||
| 333 | // master silently skipped them: on a group with one fpp_alsa_ member and one | ||
| 334 | // HDMI member, moving the master attenuated half the group and unbalanced it, | ||
| 335 | // and on an HDMI-only box the master did nothing at all. | ||
| 336 | // | ||
| 337 | // RestorePipeWireGroupVolumes() already has to know about exactly these nodes | ||
| 338 | // (it pins them to 100% because WirePlumber initialises them at ~40%), so read | ||
| 339 | // the same member list it does and hand the names to the master too. | ||
| 340 | // | ||
| 341 | // The AES67 and Opus RTP senders (aes67_*_send, opusrtp_*_send) are not | ||
| 342 | // here: their nodes are capture streams, so pactl set-sink-volume finds no | ||
| 343 | // such sink. Their pipelines apply the master themselves (see | ||
| 344 | // AES67Manager::SetMasterVolume()). | ||
| 345 | // | ||
| 346 | // KEEP IN SYNC with restorePipeWireVolumes() in FPPINIT_Audio.cpp and | ||
| 347 | // RestorePipeWireGroupVolumes() in pipewire.php -- same file selection, same | ||
| 348 | // "not fpp_/aes67_" test. | ||
| 349 | 3108 | static std::set<std::string> nonFppOutputSinkNames() { | |
| 350 | 3108 | std::set<std::string> names; | |
| 351 | // Same default as settings.json: a missing key must not select the retired | ||
| 352 | // ALSA backend, and Simple mode's groups live in a different file. | ||
| 353 |
2/4✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3108 times.
✗ Branch 3 not taken.
|
3108 | std::string backend = toLowerCopy(getSetting("MediaBackend", "pipewire-simple")); |
| 354 | // Named separately: FPP_DIR_CONFIG does not parenthesise its argument, so a | ||
| 355 | // ternary passed straight in binds to the concatenation instead. | ||
| 356 |
3/4✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3021 times.
✓ Branch 3 taken 87 times.
|
3108 | const char* groupsFile = (backend == "pipewire-simple") ? "/pipewire-audio-groups-simple.json" |
| 357 | 3108 | : "/pipewire-audio-groups.json"; | |
| 358 |
3/6✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3108 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3108 times.
✗ Branch 5 not taken.
|
3108 | std::string path = FPP_DIR_CONFIG(groupsFile); |
| 359 |
1/2✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
|
3108 | Json::Value root; |
| 360 | // Both files can hold values of the wrong JSON type (hand edits, API | ||
| 361 | // writes); jsoncpp throws on those, and this runs at startup from | ||
| 362 | // setVolume(), so a throw terminated fppd. Take what can be read. | ||
| 363 | try { | ||
| 364 |
7/10✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 3104 times.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✓ Branch 9 taken 3104 times.
|
3108 | if (LoadJsonFromFile(path, root, JsonRoot::Object) && root.isMember("groups")) { |
| 365 |
8/14✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 2 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 6 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 4 times.
✓ Branch 13 taken 2 times.
|
6 | for (const auto& grp : root["groups"]) { |
| 366 |
12/22✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 2 times.
✓ Branch 6 taken 2 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 2 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✓ Branch 11 taken 2 times.
✓ Branch 12 taken 2 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 2 times.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✓ Branch 17 taken 2 times.
✓ Branch 18 taken 2 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 2 times.
✗ Branch 21 not taken.
|
8 | if (!grp.get("enabled", false).asBool() || !grp.isMember("members")) { |
| 367 | ✗ | continue; | |
| 368 | } | ||
| 369 |
8/14✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 6 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 4 times.
✓ Branch 13 taken 2 times.
|
6 | for (const auto& mbr : grp["members"]) { |
| 370 |
3/6✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
|
4 | std::string t = mbr.get("nodeTarget", "").asString(); |
| 371 |
16/32✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 2 times.
✓ Branch 8 taken 2 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 2 times.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✓ Branch 13 taken 2 times.
✓ Branch 14 taken 2 times.
✓ Branch 15 taken 2 times.
✓ Branch 16 taken 2 times.
✓ Branch 17 taken 2 times.
✓ Branch 18 taken 4 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 4 times.
✗ Branch 21 not taken.
✓ Branch 22 taken 2 times.
✓ Branch 23 taken 2 times.
✗ Branch 24 not taken.
✗ Branch 25 not taken.
✗ Branch 26 not taken.
✗ Branch 27 not taken.
✗ Branch 28 not taken.
✗ Branch 29 not taken.
✗ Branch 30 not taken.
✗ Branch 31 not taken.
|
16 | if (t.empty() || startsWith(t, "fpp_") || startsWith(t, "aes67_")) { |
| 372 | 2 | continue; // already covered by the prefix match | |
| 373 | } | ||
| 374 | // Interpolated into a shell command below. WirePlumber node names | ||
| 375 | // are plain identifiers; anything that could escape the quoting is a | ||
| 376 | // reason to skip the node rather than to quote harder. | ||
| 377 | 2 | if (t.find_first_not_of("abcdefghijklmnopqrstuvwxyz" | |
| 378 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ" | ||
| 379 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
|
2 | "0123456789_.-") != std::string::npos) { |
| 380 | ✗ | LogWarn(VB_MEDIAOUT, "Skipping master volume for node with unexpected name: %s\n", t.c_str()); | |
| 381 | ✗ | continue; | |
| 382 | } | ||
| 383 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | names.insert(t); |
| 384 |
2/2✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
|
4 | } |
| 385 | } | ||
| 386 | } | ||
| 387 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
|
2 | } catch (const std::exception& e) { |
| 388 |
3/6✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
|
6 | WarningHolder::InvalidConfigEntry(groupsFile + 1, e.what()); |
| 389 | 2 | } | |
| 390 | |||
| 391 | 3108 | return names; | |
| 392 | 3108 | } | |
| 393 | |||
| 394 | // Whether any AES67 or Opus RTP sender is configured. Read from the config | ||
| 395 | // files, not the managers: the volume restore at startup can run before they | ||
| 396 | // have loaded theirs. | ||
| 397 | 3108 | static bool networkSendersConfigured() { | |
| 398 |
2/2✓ Branch 0 taken 6163 times.
✓ Branch 1 taken 3048 times.
|
9211 | for (const char* file : { "/pipewire-aes67-instances.json", "/pipewire-opus-rtp-instances.json" }) { |
| 399 |
1/2✓ Branch 0 taken 6163 times.
✗ Branch 1 not taken.
|
6163 | Json::Value root; |
| 400 | try { | ||
| 401 |
14/32✓ Branch 0 taken 6163 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6163 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 6163 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 6163 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 72 times.
✓ Branch 9 taken 6091 times.
✓ Branch 10 taken 72 times.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✓ Branch 13 taken 72 times.
✓ Branch 14 taken 6163 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 6163 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 6163 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 6163 times.
✗ Branch 21 not taken.
✓ Branch 22 taken 6091 times.
✓ Branch 23 taken 72 times.
✗ Branch 24 not taken.
✗ Branch 25 not taken.
✗ Branch 26 not taken.
✗ Branch 27 not taken.
✗ Branch 28 not taken.
✗ Branch 29 not taken.
✗ Branch 30 not taken.
✗ Branch 31 not taken.
|
18489 | if (!LoadJsonFromFile(FPP_DIR_CONFIG(file), root, JsonRoot::Object) || !root.isMember("instances")) { |
| 402 | 6091 | continue; | |
| 403 | } | ||
| 404 |
8/14✓ Branch 0 taken 72 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 72 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 72 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 72 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 8 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 80 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 72 times.
✓ Branch 13 taken 8 times.
|
80 | for (const auto& inst : root["instances"]) { |
| 405 |
4/6✓ Branch 0 taken 72 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 72 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 68 times.
✓ Branch 5 taken 4 times.
|
80 | std::string mode = inst.get("mode", "send").asString(); |
| 406 |
13/26✓ Branch 0 taken 68 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 68 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 68 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 68 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 68 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 8 times.
✓ Branch 11 taken 60 times.
✓ Branch 12 taken 8 times.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✓ Branch 15 taken 8 times.
✓ Branch 16 taken 68 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 68 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 60 times.
✓ Branch 21 taken 8 times.
✗ Branch 22 not taken.
✗ Branch 23 not taken.
✗ Branch 24 not taken.
✗ Branch 25 not taken.
|
68 | if (inst.get("enabled", true).asBool() && (mode == "send" || mode == "both")) { |
| 407 | 60 | return true; | |
| 408 | } | ||
| 409 |
2/2✓ Branch 0 taken 8 times.
✓ Branch 1 taken 60 times.
|
68 | } |
| 410 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
|
4 | } catch (const std::exception& e) { |
| 411 |
3/6✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
|
12 | WarningHolder::InvalidConfigEntry(file + 1, e.what()); |
| 412 | 4 | } | |
| 413 |
3/3✓ Branch 0 taken 12 times.
✓ Branch 1 taken 6091 times.
✓ Branch 2 taken 60 times.
|
6163 | } |
| 414 | 3048 | return false; | |
| 415 | } | ||
| 416 | |||
| 417 | // Put the card's hardware playback path at unity, so PipeWire's software volume | ||
| 418 | // is the only thing attenuating. | ||
| 419 | // | ||
| 420 | // This replaces pinning the single control named by the AudioMixerDevice setting | ||
| 421 | // to 100%, which was wrong in two directions. 100% is not unity on a control | ||
| 422 | // whose range runs above 0 dB: a PCM512x cape's "Main Digital" spans 0..255 with | ||
| 423 | // 0 dB at 207 and +24 dB at 255, so selecting the one control on that card that | ||
| 424 | // genuinely is the volume -- the obvious choice -- pinned it 24 dB into clipping. | ||
| 425 | // And pinning only the selected control left any other volume control sitting | ||
| 426 | // wherever the driver happened to leave it. The setting itself only ever picked | ||
| 427 | // the alphabetically first control anyway (amixer scontrols | head -1). | ||
| 428 | // | ||
| 429 | // bcm2835 keeps the old behaviour deliberately: its mixer reaches +4 dB and FPP | ||
| 430 | // wants that headroom available above the software stage. | ||
| 431 | // | ||
| 432 | // Whether to write 0dB or 100% is decided from the control's own description, | ||
| 433 | // not from amixer's exit status: fppd sets SA_NOCLDWAIT, so system() cannot | ||
| 434 | // collect the child and returns -1 even when amixer succeeded. Falling back on | ||
| 435 | // that "failure" wrote 100% straight after 0dB, which put a PCM512x's Digital | ||
| 436 | // control back at +24 dB on every volume change. | ||
| 437 | 3108 | static void pinCardPlaybackToUnity(int card, bool useMax) { | |
| 438 |
3/4✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 3108 times.
|
3112 | for (const PlaybackVolumeControl& ctl : playbackVolumeControls(card)) { |
| 439 | // No dB scale: 100% is the top of a plain range. | ||
| 440 |
3/4✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 2 times.
|
4 | const char* level = (useMax || !ctl.hasDbScale) ? "100%" : "0dB"; |
| 441 |
4/8✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
|
8 | const std::string cmd = "amixer -c " + std::to_string(card) + " sset \"" + ctl.name + "\" " + |
| 442 |
2/4✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
|
4 | level + " >/dev/null 2>&1"; |
| 443 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | system(cmd.c_str()); |
| 444 | 4 | } | |
| 445 | 3108 | } | |
| 446 | #else | ||
| 447 | int getVolume() { | ||
| 448 | // The device's own level (the user can change it outside fppd), except | ||
| 449 | // while a change is still on its way to it: a caller that sets the | ||
| 450 | // volume and reads it straight back, or steps it twice in a row, must | ||
| 451 | // see the level it asked for, not the one the worker hasn't replaced yet. | ||
| 452 | if (s_volumeApplied.load() != s_volumeRequested.load()) { | ||
| 453 | return volume; | ||
| 454 | } | ||
| 455 | return MacOSGetVolume(); | ||
| 456 | } | ||
| 457 | #endif | ||
| 458 | |||
| 459 | 3115 | static void applyVolume(int vol) { | |
| 460 | // Only the amixer command below still uses this; the pactl commands are | ||
| 461 | // built as std::strings because their length depends on the user's config. | ||
| 462 | char buffer[1024]; | ||
| 463 | |||
| 464 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 3115 times.
|
3115 | if (vol < 0) |
| 465 | ✗ | vol = 0; | |
| 466 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 3115 times.
|
3115 | else if (vol > 100) |
| 467 | ✗ | vol = 100; | |
| 468 | |||
| 469 |
1/2✓ Branch 0 taken 3115 times.
✗ Branch 1 not taken.
|
3115 | std::string mixerDevice = getSetting("AudioMixerDevice"); |
| 470 |
1/2✓ Branch 0 taken 3115 times.
✗ Branch 1 not taken.
|
3115 | int audioOutput = resolveAudioOutputCardNum(); |
| 471 |
1/2✓ Branch 0 taken 3115 times.
✗ Branch 1 not taken.
|
3115 | std::string audio0Type = getSetting("AudioCard0Type"); |
| 472 | // Same default as settings.json: a missing key must not select the retired | ||
| 473 | // ALSA path. | ||
| 474 |
2/4✓ Branch 0 taken 3115 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3115 times.
✗ Branch 3 not taken.
|
3115 | std::string mediaBackend = toLowerCopy(getSetting("MediaBackend", "pipewire-simple")); |
| 475 | |||
| 476 |
6/8✓ Branch 0 taken 3115 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3028 times.
✓ Branch 3 taken 87 times.
✓ Branch 4 taken 3028 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3021 times.
✓ Branch 7 taken 7 times.
|
3115 | bool usePipeWireBackend = (mediaBackend == "pipewire" || mediaBackend == "pipewire-simple"); |
| 477 | |||
| 478 | #ifndef PLATFORM_OSX | ||
| 479 | 3115 | float fvol = vol; | |
| 480 | |||
| 481 | // === Hardware mixer === | ||
| 482 | // PipeWire mode: put the card's playback controls at unity so the software | ||
| 483 | // volume further down is the sole attenuator. The 0 dB choice, the bcm2835 | ||
| 484 | // exception, and why only pvolume controls are touched all live in | ||
| 485 | // pinCardPlaybackToUnity(). Many capes are bare I2S DACs with no playback | ||
| 486 | // mixer at all, so the control list is empty and nothing is shelled out -- | ||
| 487 | // and nothing is lost, because the software volume is what actually | ||
| 488 | // attenuates, verified at the ALSA boundary through a loopback card (a sink | ||
| 489 | // node at 50% delivers 0.125 amplitude with no mixer involved). | ||
| 490 | // | ||
| 491 | // The ALSA branch below is legacy. "alsa" is no longer offered as a | ||
| 492 | // MediaBackend and FPPINIT migrates a stored one to pipewire-simple at boot, | ||
| 493 | // so it is unreachable in a supported configuration; it is kept as a | ||
| 494 | // fallback, and given the same taper as the PipeWire path so that the same | ||
| 495 | // number would still mean the same loudness if it ever did run. | ||
| 496 |
2/2✓ Branch 0 taken 3108 times.
✓ Branch 1 taken 7 times.
|
3115 | if (usePipeWireBackend) { |
| 497 | 3108 | bool useMax = false; | |
| 498 | #ifdef PLATFORM_PI | ||
| 499 |
3/6✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 54 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 54 times.
|
54 | useMax = (audioOutput == 0 && audio0Type == "bcm2"); |
| 500 | #endif | ||
| 501 |
1/2✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
|
3108 | pinCardPlaybackToUnity(audioOutput, useMax); |
| 502 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 7 times.
|
7 | } else if (mixerDevice.empty()) { |
| 503 | ✗ | LogDebug(VB_MEDIAOUT, "No ALSA mixer control for card %d; skipping hardware volume\n", | |
| 504 | audioOutput); | ||
| 505 |
2/2✓ Branch 0 taken 2 times.
✓ Branch 1 taken 5 times.
|
7 | } else if (mixerDevice.find('\'') != std::string::npos) { |
| 506 | // The name is interpolated inside single quotes in the amixer command | ||
| 507 | // below -- the only character that can break out of that context. A | ||
| 508 | // quote can only get here via a hand-edited setting (real control | ||
| 509 | // names never contain one), so skip hardware volume exactly like a | ||
| 510 | // missing control is skipped above. | ||
| 511 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | LogDebug(VB_MEDIAOUT, "ALSA mixer control name contains a quote; skipping hardware volume\n"); |
| 512 | } else { | ||
| 513 | // Follow the same taper as the PipeWire path, where pactl's percentage | ||
| 514 | // is PulseAudio's cubic (perceptual) scale -- the same FPP volume should | ||
| 515 | // not mean a different loudness just because the backend changed. Only | ||
| 516 | // the taper can be matched, not the absolute level: amixer's percentage | ||
| 517 | // is of the control's own raw range, and cards routinely describe that | ||
| 518 | // range uselessly (a USB DAC measured here spans 0.00 to -0.12 dB across | ||
| 519 | // all 31 of its steps), so there is no portable dB to aim at. | ||
| 520 | 5 | const float v = vol / 100.0f; | |
| 521 | 5 | fvol = 100.0f * v * v * v; | |
| 522 | #ifdef PLATFORM_PI | ||
| 523 | ✗ | if (audioOutput == 0 && audio0Type == "bcm2") { | |
| 524 | ✗ | fvol /= 2; | |
| 525 | ✗ | fvol += 50; | |
| 526 | } | ||
| 527 | #endif | ||
| 528 | 5 | snprintf(buffer, sizeof(buffer), "amixer set -c %d '%s' -- %.2f%% >/dev/null 2>&1", | |
| 529 | audioOutput, mixerDevice.c_str(), fvol); | ||
| 530 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | LogDebug(VB_SETTING, "Volume change: %d \n", vol); |
| 531 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | LogDebug(VB_MEDIAOUT, "Setting ALSA hardware volume: %s \n", buffer); |
| 532 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | system(buffer); |
| 533 | } | ||
| 534 | |||
| 535 | // === PipeWire software volume === | ||
| 536 | // Use pactl (PulseAudio compat layer) to set the sink volume. This is | ||
| 537 | // the same mechanism used by the per-output-group volume controls in the | ||
| 538 | // PHP UI (which are known to work). It avoids the fragile pw-cli node-ID | ||
| 539 | // lookup and correctly respects PIPEWIRE_RUNTIME_DIR / XDG_RUNTIME_DIR | ||
| 540 | // rather than PIPEWIRE_REMOTE. | ||
| 541 | // | ||
| 542 | // The master targets the CARD sinks (fpp_alsa_*), not PipeWireSinkName. | ||
| 543 | // PipeWireSinkName points at the primary output group, which is also the | ||
| 544 | // node the group volume control and RestorePipeWireGroupVolumes() write -- | ||
| 545 | // so master and that group's slider were one knob, and whoever wrote last | ||
| 546 | // won. At boot that is always fppd (the volume restore runs in fppinit, | ||
| 547 | // before fppd starts), which silently discarded whatever the user had set | ||
| 548 | // on the group. A group left above the master therefore came back quieter | ||
| 549 | // after every reboot. The card sinks sit downstream of every group, so | ||
| 550 | // master, group and member volumes now multiply rather than overwrite. | ||
| 551 | // | ||
| 552 | // Volume 0 is expressed as a real 0% so the sink goes silent without | ||
| 553 | // needing a separate mute call; any non-zero restore includes an explicit | ||
| 554 | // unmute in case a prior 0% left the sink muted. | ||
| 555 |
2/2✓ Branch 0 taken 3108 times.
✓ Branch 1 taken 7 times.
|
3115 | if (usePipeWireBackend) { |
| 556 | 3108 | const bool shouldMute = (vol == 0); | |
| 557 | std::string pwPrefix = "PIPEWIRE_RUNTIME_DIR=/run/pipewire-fpp " | ||
| 558 | "XDG_RUNTIME_DIR=/run/pipewire-fpp " | ||
| 559 |
1/2✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
|
3108 | "PULSE_RUNTIME_PATH=/run/pipewire-fpp/pulse "; |
| 560 | |||
| 561 | // Every FPP card sink, plus the WirePlumber-managed nodes group members | ||
| 562 | // target directly (an HDMI output, say), which match no FPP prefix and | ||
| 563 | // were being skipped. The network senders are not sinks and take the | ||
| 564 | // master in their own pipelines (below). | ||
| 565 | // Those are appended by name rather than folded into the grep: they may | ||
| 566 | // legitimately be absent from the graph right now (an unplugged card | ||
| 567 | // whose stored nodeTarget is still in the config), and pactl shrugging | ||
| 568 | // at a name it does not know is exactly what the volume-restore path | ||
| 569 | // already relies on. | ||
| 570 | // Falls back to the default sink only when that leaves nothing at all | ||
| 571 | // (no 95-fpp-alsa-sink.conf adapters, no member targets and no network | ||
| 572 | // sender), which is the old behaviour and still better than attenuating | ||
| 573 | // nothing. Not with a sender: the default sink can be the group that | ||
| 574 | // feeds it, and the sender would then be attenuated twice. | ||
| 575 | // Deliberately no single quotes in here: the whole thing is embedded in | ||
| 576 | // an sh -c '...' string, so an inner ' would close it early (awk | ||
| 577 | // '{print $2}' silently truncated the command and left the volume | ||
| 578 | // unset). pactl's short listing is tab separated, so cut does the job. | ||
| 579 | 3108 | std::string extraSinks; | |
| 580 |
3/4✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 3108 times.
|
3110 | for (const std::string& n : nonFppOutputSinkNames()) { |
| 581 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
2 | extraSinks += " " + n; |
| 582 | 3108 | } | |
| 583 |
4/6✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 60 times.
✓ Branch 3 taken 3048 times.
✓ Branch 4 taken 3108 times.
✗ Branch 5 not taken.
|
3268 | std::string fallback = networkSendersConfigured() ? "" : "[ -z \"$S\" ] && [ -z \"$E\" ] && S=@DEFAULT_SINK@; "; |
| 584 |
2/4✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3108 times.
✗ Branch 3 not taken.
|
6216 | std::string sinkList = "E=\"" + extraSinks + "\"; " + |
| 585 | "S=$(pactl list sinks short 2>/dev/null | cut -f2 | " | ||
| 586 |
1/2✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
|
6216 | "grep -e ^fpp_alsa_); " + |
| 587 |
1/2✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
|
3108 | fallback + |
| 588 |
1/2✓ Branch 0 taken 3108 times.
✗ Branch 1 not taken.
|
3108 | "S=\"$S $E\"; "; |
| 589 | |||
| 590 | // Built as a string, not into the fixed buffer above: the member-target | ||
| 591 | // names make the length depend on the user's config, and this command | ||
| 592 | // silently losing its tail is precisely how the volume went unset before. | ||
| 593 | 3108 | std::string cmd; | |
| 594 |
2/2✓ Branch 0 taken 1659 times.
✓ Branch 1 taken 1449 times.
|
3108 | if (shouldMute) { |
| 595 | // Set volume to 0% and explicitly mute for true silence. | ||
| 596 |
2/4✓ Branch 0 taken 1659 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1659 times.
✗ Branch 3 not taken.
|
3318 | cmd = pwPrefix + " sh -c '" + sinkList + |
| 597 |
1/2✓ Branch 0 taken 1659 times.
✗ Branch 1 not taken.
|
1659 | "for s in $S; do pactl set-sink-volume $s 0%; pactl set-sink-mute $s 1; done' >/dev/null 2>&1"; |
| 598 |
1/2✓ Branch 0 taken 1659 times.
✗ Branch 1 not taken.
|
1659 | LogDebug(VB_MEDIAOUT, "Muting PipeWire output sinks: %s \n", cmd.c_str()); |
| 599 |
2/2✓ Branch 0 taken 64 times.
✓ Branch 1 taken 1385 times.
|
1449 | } else if (lastPipeWireVolume == 0) { |
| 600 | // Unmute and set volume together, undoing a prior 0%. | ||
| 601 |
2/4✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 64 times.
✗ Branch 3 not taken.
|
128 | cmd = pwPrefix + " sh -c '" + sinkList + |
| 602 |
1/2✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
|
128 | "for s in $S; do pactl set-sink-mute $s 0; pactl set-sink-volume $s " + |
| 603 |
2/4✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 64 times.
✗ Branch 3 not taken.
|
192 | std::to_string(vol) + "%; done' >/dev/null 2>&1"; |
| 604 |
1/2✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
|
64 | LogDebug(VB_MEDIAOUT, "Unmuting and setting PipeWire output sink volume: %s \n", cmd.c_str()); |
| 605 | } else { | ||
| 606 |
2/4✓ Branch 0 taken 1385 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1385 times.
✗ Branch 3 not taken.
|
2770 | cmd = pwPrefix + " sh -c '" + sinkList + |
| 607 |
2/4✓ Branch 0 taken 1385 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1385 times.
✗ Branch 3 not taken.
|
5488 | "for s in $S; do pactl set-sink-volume $s " + std::to_string(vol) + |
| 608 |
1/2✓ Branch 0 taken 1385 times.
✗ Branch 1 not taken.
|
1385 | "%; done' >/dev/null 2>&1"; |
| 609 |
1/2✓ Branch 0 taken 1385 times.
✗ Branch 1 not taken.
|
1385 | LogDebug(VB_MEDIAOUT, "Setting PipeWire output sink volume: %s \n", cmd.c_str()); |
| 610 | } | ||
| 611 |
1/2✓ Branch 0 taken 3106 times.
✗ Branch 1 not taken.
|
3108 | system(cmd.c_str()); |
| 612 | |||
| 613 | 3106 | lastPipeWireVolume = vol; | |
| 614 | |||
| 615 | // The AES67 and Opus RTP senders: capture streams, which the sink pass | ||
| 616 | // above cannot reach. | ||
| 617 | #ifdef HAS_AES67_GSTREAMER | ||
| 618 |
1/2✓ Branch 0 taken 3106 times.
✗ Branch 1 not taken.
|
3106 | AES67Manager::INSTANCE.SetMasterVolume(vol); |
| 619 | #endif | ||
| 620 | #ifdef HAS_OPUS_RTP_GSTREAMER | ||
| 621 |
1/2✓ Branch 0 taken 3106 times.
✗ Branch 1 not taken.
|
3106 | OpusRTPManager::INSTANCE.SetMasterVolume(vol); |
| 622 | #endif | ||
| 623 | 3106 | } | |
| 624 | #else | ||
| 625 | MacOSSetVolume(vol); | ||
| 626 | #endif | ||
| 627 | |||
| 628 |
1/2✓ Branch 0 taken 3113 times.
✗ Branch 1 not taken.
|
3113 | std::unique_lock<std::mutex> lock(mediaOutputLock); |
| 629 | // In PipeWire mode, volume is controlled via pactl on the sink (above). | ||
| 630 | // Don't also set the GStreamer volume element or we get double attenuation. | ||
| 631 | // The GStreamer volume element is still used for per-track volumeAdjust | ||
| 632 | // (dB offset). In ALSA mode, propagate to GStreamer as before. | ||
| 633 |
3/4✓ Branch 0 taken 2 times.
✓ Branch 1 taken 3111 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 2 times.
|
3113 | if (mediaOutput && !usePipeWireBackend) |
| 634 | ✗ | mediaOutput->SetVolume(vol); | |
| 635 | 3113 | } | |
| 636 | |||
| 637 | |||
| 638 | 245 | double masterVolumeGain(int volume) { | |
| 639 |
1/2✓ Branch 0 taken 245 times.
✗ Branch 1 not taken.
|
245 | const double v = std::clamp(volume, 0, 100) / 100.0; |
| 640 | 245 | return v * v * v; | |
| 641 | } | ||
| 642 | |||
| 643 | // setVolume() shells out several times (pactl for every sink, amixer), and is | ||
| 644 | // called from the main loop (the command socket, once per step of the UI's | ||
| 645 | // volume slider), from API threads and from presets. A slow or wedged | ||
| 646 | // PipeWire stalled whichever of those asked. Requests now only record the | ||
| 647 | // target; one worker thread applies the latest, so a burst of slider steps | ||
| 648 | // costs one round of shell-outs, not one per step. | ||
| 649 | // | ||
| 650 | // The lock and the condition variable are leaked, not file-statics: the | ||
| 651 | // worker waits on them for the life of the process, and an exit() from | ||
| 652 | // startup (the command socket or the HTTP server failing, after | ||
| 653 | // InitMediaOutput() has started the worker) ran their destructors. glibc's | ||
| 654 | // pthread_cond_destroy() waits for the blocked worker, so fppd never exited | ||
| 655 | // and systemd never restarted it. | ||
| 656 | static std::mutex& s_volumeWorkerLock = *new std::mutex(); | ||
| 657 | static std::condition_variable& s_volumeWorkerWake = *new std::condition_variable(); | ||
| 658 | static int s_volumeTarget = -1; // -1: nothing pending | ||
| 659 | static uint64_t s_volumeTargetRequest = 0; | ||
| 660 | static bool s_volumeWorkerStarted = false; | ||
| 661 | |||
| 662 | 3183 | void setVolume(int vol) { | |
| 663 |
1/2✓ Branch 0 taken 3183 times.
✗ Branch 1 not taken.
|
3183 | vol = std::clamp(vol, 0, 100); |
| 664 |
1/2✓ Branch 0 taken 3183 times.
✗ Branch 1 not taken.
|
3183 | std::lock_guard<std::mutex> lock(s_volumeWorkerLock); |
| 665 | 3183 | volume = vol; | |
| 666 | 3183 | s_volumeTarget = vol; | |
| 667 | 3183 | s_volumeTargetRequest = ++s_volumeRequested; | |
| 668 |
2/2✓ Branch 0 taken 1887 times.
✓ Branch 1 taken 1296 times.
|
3183 | if (!s_volumeWorkerStarted) { |
| 669 | 1887 | s_volumeWorkerStarted = true; | |
| 670 | // Detached: it only ever waits for the next request, on a lock and | ||
| 671 | // condition variable that are never destroyed (see above). | ||
| 672 | 3774 | std::thread([]() { | |
| 673 |
2/4✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1887 times.
✗ Branch 3 not taken.
|
1887 | SetThreadName("FPP-Volume"); |
| 674 |
1/2✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
|
1887 | std::unique_lock<std::mutex> lock(s_volumeWorkerLock); |
| 675 | while (true) { | ||
| 676 |
1/2✓ Branch 0 taken 3115 times.
✗ Branch 1 not taken.
|
10944 | s_volumeWorkerWake.wait(lock, []() { return s_volumeTarget >= 0; }); |
| 677 | 3115 | int v = s_volumeTarget; | |
| 678 | 3115 | uint64_t request = s_volumeTargetRequest; | |
| 679 | 3115 | s_volumeTarget = -1; | |
| 680 |
1/2✓ Branch 0 taken 3115 times.
✗ Branch 1 not taken.
|
3115 | lock.unlock(); |
| 681 |
1/2✓ Branch 0 taken 3113 times.
✗ Branch 1 not taken.
|
3115 | applyVolume(v); |
| 682 | 3113 | s_volumeApplied = request; | |
| 683 |
1/2✓ Branch 0 taken 3113 times.
✗ Branch 1 not taken.
|
3113 | lock.lock(); |
| 684 | 3113 | } | |
| 685 |
2/4✓ Branch 0 taken 1887 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1887 times.
✗ Branch 3 not taken.
|
1887 | }).detach(); |
| 686 | } | ||
| 687 | 3183 | s_volumeWorkerWake.notify_one(); | |
| 688 | 3183 | } | |
| 689 | |||
| 690 | static std::set<std::string> AUDIO_EXTS = { | ||
| 691 | "mp3", "ogg", "m4a", "m4p", "wav", "au", "wma", "flac", "aac", | ||
| 692 | "MP3", "OGG", "M4A", "M4P", "WAV", "AU", "WMA", "FLAC", "AAC" | ||
| 693 | }; | ||
| 694 | static std::map<std::string, std::string> VIDEO_EXTS = { | ||
| 695 | { "mp4", "mp4" }, { "MP4", "mp4" }, { "avi", "avi" }, { "AVI", "avi" }, { "mov", "mov" }, { "MOV", "mov" }, { "mkv", "mkv" }, { "MKV", "mkv" }, { "mpg", "mpg" }, { "MPG", "mpg" }, { "mpeg", "mpeg" }, { "MPEG", "mpeg" } | ||
| 696 | }; | ||
| 697 | |||
| 698 | // Case-insensitive: the tables list only the all-lower and all-upper | ||
| 699 | // spellings (VIDEO_EXTS' are also the names GetVideoFilenameForMedia() | ||
| 700 | // probes for), and a folder ripped as "Song.Mp3" must still count as media. | ||
| 701 | 2959 | bool IsExtensionVideo(const std::string& ext) { | |
| 702 |
2/4✓ Branch 0 taken 2959 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2959 times.
✗ Branch 3 not taken.
|
2959 | return VIDEO_EXTS.find(toLowerCopy(ext)) != VIDEO_EXTS.end(); |
| 703 | } | ||
| 704 | 3207 | bool IsExtensionAudio(const std::string& ext) { | |
| 705 |
2/4✓ Branch 0 taken 3207 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3207 times.
✗ Branch 3 not taken.
|
3207 | return AUDIO_EXTS.find(toLowerCopy(ext)) != AUDIO_EXTS.end(); |
| 706 | } | ||
| 707 | |||
| 708 | 37 | std::string GetVideoFilenameForMedia(const std::string& filename, std::string& ext) { | |
| 709 |
1/2✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
|
37 | ext = ""; |
| 710 |
1/2✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
|
37 | std::string result(""); |
| 711 | 37 | std::size_t found = filename.find_last_of("."); | |
| 712 |
1/2✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
|
37 | std::string oext = filename.substr(found + 1); |
| 713 |
1/2✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
|
37 | std::string lext = toLowerCopy(oext); |
| 714 |
1/2✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
|
37 | std::string bfile = filename.substr(0, found + 1); |
| 715 |
1/2✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
|
37 | std::string hbfile = bfile; |
| 716 |
4/8✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 37 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 37 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 37 times.
✗ Branch 7 not taken.
|
37 | std::string videoPath = FPP_DIR_VIDEO("/" + bfile); |
| 717 | |||
| 718 |
1/2✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
|
37 | std::string hostname = getSetting("HostName"); |
| 719 |
1/2✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
|
37 | std::string hostVideoPath = ""; |
| 720 |
2/4✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 37 times.
✗ Branch 3 not taken.
|
37 | if (hostname != "") { |
| 721 |
4/8✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 37 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 37 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 37 times.
✗ Branch 7 not taken.
|
37 | hostVideoPath = videoPath.substr(0, videoPath.length() - 1) + "-" + hostname + "."; |
| 722 |
4/8✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 37 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 37 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 37 times.
✗ Branch 7 not taken.
|
37 | hbfile = filename.substr(0, found) + "-" + hostname + "."; |
| 723 | } | ||
| 724 | |||
| 725 |
2/4✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 37 times.
|
37 | if (IsExtensionVideo(lext)) { |
| 726 | ✗ | if (hostVideoPath != "" && FileExists(hostVideoPath + oext)) { | |
| 727 | ✗ | ext = lext; | |
| 728 | ✗ | result = hbfile + oext; | |
| 729 | ✗ | } else if (hostVideoPath != "" && FileExists(hostVideoPath + lext)) { | |
| 730 | ✗ | ext = lext; | |
| 731 | ✗ | result = hbfile + lext; | |
| 732 | ✗ | } else if (FileExists(videoPath + oext)) { | |
| 733 | ✗ | ext = lext; | |
| 734 | ✗ | result = bfile + oext; | |
| 735 | ✗ | } else if (FileExists(videoPath + lext)) { | |
| 736 | ✗ | ext = lext; | |
| 737 | ✗ | result = bfile + lext; | |
| 738 | } | ||
| 739 |
2/4✓ Branch 0 taken 37 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 37 times.
✗ Branch 3 not taken.
|
37 | } else if (IsExtensionAudio(lext)) { |
| 740 |
2/2✓ Branch 0 taken 384 times.
✓ Branch 1 taken 22 times.
|
406 | for (auto& n : VIDEO_EXTS) { |
| 741 |
4/6✓ Branch 0 taken 384 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 384 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 15 times.
✓ Branch 5 taken 369 times.
|
384 | if (FileExists(hostVideoPath + n.first)) { |
| 742 |
1/2✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
|
15 | ext = n.second; |
| 743 |
1/2✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
|
15 | result = hbfile + n.first; |
| 744 | 15 | return result; | |
| 745 |
3/6✓ Branch 0 taken 369 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 369 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 369 times.
|
369 | } else if (FileExists(videoPath + n.first)) { |
| 746 | ✗ | ext = n.second; | |
| 747 | ✗ | result = bfile + n.first; | |
| 748 | ✗ | return result; | |
| 749 | } | ||
| 750 | } | ||
| 751 | } | ||
| 752 | |||
| 753 | 22 | return result; | |
| 754 | 37 | } | |
| 755 | 1166 | bool HasAudioForMedia(std::string& mediaFilename) { | |
| 756 |
1/2✓ Branch 0 taken 1166 times.
✗ Branch 1 not taken.
|
1166 | std::string fullMediaPath = mediaFilename; |
| 757 | |||
| 758 |
1/2✓ Branch 0 taken 1166 times.
✗ Branch 1 not taken.
|
1166 | std::string hostname = getSetting("HostName"); |
| 759 |
2/4✓ Branch 0 taken 1166 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1166 times.
✗ Branch 3 not taken.
|
1166 | if (hostname != "") { |
| 760 | 1166 | std::size_t found = mediaFilename.find_last_of("."); | |
| 761 | 1166 | std::string hostMediaPath; | |
| 762 |
1/2✓ Branch 0 taken 1166 times.
✗ Branch 1 not taken.
|
1166 | if (found != std::string::npos) { |
| 763 |
5/10✓ Branch 0 taken 1166 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1166 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1166 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1166 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1166 times.
✗ Branch 9 not taken.
|
1166 | hostMediaPath = mediaFilename.substr(0, found) + "-" + hostname + mediaFilename.substr(found); |
| 764 | } else { | ||
| 765 | ✗ | hostMediaPath = mediaFilename + "-" + hostname; | |
| 766 | } | ||
| 767 |
2/4✓ Branch 0 taken 1166 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1166 times.
|
1166 | if (FileExists(hostMediaPath)) { |
| 768 | ✗ | mediaFilename = hostMediaPath; | |
| 769 | ✗ | return true; | |
| 770 | } | ||
| 771 |
4/8✓ Branch 0 taken 1166 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1166 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1166 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1166 times.
✗ Branch 7 not taken.
|
1166 | hostMediaPath = FPP_DIR_MUSIC("/" + hostMediaPath); |
| 772 |
2/4✓ Branch 0 taken 1166 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1166 times.
|
1166 | if (FileExists(hostMediaPath)) { |
| 773 | ✗ | mediaFilename = hostMediaPath; | |
| 774 | ✗ | return true; | |
| 775 | } | ||
| 776 |
1/2✓ Branch 0 taken 1166 times.
✗ Branch 1 not taken.
|
1166 | } |
| 777 | |||
| 778 |
2/4✓ Branch 0 taken 1166 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1166 times.
|
1166 | if (FileExists(mediaFilename)) { |
| 779 | ✗ | return true; | |
| 780 | } | ||
| 781 |
4/8✓ Branch 0 taken 1166 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1166 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1166 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1166 times.
✗ Branch 7 not taken.
|
1166 | fullMediaPath = FPP_DIR_MUSIC("/" + mediaFilename); |
| 782 |
3/4✓ Branch 0 taken 1166 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1149 times.
✓ Branch 3 taken 17 times.
|
1166 | if (FileExists(fullMediaPath)) { |
| 783 |
1/2✓ Branch 0 taken 1149 times.
✗ Branch 1 not taken.
|
1149 | mediaFilename = fullMediaPath; |
| 784 | 1149 | return true; | |
| 785 | } | ||
| 786 | 17 | return false; | |
| 787 | 1166 | } | |
| 788 | 15 | bool HasVideoForMedia(std::string& filename) { | |
| 789 | 15 | std::string ext; | |
| 790 |
1/2✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
|
15 | std::string fp = GetVideoFilenameForMedia(filename, ext); |
| 791 |
3/4✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✓ Branch 3 taken 1 times.
|
15 | if (fp != "") { |
| 792 |
1/2✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
|
14 | filename = fp; |
| 793 | } | ||
| 794 |
1/2✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
|
30 | return fp != ""; |
| 795 | 15 | } | |
| 796 | |||
| 797 | 6 | static bool IsHDMIOut(std::string& vOut) { | |
| 798 |
4/8✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 6 times.
✗ Branch 6 not taken.
✓ Branch 7 taken 6 times.
|
6 | if (vOut == "--HDMI--" || vOut == "--hdmi--" || vOut == "HDMI") { |
| 799 | ✗ | vOut = "HDMI-A-1"; | |
| 800 | } | ||
| 801 |
6/8✓ Branch 0 taken 1 times.
✓ Branch 1 taken 5 times.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 1 times.
✓ Branch 6 taken 5 times.
✓ Branch 7 taken 1 times.
|
6 | if (vOut.starts_with("HDMI-") || vOut.starts_with("DSI-") || vOut.starts_with("Composite-")) { |
| 802 |
2/2✓ Branch 0 taken 14 times.
✓ Branch 1 taken 3 times.
|
17 | for (int x = 0; x < 4; x++) { |
| 803 |
4/8✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 14 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 14 times.
✗ Branch 7 not taken.
|
14 | std::string conn = "/sys/class/drm/card" + std::to_string(x) + "-" + vOut + "/status"; |
| 804 |
3/4✓ Branch 0 taken 14 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 12 times.
|
14 | if (FileExists(conn)) { |
| 805 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | std::string status = GetFileContents(conn); |
| 806 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
2 | return !contains(status, "disconnected"); |
| 807 | 2 | } | |
| 808 |
2/2✓ Branch 0 taken 12 times.
✓ Branch 1 taken 2 times.
|
14 | } |
| 809 | } | ||
| 810 | 4 | return false; | |
| 811 | } | ||
| 812 | |||
| 813 | 134 | std::string ResolveVideoOutput(const std::string& requested) { | |
| 814 | 134 | std::string vOut = requested; | |
| 815 |
6/8✓ Branch 0 taken 134 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 134 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 132 times.
✓ Branch 5 taken 2 times.
✓ Branch 6 taken 132 times.
✓ Branch 7 taken 2 times.
|
134 | if (vOut.empty() || vOut == "--Default--") { |
| 816 |
1/2✓ Branch 0 taken 132 times.
✗ Branch 1 not taken.
|
132 | vOut = getSetting("VideoOutput"); |
| 817 | } | ||
| 818 |
2/2✓ Branch 0 taken 129 times.
✓ Branch 1 taken 5 times.
|
134 | if (vOut.empty()) { |
| 819 | #ifdef PLATFORM_OSX | ||
| 820 | // No connectors on a Mac; the display is the video window, which exists | ||
| 821 | // only in a GUI session (see MacOSApp.h). | ||
| 822 | vOut = MacOSAppActive() ? "--HDMI--" : "--Disabled--"; | ||
| 823 | #else | ||
| 824 |
7/10✓ Branch 0 taken 129 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 129 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 129 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 21 times.
✓ Branch 7 taken 108 times.
✓ Branch 8 taken 21 times.
✓ Branch 9 taken 108 times.
|
129 | if (FileExists("/sys/class/drm/card0-HDMI-A-1/status") || FileExists("/sys/class/drm/card1-HDMI-A-1/status")) { |
| 825 |
1/2✓ Branch 0 taken 21 times.
✗ Branch 1 not taken.
|
21 | vOut = "--HDMI--"; |
| 826 | } else { | ||
| 827 |
1/2✓ Branch 0 taken 108 times.
✗ Branch 1 not taken.
|
108 | vOut = "--Disabled--"; |
| 828 | } | ||
| 829 | #endif | ||
| 830 | } | ||
| 831 | 134 | return vOut; | |
| 832 | ✗ | } | |
| 833 | |||
| 834 | 133 | MediaOutputBase* CreateMediaOutput(const std::string& mediaFilename, const std::string& vOut, int streamSlot) { | |
| 835 |
1/2✓ Branch 0 taken 133 times.
✗ Branch 1 not taken.
|
133 | std::string tmpFile(mediaFilename); |
| 836 | 133 | std::size_t found = mediaFilename.find_last_of("."); | |
| 837 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 133 times.
|
133 | if (found == std::string::npos) { |
| 838 | ✗ | LogDebug(VB_MEDIAOUT, "Unable to determine extension of media file %s\n", | |
| 839 | mediaFilename.c_str()); | ||
| 840 | ✗ | return nullptr; | |
| 841 | } | ||
| 842 |
2/4✓ Branch 0 taken 133 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 133 times.
✗ Branch 3 not taken.
|
133 | std::string ext = toLowerCopy(mediaFilename.substr(found + 1)); |
| 843 |
1/2✓ Branch 0 taken 133 times.
✗ Branch 1 not taken.
|
133 | std::string vo = vOut; |
| 844 | |||
| 845 | // Use per-slot status for multi-stream; slot 1 uses global for backward compat | ||
| 846 |
2/4✓ Branch 0 taken 133 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 133 times.
✗ Branch 3 not taken.
|
133 | MediaOutputStatus* slotStatus = StreamSlotManager::Instance().GetStatus(streamSlot); |
| 847 |
1/2✓ Branch 0 taken 133 times.
✗ Branch 1 not taken.
|
133 | slotStatus->output = ""; |
| 848 | |||
| 849 | #ifdef HAS_GSTREAMER | ||
| 850 | // GStreamer is the sole media player. In PipeWire mode audio/video are | ||
| 851 | // routed through the PipeWire stack; in Hardware Direct (ALSA) mode | ||
| 852 | // GStreamer talks directly to ALSA for audio and uses kmssink for video. | ||
| 853 | 133 | bool useGStreamer = true; | |
| 854 | |||
| 855 |
6/8✓ Branch 0 taken 133 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 133 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 128 times.
✓ Branch 5 taken 5 times.
✓ Branch 6 taken 128 times.
✓ Branch 7 taken 5 times.
|
133 | if (useGStreamer && IsExtensionAudio(ext)) { |
| 856 |
1/2✓ Branch 0 taken 128 times.
✗ Branch 1 not taken.
|
128 | LogDebug(VB_MEDIAOUT, "Using GStreamer for audio playback: %s (slot %d)\n", mediaFilename.c_str(), streamSlot); |
| 857 |
4/10✓ Branch 0 taken 128 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 128 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 128 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 128 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
|
384 | return new GStreamerOutput(mediaFilename, slotStatus, "--Disabled--", streamSlot); |
| 858 | } | ||
| 859 |
8/12✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 5 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 5 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✓ Branch 9 taken 1 times.
✓ Branch 10 taken 4 times.
✓ Branch 11 taken 1 times.
|
5 | if (useGStreamer && IsExtensionVideo(ext) && !IsHDMIOut(vo)) { |
| 860 | // Video with PixelOverlay output — use GStreamer for both audio and video | ||
| 861 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | LogDebug(VB_MEDIAOUT, "Using GStreamer for video+overlay playback: %s (overlay=%s, slot %d)\n", |
| 862 | mediaFilename.c_str(), vo.c_str(), streamSlot); | ||
| 863 |
3/8✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 4 times.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
|
4 | return new GStreamerOutput(mediaFilename, slotStatus, vo, streamSlot); |
| 864 | } | ||
| 865 |
6/12✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 1 times.
✗ Branch 11 not taken.
|
1 | if (useGStreamer && IsExtensionVideo(ext) && IsHDMIOut(vo)) { |
| 866 | // Video to HDMI via GStreamer kmssink — audio through PipeWire | ||
| 867 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogDebug(VB_MEDIAOUT, "Using GStreamer for video+HDMI playback: %s (output=%s, slot %d)\n", |
| 868 | mediaFilename.c_str(), vo.c_str(), streamSlot); | ||
| 869 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | slotStatus->output = vo; |
| 870 |
3/8✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 1 times.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
|
1 | return new GStreamerOutput(mediaFilename, slotStatus, vo, streamSlot); |
| 871 | } | ||
| 872 | #endif | ||
| 873 | |||
| 874 | ✗ | return nullptr; | |
| 875 | 133 | } | |
| 876 | |||
| 877 | static std::set<std::string> alreadyWarned; | ||
| 878 | /* | ||
| 879 | * | ||
| 880 | */ | ||
| 881 | 34 | int OpenMediaOutput(const std::string& filename) { | |
| 882 |
1/2✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
|
34 | LogDebug(VB_MEDIAOUT, "OpenMediaOutput(%s)\n", filename.c_str()); |
| 883 | |||
| 884 |
1/2✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
|
34 | std::unique_lock<std::mutex> lock(mediaOutputLock); |
| 885 | |||
| 886 | try { | ||
| 887 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 33 times.
|
34 | if (mediaOutput) { |
| 888 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | lock.unlock(); |
| 889 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | CloseMediaOutput(); |
| 890 | } | ||
| 891 | // Only unlock here if we didn't already unlock above -- unlocking an | ||
| 892 | // already-unlocked mutex throws std::system_error, which was being | ||
| 893 | // mis-caught below and reported as "Error starting media" any time a | ||
| 894 | // previous mediaOutput existed (e.g. remote-mode UpdateMasterMediaPosition). | ||
| 895 |
2/2✓ Branch 0 taken 33 times.
✓ Branch 1 taken 1 times.
|
34 | if (lock.owns_lock()) { |
| 896 |
1/2✓ Branch 0 taken 33 times.
✗ Branch 1 not taken.
|
33 | lock.unlock(); |
| 897 | } | ||
| 898 | |||
| 899 |
1/2✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
|
34 | std::string tmpFile(filename); |
| 900 | 34 | std::size_t found = tmpFile.find_last_of("."); | |
| 901 |
2/2✓ Branch 0 taken 12 times.
✓ Branch 1 taken 22 times.
|
34 | if (found == std::string::npos) { |
| 902 |
1/2✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
|
12 | LogDebug(VB_MEDIAOUT, "Unable to determine extension of media file %s\n", |
| 903 | tmpFile.c_str()); | ||
| 904 | 12 | return 0; | |
| 905 | } | ||
| 906 |
2/4✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 22 times.
✗ Branch 3 not taken.
|
22 | std::string ext = toLowerCopy(tmpFile.substr(found + 1)); |
| 907 | |||
| 908 |
2/4✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 22 times.
✗ Branch 3 not taken.
|
22 | if (getFPPmode() == REMOTE_MODE) { |
| 909 |
1/2✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
|
22 | std::string orgTmp = tmpFile; |
| 910 |
1/2✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
|
22 | tmpFile = GetVideoFilenameForMedia(tmpFile, ext); |
| 911 |
8/10✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 21 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 21 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 18 times.
✓ Branch 7 taken 3 times.
✓ Branch 8 taken 18 times.
✓ Branch 9 taken 4 times.
|
22 | if (tmpFile == "" && HasAudioForMedia(orgTmp)) { |
| 912 |
1/2✓ Branch 0 taken 18 times.
✗ Branch 1 not taken.
|
18 | tmpFile = orgTmp; |
| 913 | } | ||
| 914 | |||
| 915 |
3/4✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✓ Branch 3 taken 19 times.
|
22 | if (tmpFile == "") { |
| 916 | // For v1.0 MultiSync, we can't sync audio to audio, so check for | ||
| 917 | // a video file if the master is playing an audio file | ||
| 918 | // video doesn't exist, punt | ||
| 919 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | tmpFile = filename; |
| 920 |
3/4✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 2 times.
|
3 | if (alreadyWarned.find(tmpFile) == alreadyWarned.end()) { |
| 921 | // The names come from sync packets; a show has a few | ||
| 922 | // hundred at most, and a stream of made-up ones must not | ||
| 923 | // grow this for good. | ||
| 924 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (alreadyWarned.size() >= 1000) { |
| 925 | ✗ | alreadyWarned.clear(); | |
| 926 | } | ||
| 927 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | alreadyWarned.emplace(tmpFile); |
| 928 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogDebug(VB_MEDIAOUT, "No video found for remote playing of %s\n", filename.c_str()); |
| 929 | } | ||
| 930 | 3 | return 0; | |
| 931 | } else { | ||
| 932 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | LogDebug(VB_MEDIAOUT, |
| 933 | "Player is playing %s audio, remote will try %s\n", | ||
| 934 | filename.c_str(), tmpFile.c_str()); | ||
| 935 | } | ||
| 936 |
2/2✓ Branch 0 taken 19 times.
✓ Branch 1 taken 3 times.
|
22 | } |
| 937 | |||
| 938 |
2/4✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
|
19 | std::string vOut = ResolveVideoOutput("--Default--"); |
| 939 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | LogDebug(VB_MEDIAOUT, "OpenMediaOutput: Creating media output for '%s' vOut='%s'\n", tmpFile.c_str(), vOut.c_str()); |
| 940 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | MediaOutputBase* out = CreateMediaOutput(tmpFile, vOut); |
| 941 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | LogDebug(VB_MEDIAOUT, "OpenMediaOutput: CreateMediaOutput returned %p\n", out); |
| 942 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 19 times.
|
19 | if (!out) { |
| 943 | ✗ | LogErr(VB_MEDIAOUT, "No Media Output handler for %s\n", tmpFile.c_str()); | |
| 944 | ✗ | WarningHolder::AddWarningTimeout(60, 30, "No media output handler for " + tmpFile + " (unsupported file type?)"); | |
| 945 | ✗ | return 0; | |
| 946 | } | ||
| 947 | |||
| 948 | // Install under a short lock, then run the plugin callbacks with the | ||
| 949 | // lock RELEASED. playlistCallback/mediaCallback execute arbitrary | ||
| 950 | // plugin code inline — a native plugin that reaches anything taking | ||
| 951 | // mediaOutputLock (setVolume, CloseMediaOutput) would self-deadlock, | ||
| 952 | // and a script plugin would hold the lock for its entire fork/wait | ||
| 953 | // run. Same defect and fix as PlaylistEntryMedia::OpenMediaOutput. | ||
| 954 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | lock.lock(); |
| 955 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 19 times.
|
19 | if (mediaOutput) { |
| 956 | // Another thread opened media while we were creating ours; it | ||
| 957 | // won the race. Discard ours (teardown outside the lock). | ||
| 958 | ✗ | lock.unlock(); | |
| 959 | ✗ | LogWarn(VB_MEDIAOUT, "OpenMediaOutput: another media output was opened concurrently, discarding %s\n", tmpFile.c_str()); | |
| 960 | ✗ | delete out; | |
| 961 | ✗ | return 0; | |
| 962 | } | ||
| 963 | 19 | mediaOutput = out; | |
| 964 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | lock.unlock(); |
| 965 | |||
| 966 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | Json::Value root; |
| 967 |
3/6✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 19 times.
✗ Branch 5 not taken.
|
19 | root["currentEntry"]["type"] = "media"; |
| 968 |
3/6✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 19 times.
✗ Branch 5 not taken.
|
19 | root["currentEntry"]["mediaFilename"] = out->m_mediaFilename; |
| 969 | |||
| 970 |
6/8✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 12 times.
✓ Branch 5 taken 7 times.
✓ Branch 6 taken 12 times.
✓ Branch 7 taken 7 times.
|
19 | if (getFPPmode() == REMOTE_MODE && firstOutCreate) { |
| 971 | 12 | firstOutCreate = false; | |
| 972 | // need to "fake" a playlist start as some plugins will not initialize | ||
| 973 | // until a playlist is started, but remotes don't have playlists | ||
| 974 |
2/4✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
|
12 | root["name"] = "FPP Remote"; |
| 975 |
2/4✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
|
12 | root["desc"] = "FPP Remote Mode"; |
| 976 |
2/4✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
|
12 | root["loop"] = false; |
| 977 |
2/4✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
|
12 | root["repeat"] = false; |
| 978 |
2/4✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
|
12 | root["random"] = false; |
| 979 |
2/4✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
|
12 | root["size"] = 1; |
| 980 |
3/6✓ Branch 0 taken 12 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 12 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 12 times.
✗ Branch 5 not taken.
|
48 | PluginManager::INSTANCE.playlistCallback(root, "start", "Main", 0); |
| 981 | } | ||
| 982 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | MediaDetails::INSTANCE.ParseMedia(out->m_mediaFilename.c_str()); |
| 983 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | PluginManager::INSTANCE.mediaCallback(root, MediaDetails::INSTANCE); |
| 984 | |||
| 985 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 19 times.
|
19 | if (multiSync->isMultiSyncEnabled()) { |
| 986 | ✗ | multiSync->SendMediaOpenPacket(out->m_mediaFilename); | |
| 987 | } | ||
| 988 | 19 | return 1; | |
| 989 |
0/2✗ Branch 0 not taken.
✗ Branch 1 not taken.
|
34 | } catch (const std::system_error& e) { |
| 990 | ✗ | LogErr(VB_MEDIAOUT, "System exception starting media for %s. Code: %d What: %s\n", filename.c_str(), e.code().value(), e.what()); | |
| 991 | ✗ | WarningHolder::AddWarningTimeout(60, 30, "Error starting media " + filename + ": " + e.what()); | |
| 992 | ✗ | return 0; | |
| 993 | ✗ | } | |
| 994 | 34 | } | |
| 995 | 1156 | bool MatchesRunningMediaFilename(const std::string& filename) { | |
| 996 |
2/2✓ Branch 0 taken 1145 times.
✓ Branch 1 taken 11 times.
|
1156 | if (mediaOutput) { |
| 997 |
1/2✓ Branch 0 taken 1145 times.
✗ Branch 1 not taken.
|
1145 | std::string tmpFile = filename; |
| 998 |
3/4✓ Branch 0 taken 1145 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1131 times.
✓ Branch 3 taken 14 times.
|
1145 | if (HasAudioForMedia(tmpFile)) { |
| 999 |
5/6✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1130 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 1130 times.
✓ Branch 5 taken 1 times.
|
1131 | if (mediaOutput->m_mediaFilename == tmpFile || mediaOutput->m_mediaFilename == filename) { |
| 1000 | 1130 | return true; | |
| 1001 | } | ||
| 1002 | } | ||
| 1003 |
1/2✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
|
15 | tmpFile = filename; |
| 1004 |
3/4✓ Branch 0 taken 15 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 14 times.
✓ Branch 3 taken 1 times.
|
15 | if (HasVideoForMedia(tmpFile)) { |
| 1005 |
2/6✗ Branch 0 not taken.
✓ Branch 1 taken 14 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✓ Branch 4 taken 14 times.
✗ Branch 5 not taken.
|
14 | if (mediaOutput->m_mediaFilename == tmpFile || mediaOutput->m_mediaFilename == filename) { |
| 1006 | 14 | return true; | |
| 1007 | } | ||
| 1008 | } | ||
| 1009 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1144 times.
|
1145 | } |
| 1010 | 12 | return false; | |
| 1011 | } | ||
| 1012 | |||
| 1013 | 26 | int StartMediaOutput(const std::string& filename, int msTime) { | |
| 1014 |
3/4✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✓ Branch 3 taken 19 times.
|
26 | if (!MatchesRunningMediaFilename(filename)) { |
| 1015 |
1/2✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
|
7 | CloseMediaOutput(); |
| 1016 | } | ||
| 1017 | |||
| 1018 |
5/8✓ Branch 0 taken 19 times.
✓ Branch 1 taken 7 times.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 19 times.
✗ Branch 6 not taken.
✓ Branch 7 taken 26 times.
|
26 | if (mediaOutput && mediaOutput->IsPlaying()) { |
| 1019 | ✗ | CloseMediaOutput(); | |
| 1020 | } | ||
| 1021 |
2/2✓ Branch 0 taken 7 times.
✓ Branch 1 taken 19 times.
|
26 | if (!mediaOutput) { |
| 1022 |
1/2✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
|
7 | OpenMediaOutput(filename); |
| 1023 | } | ||
| 1024 |
2/2✓ Branch 0 taken 7 times.
✓ Branch 1 taken 19 times.
|
26 | if (!mediaOutput) { |
| 1025 | 7 | return 0; | |
| 1026 | } | ||
| 1027 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | std::unique_lock<std::mutex> lock(mediaOutputLock); |
| 1028 | // Snapshot under the lock — the pre-flight checks above read the global | ||
| 1029 | // unlocked, and a concurrent CloseMediaOutput() may have nulled it. | ||
| 1030 | 19 | MediaOutputBase* out = mediaOutput; | |
| 1031 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 19 times.
|
19 | if (!out) { |
| 1032 | ✗ | return 0; | |
| 1033 | } | ||
| 1034 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 19 times.
|
19 | if (multiSync->isMultiSyncEnabled()) { |
| 1035 | ✗ | multiSync->SendMediaSyncStartPacket(out->m_mediaFilename); | |
| 1036 | } | ||
| 1037 | |||
| 1038 | // mediaOutputStatus is global and shared by every media output, and only | ||
| 1039 | // Process() ever writes the elapsed fields -- so until the new pipeline | ||
| 1040 | // reports its first position they still describe the *previous* item. On a | ||
| 1041 | // synced remote that stale value is actively dangerous: the first sync | ||
| 1042 | // packet after a clip change hands AdjustSpeed() a ~15s "local position" | ||
| 1043 | // against a master position of ~1s, it reads that as 14s out of sync and | ||
| 1044 | // issues a flushing seek into a pipeline that has not even prerolled yet | ||
| 1045 | // (issue #2727). Start every item from zero instead; AdjustSpeed()'s own | ||
| 1046 | // "not playing yet" guard then holds until a real position arrives. | ||
| 1047 | { | ||
| 1048 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | std::lock_guard<std::mutex> lock(MediaOutputStatus::FieldsLock()); |
| 1049 | 19 | mediaOutputStatus.mediaSeconds = 0.0; | |
| 1050 | 19 | mediaOutputStatus.secondsElapsed = 0; | |
| 1051 | 19 | mediaOutputStatus.subSecondsElapsed = 0; | |
| 1052 | 19 | mediaOutputStatus.secondsRemaining = 0; | |
| 1053 | 19 | mediaOutputStatus.subSecondsRemaining = 0; | |
| 1054 | 19 | } | |
| 1055 | |||
| 1056 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | LogWarn(VB_MEDIAOUT, "StartMediaOutput: Calling Start(%d) on mediaOutput=%p\n", msTime, out); |
| 1057 |
2/4✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 19 times.
|
19 | if (!out->Start(msTime)) { |
| 1058 | ✗ | LogErr(VB_MEDIAOUT, "Could not start media %s\n", out->m_mediaFilename.c_str()); | |
| 1059 | ✗ | WarningHolder::AddWarningTimeout(60, 30, "Could not start media " + out->m_mediaFilename); | |
| 1060 | ✗ | mediaOutput = 0; | |
| 1061 | ✗ | lock.unlock(); | |
| 1062 | // Teardown (GStreamer state change + sleeps) never under the lock. | ||
| 1063 | ✗ | delete out; | |
| 1064 | ✗ | return 0; | |
| 1065 | } | ||
| 1066 | // MEDIA_STARTED preset commands run INLINE on this thread. A preset | ||
| 1067 | // containing a volume command takes mediaOutputLock again (self-deadlock | ||
| 1068 | // on this non-recursive mutex); one containing a playlist command takes | ||
| 1069 | // m_playlistMutex under mediaOutputLock, the reverse of every other | ||
| 1070 | // thread's order (AB-BA). Release before triggering. | ||
| 1071 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | lock.unlock(); |
| 1072 | 19 | std::map<std::string, std::string> keywords; | |
| 1073 |
3/6✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 19 times.
✗ Branch 5 not taken.
|
38 | keywords["MEDIA_NAME"] = filename; |
| 1074 |
4/6✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 18 times.
|
38 | if (CommandManager::INSTANCE.HasPreset("MEDIA_STARTED")) { |
| 1075 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
2 | CommandManager::INSTANCE.TriggerPreset("MEDIA_STARTED", keywords); |
| 1076 | } | ||
| 1077 | |||
| 1078 | 19 | return 1; | |
| 1079 | 19 | } | |
| 1080 | 1891 | void CloseMediaOutput() { | |
| 1081 |
1/2✓ Branch 0 taken 1891 times.
✗ Branch 1 not taken.
|
1891 | LogDebug(VB_MEDIAOUT, "CloseMediaOutput()\n"); |
| 1082 | |||
| 1083 | 1891 | mediaOutputStatus.status = MEDIAOUTPUTSTATUS_IDLE; | |
| 1084 | |||
| 1085 |
1/2✓ Branch 0 taken 1891 times.
✗ Branch 1 not taken.
|
1891 | std::unique_lock<std::mutex> lock(mediaOutputLock); |
| 1086 |
2/2✓ Branch 0 taken 1872 times.
✓ Branch 1 taken 19 times.
|
1891 | if (!mediaOutput) { |
| 1087 | 1872 | return; | |
| 1088 | } | ||
| 1089 | |||
| 1090 | // Move the global pointer into a local and clear the global while still | ||
| 1091 | // holding the lock, then release the lock before the (potentially | ||
| 1092 | // ~400ms) Stop()/teardown. This ensures only one thread can ever "win" | ||
| 1093 | // the null-check above -- a second, concurrent caller (e.g. the | ||
| 1094 | // MultiSync thread racing the playlist thread in remote mode) will see | ||
| 1095 | // mediaOutput already null and return immediately, instead of both | ||
| 1096 | // threads operating on (and eventually deleting) the same object. | ||
| 1097 | 19 | MediaOutputBase* out = mediaOutput; | |
| 1098 | 19 | mediaOutput = nullptr; | |
| 1099 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | lock.unlock(); |
| 1100 | |||
| 1101 |
3/4✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 18 times.
✓ Branch 3 taken 1 times.
|
19 | if (out->IsPlaying()) { |
| 1102 |
1/2✓ Branch 0 taken 18 times.
✗ Branch 1 not taken.
|
18 | out->Stop(); |
| 1103 | } | ||
| 1104 | |||
| 1105 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 19 times.
|
19 | if (multiSync->isMultiSyncEnabled()) { |
| 1106 | ✗ | multiSync->SendMediaSyncStopPacket(out->m_mediaFilename); | |
| 1107 | } | ||
| 1108 | |||
| 1109 | 19 | std::map<std::string, std::string> keywords; | |
| 1110 |
3/6✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 19 times.
✗ Branch 5 not taken.
|
57 | keywords["MEDIA_NAME"] = out->m_mediaFilename; |
| 1111 |
3/6✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 19 times.
|
38 | if (CommandManager::INSTANCE.HasPreset("MEDIA_STOPPED")) { |
| 1112 | ✗ | CommandManager::INSTANCE.TriggerPreset("MEDIA_STOPPED", keywords); | |
| 1113 | } | ||
| 1114 | |||
| 1115 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | delete out; |
| 1116 | |||
| 1117 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | Json::Value root; |
| 1118 |
2/4✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
|
19 | root["name"] = "FPP Remote"; |
| 1119 |
2/4✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
|
19 | root["desc"] = "FPP Remote Mode"; |
| 1120 |
3/6✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 19 times.
✗ Branch 5 not taken.
|
19 | root["currentEntry"]["type"] = "media"; |
| 1121 |
3/6✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 19 times.
✗ Branch 5 not taken.
|
19 | root["currentEntry"]["mediaFilename"] = ""; |
| 1122 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | MediaDetails::INSTANCE.Clear(); |
| 1123 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | PluginManager::INSTANCE.mediaCallback(root, MediaDetails::INSTANCE); |
| 1124 |
2/2✓ Branch 0 taken 19 times.
✓ Branch 1 taken 1872 times.
|
1891 | } |
| 1125 | |||
| 1126 | 1114 | void UpdateMasterMediaPosition(const std::string& filename, float seconds) { | |
| 1127 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1114 times.
|
1114 | if (getFPPmode() != REMOTE_MODE) { |
| 1128 | ✗ | return; | |
| 1129 | } | ||
| 1130 | |||
| 1131 |
2/2✓ Branch 0 taken 1109 times.
✓ Branch 1 taken 5 times.
|
1114 | if (MatchesRunningMediaFilename(filename)) { |
| 1132 | 1109 | masterMediaPosition = seconds; | |
| 1133 |
1/2✓ Branch 0 taken 1109 times.
✗ Branch 1 not taken.
|
1109 | std::unique_lock<std::mutex> lock(mediaOutputLock); |
| 1134 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1109 times.
|
1109 | if (!mediaOutput) { |
| 1135 | ✗ | return; | |
| 1136 | } | ||
| 1137 |
1/2✓ Branch 0 taken 1109 times.
✗ Branch 1 not taken.
|
1109 | mediaOutput->AdjustSpeed(seconds); |
| 1138 | 1109 | return; | |
| 1139 | 1109 | } else { | |
| 1140 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | OpenMediaOutput(filename); |
| 1141 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | int msTime = (seconds > 0.0f) ? (int)(seconds * 1000.0f) : 0; |
| 1142 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | StartMediaOutput(filename, msTime); |
| 1143 | 5 | masterMediaPosition = seconds; | |
| 1144 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | std::unique_lock<std::mutex> lock(mediaOutputLock); |
| 1145 |
2/2✓ Branch 0 taken 4 times.
✓ Branch 1 taken 1 times.
|
5 | if (!mediaOutput) { |
| 1146 | 4 | return; | |
| 1147 | } | ||
| 1148 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | mediaOutput->AdjustSpeed(seconds); |
| 1149 | 1 | return; | |
| 1150 | 5 | } | |
| 1151 | } | ||
| 1152 |