You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
267 lines
11 KiB
267 lines
11 KiB
#include <QCoreApplication> |
|
#include "memberlistmodel.h" |
|
#include "debug_ui.h" |
|
#include "avatars/avatarmanager.h" |
|
#include "../transport/gui_bridge.h" |
|
#include <QPainter> |
|
#include <QPainterPath> |
|
#include <QFont> |
|
#include <QColor> |
|
#include <cstring> |
|
|
|
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; |
|
} |
|
}
|
|
|