// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 #pragma once #include "upnp/gena.h" #include "transport_source.h" #include #include #include #include #include #include namespace bridge { // Shared by the event receiver and relay worker; no playback decisions here. class SpeakerChanges { std::mutex mutex; std::map values; // A response may contain both fresh fields and fields superseded during I/O. using Clock = std::chrono::steady_clock; std::map eventTimes; std::string sid; uint32_t sequence = 0; bool sequenced = false; struct EditEcho { std::string uri, ordinal, count, transport; }; std::deque edits; public: std::string foreignTransport(const std::string &queueUri) { std::lock_guard lock(mutex); const auto uri = values.find("AVTransportURI"); return uri != values.end() && !uri->second.empty() && uri->second != queueUri ? transportSourceClass(uri->second) : ""; } private: void report(const std::string &source, const std::map &fields) { for (const auto &field : fields) { // Position advances every second; order it without routine poll logging. if (field.first == "RelativeTimePosition") { values[field.first] = field.second; continue; } auto old = values.find(field.first); if (old != values.end() && old->second == field.second) continue; const bool uri = field.first == "AVTransportURI" || field.first == "CurrentTrackURI"; const auto display = [&](const std::string &value) { return uri ? transportSourceLog(value) : value; }; std::ostringstream line; line << "yeney: queue speaker-change source=" << source << " field=" << field.first << " old=" << std::quoted(old == values.end() ? "" : display(old->second)) << " new=" << std::quoted(display(field.second)); auto text = line.str(); for (char &c : text) if (c == '\n' || c == '\r') c = ' '; std::printf("%s\n", text.c_str()); values[field.first] = field.second; } } public: std::map poll(Clock::time_point started, std::map fields) { std::lock_guard lock(mutex); bool stale = false; for (auto &field : fields) { const auto stamp = eventTimes.find(field.first); if (stamp != eventTimes.end() && started < stamp->second) { field.second = values.at(field.first); stale = true; } } if (stale) std::printf("yeney: queue stale poll answer discarded after GENA seq=%u\n", sequence); report("poll", fields); return fields; } // Register before SOAP: GENA may arrive before the action response. // Exact expected states fence delayed edit echoes without a timed blackout // that could swallow a real app Next or pause. void expectEdit(std::string uri, unsigned ordinal, unsigned count, std::string transport) { std::lock_guard lock(mutex); edits.push_back({std::move(uri), std::to_string(ordinal), std::to_string(count), std::move(transport)}); if (edits.size() > 64) edits.pop_front(); } void transportSelected(const std::string &uri) { std::lock_guard lock(mutex); report("command", {{"AVTransportURI", uri}}); eventTimes.erase("AVTransportURI"); } void selected() { std::lock_guard lock(mutex); // A confirmed explicit seek supersedes observations of the old item. for (const auto *name : {"CurrentTrack", "CurrentTrackURI", "RelativeTimePosition", "TransportState"}) eventTimes.erase(name); } bool event(const upnp::GenaEvent &event) { upnp::XmlNode instance; if (!upnp::lastChangeInstance(event.body, instance)) return false; std::map fields; for (const auto *name : {"TransportState", "TransportStatus", "CurrentTrack", "CurrentTrackURI", "NumberOfTracks", "AVTransportURI", "RelativeTimePosition"}) if (const auto node = instance.child(name)) fields[name] = node->attribute("val"); if (const auto node = instance.child("CurrentTransportStatus")) fields["TransportStatus"] = node->attribute("val"); std::lock_guard lock(mutex); // SEQ is scoped to the subscription and wraps as an unsigned counter. if (sequenced && sid == event.sid && (event.sequence == sequence || uint32_t(event.sequence - sequence) >= 0x80000000u)) return false; sid = event.sid; sequence = event.sequence; sequenced = true; const auto field = [&fields](const std::string &name) -> std::string { const auto found = fields.find(name); return found == fields.end() ? std::string{} : found->second; }; const auto matches = [&fields](const std::string &name, const std::string &expected) { const auto found = fields.find(name); return found == fields.end() || found->second == expected; }; for (const auto &edit : edits) { // LastChange may contain only the changed ordinal/count, with the // unchanged URI omitted. Never swallow state-only app controls or // a playback error as queue feedback. const auto state = field("TransportState"); if ((fields.count("CurrentTrack") || fields.count("NumberOfTracks")) && matches("CurrentTrackURI", edit.uri) && matches("CurrentTrack", edit.ordinal) && matches("NumberOfTracks", edit.count) && matches("AVTransportURI", edit.transport) && (state.empty() || state == "PLAYING" || state == "TRANSITIONING" || (edit.uri.empty() && state == "STOPPED")) && (field("TransportStatus").empty() || field("TransportStatus") == "OK")) { std::printf("yeney: queue self-edit echo fenced tracks=%s ordinal=%s\n", edit.count.c_str(), edit.ordinal.c_str()); return false; } } const auto now = Clock::now(); for (const auto &field : fields) eventTimes[field.first] = now; report("GENA", fields); return true; } }; } // namespace bridge