#include #include "messagedelegate.h" #include "message_links.h" #include "chatview.h" #include "debug_ui.h" #include "emoji.h" #include "animtimer.h" #include "lottieicon.h" #include "voiceplayback.h" #include "sound_manager.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include /* Один layout для размеров, отрисовки, выделения и hit-test ссылок. */ static qreal layoutMessageText(QTextLayout& layout, int width, const QString& compactLink = {}) { int linkStart = -1; if (!compactLink.isEmpty() && layout.text().endsWith(compactLink)) { linkStart = layout.text().length() - compactLink.length(); const QString shortened = QFontMetrics(layout.font()).elidedText(compactLink, Qt::ElideRight, qMax(1, width - 1)); layout.setText(layout.text().left(linkStart) + shortened); } QTextOption option; option.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); layout.setTextOption(option); QVector formats; const auto links = linkStart >= 0 ? QVector{{linkStart, (int)layout.text().length() - linkStart, compactLink}} : messageLinks(layout.text()); for (const auto& link : links) { QTextLayout::FormatRange range; range.start = link.start; range.length = link.length; range.format.setForeground(QColor(0x33, 0x90, 0xEC)); range.format.setFontUnderline(true); formats.append(range); } layout.setFormats(formats); layout.beginLayout(); qreal y = 0; while (true) { auto line = layout.createLine(); if (!line.isValid()) break; line.setLineWidth(qMax(1, width)); line.setPosition(QPointF(0, y)); y += line.height(); } layout.endLayout(); return y; } QString MessageDelegate::linkAt(const QStyleOptionViewItem& option, const QModelIndex& index, const QPoint& point) const { const QString text = index.data(MsgTextRole).toString(); if (messageLinks(text).isEmpty()) return {}; const Layout geometry = calcLayout(option, index); if (geometry.isVoice || geometry.isFile || geometry.isVideo || geometry.isImage || geometry.emojiOnly) return {}; const QPoint local = point - option.rect.topLeft() - geometry.textRect.topLeft(); if (!QRect(QPoint(0, 0), geometry.textRect.size()).contains(local)) return {}; QString display = text; display.replace('\n', QChar(0x2028)); QTextLayout layout(display, option.font); const QString compactLink = index.data(MsgInviteLinkRole).toString(); layoutMessageText(layout, geometry.textRect.width(), compactLink); for (int i = 0; i < layout.lineCount(); ++i) { const auto line = layout.lineAt(i); if (local.y() < line.y() || local.y() >= line.y() + line.height()) continue; if (local.x() < line.x() || local.x() >= line.x() + line.naturalTextWidth()) return {}; const int position = line.xToCursor(local.x(), QTextLine::CursorOnCharacter); for (const auto& range : layout.formats()) { if (position >= range.start && position < range.start + range.length) return compactLink.isEmpty() ? text.mid(range.start, range.length) : compactLink; } } return {}; } MessageDelegate::MessageDelegate(QObject *parent) : QStyledItemDelegate(parent) { GUI_DEBUG("MessageDelegate: avatar size=%dpx", kAvatar); } struct VoicePlayState { uint64_t playbackId = 0; bool loading = false; bool playing = false; bool paused = false; qint64 startMs = 0; float durationSec = 0.0f; float pausedPosSec = 0.0f; float displayFrac = 0.0f; float displayElapsed = 0.0f; }; static QMap s_voiceStates; static QTimer* s_voiceTimer = nullptr; static QAbstractItemView* s_voiceView = nullptr; static void ensureVoiceTimer() { if (!s_voiceTimer) { s_voiceTimer = new QTimer(); s_voiceTimer->setInterval(16); QObject::connect(s_voiceTimer, &QTimer::timeout, []() { bool anyActive = false; SoundManager* sm = SoundManager::instance(); for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it) { if (it->loading) { anyActive = true; continue; } if (!it->playing && !it->paused) { if (it->displayFrac > 0.001f || it->displayElapsed > 0.001f) anyActive = true; continue; } if (it->paused) { anyActive = true; continue; } if (!sm->isPcmPlaying(it->playbackId)) { it->playing = false; it->paused = false; anyActive = true; } else { anyActive = true; } } if (!anyActive) s_voiceTimer->stop(); if (s_voiceView) s_voiceView->viewport()->update(); }); } } bool MessageDelegate::editorEvent(QEvent *event, QAbstractItemModel *model, const QStyleOptionViewItem &option, const QModelIndex &index) { if (event->type() != QEvent::MouseButtonPress && event->type() != QEvent::MouseButtonRelease) return false; Layout L = calcLayout(option, index); const auto* mouse = static_cast(event); if (mouse->button() != Qt::LeftButton) return false; const QPoint point = mouse->pos() - option.rect.topLeft(); const bool onQuote = L.quoteRect.contains(point); int reaction = L.reactionHeart.contains(point) ? -1 : -2; for (const auto& chip : L.reactionChips) if (chip.rect.contains(point)) { reaction = chip.id; break; } const bool onReaction = mouse->button() == Qt::LeftButton && reaction != -2; const bool onAvatar=mouse->button()==Qt::LeftButton && !L.narrow && L.avatarRect.contains(mouse->pos()-option.rect.topLeft()); const bool onPoll = !onQuote && index.data(MsgContentTypeRole).toString() == "poll" && !index.data(MsgChannelIdRole).toString().startsWith("dm:") && (L.bubbleRect.contains(point) || L.emojiArea.contains(point)) && (!L.reactionHeart.isValid() || point.y() < L.reactionHeart.top()); if (event->type()==QEvent::MouseButtonPress) { m_quotePress = onQuote ? index : QModelIndex(); m_quotePressPosition = mouse->pos(); m_reactionPress = onReaction ? index : QModelIndex(); m_pressedReaction = reaction; m_reactionPressPosition = mouse->pos(); m_avatarPress=onAvatar ? index : QModelIndex(); m_avatarPressPosition=mouse->pos(); m_pollPress = onPoll ? index : QModelIndex(); m_pollPressPosition = mouse->pos(); return onQuote || onAvatar || onReaction || onPoll; } const auto* view = qobject_cast(option.widget); const bool hadQuotePress = m_quotePress.isValid(); const bool activateQuote = onQuote && m_quotePress == index && (mouse->pos() - m_quotePressPosition).manhattanLength() < 5 && (!view || !view->hasSelection()); m_quotePress = QPersistentModelIndex(); const bool activatePoll = onPoll && m_pollPress == index && (mouse->pos() - m_pollPressPosition).manhattanLength() < 5 && (!view || !view->textSelection().active); m_pollPress = QPersistentModelIndex(); const bool hadReactionPress = m_reactionPress.isValid(); const bool activateAvatar=onAvatar && m_avatarPress==index && (mouse->pos()-m_avatarPressPosition).manhattanLength()<5; m_avatarPress=QPersistentModelIndex(); const bool activateReaction = onReaction && m_reactionPress == index && m_pressedReaction == reaction && (mouse->pos() - m_reactionPressPosition).manhattanLength() < 5; m_reactionPress = QPersistentModelIndex(); m_pressedReaction = -2; if (activateQuote) { emit replyRequested(index.data(MsgReplyToRole).toString()); return true; } if (hadQuotePress || onQuote) return true; if (activateReaction) { emit reactionRequested(index, reaction); return true; } if (hadReactionPress || onReaction) return true; if (activateAvatar) { emit avatarRequested(index.data(MsgChannelIdRole).toString(), index.data(MsgAuthorNodeIdRole).toULongLong()); return true; } if (onAvatar) return true; if (L.reactionHeart.isValid() && point.y() >= L.reactionHeart.top()) return true; if (onPoll) { if (!activatePoll) return true; const QString messageId = index.data(MsgMessageKeyRole).toString(); if (messageId.size() != 64) { GUI_WARN("poll: cannot open message with invalid key row=%d key_bytes=%d", index.row(), int(messageId.size())); return true; } GUI_INFO("poll: left click ch=%s message=%s", qPrintable(index.data(MsgChannelIdRole).toString()), qPrintable(messageId)); emit pollRequested(messageId); return true; } if (L.isVideo) { int dlState = index.data(MsgFileDownloadStateRole).toInt(); if (dlState == 2) { /* скачано — открыть плеер */ QString fp = index.data(MsgVoiceFileRole).toString(); if (!fp.isEmpty() && QFile::exists(fp) && m_videoOpenCb) { m_videoOpenCb(fp); return true; } /* файл пропал (вычистка кеша/удаление) — перекачиваем ниже */ } if (dlState == 1) return false; /* скачивается */ return m_downloadCb && m_downloadCb(index); } if (L.isImage) { int dlState = index.data(MsgFileDownloadStateRole).toInt(); if (dlState == 2) { /* скачано — открыть просмотрщик */ QString fp = index.data(MsgVoiceFileRole).toString(); if (!fp.isEmpty() && QFile::exists(fp) && m_imageOpenCb) { m_imageOpenCb(fp); return true; } /* файл пропал (вычистка кеша/удаление) — перекачиваем ниже */ } if (dlState == 1) return false; /* скачивается */ return m_downloadCb && m_downloadCb(index); } if (L.isFile) { int dlState = index.data(MsgFileDownloadStateRole).toInt(); qint64 msgId = index.data(MsgIdRole).toLongLong(); QString chId = index.data(MsgChannelIdRole).toString(); if (chId.startsWith(QStringLiteral("dm:"))) return false; /* PM сохраняется через контекстное меню. */ if (dlState == 2) { /* downloaded: open file */ QString fp = index.data(MsgVoiceFileRole).toString(); if (!fp.isEmpty() && QFile::exists(fp)) { QDesktopServices::openUrl(QUrl::fromLocalFile(fp)); return true; } /* файл пропал (вычистка кеша/удаление) — перекачиваем ниже */ } if (dlState == 1) return false; /* downloading — no action */ return m_downloadCb && m_downloadCb(index); } if (!L.isVoice) return false; QMouseEvent* me = static_cast(event); QPoint localPos = me->pos() - option.rect.topLeft(); bool onPlayBtn = L.voicePlayBtnRect.contains(localPos); bool onWaveform = L.voiceWaveformRect.contains(localPos); if (!onPlayBtn && !onWaveform) return false; QString filePath = index.data(MsgVoiceFileRole).toString(); int dlState = index.data(MsgFileDownloadStateRole).toInt(); qint64 msgId = index.data(MsgIdRole).toLongLong(); QString chId = index.data(MsgChannelIdRole).toString(); GUI_DEBUG("editorEvent voice: playBtn=%d wave=%d file='%s' exists=%d dlState=%d msgId=%lld", (int)onPlayBtn, (int)onWaveform, qPrintable(filePath), QFile::exists(filePath), dlState, (long long)msgId); /* not downloaded (or file evicted): start download (play will start on completion) */ bool fileMissing = filePath.isEmpty() || !QFile::exists(filePath); if (fileMissing && dlState != 1) { if (!m_downloadCb || !m_downloadCb(index)) return false; model->setData(index, true, MsgVoiceDownloadPendingRole); return true; } if (fileMissing) return false; GUI_DEBUG("editorEvent voice click: filePath=%s exists=%d wave=%d", qPrintable(filePath), QFile::exists(filePath), (int)onWaveform); if (filePath.isEmpty()) return false; ensureVoiceTimer(); VoicePlayState& st = s_voiceStates[filePath]; if (st.loading) return true; QString durStr = index.data(MsgVoiceDurationRole).toString(); durStr.remove('s'); float dur = durStr.toFloat(); if (dur <= 0) dur = 1.0f; if (onWaveform) { float clickFrac = (float)(localPos.x() - L.voiceWaveformRect.x()) / L.voiceWaveformRect.width(); if (clickFrac < 0) clickFrac = 0; if (clickFrac > 1) clickFrac = 1; float targetSec = clickFrac * dur; qint64 now = QDateTime::currentMSecsSinceEpoch(); if (!st.playing && !st.paused) { /* Start playback then seek */ st.loading = true; QPersistentModelIndex persistent(index); VoicePlayer::playOpusFile(filePath, this, [filePath, clickFrac, persistent](float actualDur, uint64_t playbackId) { s_voiceStates[filePath].loading = false; if (actualDur <= 0) return; MessageDelegate::startAutoPlay(filePath, actualDur, playbackId); float position = clickFrac * actualDur; auto& state = s_voiceStates[filePath]; state.startMs -= (qint64)(position * 1000); state.pausedPosSec = position; state.displayFrac = clickFrac; state.displayElapsed = position; int rate = SoundManager::instance()->pcmSampleRate(playbackId); if (rate > 0) SoundManager::instance()->seekPcm(playbackId, (unsigned long long)(position * rate)); if (persistent.isValid()) const_cast(persistent.model())->setData(persistent, 1, MsgVoicePlayStateRole); }); s_voiceTimer->start(); } else { /* Currently playing or paused — seek */ st.startMs = now - (qint64)(targetSec * 1000.0); if (st.paused) st.pausedPosSec = targetSec; else if (!st.playing) { st.playing = true; s_voiceTimer->start(); } st.displayFrac = (targetSec > 0 && dur > 0) ? (targetSec / dur) : 0; st.displayElapsed = targetSec; int sr = SoundManager::instance()->pcmSampleRate(st.playbackId); if (sr > 0) SoundManager::instance()->seekPcm(st.playbackId, (unsigned long long)(targetSec * sr)); } return true; } /* --- Play button click (original logic) --- */ bool isOtherPlaying = false; for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it) { if (it.key() != filePath && (it->playing || it->paused)) isOtherPlaying = true; } if (st.playing) { /* Playing → Pause */ qint64 now = QDateTime::currentMSecsSinceEpoch(); st.pausedPosSec = (now - st.startMs) / 1000.0f; st.playing = false; st.paused = true; SoundManager::instance()->pausePcm(st.playbackId); model->setData(index, 2, MsgVoicePlayStateRole); return true; } if (st.paused) { /* Paused → Resume */ st.paused = false; st.playing = true; qint64 now = QDateTime::currentMSecsSinceEpoch(); st.startMs = now - (qint64)(st.pausedPosSec * 1000.0); st.displayFrac = (dur > 0) ? (st.pausedPosSec / dur) : 0; st.displayElapsed = st.pausedPosSec; SoundManager::instance()->resumePcm(st.playbackId); s_voiceTimer->start(); model->setData(index, 1, MsgVoicePlayStateRole); return true; } /* Stopped or different file → stop old, play new */ if (isOtherPlaying) { for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it) { it->playing = false; it->paused = false; } VoicePlayer::stop(); } st.loading = true; QPersistentModelIndex persistent(index); VoicePlayer::playOpusFile(filePath, this, [filePath, persistent](float actualDur, uint64_t playbackId) { s_voiceStates[filePath].loading = false; if (actualDur <= 0) return; MessageDelegate::startAutoPlay(filePath, actualDur, playbackId); if (persistent.isValid()) const_cast(persistent.model())->setData(persistent, 1, MsgVoicePlayStateRole); }); s_voiceTimer->start(); return true; } QSize MessageDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { Layout L = calcLayout(option, index); return QSize(L.viewWidth, L.totalHeight); } static int measureTextHeight(const QFont &font, const QString &text, int width, const QString& compactLink = {}) { if (text.isEmpty() || width <= 0) return 0; if (!messageLinks(text).isEmpty()) { QString display = text; display.replace('\n', QChar(0x2028)); QTextLayout layout(display, font); return (int)std::ceil(layoutMessageText(layout, width, compactLink)); } QRect r = QFontMetrics(font).boundingRect(QRect(0, 0, width, 100000), Qt::AlignLeft | Qt::TextWordWrap, text); return r.height(); } static QString formatFileSize(qint64 bytes) { if (bytes < 1024) return QCoreApplication::translate("ChatGui", "%1B").arg(bytes); double kb = bytes / 1024.0; if (kb < 1024.0) return QCoreApplication::translate("ChatGui", "%1KB").arg(kb, 0, 'f', kb < 10.0 ? 1 : 0); double mb = kb / 1024.0; if (mb < 1024.0) return QCoreApplication::translate("ChatGui", "%1MB").arg(mb, 0, 'f', mb < 10.0 ? 1 : 0); double gb = mb / 1024.0; return QCoreApplication::translate("ChatGui", "%1GB").arg(gb, 0, 'f', 1); } static QString formatBitrate(qint64 bytes, qint64 durMs) { if (bytes <= 0 || durMs <= 0) return QString(); qint64 kbps = bytes * 8 / durMs; if (kbps >= 1000) return QCoreApplication::translate("ChatGui", "%1Mbps").arg(kbps / 1000.0, 0, 'f', 1); return QCoreApplication::translate("ChatGui", "%1kbps").arg(kbps); } static int calcMaxLineWidth(const QFont &font, const QString &text, int availW) { int maxW = 0; int pos = 0; while (pos <= text.length()) { int end = text.indexOf('\n', pos); if (end < 0) end = text.length(); QString para = text.mid(pos, end - pos); if (!para.isEmpty()) { QTextLayout layout(para, font); layout.beginLayout(); while (true) { QTextLine line = layout.createLine(); if (!line.isValid()) break; line.setLineWidth(availW); line.setPosition(QPointF(0, 0)); maxW = qMax(maxW, (int)line.naturalTextWidth()); } layout.endLayout(); } pos = end + 1; } return maxW; } MessageDelegate::Layout MessageDelegate::calcLayout( const QStyleOptionViewItem& option, const QModelIndex& index) const { Layout layout = calcBodyLayout(option, index); // Медиа и крупные emoji получают ту же цитату над своим содержимым. if (index.data(MsgHasQuoteRole).toBool() && !layout.quoteRect.isValid()) { const int line = QFontMetrics(option.font).height(), height = 2 * line + 2 * kQuotePadV + 2; const QRect body = layout.emojiOnly ? layout.emojiArea : layout.bubbleRect; const int width = qMin(qMax(200, body.width()), qMax(40, layout.viewWidth - 2 * kMarginH)); const int x = layout.isOutgoing ? body.right() - width + 1 : body.left(); layout.quoteRect = QRect(qMax(kMarginH, x), 0, width, height); layout.quoteBarRect = QRect(layout.quoteRect.left(), 0, kQuoteBarW, height); layout.quoteAuthorRect = QRect(layout.quoteRect.left() + kQuotePadH + kQuoteBarW, kQuotePadV, width - 2 * kQuotePadH - kQuoteBarW, line); layout.quoteTextRect = layout.quoteAuthorRect.translated(0, line + 2); for (QRect* rect : {&layout.bubbleRect, &layout.avatarRect, &layout.authorRect, &layout.textRect, &layout.statusRect, &layout.deliveryRect, &layout.emojiArea, &layout.voicePlayBtnRect, &layout.voiceWaveformRect, &layout.voiceDurationRect, &layout.voiceTimeRect, &layout.mediaBarRect, &layout.progressBarRect, &layout.videoThumbRect, &layout.videoPlayBtnRect, &layout.videoDurBadgeRect, &layout.imageThumbRect, &layout.imageBadgeRect}) if (rect->isValid()) rect->translate(0, height + kQuoteGap); layout.totalHeight += height + kQuoteGap; } const QString channel = index.data(MsgChannelIdRole).toString(); if (layout.emojiOnly || channel.isEmpty() || channel.startsWith("dm:") || index.data(MsgMessageKeyRole).toString().size() != 64 || layout.bubbleRect.isEmpty()) return layout; QFont font = option.font; font.setPointSize(qMax(1, font.pointSize() - 1)); QFont timeFont = option.font; timeFont.setPointSize(qMax(1, timeFont.pointSize() - 2)); const int height = qMax(24, QFontMetrics(font).height() + 6), left = layout.bubbleRect.left() + kPadH; layout.reactionTimeText = index.data(MsgEditedRole).toBool() ? QCoreApplication::translate("ChatGui", "edited ") : QString(); layout.reactionTimeText += index.data(MsgTimeRole).toString(); const int innerWidth = layout.bubbleRect.width() - 2 * kPadH; const int timeWidth = qMin(QFontMetrics(timeFont).horizontalAdvance(layout.reactionTimeText) + 2, qMax(0, innerWidth - height - 6)); const int reactionsRight = layout.bubbleRect.right() - kPadH - timeWidth - 6; int top; if (layout.isVoice) top = layout.voiceDurationRect.bottom() + 4; else if (layout.isVideo) top = layout.statusRect.bottom() + 4; else if (layout.isImage) top = layout.bubbleRect.bottom() + 3; else if (layout.isFile) top = qMax(layout.statusRect.bottom() + 4, layout.bubbleRect.bottom() - kPadBot); else top = layout.statusRect.top(); if (layout.deliveryRect.isValid()) top = qMax(top, layout.deliveryRect.bottom() + 4); int y = top, x = left + height + 4; layout.reactionHeart = QRect(left, top, height, height); for (const auto& value : index.data(MsgReactionsRole).toList()) { const auto reaction = value.toMap(); font.setBold(reaction.value("mine").toBool()); const int width = qMin(reactionsRight - left + 1, 30 + QFontMetrics(font).horizontalAdvance(reaction.value("count").toString())); if (x + width - 1 > reactionsRight) { x = left; y += height + 4; } layout.reactionChips.append({QRect(x, y, width, height), reaction.value("id").toInt(), reaction.value("emoji").toString(), reaction.value("count").toInt(), reaction.value("mine").toBool()}); x += width + 4; } layout.reactionTime = QRect(layout.bubbleRect.right() - kPadH - timeWidth + 1, y, timeWidth, height); layout.bubbleRect.setBottom(y + height + kPadBot - 1); layout.totalHeight = qMax(layout.bubbleRect.bottom() + 1, kAvatar) + kItemGap; if (layout.avatarRect.isValid()) layout.avatarRect.moveTop(qMax(0, layout.bubbleRect.bottom() + 1 - kAvatar)); return layout; } MessageDelegate::Layout MessageDelegate::calcBodyLayout( const QStyleOptionViewItem &option, const QModelIndex &index) const { Layout L{}; L.viewWidth = 300; if (option.widget) { auto *v = qobject_cast(option.widget); if (v) L.viewWidth = v->viewport()->width(); } if (L.viewWidth <= 0) L.viewWidth = 300; L.narrow = L.viewWidth - 2 * kMarginH - kAvatar - kGap < 400; bool hasQuote = index.data(MsgHasQuoteRole).toBool(); QString text = index.data(MsgTextRole).toString(); L.isOutgoing = index.data(MsgIsOutgoingRole).toBool(); /* Voice message detection */ QString contentType = index.data(MsgContentTypeRole).toString(); L.isVoice = (contentType == "audio/opus"); if (L.isVoice) { L.voiceDuration = index.data(MsgVoiceDurationRole).toString(); int pbs = 16; // play button size int wh = 25; // waveform height int th = 14; // text line height L.bubbleWidth = 220; int barW = 3, barPad = 4; int bubbleH = kPadTop + wh + 4 + th + kPadBot; L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap; int contentEdge = L.isOutgoing ? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)) : (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)); int bx = L.isOutgoing ? (contentEdge - L.bubbleWidth - barW - barPad) : contentEdge; L.bubbleRect = QRect(bx, 0, L.bubbleWidth, bubbleH); L.mediaBarRect = QRect(L.isOutgoing ? bx + L.bubbleWidth + 1 : bx - barW - 1, 0, barW, bubbleH); int dlState = index.data(MsgFileDownloadStateRole).toInt(); int bd = index.data(MsgMediaBlocksDoneRole).toInt(); int nb = index.data(MsgMediaNumBlocksRole).toInt(); L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0; if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb; if (dlState == 1) { L.progressBarRect = QRect(bx, bubbleH - 3, L.bubbleWidth, 3); } L.voicePlayBtnRect = QRect(bx + kPadH, kPadTop + 4, pbs, pbs); int wx = bx + kPadH + pbs + 4; int ww = L.bubbleWidth - 2*kPadH - pbs - 4; L.voiceWaveformRect = QRect(wx, kPadTop + 1, ww, wh); L.voiceDurationRect = QRect(wx, kPadTop + wh + 4, ww - 40, th); L.voiceTimeRect = QRect(wx + ww - 40, kPadTop + wh + 4, 40, th); if (!index.data(MsgDeliveryStateRole).toString().isEmpty()) { L.deliveryRect = QRect(bx, bubbleH + 2, L.bubbleWidth, th); L.totalHeight += th + 2; } if (!L.narrow) { int avatarY = qMax(0, L.bubbleRect.bottom() + 1 - kAvatar); if (!L.isOutgoing) L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar); else L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar); } return L; } /* Video message detection */ L.isVideo = (contentType == "video/mp4"); if (L.isVideo) { int barW = 3, barPad = 4; int thW = 200, thH = 112; /* 16:9 превью */ int infoH = 16; /* строка под пузырём: res·size·bitrate + время */ L.bubbleWidth = thW; L.totalHeight = qMax(thH, kAvatar) + infoH + kItemGap; int contentEdge = L.isOutgoing ? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)) : (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)); int bx = L.isOutgoing ? (contentEdge - thW - barW - barPad) : contentEdge; L.bubbleRect = QRect(bx, 0, thW, thH); L.videoThumbRect = L.bubbleRect; L.mediaBarRect = QRect(L.isOutgoing ? bx + thW + 1 : bx - barW - 1, 0, barW, thH); L.statusRect = QRect(bx, thH + 2, thW, infoH - 2); if (!index.data(MsgDeliveryStateRole).toString().isEmpty()) { L.deliveryRect = QRect(bx, thH + infoH + 2, thW, infoH - 2); L.totalHeight += infoH; } int dlState = index.data(MsgFileDownloadStateRole).toInt(); int bd = index.data(MsgMediaBlocksDoneRole).toInt(); int nb = index.data(MsgMediaNumBlocksRole).toInt(); L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0; if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb; if (dlState == 1) { L.progressBarRect = QRect(bx, thH - 3, thW, 3); } L.videoPlayBtnRect = QRect(bx + thW / 2 - 24, thH / 2 - 24, 48, 48); L.videoDurBadgeRect = QRect(bx + thW - 66, thH - 26, 58, 18); if (!L.narrow) { int avatarY = qMax(0, L.bubbleRect.bottom() + 1 - kAvatar); if (!L.isOutgoing) L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar); else L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar); } return L; } /* Image message detection */ L.isImage = contentType.startsWith("image/"); if (L.isImage) { int barW = 3, barPad = 4; int thW = 220, thH = 160; L.bubbleWidth = thW; L.totalHeight = qMax(thH, kAvatar) + kItemGap; int contentEdge = L.isOutgoing ? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)) : (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)); int bx = L.isOutgoing ? (contentEdge - thW - barW - barPad) : contentEdge; L.bubbleRect = QRect(bx, 0, thW, thH); L.imageThumbRect = L.bubbleRect; L.mediaBarRect = QRect(L.isOutgoing ? bx + thW + 1 : bx - barW - 1, 0, barW, thH); int dlState = index.data(MsgFileDownloadStateRole).toInt(); int bd = index.data(MsgMediaBlocksDoneRole).toInt(); int nb = index.data(MsgMediaNumBlocksRole).toInt(); L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0; if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb; if (dlState == 1) { L.progressBarRect = QRect(bx, thH - 3, thW, 3); } L.imageBadgeRect = QRect(bx + thW - 150, thH - 26, 142, 18); if (!L.narrow) { int avatarY = qMax(0, L.bubbleRect.bottom() + 1 - kAvatar); if (!L.isOutgoing) L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar); else L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar); } return L; } /* File attachment detection */ L.isFile = (contentType == "application/octet-stream"); if (L.isFile) { QFontMetrics fm(option.font); int th = fm.height(); int ih = 24; /* icon height */ L.bubbleWidth = 230; int barW = 3, barPad = 4; int bubbleH = kPadTop + ih + 2 + th + kPadBot; L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap; int contentEdge = L.isOutgoing ? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)) : (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)); int bx = L.isOutgoing ? (contentEdge - L.bubbleWidth - barW - barPad) : contentEdge; L.bubbleRect = QRect(bx, 0, L.bubbleWidth, bubbleH); L.mediaBarRect = QRect(L.isOutgoing ? bx + L.bubbleWidth + 1 : bx - barW - 1, 0, barW, bubbleH); int dlState = index.data(MsgFileDownloadStateRole).toInt(); int bd = index.data(MsgMediaBlocksDoneRole).toInt(); int nb = index.data(MsgMediaNumBlocksRole).toInt(); L.mediaBarColor = (dlState == 2) ? 2 : (dlState == 1 || (bd > 0 && nb > 0)) ? 1 : 0; if (dlState == 1 && nb > 0) L.progressFrac = (float)bd / nb; if (dlState == 1) { L.progressBarRect = QRect(bx, bubbleH - 3, L.bubbleWidth, 3); } L.textRect = QRect(bx + kPadH + ih + 8, kPadTop, L.bubbleWidth - 2*kPadH - ih - 8, th); L.statusRect = QRect(bx + kPadH + ih + 8 - 48, kPadTop + th + 2, L.bubbleWidth - 2*kPadH - ih - 8 + 48 - 40, th); if (!L.narrow) { int avatarY = qMax(0, L.bubbleRect.bottom() + 1 - kAvatar); if (!L.isOutgoing) L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar); else L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar); } return L; } L.emojiCount = detectEmojiOnly(text); L.emojiOnly = (L.emojiCount >= 1); if (L.emojiOnly) { QFontMetrics fm(option.font); int lineH = fm.lineSpacing(); int availW = L.viewWidth - kMarginH - kAvatar - kGap - kMarginH; bool narrow = (availW < 400); int contentEdge = !L.isOutgoing ? (narrow ? kMarginH : (kMarginH + kAvatar + kGap)) : L.viewWidth - (narrow ? kMarginH : (kMarginH + kAvatar + kGap)); int eSize = (L.emojiCount == 1) ? lineH * 4 : lineH * 2; int emojiGap = lineH / 2; int totalW = L.emojiCount * eSize + (L.emojiCount - 1) * emojiGap; int x = !L.isOutgoing ? (contentEdge + kPadH) : (contentEdge - kPadH - totalW); L.emojiArea = QRect(x, kGap * 2, totalW, eSize); L.totalHeight = eSize + kGap * 2 + kItemGap; L.bubbleRect = QRect(); return L; } QString qText = hasQuote ? index.data(MsgQuoteTextRole).toString() : QString(); int availW = L.viewWidth - kMarginH - kAvatar - kGap - kMarginH; L.narrow = (availW < 400); if (L.narrow) availW = L.viewWidth - 2 * kMarginH; int textLimitW = availW - 2 * kPadH; int limitW = text.contains(' ') ? qMin(textLimitW, 700) : qMin(textLimitW, 1200); QFontMetrics fm(option.font); int contentW = calcMaxLineWidth(option.font, text, limitW); if (!L.isOutgoing) { QString author = index.data(MsgAuthorRole).toString(); QFont boldFm = option.font; boldFm.setBold(true); int authorW = QFontMetrics(boldFm).horizontalAdvance(author); contentW = qMax(contentW, authorW); } if (hasQuote) { QString qAuthor = index.data(MsgQuoteAuthorRole).toString(); QFont boldFm = option.font; boldFm.setBold(true); int qAuthorW = QFontMetrics(boldFm).horizontalAdvance(qAuthor); contentW = qMax(contentW, qAuthorW + kQuoteBarW + kQuotePadH + 4); int qTextW = qMin(contentW + 8, limitW); int qWidth = calcMaxLineWidth(option.font, qText, qTextW); contentW = qMax(contentW, qWidth + kQuoteBarW + kQuotePadH + 4); } int textW = qMax(200 - 2 * kPadH, contentW); textW = qMin(textW, textLimitW); int bubbleW = textW + 2 * kPadH; L.bubbleWidth = bubbleW; int qH = 0; if (hasQuote) { int qTextH = fm.height(); int qOneLineH = fm.height(); qH = kQuotePadV + qOneLineH + (qText.isEmpty() ? 0 : 2 + qTextH) + kQuotePadV; } int textH = measureTextHeight(option.font, text, textW, index.data(MsgInviteLinkRole).toString()); int authorH = fm.height(); int bubbleH = L.isOutgoing ? kPadTop + (hasQuote ? qH + kQuoteGap : 0) + textH + kStatusH + kPadBot : kPadTop + authorH + 2 + (hasQuote ? qH + kQuoteGap : 0) + textH + kStatusH + kPadBot; L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap; int contentEdge = L.isOutgoing ? L.viewWidth - (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)) : (L.narrow ? kMarginH : (kMarginH + kAvatar + kGap)); if (!L.isOutgoing) L.bubbleRect = QRect(contentEdge, 0, bubbleW, bubbleH); else L.bubbleRect = QRect(contentEdge - bubbleW, 0, bubbleW, bubbleH); int contentTop = kPadTop; if (!L.isOutgoing) { L.authorRect = QRect(contentEdge + kPadH, contentTop, textW, authorH); contentTop += authorH + 2; } if (hasQuote) { int qX = !L.isOutgoing ? (contentEdge + kPadH) : (contentEdge - kPadH - (bubbleW - 2 * kPadH)); L.quoteRect = QRect(qX, contentTop, bubbleW - 2 * kPadH, qH); if (!L.isOutgoing) L.quoteBarRect = QRect(L.quoteRect.left(), L.quoteRect.top(), kQuoteBarW, qH); else L.quoteBarRect = QRect(L.quoteRect.right() - kQuoteBarW, L.quoteRect.top(), kQuoteBarW, qH); int tx = !L.isOutgoing ? (L.quoteBarRect.right() + kQuotePadH) : (L.quoteRect.left() + kQuotePadH); L.quoteAuthorRect = QRect(tx, L.quoteRect.top() + kQuotePadV, L.quoteRect.width() - kQuoteBarW - kQuotePadH, QFontMetrics(option.font).height()); L.quoteTextRect = QRect(tx, L.quoteAuthorRect.bottom() + 2, L.quoteAuthorRect.width(), qH - kQuotePadV - QFontMetrics(option.font).height() - 2 - kQuotePadV); contentTop += qH + kQuoteGap; } int txRect = !L.isOutgoing ? (contentEdge + kPadH) : (contentEdge - kPadH - textW); L.textRect = QRect(txRect, contentTop, textW, textH); L.statusRect = QRect(txRect, contentTop + textH + 2, textW, kStatusH); if (!L.narrow) { int avatarY = qMax(0, L.bubbleRect.bottom() + 1 - kAvatar); if (!L.isOutgoing) L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar); else L.avatarRect = QRect(L.viewWidth - kMarginH - kAvatar, avatarY, kAvatar, kAvatar); } return L; } static int paraLayoutHeight(const QFont &font, const QString &text, int textWidth, int globalChar, qreal globalY, int yTop, int yBot, int xTop, int xBot, bool &foundStart, bool &foundEnd, int &startChar, int &endChar) { if (text.isEmpty()) return QFontMetrics(font).lineSpacing(); QTextLayout layout(text, font); layout.beginLayout(); qreal localY = 0; while (true) { QTextLine line = layout.createLine(); if (!line.isValid()) break; line.setLineWidth(textWidth); line.setPosition(QPointF(0, globalY + localY)); localY += line.height(); if (!foundStart && globalY + localY > yTop) { startChar = globalChar + line.xToCursor(xTop); foundStart = true; } if (!foundEnd && globalY >= yBot) break; if (!foundEnd && globalY + localY >= yBot) { endChar = globalChar + line.xToCursor(xBot); foundEnd = true; } } layout.endLayout(); return (int)ceil(localY); } static void calcTextSelection(const QFont &font, const QString &text, int textWidth, const QPoint &p1, const QPoint &p2, int &startChar, int &endChar) { startChar = 0; endChar = 0; if (text.isEmpty() || textWidth <= 0) return; int yTop = qMin(p1.y(), p2.y()); int yBot = qMax(p1.y(), p2.y()); int xTop = (p1.y() < p2.y()) ? p1.x() : p2.x(); int xBot = (p1.y() < p2.y()) ? p2.x() : p1.x(); int globalChar = 0; qreal globalY = 0; bool foundStart = false, foundEnd = false; int paraStart = 0; while (paraStart <= text.length()) { int paraEnd = text.indexOf('\n', paraStart); if (paraEnd < 0) paraEnd = text.length(); QString para = text.mid(paraStart, paraEnd - paraStart); int paraH = paraLayoutHeight(font, para, textWidth, globalChar, globalY, yTop, yBot, xTop, xBot, foundStart, foundEnd, startChar, endChar); globalChar += para.length(); globalY += paraH; if (paraEnd < text.length()) globalChar++; paraStart = paraEnd + 1; if (foundStart && foundEnd) break; } if (!foundStart) startChar = 0; if (!foundEnd) endChar = text.length(); if (startChar == endChar && endChar < text.length()) endChar = startChar + 1; if (startChar > endChar) qSwap(startChar, endChar); } static void drawTextSelectionHighlight(QPainter *p, const QFont &font, const QString &text, int textWidth, const QRect &textRect, const QColor &selColor, int startChar, int endChar) { if (startChar >= endChar) return; int globalChar = 0; qreal globalY = 0; int paraStart = 0; while (paraStart <= text.length()) { int paraEnd = text.indexOf('\n', paraStart); if (paraEnd < 0) paraEnd = text.length(); QString para = text.mid(paraStart, paraEnd - paraStart); if (para.isEmpty()) { globalY += QFontMetrics(font).lineSpacing(); } else { QTextLayout layout(para, font); layout.beginLayout(); qreal localY = 0; while (true) { QTextLine line = layout.createLine(); if (!line.isValid()) break; line.setLineWidth(textWidth); line.setPosition(QPointF(0, globalY + localY)); int ls = globalChar + line.textStart(); int le = ls + line.textLength(); localY += line.height(); if (ls >= endChar || le <= startChar) continue; int cs = qMax(startChar, ls); int ce = qMin(endChar, le); qreal x1 = line.cursorToX(cs - globalChar); qreal x2 = line.cursorToX(ce - globalChar); QRectF hr(textRect.left() + x1, textRect.top() + globalY + line.y(), x2 - x1, line.height()); p->fillRect(hr, selColor); } layout.endLayout(); globalY += localY; } globalChar += para.length(); if (paraEnd < text.length()) globalChar++; paraStart = paraEnd + 1; } } static void drawAnimatedEmojiOverlay(QPainter *p, const QFont &font, const QString &text, int textWidth, const QRect &textRect) { if (g_animatedEmojiMap.isEmpty()) return; int globalChar = 0; qreal globalY = 0; int paraStart = 0; while (paraStart <= text.length()) { int paraEnd = text.indexOf('\n', paraStart); if (paraEnd < 0) paraEnd = text.length(); QString para = text.mid(paraStart, paraEnd - paraStart); if (!para.isEmpty()) { QTextLayout layout(para, font); if (!messageLinks(text).isEmpty()) { QTextOption wrap; wrap.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); layout.setTextOption(wrap); } layout.beginLayout(); qreal localY = 0; while (true) { QTextLine line = layout.createLine(); if (!line.isValid()) break; line.setLineWidth((qreal)textWidth); line.setPosition(QPointF(0, globalY + localY)); localY += line.height(); int ls = globalChar + line.textStart(); int le = ls + line.textLength(); for (auto it = g_animatedEmojiMap.begin(); it != g_animatedEmojiMap.end(); ++it) { const QString &unicode = it.key(); int pos = text.indexOf(unicode, ls); while (pos >= 0 && pos + unicode.length() <= le) { int linePos = pos - globalChar; int cp1 = linePos, cp2 = linePos + unicode.length(); qreal x1 = line.cursorToX(&cp1); qreal x2 = line.cursorToX(&cp2); LottieIcon *icon = AnimTimer::instance()->icon(it.value()); if (icon && icon->isValid()) { int eW = qMax(1, (int)((x2 - x1) * 1.3)); int eH = qMax(1, (int)(line.height() * 1.3)); QImage frame = icon->renderFrame(icon->currentFrame(), QSize(eW, eH)); qreal cx = x1 + (x2 - x1 - eW) * 0.5; qreal cy = (line.height() - eH) * 0.5; p->drawImage(QPointF(textRect.left() + cx, textRect.top() + line.y() + cy), frame); } pos = text.indexOf(unicode, pos + unicode.length()); } } } layout.endLayout(); globalY += localY; } else { globalY += QFontMetrics(font).lineSpacing(); } globalChar += para.length(); if (paraEnd < text.length()) globalChar++; paraStart = paraEnd + 1; } } void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { const Layout layout = calcLayout(option, index); paintBody(painter, option, index, layout); painter->save(); painter->translate(option.rect.topLeft()); painter->setClipRect(QRect(QPoint(0, 0), option.rect.size())); if (layout.quoteRect.isValid()) drawQuote(painter, layout, index, option); if (layout.reactionHeart.isValid()) drawReactions(painter, layout, option); if (index.data(MsgHighlightRole).toBool()) { QColor color = option.palette.highlight().color(); color.setAlpha(55); painter->fillRect(QRect(QPoint(0, 0), option.rect.size()), color); } painter->restore(); } void MessageDelegate::paintBody(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index, const Layout& L) const { painter->save(); painter->translate(option.rect.topLeft()); painter->setClipRect(QRect(QPoint(0, 0), option.rect.size())); if (L.deliveryRect.isValid()) { QFont font = option.font; font.setPointSize(qMax(1, font.pointSize() - 2)); painter->setFont(font); painter->setPen(option.palette.text().color().lighter(150)); const auto text = QFontMetrics(font).elidedText(index.data(MsgDeliveryStateRole).toString(), Qt::ElideRight, L.deliveryRect.width()); painter->drawText(L.deliveryRect, Qt::AlignRight | Qt::AlignVCenter, text); } if (L.emojiOnly) { QString msgText = index.data(MsgTextRole).toString(); QString trimmed = msgText.trimmed(); QFontMetrics fm(option.font); int lineH = fm.lineSpacing(); int eSize = (L.emojiCount == 1) ? lineH * 4 : lineH * 2; int x = L.emojiArea.left(); int y = L.emojiArea.top(); int pos = 0; while (pos < trimmed.length()) { if (trimmed.at(pos).isSpace()) { ++pos; continue; } QString emojiStr; bool found = false; for (const auto &cat : builtinEmojiSet()) { for (const auto &e : cat.items) { if (e.unicode.length() > trimmed.length() - pos) continue; if (QStringView(trimmed).mid(pos, e.unicode.length()) == e.unicode) { emojiStr = e.unicode; pos += e.unicode.length(); found = true; break; } } if (found) break; } if (emojiStr.isEmpty()) { ++pos; continue; } bool animated = g_animatedEmojiMap.contains(emojiStr); if (animated) { LottieIcon *icon = AnimTimer::instance()->icon(g_animatedEmojiMap[emojiStr]); if (icon && icon->isValid()) { QImage frame = icon->renderFrame(icon->currentFrame(), QSize(eSize, eSize)); painter->drawImage(x, y, frame); } else { QFont big = option.font; big.setPointSize((int)(big.pointSize() * (L.emojiCount == 1 ? 4.0 : 2.0))); painter->setFont(big); painter->setPen(option.palette.text().color()); painter->drawText(QRect(x, y, eSize, eSize), Qt::AlignCenter, emojiStr); } } else { QFont big = option.font; big.setPointSize((int)(big.pointSize() * (L.emojiCount == 1 ? 4.0 : 2.0))); painter->setFont(big); painter->setPen(option.palette.text().color()); painter->drawText(QRect(x, y, eSize, eSize), Qt::AlignCenter, emojiStr); } x += eSize + lineH / 2; } painter->restore(); return; } if (L.isVoice) { if (option.widget) s_voiceView = (QAbstractItemView*)option.widget; drawBubble(painter, L, option); if (!L.narrow) drawAvatar(painter, L, index); QString filePath = index.data(MsgVoiceFileRole).toString(); VoicePlayState copySt; VoicePlayState* st = ©St; if (!filePath.isEmpty() && s_voiceStates.contains(filePath)) { st = &s_voiceStates[filePath]; } else { int stateVal = index.data(MsgVoicePlayStateRole).toInt(); copySt.playing = (stateVal == 1); st = ©St; } bool isPlaying = st->playing; bool isPaused = st->paused; /* compute elapsed/frac for progress */ float elapsed = 0, targetFrac = 0, durSec = st->durationSec; if (durSec <= 0) { QString ds = index.data(MsgVoiceDurationRole).toString(); ds.remove('s'); durSec = ds.toFloat(); } if (isPaused) { elapsed = st->pausedPosSec; } else if (isPlaying) { SoundManager* sm = SoundManager::instance(); if (sm->isPcmPlaying(st->playbackId)) { int sr = sm->pcmSampleRate(st->playbackId); if (sr > 0) elapsed = (float)((double)sm->pcmCursor(st->playbackId) / sr); } if (elapsed <= 0) { qint64 now = QDateTime::currentMSecsSinceEpoch(); elapsed = (now - st->startMs) / 1000.0f; } if (elapsed > durSec) elapsed = durSec; } targetFrac = (durSec > 0) ? (elapsed / durSec) : 0; if (targetFrac < 0) targetFrac = 0; if (targetFrac > 1) targetFrac = 1; st->displayFrac += (targetFrac - st->displayFrac) * 0.15f; st->displayElapsed += (elapsed - st->displayElapsed) * 0.15f; float frac = st->displayFrac; QRectF playR = L.voicePlayBtnRect; /* Play/Pause icon (small — 10px triangle) */ painter->setPen(Qt::NoPen); painter->setBrush(QColor(0x33, 0x90, 0xEC)); if (isPlaying || isPaused) { qreal bw = playR.width() / 5.0; qreal gap = playR.width() / 8.0; qreal top = playR.center().y() - playR.height() / 3.0; qreal bot = playR.center().y() + playR.height() / 3.0; painter->drawRoundedRect(QRectF(playR.center().x() - gap - bw, top, bw, bot - top), 1, 1); painter->drawRoundedRect(QRectF(playR.center().x() + gap, top, bw, bot - top), 1, 1); } else { QPainterPath path; qreal cx = playR.center().x() - playR.width() / 6.0; qreal cy = playR.center().y(); qreal hs = playR.height() / 2.5; path.moveTo(cx, cy - hs); path.lineTo(cx + hs * 1.5, cy); path.lineTo(cx, cy + hs); path.closeSubpath(); painter->drawPath(path); } /* Waveform (25px) */ QList wf = index.data(MsgVoiceWaveformRole).toList(); int wfN = wf.size(); QRect wfR = L.voiceWaveformRect; if (wfN > 0) { float barW = (float)wfR.width() / wfN; int ph = wfR.height() - 3; for (int i = 0; i < wfN; i++) { float logV = wf[i].toFloat(); /* already 0..1 normalized */ int barH = 2 + (int)(logV * (ph - 2)); int x = wfR.left() + (int)(i * barW); int y = wfR.top() + ph - barH; int bw = qMax(2, (int)(barW * 0.8f)); bool before = wfN > 0 && (float)i / wfN <= frac; QColor played(0x33, 0x90, 0xEC); QColor unplayed(0x33, 0x90, 0xEC, 80); QColor idle(0x70, 0x70, 0x78, 130); painter->setBrush((isPlaying || isPaused) ? (before ? played : unplayed) : idle); painter->drawRect(QRect(x, y, bw, barH)); } } /* Progress bar (3px, at bottom of waveform area) */ int ph = 3; int pbY = wfR.bottom() - ph; painter->setBrush(QColor(0x44, 0x44, 0x44)); painter->drawRoundedRect(QRect(wfR.left(), pbY, wfR.width(), ph), 2, 2); if (frac > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(wfR.left(), pbY, (int)(wfR.width() * frac), ph), 2, 2); } /* Duration + elapsed on bottom line */ QFont timeFont = option.font; timeFont.setPointSize(timeFont.pointSize() - 2); painter->setFont(timeFont); painter->setPen(option.palette.text().color().lighter(150)); int eSec = (int)st->displayElapsed, dSec = (int)durSec; QString durText = QString("%1:%2 / %3:%4") .arg(eSec / 60).arg(eSec % 60, 2, 10, QChar('0')) .arg(dSec / 60).arg(dSec % 60, 2, 10, QChar('0')); painter->drawText(L.voiceDurationRect, Qt::AlignLeft | Qt::AlignVCenter, durText); /* Timestamp */ QFont tsFont = option.font; tsFont.setPointSize(tsFont.pointSize() - 1); painter->setFont(tsFont); if (!L.reactionTime.isValid()) painter->drawText(L.voiceTimeRect, Qt::AlignRight | Qt::AlignVCenter, index.data(MsgTimeRole).toString()); /* media bar (left edge, 3px) */ { QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80); painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); } /* progress bar (bottom edge, 3px) */ if (L.progressBarRect.isValid()) { painter->setBrush(QColor(0x44, 0x44, 0x44)); painter->drawRoundedRect(L.progressBarRect, 2, 2); int pw = (int)(L.progressBarRect.width() * L.progressFrac); if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); } } painter->restore(); return; } if (L.isVideo) { drawBubble(painter, L, option); if (!L.narrow) drawAvatar(painter, L, index); QString filePath = index.data(MsgVoiceFileRole).toString(); int dlState = index.data(MsgFileDownloadStateRole).toInt(); QString thumbPath = filePath + ".jpg"; painter->save(); painter->setRenderHint(QPainter::Antialiasing); QPainterPath clip; clip.addRoundedRect(L.videoThumbRect, kRadius, kRadius); painter->setClipPath(clip); QPixmap thumb; if (!thumbPath.isEmpty() && QFile::exists(thumbPath)) thumb.load(thumbPath); if (!thumb.isNull()) { QPixmap scaled = thumb.scaled(L.videoThumbRect.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); int dx = (scaled.width() - L.videoThumbRect.width()) / 2; int dy = (scaled.height() - L.videoThumbRect.height()) / 2; painter->drawPixmap(L.videoThumbRect.topLeft(), scaled, QRect(dx, dy, L.videoThumbRect.width(), L.videoThumbRect.height())); } else { painter->fillRect(L.videoThumbRect, QColor(30, 30, 35)); QFont iconFont = option.font; iconFont.setPointSize(iconFont.pointSize() + 10); painter->setFont(iconFont); painter->setPen(QColor(150, 150, 150)); painter->drawText(L.videoThumbRect, Qt::AlignCenter, QString::fromUtf8("\xF0\x9F\x8E\xAC")); } /* play-оверлей */ if (dlState == 2) { painter->setPen(Qt::NoPen); painter->setBrush(QColor(0, 0, 0, 110)); painter->drawEllipse(L.videoPlayBtnRect); QPainterPath tri; int cx = L.videoPlayBtnRect.center().x() - L.videoPlayBtnRect.width() / 12; int cy = L.videoPlayBtnRect.center().y(); int hs = L.videoPlayBtnRect.height() / 4; tri.moveTo(cx, cy - hs); tri.lineTo(cx + hs * 2, cy); tri.lineTo(cx, cy + hs); tri.closeSubpath(); painter->setBrush(Qt::white); painter->drawPath(tri); } /* бейдж: длительность (m:ss) */ QString durStr = index.data(MsgVoiceDurationRole).toString(); if (!durStr.isEmpty()) { QFont durFont = option.font; durFont.setPointSize(durFont.pointSize() - 1); painter->setFont(durFont); painter->setPen(Qt::NoPen); painter->setBrush(QColor(0, 0, 0, 160)); painter->drawRoundedRect(L.videoDurBadgeRect, 5, 5); painter->setPen(Qt::white); painter->drawText(L.videoDurBadgeRect, Qt::AlignCenter, durStr); } painter->restore(); /* progress overlay (скачивание) */ if (dlState == 1) { painter->setBrush(QColor(0, 0, 0, 140)); painter->drawRect(L.videoThumbRect); QFont pfont = option.font; pfont.setPointSize(pfont.pointSize() + 2); painter->setFont(pfont); painter->setPen(Qt::white); painter->drawText(L.videoThumbRect, Qt::AlignCenter, QStringLiteral("%1%").arg((int)(L.progressFrac * 100))); } /* media bar (левая кромка) */ { QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80); painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); } if (L.progressBarRect.isValid()) { painter->setBrush(QColor(0x44, 0x44, 0x44)); painter->drawRoundedRect(L.progressBarRect, 2, 2); int pw = (int)(L.progressBarRect.width() * L.progressFrac); if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); } } /* строка под пузырём: res · size · bitrate + время */ { qint64 fsz = index.data(MsgFileSizeRole).toLongLong(); qint64 durMs = index.data(MsgVideoDurationMsRole).toLongLong(); int vw = index.data(MsgVideoWidthRole).toInt(); int vh = index.data(MsgVideoHeightRole).toInt(); QStringList info; if (vw > 0 && vh > 0) info << QString("%1×%2").arg(vw).arg(vh); QString sz = formatFileSize(fsz); if (!sz.isEmpty()) info << sz; QString br = formatBitrate(fsz, durMs); if (!br.isEmpty()) info << br; QFont smallFont = option.font; smallFont.setPointSize(smallFont.pointSize() - 2); painter->setFont(smallFont); painter->setPen(QColor(0x80, 0x80, 0x80)); if (!info.isEmpty()) painter->drawText(L.statusRect, Qt::AlignLeft | Qt::AlignVCenter, info.join(" \xC2\xB7 ")); if (!L.reactionTime.isValid()) painter->drawText(L.statusRect, Qt::AlignRight | Qt::AlignVCenter, index.data(MsgTimeRole).toString()); } painter->restore(); return; } if (L.isImage) { drawBubble(painter, L, option); if (!L.narrow) drawAvatar(painter, L, index); int dlState = index.data(MsgFileDownloadStateRole).toInt(); QString filePath = index.data(MsgVoiceFileRole).toString(); painter->save(); painter->setRenderHint(QPainter::Antialiasing); QPainterPath clip; clip.addRoundedRect(L.imageThumbRect, kRadius, kRadius); painter->setClipPath(clip); QPixmap img; if (!filePath.isEmpty() && QFile::exists(filePath)) { QImageReader reader(filePath); reader.setAutoTransform(true); reader.setScaledSize(L.imageThumbRect.size() * 2); QImage im = reader.read(); if (!im.isNull()) img = QPixmap::fromImage(im); } if (!img.isNull()) { painter->fillRect(L.imageThumbRect, QColor(20, 20, 24)); QPixmap scaled = img.scaled(L.imageThumbRect.size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); int dx = (L.imageThumbRect.width() - scaled.width()) / 2; int dy = (L.imageThumbRect.height() - scaled.height()) / 2; painter->drawPixmap(L.imageThumbRect.left() + dx, L.imageThumbRect.top() + dy, scaled); } else { painter->fillRect(L.imageThumbRect, QColor(30, 30, 35)); QFont iconFont = option.font; iconFont.setPointSize(iconFont.pointSize() + 10); painter->setFont(iconFont); painter->setPen(QColor(150, 150, 150)); painter->drawText(L.imageThumbRect, Qt::AlignCenter, QString::fromUtf8("\xF0\x9F\x93\xB7")); } /* бейдж: разрешение · размер */ qint64 fsz = index.data(MsgFileSizeRole).toLongLong(); int iw = index.data(MsgImageWidthRole).toInt(); int ih = index.data(MsgImageHeightRole).toInt(); QString res = (iw > 0 && ih > 0) ? QString("%1×%2").arg(iw).arg(ih) : QString(); QString sizeStr = formatFileSize(fsz); QString badge = res.isEmpty() ? sizeStr : res + " \xC2\xB7 " + sizeStr; if (!badge.isEmpty()) { QFont durFont = option.font; durFont.setPointSize(durFont.pointSize() - 1); painter->setFont(durFont); painter->setPen(Qt::NoPen); painter->setBrush(QColor(0, 0, 0, 160)); painter->drawRoundedRect(L.imageBadgeRect, 5, 5); painter->setPen(Qt::white); painter->drawText(L.imageBadgeRect, Qt::AlignCenter, badge); } painter->restore(); /* progress overlay (скачивание) */ if (dlState == 1) { painter->setBrush(QColor(0, 0, 0, 140)); painter->drawRect(L.imageThumbRect); QFont pfont = option.font; pfont.setPointSize(pfont.pointSize() + 2); painter->setFont(pfont); painter->setPen(Qt::white); painter->drawText(L.imageThumbRect, Qt::AlignCenter, QStringLiteral("%1%").arg((int)(L.progressFrac * 100))); } /* media bar (левая кромка) */ { QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80); painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); } if (L.progressBarRect.isValid()) { painter->setBrush(QColor(0x44, 0x44, 0x44)); painter->drawRoundedRect(L.progressBarRect, 2, 2); int pw = (int)(L.progressBarRect.width() * L.progressFrac); if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); } } painter->restore(); return; } if (L.isFile) { drawBubble(painter, L, option); if (!L.narrow) drawAvatar(painter, L, index); int dlState = index.data(MsgFileDownloadStateRole).toInt(); /* File icon based on download state: 0=not_started, 1=downloading, 2=downloaded */ QFont iconFont = option.font; iconFont.setPointSize(iconFont.pointSize() + 6); painter->setFont(iconFont); painter->setPen(option.palette.text().color()); QRect iconRect(L.bubbleRect.left() + kPadH, L.bubbleRect.top() + kPadTop, 24, 24); if (dlState == 2) painter->drawText(iconRect, Qt::AlignCenter, QString::fromUtf8("\xE2\x9C\x85")); else painter->drawText(iconRect, Qt::AlignCenter, QString::fromUtf8("\xE2\xAC\x87\xEF\xB8\x8F")); QFont fileFont = option.font; fileFont.setBold(true); painter->setFont(fileFont); painter->setPen(option.palette.text().color()); QString displayName = index.data(MsgFileDisplayNameRole).toString(); painter->drawText(L.textRect, Qt::AlignLeft | Qt::AlignVCenter, displayName); QFont sizeFont = option.font; sizeFont.setPointSize(sizeFont.pointSize() - 2); painter->setFont(sizeFont); painter->setPen(QColor(0x80, 0x80, 0x80)); qint64 fsz = index.data(MsgFileSizeRole).toLongLong(); QString sizeStr = formatFileSize(fsz); QString stateStr = (dlState == 2) ? QString::fromUtf8("\xE2\x9C\x85") : (dlState == 1) ? QCoreApplication::translate("ChatGui", "Downloading...") : QCoreApplication::translate("ChatGui", "Download"); QString deliveryState = index.data(MsgDeliveryStateRole).toString(); if (!deliveryState.isEmpty()) stateStr = deliveryState; QString info = sizeStr + " \xC2\xB7 " + stateStr; int nameH = QFontMetrics(fileFont).height(); QRect sizeRect(L.textRect.left(), L.textRect.top() + nameH, L.textRect.width(), QFontMetrics(sizeFont).height()); painter->drawText(sizeRect, Qt::AlignLeft | Qt::AlignVCenter, info); /* media bar (left edge, 3px) */ { QColor barC = L.mediaBarColor == 2 ? QColor(0x40, 0xC0, 0x40) : L.mediaBarColor == 1 ? QColor(0xF0, 0xC0, 0x40) : QColor(0x80, 0x80, 0x80); painter->setPen(Qt::NoPen); painter->setBrush(barC); painter->drawRect(L.mediaBarRect); } /* progress bar (bottom edge, 3px) */ if (L.progressBarRect.isValid()) { painter->setBrush(QColor(0x44, 0x44, 0x44)); painter->drawRoundedRect(L.progressBarRect, 2, 2); int pw = (int)(L.progressBarRect.width() * L.progressFrac); if (pw > 0) { painter->setBrush(QColor(0x33, 0x90, 0xEC)); painter->drawRoundedRect(QRect(L.progressBarRect.left(), L.progressBarRect.top(), pw, 3), 2, 2); } } painter->restore(); return; } auto *cv = qobject_cast(option.widget); bool singleTextSel = false; bool multiRowSel = false; if (cv && cv->hasSelection()) { if (cv->isRowSelected(index)) { if (cv->selStart() == cv->selEnd()) { singleTextSel = true; } else { multiRowSel = true; } } } drawBubble(painter, L, option); if (multiRowSel) { painter->save(); painter->setPen(Qt::NoPen); painter->setBrush(QColor(0x33, 0x90, 0xEC, 40)); painter->drawRect(L.bubbleRect); painter->restore(); } if (!L.narrow) drawAvatar(painter, L, index); if (!L.isOutgoing) { QFont authorFont = option.font; authorFont.setBold(true); painter->setFont(authorFont); painter->setPen(QColor(0x33, 0x90, 0xEC)); painter->drawText(L.authorRect, Qt::AlignLeft | Qt::AlignVCenter, index.data(MsgAuthorRole).toString()); } painter->setPen(option.palette.text().color()); QFont textFont = option.font; textFont.setPointSize(textFont.pointSize()); painter->setFont(textFont); QString msgText = index.data(MsgTextRole).toString(); QString overlayText = msgText; const bool hasLinks = !messageLinks(msgText).isEmpty(); if (hasLinks) { QString display = msgText; display.replace('\n', QChar(0x2028)); QTextLayout layout(display, textFont); const QString compactLink = index.data(MsgInviteLinkRole).toString(); layoutMessageText(layout, L.textRect.width(), compactLink); overlayText = layout.text(); overlayText.replace(QChar(0x2028), '\n'); QVector selections; if (singleTextSel && cv) { auto cursorAt = [&](const QPoint& point) -> int { for (int i = 0; i < layout.lineCount(); ++i) { const auto line = layout.lineAt(i); if (point.y() < line.y() + line.height()) return line.xToCursor(point.x()); } return layout.text().length(); }; const auto& selection = cv->textSelection(); int start = cursorAt(selection.clickPos - L.textRect.topLeft()); int end = cursorAt(selection.dragPos - L.textRect.topLeft()); if (start > end) qSwap(start, end); if (start == end && end < layout.text().length()) ++end; int copyStart = start, copyEnd = end; if (!compactLink.isEmpty()) { const int linkStart = msgText.length() - compactLink.length(); if (end > linkStart) { if (start >= linkStart) { start = linkStart; copyStart = linkStart; } end = layout.text().length(); copyEnd = msgText.length(); } } const_cast(cv)->setTextSelChars(copyStart, copyEnd); QTextLayout::FormatRange range; range.start = start; range.length = end - start; range.format.setBackground(QColor(0x33, 0x90, 0xEC, 60)); selections.append(range); } layout.draw(painter, L.textRect.topLeft(), selections); } else painter->drawText(L.textRect, Qt::AlignLeft | Qt::TextWordWrap, msgText); if (singleTextSel && cv && !hasLinks) { const auto &ts = cv->textSelection(); QPoint localP1(ts.clickPos.x() - L.textRect.left(), ts.clickPos.y() - L.textRect.top()); QPoint localP2(ts.dragPos.x() - L.textRect.left(), ts.dragPos.y() - L.textRect.top()); int startChar, endChar; calcTextSelection(textFont, msgText, L.textRect.width(), localP1, localP2, startChar, endChar); const_cast(cv)->setTextSelChars(startChar, endChar); drawTextSelectionHighlight(painter, textFont, msgText, L.textRect.width(), L.textRect, QColor(0x33, 0x90, 0xEC, 60), startChar, endChar); } drawAnimatedEmojiOverlay(painter, textFont, overlayText, L.textRect.width(), L.textRect); drawStatusBar(painter, L, index, option); painter->restore(); } void MessageDelegate::drawQuote(QPainter *p, const Layout &L, const QModelIndex &index, const QStyleOptionViewItem &opt) const { p->save(); p->setPen(Qt::NoPen); QColor background = opt.palette.highlight().color(); background.setAlpha(28); p->setBrush(background); p->drawRoundedRect(L.quoteRect, 5, 5); p->setPen(Qt::NoPen); p->setBrush(opt.palette.highlight()); p->drawRect(L.quoteBarRect); QFont boldFont = opt.font; boldFont.setBold(true); p->setFont(boldFont); p->setPen(opt.palette.text().color()); p->drawText(L.quoteAuthorRect, Qt::AlignLeft | Qt::AlignVCenter, QFontMetrics(boldFont).elidedText(index.data(MsgQuoteAuthorRole).toString(), Qt::ElideRight, L.quoteAuthorRect.width())); p->setFont(opt.font); p->setPen(opt.palette.text().color()); p->drawText(L.quoteTextRect, Qt::AlignLeft | Qt::AlignVCenter, QFontMetrics(opt.font).elidedText(index.data(MsgQuoteTextRole).toString(), Qt::ElideRight, L.quoteTextRect.width())); p->restore(); } void MessageDelegate::drawBubble(QPainter *p, const Layout &L, const QStyleOptionViewItem &opt) const { p->save(); p->setRenderHint(QPainter::Antialiasing); QRectF b = L.bubbleRect; qreal r = kRadius; qreal t = kTail; QPainterPath body; body.addRoundedRect(b, r, r); QPainterPath tail; if (!L.isOutgoing) { qreal tx = b.left(); qreal ty = b.bottom(); tail.moveTo(tx, ty - r); tail.lineTo(tx - t, ty - t / 2.0); tail.lineTo(tx + r, ty); } else { qreal tx = b.right(); qreal ty = b.bottom(); tail.moveTo(tx, ty - r); tail.lineTo(tx + t, ty - t / 2.0); tail.lineTo(tx - r, ty); } tail.closeSubpath(); QPainterPath combined = body.united(tail); QColor bubbleFill; if (!L.isOutgoing) { QColor base = opt.palette.window().color(); bubbleFill = base.lighter(135); } else { QColor base = opt.palette.window().color(); bubbleFill = base.lighter(135); bubbleFill.setRed(qMax(0, bubbleFill.red() - 10)); bubbleFill.setGreen(qMin(255, bubbleFill.green() + 5)); bubbleFill.setBlue(qMax(0, bubbleFill.blue() - 10)); } p->setPen(Qt::NoPen); p->setBrush(bubbleFill); p->drawPath(combined); p->restore(); } void MessageDelegate::drawReactions(QPainter* p, const Layout& layout, const QStyleOptionViewItem& option) const { p->save(); p->setRenderHint(QPainter::Antialiasing); const QPointF origin = QRectF(layout.reactionHeart).center() - QPointF(10, 10); QPainterPath heart; heart.moveTo(origin + QPointF(12, 21)); heart.cubicTo(origin + QPointF(10.5, 19.5), origin + QPointF(3, 14.2), origin + QPointF(3, 8.5)); heart.cubicTo(origin + QPointF(3, 3), origin + QPointF(9, 2), origin + QPointF(12, 6)); heart.cubicTo(origin + QPointF(15, 2), origin + QPointF(21, 3), origin + QPointF(21, 8.5)); heart.cubicTo(origin + QPointF(21, 14.2), origin + QPointF(13.5, 19.5), origin + QPointF(12, 21)); heart.closeSubpath(); QTransform shrink; shrink.translate(origin.x(), origin.y()); shrink.scale(20.0 / 24, 20.0 / 24); shrink.translate(-origin.x(), -origin.y()); heart = shrink.map(heart); p->setBrush(Qt::NoBrush); p->setPen(QPen(QColor(0, 0, 0, 153), 1.5 + 2.0 / p->device()->devicePixelRatioF(), Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); p->drawPath(heart); p->setPen(QPen(Qt::white, 1.5, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); p->drawPath(heart); for (const auto& chip : layout.reactionChips) { p->setPen(Qt::NoPen); p->setBrush(chip.mine ? QColor(51, 144, 236, 100) : QColor(0, 0, 0, 40)); p->drawRoundedRect(chip.rect, 9, 9); const QRect iconRect(chip.rect.left() + 5, chip.rect.top() + (chip.rect.height() - 18) / 2, 18, 18); LottieIcon* icon = g_animatedEmojiMap.contains(chip.emoji) ? AnimTimer::instance()->icon(g_animatedEmojiMap[chip.emoji]) : nullptr; QFont font = option.font; font.setPointSize(qMax(1, font.pointSize() - 1)); p->setFont(font); if (icon && icon->isValid()) p->drawImage(iconRect, icon->renderFrame(icon->currentFrame(), QSize(18, 18))); else { p->setPen(option.palette.text().color()); p->drawText(iconRect, Qt::AlignCenter, chip.emoji); } font.setBold(chip.mine); p->setFont(font); p->setPen(option.palette.text().color()); p->drawText(chip.rect.adjusted(25, 0, -5, 0), Qt::AlignLeft | Qt::AlignVCenter, QString::number(chip.count)); } QFont timeFont = option.font; timeFont.setPointSize(qMax(1, timeFont.pointSize() - 2)); p->setFont(timeFont); p->setPen(QColor(128, 128, 128)); p->drawText(layout.reactionTime, Qt::AlignRight | Qt::AlignVCenter, QFontMetrics(timeFont).elidedText(layout.reactionTimeText, Qt::ElideLeft, layout.reactionTime.width())); p->restore(); } void MessageDelegate::drawStatusBar(QPainter* p, const Layout& layout, const QModelIndex& index, const QStyleOptionViewItem& option) const { if (layout.reactionTime.isValid()) return; p->save(); QFont font = option.font; font.setPointSize(qMax(1, font.pointSize() - 2)); p->setFont(font); p->setPen(QColor(128, 128, 128)); QString text = index.data(MsgEditedRole).toBool() ? QCoreApplication::translate("ChatGui", "edited ") : QString(); text += index.data(MsgTimeRole).toString(); p->drawText(layout.statusRect, Qt::AlignRight | Qt::AlignVCenter, text); p->restore(); } void MessageDelegate::drawAvatar(QPainter *p, const Layout &L, const QModelIndex &index) const { QPixmap av = index.data(MsgAvatarRole).value(); if (av.isNull()) return; p->save(); p->setRenderHint(QPainter::Antialiasing); QPainterPath clip; clip.addEllipse(L.avatarRect); p->setRenderHint(QPainter::SmoothPixmapTransform); p->setClipPath(clip); p->drawPixmap(L.avatarRect, av); p->restore(); } void MessageDelegate::startAutoPlay(const QString& filePath, float actualDurationSec, uint64_t playbackId) { if (actualDurationSec <= 0) return; ensureVoiceTimer(); VoicePlayState& st = s_voiceStates[filePath]; st.playbackId = playbackId; st.playing = true; st.paused = false; st.durationSec = (actualDurationSec > 0) ? actualDurationSec : 1.0f; st.startMs = QDateTime::currentMSecsSinceEpoch(); st.pausedPosSec = 0.0f; st.displayFrac = 0.0f; st.displayElapsed = 0.0f; s_voiceTimer->start(); if (s_voiceView) s_voiceView->viewport()->update(); } void MessageDelegate::stopAllAutoPlay() { VoicePlayer::stop(); for (auto it = s_voiceStates.begin(); it != s_voiceStates.end(); ++it) { it->playing = false; it->paused = false; it->loading = false; } }