GCC Code Coverage Report


Directory: ./
File: mediaoutput/mediaoutput.cpp
Date: 2026-10-29 08:11:58
Exec Total Coverage
Lines: 480 562 85.4%
Functions: 32 33 97.0%
Branches: 680 1387 49.0%

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