diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index 0decf11f..94298521 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -1,5 +1,6 @@ #include #include "messagelist.h" +#include "../transport/stream_bridge.h" #include "debug_ui.h" #include "chatview.h" #include "messagedelegate.h" @@ -426,6 +427,30 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent connect(m_radioBtn, &QPushButton::toggled, this, &MessageList::onRadioToggled); hl->addWidget(m_radioBtn); + m_streamBtn = new QPushButton(header); m_streamBtn->setObjectName("streamButton"); + QPixmap streamIcon(24, 24); streamIcon.fill(Qt::transparent); + { QPainter painter(&streamIcon); painter.setRenderHint(QPainter::Antialiasing); painter.setPen(QPen(QColor("#e53935"), 2)); + painter.drawRoundedRect(QRectF(2, 6, 20, 14), 2, 2); painter.drawLine(8, 1, 12, 5); painter.drawLine(16, 1, 12, 5); + painter.setBrush(QColor("#e53935")); painter.drawEllipse(QPointF(12, 13), 3, 3); } + m_streamBtn->setIcon(QIcon(streamIcon)); m_streamBtn->setIconSize(QSize(24, 24)); m_streamBtn->hide(); + m_streamBtn->setAccessibleName(QCoreApplication::translate("ChatGui", "Live streams")); + m_streamBtn->setCursor(Qt::PointingHandCursor); + connect(m_streamBtn, &QPushButton::clicked, this, [this] { + auto watch = [this](const LiveSource& source) { + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop selected stream group=%s source=%llx epoch=%llx", + qPrintable(m_currentChannelId), (unsigned long long)source.id, (unsigned long long)source.epoch); + emit streamRequested(m_currentChannelId.toULongLong(), source.id, source.epoch, source.name); + }; + if (m_streamSources.size() == 1) { watch(m_streamSources.front()); return; } + QMenu menu(this); + for (const auto& source : m_streamSources) { + auto* action = menu.addAction(QStringLiteral("%1 · %2").arg(source.name).arg(source.id)); + connect(action, &QAction::triggered, this, [watch, source] { watch(source); }); + } + menu.exec(m_streamBtn->mapToGlobal(QPoint(0, m_streamBtn->height()))); + }); + hl->addWidget(m_streamBtn); + m_groupMenuButton = new QPushButton(QStringLiteral("…"), header); m_groupMenuButton->setObjectName("groupMenuButton"); m_groupMenuButton->setFixedSize(34, 30); @@ -749,6 +774,7 @@ void MessageList::loadChannel(const QString& channelId) { m_avatarCache.clear(); if (m_currentChannelId != channelId) { m_feedback.clear(); m_pendingPoll.clear(); } m_currentChannelId = channelId; + clearStreams(); watchStreams(); m_groupMenuButton->setVisible(channelId.toULongLong() != 0); clearDmCallRoute(); m_callBtn->setVisible(isDmChannel(channelId)); @@ -1451,6 +1477,43 @@ void MessageList::onRadioTalk(quint64 groupId, quint64 srcNodeId, bool on) { } } +/* Каталог выбранного канала не удерживает media owner; DM/пустой target снимает наблюдение. */ +void MessageList::watchStreams() { + if (gui_stream_ready()) gui_stream_watch(isDmChannel(m_currentChannelId) ? 0 : m_currentChannelId.toULongLong()); +} + +void MessageList::clearStreams() { m_streamSources.clear(); m_streamBtn->hide(); } + +/* Полный снимок применяется атомарно; ответы предыдущего канала не меняют текущую шапку. */ +void MessageList::onStreamSources(const uint8_t* data, int len) { + if (!data || len < 11 || data[8] > 1) { DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop source event invalid len=%d", len); return; } + quint64 group = stream_read_be(data, 8); + if (isDmChannel(m_currentChannelId) || group != m_currentChannelId.toULongLong()) return; + QJsonParseError error; + auto document = QJsonDocument::fromJson(QByteArray(reinterpret_cast(data + 9), len - 9), &error); + if (!data[8] || error.error != QJsonParseError::NoError || !document.isArray()) { + DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop source list failed group=%llx valid=%u parse=%s", + (unsigned long long)group, data[8], qPrintable(error.errorString())); + clearStreams(); return; + } + QList sources; + for (const auto& value : document.array()) { + auto member = value.toObject(); bool idOk = false, epochOk = false; + quint64 id = member.value("id").toString().toULongLong(&idOk); + quint64 epoch = member.value("epoch").toString().toULongLong(&epochOk, 16); + if (!idOk || !epochOk || !id || !epoch || !member.value("name").isString()) { + DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "desktop source identity malformed group=%llx", (unsigned long long)group); + clearStreams(); return; + } + QString name = member.value("name").toString(); if (name.isEmpty()) name = QString::number(id); + sources.push_back({id, epoch, name}); + } + m_streamSources = std::move(sources); m_streamBtn->setVisible(!m_streamSources.isEmpty()); + m_streamBtn->setText(m_streamSources.size() > 1 ? QString::number(m_streamSources.size()) : QString()); + m_streamBtn->setToolTip(QCoreApplication::translate("ChatGui", "Live streams: %1").arg(m_streamSources.size())); + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "desktop stream header group=%llx sources=%d", (unsigned long long)group, (int)m_streamSources.size()); +} + void MessageList::onRadioToggled(bool on) { if (m_currentChannelId.isEmpty() || isDmChannel(m_currentChannelId)) return; diff --git a/tools/chatgui/src/messagelist.h b/tools/chatgui/src/messagelist.h index deeb1236..110e5a12 100644 --- a/tools/chatgui/src/messagelist.h +++ b/tools/chatgui/src/messagelist.h @@ -46,11 +46,15 @@ public: void onFeedbackResult(const uint8_t* data, int len); void onMessageSubmitted(const uint8_t* data, int len); void onRadioTalk(quint64 groupId, quint64 srcNodeId, bool on); /* индикация говорящего */ + void onStreamSources(const uint8_t* data, int len); + void clearStreams(); + void watchStreams(); signals: void readPositionChanged(const QString& channelId, qint64 lastReadMsgId); void callRequested(const QString& target, quint64 peer, const QString& name); void openVideoRequested(const QString& filePath); + void streamRequested(quint64 group, quint64 source, quint64 epoch, const QString& name); void openImageRequested(const QString& filePath); void inviteRequested(const QString& sourceChannel, quint64 peer, const QString& name); void ownFilesRequested(const QString& channelId); @@ -83,6 +87,9 @@ private: quint64 m_callPeer = 0; quint64 m_callGroup = 0; QPushButton* m_radioBtn = nullptr; /* шапка: переключатель рации */ + QPushButton* m_streamBtn = nullptr; + struct LiveSource { quint64 id, epoch; QString name; }; + QList m_streamSources; RadioPanel* m_radioPanel = nullptr; /* верхняя панель устройств, VAD и PTT */ bool m_radioOn = false; QThreadPool m_thumbnailPool;