#include #include "memberlistmodel.h" #include "debug_ui.h" #include "avatars/avatarmanager.h" #include "../transport/gui_bridge.h" #include #include #include #include #include extern "C" { #include "../../../src/chat/chat_core.h" #include "../../../src/chat/chat_member.h" #include "../../../lib/mem.h" } static void applyPhoto(MemberListItem& item, const QString& channel) { auto* manager = AvatarManager::instance(); if (!manager) return; const QImage image = manager->image(channel, item.nodeId); if (image.isNull()) return; const int size = MemberListModel::kAvatarSize; QPixmap pixmap(size, size); pixmap.fill(Qt::transparent); QPainter painter(&pixmap); painter.setRenderHint(QPainter::Antialiasing); painter.setRenderHint(QPainter::SmoothPixmapTransform); QPainterPath clip; clip.addEllipse(QRectF(0, 0, size, size)); painter.setClipPath(clip); painter.drawImage(QRect(0, 0, size, size), image); painter.setClipping(false); painter.setPen(QPen(Qt::white, 1)); painter.setBrush(item.online ? QColor("#4CAF50") : QColor("#9E9E9E")); painter.drawEllipse(QRect(size - 7, size - 7, 6, 6)); painter.end(); item.avatar = pixmap; } MemberListModel::MemberListModel(QObject* parent) : QAbstractListModel(parent) , m_rttTimer(new QTimer(this)) { if (auto* manager = AvatarManager::instance()) connect(manager, &AvatarManager::changed, this, [this](const QString& channel, quint64 owner) { if (channel != m_channelId) return; for (auto it = m_cache.begin(); it != m_cache.end(); ++it) if (it->nodeId == owner) { applyPhoto(*it, channel); emit dataChanged(index(it.key()), index(it.key()), {Qt::DecorationRole}); } }); m_rttTimer->setInterval(2000); connect(m_rttTimer, &QTimer::timeout, this, &MemberListModel::refreshRtt); m_rttTimer->start(); } int MemberListModel::rowCount(const QModelIndex& parent) const { (void)parent; return m_totalCount; } QVariant MemberListModel::data(const QModelIndex& index, int role) const { if (!index.isValid() || index.row() < 0 || index.row() >= m_totalCount) return {}; auto it = m_cache.find(index.row()); if (it != m_cache.end()) { if (role == Qt::DisplayRole) return it->name; if (role == Qt::DecorationRole) return QVariant::fromValue(QIcon(it->avatar)); if (role == Qt::UserRole + 1) { QString sub; if (it->rtt >= 0) sub += QString("rtt=%1ms").arg(it->rtt / 10); if (it->natType >= 0) { static const char* nat[] = {"NET","DIR","EIM","STR","NAT"}; const char* nt = (it->natType <= 4) ? nat[it->natType] : "?"; if (!sub.isEmpty()) sub += " "; sub += nt; } return sub; } if (role == Qt::ToolTipRole) return (it->online ? QCoreApplication::translate("ChatGui", "online") : QCoreApplication::translate("ChatGui", "offline")) + " | 0x" + QString("%1").arg(it->nodeId, 16, 16, QChar('0')) + ((it->flags & CHAT_MEMBER_FLAG_RADIO) ? QCoreApplication::translate("ChatGui", " · Radio enabled") : QString()); if (role == Qt::UserRole + 2) return it->isSelf; if (role == Qt::UserRole + 3) return it->flags; if (role == Qt::UserRole + 4) return (bool)it->storage; if (role == RadioSpeakingRole) return it->isSelf ? m_selfTransmitting : m_radioTalkers.contains(it->nodeId); return {}; } return {}; } void MemberListModel::setChannel(const QString& channelId, bool isOwner) { beginResetModel(); if (m_channelId != channelId) { DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: channel %s -> %s cleared talkers=%d self_tx=%d", qPrintable(m_channelId), qPrintable(channelId), int(m_radioTalkers.size()), m_selfTransmitting); m_radioTalkers.clear(); m_radioActive = false; m_selfTransmitting = false; } m_channelId = channelId; m_isOwner = isOwner; m_totalCount = 0; m_cache.clear(); endResetModel(); if (!channelId.isEmpty()) { struct chat_member_list_req* req = (struct chat_member_list_req*)u_calloc(1, sizeof(*req)); if (!req) return; req->inst = gui_bridge_get_inst(); strncpy(req->ch_id, channelId.toUtf8().constData(), sizeof(req->ch_id) - 1); gui_bridge_post_uasync_fn(chat_core_request_member_list_trampoline, req); } } void MemberListModel::refresh() { if (!m_channelId.isEmpty()) setChannel(m_channelId, m_isOwner); } void MemberListModel::refreshRtt() { if (m_channelId.isEmpty()) return; struct chat_member_list_req* req = (struct chat_member_list_req*)u_calloc(1, sizeof(*req)); if (!req) return; req->inst = gui_bridge_get_inst(); strncpy(req->ch_id, m_channelId.toUtf8().constData(), sizeof(req->ch_id) - 1); gui_bridge_post_uasync_fn(chat_core_request_member_rtt_trampoline, req); } quint64 MemberListModel::nodeIdAt(int row) const { auto it = m_cache.find(row); return it != m_cache.end() ? it->nodeId : 0; } bool MemberListModel::isOnlineAt(int row) const { auto it = m_cache.find(row); return it != m_cache.end() ? it->online : false; } static void deserialize_member(const uint8_t* src, MemberListItem& item) { memcpy(&item.nodeId, src, 8); uint8_t online = src[8]; item.isSelf = src[9] != 0; item.flags = src[10]; uint16_t rtt; memcpy(&rtt, src + 11, 2); item.rtt = (rtt == 0xFFFF) ? -1 : (int)rtt; int nat = (int)src[13]; item.natType = (nat == 0xFF) ? -1 : nat; item.storage = src[14]; item.online = (online != 0); char name[64]; memcpy(name, src + 15, 64); name[63] = '\0'; item.name = QString::fromUtf8(name); QColor c = item.online ? QColor("#4CAF50") : QColor("#9E9E9E"); const int size = MemberListModel::kAvatarSize; QPixmap pm(size, size); pm.fill(Qt::transparent); QPainter p(&pm); p.setRenderHint(QPainter::Antialiasing); p.setBrush(c); p.setPen(Qt::NoPen); p.drawEllipse(0, 0, size, size); p.setPen(Qt::white); QFont f; f.setBold(true); f.setPixelSize(size / 2); p.setFont(f); p.drawText(QRect(0, 0, size, size), Qt::AlignCenter, item.name.left(1)); p.end(); item.avatar = pm; } void MemberListModel::onMemberList(const QString& chId, int count, const uint8_t* data, int dataLen) { if (chId != m_channelId) return; if (count < 0 || count > dataLen / CHAT_MEMBER_DISPLAY_SIZE) return; beginResetModel(); m_totalCount = 0; m_cache.clear(); int row = 0; for (int i = 0; i < count; i++) { MemberListItem item; deserialize_member(data + i * CHAT_MEMBER_DISPLAY_SIZE, item); applyPhoto(item, chId); if ((item.flags & CHAT_MEMBER_FLAG_DELETED) && !m_isOwner) continue; m_cache[row++] = item; } m_totalCount = row; endResetModel(); } void MemberListModel::onMemberUpdated(const QString& chId, const uint8_t* data) { if (chId != m_channelId) return; MemberListItem item; deserialize_member(data, item); applyPhoto(item, chId); /* find existing row by nodeId */ int row = -1; for (auto it = m_cache.begin(); it != m_cache.end(); ++it) { if (it->nodeId == item.nodeId) { row = it.key(); break; } } if ((item.flags & CHAT_MEMBER_FLAG_DELETED) && !m_isOwner) { /* deleted member — remove from non-owner's list */ onMemberRemoved(chId, item.nodeId); return; } if (row >= 0) { m_cache[row] = item; emit dataChanged(index(row), index(row)); } else { /* new member — add at end */ row = m_totalCount; beginInsertRows(QModelIndex(), row, row); m_cache[row] = item; m_totalCount++; endInsertRows(); } } void MemberListModel::onMemberRemoved(const QString& chId, uint64_t nodeId) { if (chId != m_channelId) return; if (m_radioTalkers.remove(nodeId)) DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: removed talker ch=%s node=%016llx", qPrintable(chId), (unsigned long long)nodeId); for (auto it = m_cache.begin(); it != m_cache.end(); ++it) { if (it->nodeId == nodeId) { int row = it.key(); beginRemoveRows(QModelIndex(), row, row); m_cache.remove(row); for (int next = row + 1; next < m_totalCount; ++next) m_cache[next - 1] = m_cache.take(next); m_totalCount--; endRemoveRows(); return; } } } /* Состояние собственного TX берём из аудио-движка: оно включает PTT и VAD. */ void MemberListModel::setRadioState(quint64 groupId, bool active, bool transmitting) { active = active && groupId != 0 && groupId == m_channelId.toULongLong(); transmitting = active && transmitting; if (m_radioActive == active && m_selfTransmitting == transmitting) return; DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: state ch=%s group=%016llx active=%d self_tx=%d talkers=%d", qPrintable(m_channelId), (unsigned long long)groupId, active, transmitting, int(m_radioTalkers.size())); m_radioActive = active; m_selfTransmitting = transmitting; if (!active) m_radioTalkers.clear(); if (m_totalCount) emit dataChanged(index(0), index(m_totalCount - 1), {RadioSpeakingRole}); } /* START может прийти до списка участников; STOP снимает только указанный источник. */ void MemberListModel::onRadioTalk(quint64 groupId, quint64 nodeId, bool on) { if (!groupId || !nodeId) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio members: invalid talk group=%016llx node=%016llx on=%d", (unsigned long long)groupId, (unsigned long long)nodeId, on); return; } if (groupId != m_channelId.toULongLong() || (on && !m_radioActive)) { DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: ignored talk ch=%s group=%016llx node=%016llx on=%d active=%d", qPrintable(m_channelId), (unsigned long long)groupId, (unsigned long long)nodeId, on, m_radioActive); return; } if (m_radioTalkers.contains(nodeId) == on) return; if (on) m_radioTalkers.insert(nodeId); else m_radioTalkers.remove(nodeId); DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio members: talk ch=%s node=%016llx on=%d talkers=%d", qPrintable(m_channelId), (unsigned long long)nodeId, on, int(m_radioTalkers.size())); for (auto it = m_cache.cbegin(); it != m_cache.cend(); ++it) if (it->nodeId == nodeId) { emit dataChanged(index(it.key()), index(it.key()), {RadioSpeakingRole}); return; } }