From eade913b4aff2030ba04d29782f4d0a1533a8a1d Mon Sep 17 00:00:00 2001 From: Erik <90757653+GeneralUser01@users.noreply.github.com> Date: Wed, 13 Nov 2024 19:39:57 +0100 Subject: [PATCH] FEAT(client,images): Add animated gif support The way images are handled for the `gif` file format when pasting and receiving one in the chat was here changed to support playing the animation. By default they do not play but whenever an animation is not running this is indicated by a play-icon, thereby differentiating them from still images on a glance. Whether the animation is paused is toggled by left-clicking it and reset by middle-clicking it. Saving a `gif` image file from log is now also supported just as it is for other image file formats. Additionally, one can also toggle video controls for animations via the log context menu on them. This enables the following features: - Jumping to any point of the animation via the video bar - Viewing the current time and total time of the animation - Switching caching of all frames on or off - Switching loop mode between "Unchanged", "Loop" and "No loop" - Traversing frame-by-frame backwards or forward - Changing and resetting the playback speed The main limitation is the character limit on text messages for images. This is usually set to 128 KB, which is very small for a `gif` image file and on top of this requires the data to be 33% smaller before being sent in base64 encoding. This limit should be at least ten times larger in order to fit many `gif` image files, or this should apply to another limit specifically for animations. Other than that, the functionality for pasting images from the clipboard itself, instead of by a file path from it, could not be implemented for `gif` image files due to not being able to get compatible formatted data from the MIME data received. Here are workarounds that can be applied to these limitations: - Configure the server to increase or disable the limit on image message length - Save copied `gif` images to file and then copy and paste those files --- src/mumble/CustomElements.cpp | 575 +++++++++++++++++++++++++++++++++- src/mumble/CustomElements.h | 51 ++- src/mumble/Log.cpp | 117 ++++++- src/mumble/Log.h | 3 + src/mumble/MainWindow.cpp | 60 +++- src/mumble/MainWindow.h | 2 + 6 files changed, 792 insertions(+), 16 deletions(-) diff --git a/src/mumble/CustomElements.cpp b/src/mumble/CustomElements.cpp index 2fe2474f62c..9ffcbe840a6 100644 --- a/src/mumble/CustomElements.cpp +++ b/src/mumble/CustomElements.cpp @@ -18,6 +18,9 @@ #include #include #include +#include +#include +#include #include LogTextBrowser::LogTextBrowser(QWidget *p) : QTextBrowser(p) { @@ -36,6 +39,16 @@ bool LogTextBrowser::isScrolledToBottom() { return scrollBar->value() == scrollBar->maximum(); } +void LogTextBrowser::mousePressEvent(QMouseEvent *mouseEvt) { + QPoint mouseDocPos = mouseEvt->pos(); + Qt::MouseButton mouseButton = mouseEvt->button(); + QAbstractTextDocumentLayout *docLayout = document()->documentLayout(); + // Set the vertical axis of the position to include the scrollable area above it: + mouseDocPos.setY(mouseDocPos.y() + verticalScrollBar()->value()); + + AnimationTextObject::mousePress(docLayout, mouseDocPos, mouseButton); +} + void ChatbarTextEdit::focusInEvent(QFocusEvent *qfe) { inFocus(true); @@ -201,7 +214,7 @@ bool ChatbarTextEdit::sendImagesFromMimeData(const QMimeData *source) { if (image.isNull()) continue; - if (emitPastedImage(image)) { + if (emitPastedImage(image, path)) { ++count; } else { Global::get().l->log(Log::Information, tr("Unable to send image %1: too large.").arg(path)); @@ -217,7 +230,20 @@ bool ChatbarTextEdit::sendImagesFromMimeData(const QMimeData *source) { return false; } -bool ChatbarTextEdit::emitPastedImage(QImage image) { +bool ChatbarTextEdit::emitPastedImage(QImage image, QString filePath) { + if (filePath.endsWith(".gif")) { + QFile file(filePath); + if (file.open(QIODevice::ReadOnly)) { + QByteArray animationBa(file.readAll()); + file.close(); + QString base64ImageData = qvariant_cast< QString >(animationBa.toBase64()); + emit pastedImage("
"); + } else { + Global::get().l->log(Log::Information, tr("Unable to read animated image file: %1").arg(filePath)); + } + return true; + } + QString processedImage = Log::imageToImg(image, static_cast< int >(Global::get().uiImageLength)); if (processedImage.length() > 0) { QString imgHtml = QLatin1String("
") + processedImage; @@ -227,6 +253,551 @@ bool ChatbarTextEdit::emitPastedImage(QImage image) { return false; } + +bool AnimationTextObject::areVideoControlsOn = false; + +void AnimationTextObject::setFrame(QMovie *animation, int frameIndex) { + int lastFrameIndex = animation->property("lastFrameIndex").toInt(); + bool isFrameIndexTooLow = frameIndex < 0; + bool isFrameIndexTooHigh = frameIndex > lastFrameIndex; + if (isFrameIndexTooLow || isFrameIndexTooHigh) { + frameIndex = isFrameIndexTooLow ? 0 : lastFrameIndex; + } + if (animation->cacheMode() == QMovie::CacheAll) { + animation->jumpToFrame(frameIndex); + return; + } + + bool wasRunning = animation->state() == QMovie::Running; + if (!wasRunning) { + animation->setPaused(false); + } + bool isStartTried = false; + // Can only load the target frame by traversing + // in sequential order when the frames are not cached: + while (animation->currentFrameNumber() != frameIndex) { + if (!animation->jumpToNextFrame()) { + // Continue traversing animations that either are stopped or do stop after one or more iterations: + if (animation->state() == QMovie::NotRunning && !isStartTried) { + animation->start(); + isStartTried = true; + continue; + } + break; + } + } + if (!wasRunning) { + animation->setPaused(true); + } +} + +QString AnimationTextObject::loopModeToString(LoopMode mode) { + switch (mode) { + case Unchanged: return "Unchanged"; + case Loop: return "Loop"; + case NoLoop: return "No loop"; + default: return "Undefined"; + } +} + +void AnimationTextObject::drawCenteredPlayIcon(QPainter *painter, QRect rect) { + int centerX = (int)round(rect.x() + rect.width() / (double)2); + int centerY = (int)round(rect.y() + rect.height() / (double)2); + // Add a play-icon, which is a right-pointing triangle, like this "▶": + QPolygonF polygon({ + QPointF(centerX - 8, centerY - 10), + QPointF(centerX + 12, centerY), + QPointF(centerX - 8, centerY + 10) + }); + QPainterPath path; + QPen thinBlackPen(Qt::black, 0.25); + path.addPolygon(polygon); + painter->fillPath(path, QBrush(Qt::white)); + // Add outline contrast to the triangle: + painter->strokePath(path, thinBlackPen); + + auto drawCenteredCircle = [painter, centerX, centerY](int diameter) { + int radius = diameter / 2; + painter->drawEllipse(centerX - radius, centerY - radius, diameter, diameter); + }; + // Add a ring around the triangle: + painter->setPen(QPen(Qt::white, 2)); + drawCenteredCircle(40); + // Add outline contrast to the ring: + painter->setPen(thinBlackPen); + drawCenteredCircle(36); + drawCenteredCircle(44); +} + +void AnimationTextObject::updatePropertiesIfChanged(QObject *propertyHolder, QRect rect) { + QVariant propertyPosAndSize = propertyHolder->property("posAndSize"); + if (!propertyPosAndSize.isValid()) { + setRectAndVideoControlPositioning(propertyHolder, rect); + return; + } + + QRect propertyRect = propertyPosAndSize.toRect(); + // Update the position in case content above it has increased in height, such as by text wrapping: + if (propertyRect.y() != rect.y()) { + propertyHolder->setProperty("posAndSize", QVariant(rect)); + } +} + +bool AnimationTextObject::isInBoundsOnAxis(QPoint pos, bool yInsteadOfX, int start, int length) { + int posOnAxis = yInsteadOfX ? pos.y() : pos.x(); + return posOnAxis >= start && posOnAxis <= start + length; +} + +bool AnimationTextObject::isInBounds(QPoint pos, QRect bounds) { + return isInBoundsOnAxis(pos, false, bounds.x(), bounds.width()) && isInBoundsOnAxis(pos, true, bounds.y(), bounds.height()); +} + +void AnimationTextObject::setRectAndVideoControlPositioning(QObject *propertyHolder, QRect rect, int videoBarHeight, int underVideoBarHeight, + int cacheX, int loopModeX, int frameTraversalX, int speedX) { + int videoControlsHeight = videoBarHeight + underVideoBarHeight; + QSize sizeMin(rect.width() - videoControlsHeight, rect.height() - videoControlsHeight); + int xWithWidth = rect.x() + sizeMin.width(); + auto xOffset = [rect, xWithWidth](int offset) { + return offset < 0 ? xWithWidth + offset : rect.x() + offset; + }; + cacheX = xOffset(cacheX); + loopModeX = xOffset(loopModeX); + frameTraversalX = xOffset(frameTraversalX); + speedX = xOffset(speedX); + + QList< QString > propertyNames = { + "posAndSize", + "videoBarHeight", + "underVideoBarHeight", + "cacheX", + "loopModeX", + "frameTraversalX", + "speedX" + }; + QList< QVariant > properties = { + QVariant(rect), + QVariant(videoBarHeight), + QVariant(underVideoBarHeight), + QVariant(cacheX), + QVariant(loopModeX), + QVariant(frameTraversalX), + QVariant(speedX), + }; + // Make the locations of certain interactive areas available through the object: + for (int i = 0; i < properties.length(); ++i) { + propertyHolder->setProperty(propertyNames[i].toStdString().c_str(), properties[i]); + } +} + +void AnimationTextObject::drawVideoControls(QPainter *painter, QObject *propertyHolder, QPixmap frame, + bool isPaused, bool isCached, int frameIndex, int speedPercentage) { + QRect rect = propertyHolder->property("posAndSize").toRect(); + int lastFrameIndex = propertyHolder->property("lastFrameIndex").toInt(); + int videoBarHeight = propertyHolder->property("videoBarHeight").toInt(); + int underVideoBarHeight = propertyHolder->property("underVideoBarHeight").toInt(); + int cacheX = propertyHolder->property("cacheX").toInt(); + int loopModeX = propertyHolder->property("loopModeX").toInt(); + int frameTraversalX = propertyHolder->property("frameTraversalX").toInt(); + int speedX = propertyHolder->property("speedX").toInt(); + + int videoControlsHeight = videoBarHeight + underVideoBarHeight; + int videoBarX = rect.x(); + int videoBarY = rect.y() + rect.height() - videoControlsHeight; + int underVideoBarY = videoBarY + videoBarHeight; + int underVideoBarWithMarginY = underVideoBarY + 14; + QSize sizeMin(rect.width() - videoControlsHeight, rect.height() - videoControlsHeight); + + auto convertUnit = [](int integer, int exponent, int decimalAmount = 0) { + bool noDecimals = decimalAmount == 0; + int exponentForDecimalAmount = exponent < 0 ? exponent + decimalAmount : exponent - decimalAmount; + + double product = integer * pow(10, exponentForDecimalAmount); + return noDecimals ? product : round(product) / (double)pow(10, decimalAmount); + }; + auto padDecimals = [](QString numberStr, int decimalAmount) { + qsizetype decimalMarkIndex = numberStr.lastIndexOf('.'); + bool isDecimal = decimalMarkIndex != -1 && decimalMarkIndex < numberStr.length() - 1; + qsizetype currentDecimalAmount = isDecimal ? numberStr.sliced(decimalMarkIndex + 1).length() : 0; + int decimalFillerAmount = decimalAmount - currentDecimalAmount; + + QString decimalFillers = QString('0').repeated(decimalFillerAmount); + return decimalFillerAmount > 0 ? numberStr.append((decimalFillerAmount == 1 ? "." : "") + decimalFillers) : numberStr; + }; + auto padNumber = [](QString numberStr, int digitAmount) { + qsizetype numberLength = numberStr.length(); + qsizetype decimalMarkIndex = numberStr.lastIndexOf('.'); + qsizetype decimalsIncludingMarkLength = decimalMarkIndex != -1 ? numberLength - decimalMarkIndex : 0; + return numberStr.rightJustified(digitAmount + decimalsIncludingMarkLength, '0'); + }; + auto formatTime = [padDecimals, padNumber](double seconds, double totalSeconds = 0) { + auto getTimeNumbers = [](double seconds) { + auto floorDivision = [](double dividend, double divisor = 60) { + return (int)floor(dividend / divisor); + }; + int minutes = floorDivision(seconds); + int hours = floorDivision(minutes); + int remainingMinutes = std::max(minutes - hours * 60, 0); + double remainingSeconds = std::max(seconds - minutes * 60, 0); + return QList< double >({ remainingSeconds, (double)remainingMinutes, (double)hours }); + }; + auto getDigitAmount = [](int number) { + int digitAmount = 0; + do { + number /= 10; + ++digitAmount; + } while (number != 0); + return (int)digitAmount; + }; + + QList< double > timeNumbers = getTimeNumbers(seconds); + QList< double > totalTimeNumbers = totalSeconds == 0 ? timeNumbers : getTimeNumbers(totalSeconds); + qsizetype timeNumberAmount = timeNumbers.length(); + int decimalAmount = 1; + + int lastTimeNumberIndex = 0; + for (int i = timeNumberAmount - 1; i >= 0; --i) { + if (totalTimeNumbers[i] > 0) { + lastTimeNumberIndex = i; + break; + } + } + + QString timeStr; + for (int i = 0; i < timeNumberAmount; ++i) { + double number = timeNumbers[i]; + bool isSeconds = i == 0; + bool isLastNumber = i == lastTimeNumberIndex; + bool hasAnotherNumber = i < lastTimeNumberIndex; + if (number == 0 && !hasAnotherNumber && !isLastNumber) { + break; + } + QString numberStr = QString::number(number); + if (hasAnotherNumber || isLastNumber) { + int digitAmount = isLastNumber ? getDigitAmount(totalTimeNumbers[i]) : 2; + numberStr = padNumber(numberStr, digitAmount); + } + timeStr.prepend(isSeconds ? padDecimals(numberStr, decimalAmount) : numberStr.append(":")); + } + return timeStr; + }; + + QList< QVariant > frameDelays = propertyHolder->property("frameDelays").toList(); + int totalMs = propertyHolder->property("totalMs").toInt(); + int msUntilCurrentFrame = 0; + bool isLastFrame = frameIndex == lastFrameIndex; + // Determine the time until the current frame or the time until the end of the last frame + // if on the last frame, so as to show a clear time for the start and end: + for (int i = 0; i < (isLastFrame ? frameDelays.length() : frameIndex); ++i) { + msUntilCurrentFrame += frameDelays[i].toInt(); + } + // Convert to seconds rounded to one decimal: + double totalS = convertUnit(totalMs, -3, 1); + double currentS = convertUnit(msUntilCurrentFrame, -3, 1); + + painter->drawPixmap(QRect(rect.topLeft(), sizeMin), frame); + painter->fillRect(videoBarX, videoBarY, sizeMin.width(), videoControlsHeight, QBrush(QColor(50, 50, 50, 180))); + + double videoBarProgress = msUntilCurrentFrame / (double)totalMs; + QBrush videoBarBrush(QColor(0, 0, 200)); + painter->fillRect(videoBarX, videoBarY, sizeMin.width(), 4, QBrush(QColor(90, 90, 90, 180))); + painter->fillRect(videoBarX, videoBarY, (int)round(sizeMin.width() * videoBarProgress), 4, videoBarBrush); + + QString speedStr = padDecimals(QString::number(convertUnit(speedPercentage, -2)), 1); + QPoint speedPos(speedX, underVideoBarWithMarginY); + painter->drawText(speedPos, speedStr); + // Draw the plus "+": + painter->drawLine(speedPos.x() - 9, speedPos.y() - 11, speedPos.x() - 9, speedPos.y() - 3); + painter->drawLine(speedPos.x() - 13, speedPos.y() - 7, speedPos.x() - 5, speedPos.y() - 7); + // Draw the minus "-": + painter->drawLine(speedPos.x() - 13, speedPos.y() + 2, speedPos.x() - 5, speedPos.y() + 2); + // Draw the circle "o": + painter->drawEllipse(speedPos.x() - 26, speedPos.y() - 6, 6, 6); + + QPoint frameTraversalPos(frameTraversalX, underVideoBarWithMarginY); + painter->drawText(frameTraversalPos, "< >"); + + LoopMode loopMode = qvariant_cast< LoopMode >(propertyHolder->property("LoopMode")); + QString loopModeStr = loopModeToString(loopMode); + QFont font = painter->font(); + double fontSizeSmall = 0.7; + font.setPointSize((int)round(font.pointSize() * fontSizeSmall)); + painter->setFont(font); + painter->drawText(QPointF(loopModeX, underVideoBarY + 8), "mode:"); + painter->drawText(QPointF(loopModeX - (loopModeStr.length() > 6 ? 13 : 0), underVideoBarY + 17), loopModeStr); + + QString cachedStr = QString::fromStdString(isCached ? "on" : "off"); + painter->drawText(QPointF(cacheX, underVideoBarY + 8), "cache:"); + painter->drawText(QPointF(cacheX + 5, underVideoBarY + 17), cachedStr); + font.setPointSize((int)round(font.pointSize() / fontSizeSmall)); + painter->setFont(font); + + QString totalTimeStr = formatTime(totalS); + QString currentTimeStr = formatTime(currentS, totalS); + painter->drawText(QPoint(videoBarX + 20, underVideoBarWithMarginY), tr("%1 / %2").arg(currentTimeStr).arg(totalTimeStr)); + + QPointF iconTopPos(videoBarX + 2, underVideoBarY + 2); + if (isPaused) { + // Add a play-icon, which is a right-pointing triangle, like this "▶": + QPolygonF polygon({ + iconTopPos, + QPointF(videoBarX + 15, underVideoBarY + 10), + QPointF(videoBarX + 2, underVideoBarY + 18) + }); + QPainterPath path; + path.addPolygon(polygon); + painter->fillPath(path, QBrush(Qt::white)); + } else { + // Add a pause-icon, which is two vertical rectangles next to each other, like this "||": + QSize pauseBarSize(4, 16); + QBrush brush(Qt::white); + painter->fillRect(QRect(iconTopPos.toPoint(), pauseBarSize), brush); + painter->fillRect(QRect(QPoint(videoBarX + 11, underVideoBarY + 2), pauseBarSize), brush); + } +} + +AnimationTextObject::VideoController AnimationTextObject::mousePressVideoControls(QObject *propertyHolder, QPoint mouseDocPos) { + QRect rect = propertyHolder->property("posAndSize").toRect(); + int videoBarHeight = propertyHolder->property("videoBarHeight").toInt(); + int underVideoBarHeight = propertyHolder->property("underVideoBarHeight").toInt(); + int cacheX = propertyHolder->property("cacheX").toInt(); + int loopModeX = propertyHolder->property("loopModeX").toInt(); + int frameTraversalX = propertyHolder->property("frameTraversalX").toInt(); + int speedX = propertyHolder->property("speedX").toInt(); + + auto isThisInBoundsOnAxis = [mouseDocPos](bool yInsteadOfX, int start, int length) { + return isInBoundsOnAxis(mouseDocPos, yInsteadOfX, start, length); + }; + auto isThisInBounds = [mouseDocPos](QRect bounds) { + return isInBounds(mouseDocPos, bounds); + }; + + int videoControlsHeight = videoBarHeight + underVideoBarHeight; + int videoControlsY = rect.y() + rect.height() - videoControlsHeight; + int underVideoBarY = videoControlsY + videoBarHeight; + int underVideoBarHalfHeight = underVideoBarHeight / 2; + + QRect viewRect(rect.x(), rect.y(), rect.width() - videoControlsHeight, rect.height() - videoControlsHeight); + QRect playPauseRect(rect.x(), underVideoBarY, 15, underVideoBarHeight); + + QRect cacheRect(cacheX, underVideoBarY, 25, underVideoBarHeight); + QRect loopModeRect(loopModeX, underVideoBarY, 24, underVideoBarHeight); + + int frameTraversalWidth = 12; + QRect framePreviousRect(frameTraversalX, underVideoBarY, frameTraversalWidth, underVideoBarHeight); + QRect frameNextRect(frameTraversalX + frameTraversalWidth + 2, underVideoBarY, frameTraversalWidth, underVideoBarHeight); + + int speedWidth = 9; + QRect speedResetRect(speedX - 28, underVideoBarY, speedWidth, underVideoBarHeight); + QRect speedMinusRect(speedX - 14, underVideoBarY + underVideoBarHalfHeight, speedWidth, underVideoBarHalfHeight); + QRect speedPlusRect(speedX - 14, underVideoBarY, speedWidth, underVideoBarHalfHeight); + + if (!isThisInBoundsOnAxis(false, viewRect.x(), viewRect.width())) return None; + if (isThisInBoundsOnAxis(true, viewRect.y(), viewRect.height())) return View; + if (isThisInBoundsOnAxis(true, videoControlsY, videoBarHeight)) return VideoBar; + if (isThisInBounds(playPauseRect)) return PlayPause; + if (isThisInBounds(cacheRect)) return CacheSwitch; + if (isThisInBounds(loopModeRect)) return LoopSwitch; + if (isThisInBounds(framePreviousRect)) return PreviousFrame; + if (isThisInBounds(frameNextRect)) return NextFrame; + if (isThisInBounds(speedResetRect)) return ResetSpeed; + if (isThisInBounds(speedMinusRect)) return DecreaseSpeed; + if (isThisInBounds(speedPlusRect)) return IncreaseSpeed; + return None; +} + +void AnimationTextObject::mousePress(QAbstractTextDocumentLayout *docLayout, QPoint mouseDocPos, Qt::MouseButton mouseButton) { + QTextFormat baseFmt = docLayout->formatAt(mouseDocPos); + if (!baseFmt.isCharFormat() || baseFmt.objectType() != Log::Animation) { + return; + } + QMovie *animation = qvariant_cast< QMovie* >(baseFmt.toCharFormat().property(1)); + QRect rect = animation->property("posAndSize").toRect(); + int lastFrameIndex = animation->property("lastFrameIndex").toInt(); + int videoBarHeight = animation->property("videoBarHeight").toInt(); + int underVideoBarHeight = animation->property("underVideoBarHeight").toInt(); + int videoControlsHeight = videoBarHeight + underVideoBarHeight; + int videoControlsY = rect.y() + rect.height() - videoControlsHeight; + int widthMin = rect.width() - videoControlsHeight; + + QRect videoControlsRect(rect.x(), videoControlsY, widthMin, videoControlsHeight); + auto updateVideoControls = [videoControlsRect, docLayout]() { + emit docLayout->update(videoControlsRect); + }; + auto thisSetFrame = [animation](int frameIndex) { + setFrame(animation, frameIndex); + }; + auto setFrameByPercentage = [animation, thisSetFrame, lastFrameIndex](double percentage) { + QList< QVariant > frameDelays = animation->property("frameDelays").toList(); + int totalMs = animation->property("totalMs").toInt(); + int msPassedAtSelection = (int)round(percentage * (double)totalMs); + int msUntilCurrentFrame = 0; + + int frameIndex = 0; + qsizetype frameDelayAmount = frameDelays.length(); + for (int i = 0; i < frameDelayAmount; ++i) { + int delay = frameDelays[i].toInt(); + msUntilCurrentFrame += delay; + if (msPassedAtSelection <= msUntilCurrentFrame) { + bool isNextFrame = i + 1 < frameDelayAmount; + int currentFrameDifference = msUntilCurrentFrame - msPassedAtSelection; + int previousFrameDifference = currentFrameDifference - delay; + int nextFrameDifference = isNextFrame ? msUntilCurrentFrame + frameDelays[i + 1].toInt() - msPassedAtSelection : -9999; + + bool isPreviousFrameCloser = abs(previousFrameDifference) < currentFrameDifference; + bool isNextFrameCloser = isNextFrame ? abs(nextFrameDifference) < currentFrameDifference : false; + // The first delay has passed by the second frame and so on, + // hence the index is greater by 1 for the frame of the full delay: + frameIndex = i + 1 + (isPreviousFrameCloser ? -1 : isNextFrameCloser ? 1 : 0); + break; + } + } + thisSetFrame(frameIndex); + }; + auto setFrameByVideoBarSelection = [setFrameByPercentage, mouseDocPos, rect, widthMin]() { + double videoBarPercentage = (mouseDocPos.x() - rect.x()) / (double)widthMin; + setFrameByPercentage(videoBarPercentage); + }; + auto resetPlayback = [animation, thisSetFrame]() { + // Show the first frame that the animation would continue from if started again, + // indicating that the animation was stopped instead of paused: + thisSetFrame(0); + animation->stop(); + }; + auto togglePause = [animation]() { + QMovie::MovieState state = animation->state(); + if (state == QMovie::NotRunning) { + animation->start(); + // Ensure the animation starts on the first attempt to do so: + animation->setPaused(false); + } else { + animation->setPaused(state != QMovie::Paused); + } + }; + auto toggleCache = [animation, thisSetFrame, updateVideoControls, lastFrameIndex]() { + bool wasCached = animation->cacheMode() == QMovie::CacheAll; + QMovie::CacheMode cacheModeToSet = wasCached ? QMovie::CacheNone : QMovie::CacheAll; + QMovie::MovieState state = animation->state(); + bool wasPaused = state == QMovie::Paused; + bool wasRunning = state == QMovie::Running; + + int previousFrame = animation->currentFrameNumber(); + // Turning caching on or off requires reloading the animation, which is done via `setDevice`, + // otherwise it will not play properly or dispose of the cache when it is not to be used: + animation->stop(); + QIODevice *device = animation->device(); + // Prepare the animation to be loaded when starting for the first time: + device->reset(); + animation->setDevice(device); + animation->setCacheMode(cacheModeToSet); + animation->start(); + + // Restore the state of the animation playback to what it was before reloading it + // but ensure it can be resumed when caching is off: + thisSetFrame(previousFrame); + if (wasPaused || (!wasRunning && previousFrame != 0 && previousFrame != lastFrameIndex)) { + animation->setPaused(true); + } else if (!wasRunning) { + animation->stop(); + } + updateVideoControls(); + }; + auto setSpeed = [animation, updateVideoControls](int percentage) { + // QMovie does not itself support reverse playback and + // pausing the animation should only be done via the + // play state to avoid confusion: + int speedMin = 10; + animation->setSpeed(percentage < speedMin ? speedMin : percentage); + updateVideoControls(); + }; + auto changeLoopMode = [animation, updateVideoControls](int steps) { + LoopMode loopMode = qvariant_cast< LoopMode >(animation->property("LoopMode")); + int loopModeChangedTo = loopMode + steps; + int loopModeResult = loopModeChangedTo > NoLoop ? 0 : loopModeChangedTo < 0 ? static_cast< int >(NoLoop) : loopModeChangedTo; + animation->setProperty("LoopMode", static_cast< LoopMode >(loopModeResult)); + updateVideoControls(); + }; + auto changeFrame = [animation, thisSetFrame, lastFrameIndex](int amount) { + int frameIndex = animation->currentFrameNumber() + amount; + int amountOfTimesGreater = (int)abs(floor(frameIndex / (double)lastFrameIndex)); + + int lastFrameIndexScaledToInput = lastFrameIndex * amountOfTimesGreater; + int frameIndexWrappedBackward = lastFrameIndexScaledToInput + 1 + frameIndex; + int frameIndexWrappedForward = frameIndex - 1 - lastFrameIndexScaledToInput; + thisSetFrame(frameIndex < 0 ? frameIndexWrappedBackward : frameIndex > lastFrameIndex ? frameIndexWrappedForward : frameIndex); + }; + auto changeSpeed = [animation, setSpeed](int percentage) { + setSpeed(animation->speed() + percentage); + }; + + bool isLeftMouseButtonPressed = mouseButton == Qt::LeftButton; + bool isMiddleMouseButtonPressed = mouseButton == Qt::MiddleButton; + if (areVideoControlsOn) { + VideoController videoController = mousePressVideoControls(animation, mouseDocPos); + if (isLeftMouseButtonPressed) { + switch (videoController) { + case VideoBar: return setFrameByVideoBarSelection(); + case View: + case PlayPause: return togglePause(); + case CacheSwitch: return toggleCache(); + case LoopSwitch: return changeLoopMode(1); + case PreviousFrame: return changeFrame(-1); + case NextFrame: return changeFrame(1); + case ResetSpeed: return setSpeed(100); + case DecreaseSpeed: return changeSpeed(-10); + case IncreaseSpeed: return changeSpeed(10); + default: return; + } + } else if (isMiddleMouseButtonPressed) { + switch (videoController) { + case View: + case PlayPause: return resetPlayback(); + case LoopSwitch: return changeLoopMode(-1); + case PreviousFrame: return changeFrame(-5); + case NextFrame: return changeFrame(5); + case DecreaseSpeed: return changeSpeed(-50); + case IncreaseSpeed: return changeSpeed(50); + default: return; + } + } + return; + } + if (isLeftMouseButtonPressed) { + togglePause(); + } else if (isMiddleMouseButtonPressed) { + resetPlayback(); + } + // Right mouse button shows the context menu for the text object, + // which is handled where the custom context menu for the log is. +} + +AnimationTextObject::AnimationTextObject() : QObject() { +} + +QSizeF AnimationTextObject::intrinsicSize(QTextDocument *, int, const QTextFormat &fmt) { + QMovie *animation = qvariant_cast< QMovie* >(fmt.property(1)); + return QSizeF(animation->currentPixmap().size()); +} + +void AnimationTextObject::drawObject(QPainter *painter, const QRectF &rectF, QTextDocument *, int, const QTextFormat &fmt) { + QRect rect = rectF.toRect(); + QMovie *animation = qvariant_cast< QMovie* >(fmt.property(1)); + QPixmap frame = animation->currentPixmap(); + bool isRunning = animation->state() == QMovie::Running; + bool isCached = animation->cacheMode() == QMovie::CacheAll; + updatePropertiesIfChanged(animation, rect); + + painter->setRenderHint(QPainter::Antialiasing); + if (areVideoControlsOn) { + drawVideoControls(painter, animation, frame, !isRunning, isCached, animation->currentFrameNumber(), animation->speed()); + return; + } + painter->drawPixmap(rect, frame); + if (!isRunning) { + drawCenteredPlayIcon(painter, rect); + } +} + + bool ChatbarTextEdit::event(QEvent *evt) { if (evt->type() == QEvent::ShortcutOverride) { return false; diff --git a/src/mumble/CustomElements.h b/src/mumble/CustomElements.h index 2478d2afa5f..6cf836fba31 100644 --- a/src/mumble/CustomElements.h +++ b/src/mumble/CustomElements.h @@ -10,12 +10,19 @@ #include #include #include +#include +#include + +Q_DECLARE_METATYPE(QMovie*) class LogTextBrowser : public QTextBrowser { private: Q_OBJECT Q_DISABLE_COPY(LogTextBrowser) +protected: + void mousePressEvent(QMouseEvent *) Q_DECL_OVERRIDE; + public: LogTextBrowser(QWidget *p = nullptr); @@ -51,7 +58,7 @@ class ChatbarTextEdit : public QTextEdit { void resizeEvent(QResizeEvent *e) Q_DECL_OVERRIDE; void insertFromMimeData(const QMimeData *source) Q_DECL_OVERRIDE; bool sendImagesFromMimeData(const QMimeData *source); - bool emitPastedImage(QImage image); + bool emitPastedImage(QImage image, QString filePath = ""); public: void setDefaultText(const QString &, bool = false); @@ -75,6 +82,48 @@ public slots: ChatbarTextEdit(QWidget *p = nullptr); }; +class AnimationTextObject : public QObject, public QTextObjectInterface { + Q_OBJECT + Q_INTERFACES(QTextObjectInterface) + +protected: + QSizeF intrinsicSize(QTextDocument *doc, int posInDoc, const QTextFormat &fmt) Q_DECL_OVERRIDE; + void drawObject(QPainter *painter, const QRectF &rectF, QTextDocument *doc, int posInDoc, const QTextFormat &fmt) Q_DECL_OVERRIDE; + +public: + AnimationTextObject(); + static void mousePress(QAbstractTextDocumentLayout *docLayout, QPoint mouseDocPos, Qt::MouseButton button); + static bool areVideoControlsOn; + + enum VideoController { + VideoBar, + View, + PlayPause, + CacheSwitch, + LoopSwitch, + PreviousFrame, + NextFrame, + ResetSpeed, + DecreaseSpeed, + IncreaseSpeed, + None + }; + enum LoopMode { Unchanged, Loop, NoLoop }; + static QString loopModeToString(LoopMode mode); + + static void setFrame(QMovie *animation, int frameIndex); + static void updatePropertiesIfChanged(QObject *propertyHolder, QRect rect); + static void drawCenteredPlayIcon(QPainter *painter, QRect rect); + + static bool isInBoundsOnAxis(QPoint pos, bool yInsteadOfX, int start, int length); + static bool isInBounds(QPoint pos, QRect bounds); + static void setRectAndVideoControlPositioning(QObject *propertyHolder, QRect rect, int videoBarHeight = 4, int underVideoBarHeight = 20, + int cacheX = -170, int loopModeX = -130, int frameTraversalX = -90, int speedX = -30); + static VideoController mousePressVideoControls(QObject *propertyHolder, QPoint mouseDocPos); + static void drawVideoControls(QPainter *painter, QObject *propertyHolder, QPixmap frame, + bool isPaused, bool isCached, int frameIndex, int speedPercentage); +}; + class DockTitleBar : public QLabel { private: Q_OBJECT diff --git a/src/mumble/Log.cpp b/src/mumble/Log.cpp index eb01bddf26e..e77814689ed 100644 --- a/src/mumble/Log.cpp +++ b/src/mumble/Log.cpp @@ -415,6 +415,9 @@ QVector< LogMessage > Log::qvDeferredLogs; Log::Log(QObject *p) : QObject(p) { qRegisterMetaType< Log::MsgType >(); + QAbstractTextDocumentLayout *docLayout = Global::get().mw->qteLog->document()->documentLayout(); + docLayout->registerHandler(Animation, new AnimationTextObject()); + #ifndef USE_NO_TTS tts = new TextToSpeech(this); tts->setVolume(Global::get().s.iTTSVolume); @@ -634,6 +637,114 @@ QString Log::imageToImg(QImage img, int maxSize) { return QString(); } +bool Log::htmlWithAnimation(const QString &html, QTextCursor *tc) { + qsizetype imgStartIndex = html.indexOf(" 0 && htmlLastIndex > base64StartIndex + 2 ? + qvariant_cast< QString >(QByteArray::fromBase64(qvariant_cast< QByteArray >(html.sliced(base64StartIndex, 4)))) : + ""; + if (imageFirstThreeBytes != "GIF") { + return false; + } + qsizetype base64EndIndex = html.indexOf('\"', base64StartIndex) - 1; + qsizetype imgEndIndex = html.indexOf('>', base64EndIndex); + if (base64EndIndex == -1 || imgEndIndex == -1) { + return false; + } + int base64Size = base64EndIndex - base64StartIndex + 1; + QString animationBase64 = html.sliced(base64StartIndex, base64Size); + QByteArray animationBa = QByteArray::fromBase64(qvariant_cast< QByteArray >(animationBase64)); + + QMovie *animation = new QMovie(); + QBuffer *buffer = new QBuffer(animation); + buffer->setData(animationBa); + buffer->open(QIODevice::ReadOnly); + animation->setDevice(buffer); + if (!animation->isValid()) { + delete animation; + return false; + } + animation->setProperty("LoopMode", QVariant::fromValue(AnimationTextObject::Unchanged)); + // Load and start the animation but stop or pause it after this when it should not play by default: + animation->start(); + + int frameCount = animation->frameCount(); + int frameCountTest = 0; + int totalMs = 0; + QList< QVariant > frameDelays; + // Test how many frames there are by index in case the animation format does not support `frameCount`. + // Also determine the total play time for the video controls by gathering the time from each frame. + // The current time is determined by a list of the time between frames since each delay until the next frame may vary: + while (animation->jumpToFrame(++frameCountTest)) { + int delay = animation->nextFrameDelay(); + frameDelays.append(QVariant(delay)); + totalMs += delay; + } + if (frameCount == 0) { + frameCount = frameCountTest; + } + int lastFrameIndex = frameCount - 1; + animation->setProperty("lastFrameIndex", QVariant(lastFrameIndex)); + animation->setProperty("totalMs", QVariant(totalMs)); + animation->setProperty("frameDelays", frameDelays); + animation->jumpToFrame(0); + animation->stop(); + + QAbstractTextDocumentLayout *docLayout = Global::get().mw->qteLog->document()->documentLayout(); + auto refresh = [animation, docLayout]() { + QRect rect = animation->property("posAndSize").toRect(); + emit docLayout->update(rect); + }; + auto getLoopMode = [animation]() { + return qvariant_cast< AnimationTextObject::LoopMode >(animation->property("LoopMode")); + }; + // Refresh the image on change: + connect(animation, &QMovie::updated, refresh); + // Ensure the image is refreshed once more when the animation is paused or stopped: + connect(animation, &QMovie::stateChanged, [refresh](QMovie::MovieState currentState) { + if (currentState != QMovie::Running) { + refresh(); + } + }); + // Start the animation again when it finishes if the loop mode is `Loop`: + connect(animation, &QMovie::finished, [animation, getLoopMode]() { + if (getLoopMode() == AnimationTextObject::Loop) { + animation->start(); + } + }); + // Stop the animation at the end of the last frame if the loop mode is `NoLoop`: + connect(animation, &QMovie::frameChanged, [animation, getLoopMode, lastFrameIndex](int frameIndex) { + auto isNoLoopAndUnpaused = [animation, getLoopMode]() { + return getLoopMode() == AnimationTextObject::NoLoop && animation->state() != QMovie::Paused; + }; + if (frameIndex != lastFrameIndex || !isNoLoopAndUnpaused()) { + return; + } + QTimer::singleShot(animation->nextFrameDelay(), Qt::PreciseTimer, [animation, isNoLoopAndUnpaused, lastFrameIndex]() { + if (!isNoLoopAndUnpaused()) { + return; + } + AnimationTextObject::setFrame(animation, lastFrameIndex); + animation->stop(); + }); + }); + + QTextCharFormat fmt = Global::get().mw->qteLog->currentCharFormat(); + fmt.setObjectType(Animation); + fmt.setProperty(1, QVariant::fromValue(animation)); + + QString htmlBeforeImgTag = imgStartIndex > 0 ? html.sliced(0, imgStartIndex - 1) : ""; + QString htmlAfterImgTag = imgEndIndex < htmlLastIndex ? html.sliced(imgEndIndex + 1) : ""; + tc->insertHtml(htmlBeforeImgTag); + tc->insertText(QString(QChar::ObjectReplacementCharacter), fmt); + tc->insertHtml(htmlAfterImgTag); + return true; +} + QString Log::validHtml(const QString &html, QTextCursor *tc) { LogDocument qtd; @@ -648,7 +759,11 @@ QString Log::validHtml(const QString &html, QTextCursor *tc) { // allowing our validation checks for things such as // data URL images to run. (void) qtd.documentLayout(); - qtd.setHtml(html); + // Parse and insert an animated image file along with the rest of the HTML + // if a tag, a header and valid data for it is detected, otherwise log the HTML as usual: + if (!tc || !htmlWithAnimation(html, tc)) { + qtd.setHtml(html); + } QStringList qslAllowed = allowedSchemes(); for (QTextBlock qtb = qtd.begin(); qtb != qtd.end(); qtb = qtb.next()) { diff --git a/src/mumble/Log.h b/src/mumble/Log.h index eee10bd7f7d..dd8da7eadc7 100644 --- a/src/mumble/Log.h +++ b/src/mumble/Log.h @@ -118,6 +118,8 @@ class Log : public QObject { // versions. static const MsgType msgOrder[]; + enum TextObjectType { Animation = QTextFormat::UserObject }; + protected: /// Mutex for qvDeferredLogs static QMutex qmDeferredLogs; @@ -135,6 +137,7 @@ class Log : public QObject { static const QStringList allowedSchemes(); void postNotification(MsgType mt, const QString &plain); void postQtNotification(MsgType mt, const QString &plain); + static bool htmlWithAnimation(const QString &html, QTextCursor *tc); public: Log(QObject *p = nullptr); diff --git a/src/mumble/MainWindow.cpp b/src/mumble/MainWindow.cpp index 0b533d3e919..0d3a4fc210d 100644 --- a/src/mumble/MainWindow.cpp +++ b/src/mumble/MainWindow.cpp @@ -69,6 +69,7 @@ #include #include #include +#include #include #include #include @@ -1064,12 +1065,18 @@ void MainWindow::on_qteLog_customContextMenuRequested(const QPoint &mpos) { cursor.movePosition(QTextCursor::NextCharacter); fmt = cursor.charFormat(); } - if (cursor.charFormat().isImageFormat()) { + bool isAnimation = fmt.objectType() == Log::Animation; + if (fmt.isImageFormat() || isAnimation) { menu->addSeparator(); menu->addAction(tr("Save Image As..."), this, SLOT(saveImageAs(void))); qtcSaveImageCursor = cursor; } + if (isAnimation) { + bool areVideoControlsOn = AnimationTextObject::areVideoControlsOn; + QString actionFirstWord = areVideoControlsOn ? "Hide" : "Show"; + menu->addAction(tr("%1 Video Controls").arg(actionFirstWord), this, SLOT(toggleVideoControls(void))); + } menu->addSeparator(); menu->addAction(tr("Clear"), qteLog, SLOT(clear(void))); @@ -1077,25 +1084,54 @@ void MainWindow::on_qteLog_customContextMenuRequested(const QPoint &mpos) { delete menu; } +void MainWindow::toggleVideoControls() { + bool areVideoControlsOn = AnimationTextObject::areVideoControlsOn; + AnimationTextObject::areVideoControlsOn = !areVideoControlsOn; + + QAction *action = qobject_cast< QAction* >(sender()); + QString actionText = action->text(); + QString actionFirstWord = areVideoControlsOn ? "Hide" : "Show"; + action->setText(actionFirstWord + actionText.sliced(actionText.indexOf(' '))); +} + void MainWindow::saveImageAs() { + QTextCharFormat fmt = qtcSaveImageCursor.charFormat(); + bool isAnimation = fmt.objectType() == Log::Animation; + QString fileExtension = isAnimation ? "gif" : "jpg"; QDateTime now = QDateTime::currentDateTime(); QString defaultFname = - QString::fromLatin1("Mumble-%1.jpg").arg(now.toString(QString::fromLatin1("yyyy-MM-dd-HHmmss"))); + QString::fromLatin1("Mumble-%1.%2").arg(now.toString(QString::fromLatin1("yyyy-MM-dd-HHmmss"))).arg(fileExtension); QString fname = QFileDialog::getSaveFileName(this, tr("Save Image File"), getImagePath(defaultFname), - tr("Images (*.png *.jpg *.jpeg)")); + tr("Images (*.png *.jpg *.jpeg *.gif)")); if (fname.isNull()) { return; } - QString resName = qtcSaveImageCursor.charFormat().toImageFormat().name(); - QVariant res = qteLog->document()->resource(QTextDocument::ImageResource, resName); - QImage img = res.value< QImage >(); - bool ok = img.save(fname); - if (!ok) { - // In case fname did not contain a file extension, try saving with an - // explicit format. - ok = img.save(fname, "PNG"); + bool ok = false; + if (isAnimation) { + QMovie *animation = qvariant_cast< QMovie* >(fmt.property(1)); + QIODevice *device = animation->device(); + qint64 previousPos = device->pos(); + if (device->reset()) { + QByteArray fileData = device->readAll(); + QSaveFile saveFile(fname); + if (saveFile.open(QIODevice::WriteOnly)) { + saveFile.write(fileData); + ok = saveFile.commit(); + } + } + device->seek(previousPos); + } else { + QString resName = fmt.toImageFormat().name(); + QVariant res = qteLog->document()->resource(QTextDocument::ImageResource, resName); + QImage img = res.value< QImage >(); + ok = img.save(fname); + if (!ok) { + // In case fname did not contain a file extension, try saving with an + // explicit format. + ok = img.save(fname, "PNG"); + } } updateImagePath(fname); @@ -3914,7 +3950,7 @@ QPair< QByteArray, QImage > MainWindow::openImageFile() { QPair< QByteArray, QImage > retval; QString fname = - QFileDialog::getOpenFileName(this, tr("Choose image file"), getImagePath(), tr("Images (*.png *.jpg *.jpeg)")); + QFileDialog::getOpenFileName(this, tr("Choose image file"), getImagePath(), tr("Images (*.png *.jpg *.jpeg *.gif)")); if (fname.isNull()) return retval; diff --git a/src/mumble/MainWindow.h b/src/mumble/MainWindow.h index 49df1147215..831866189d0 100644 --- a/src/mumble/MainWindow.h +++ b/src/mumble/MainWindow.h @@ -372,6 +372,8 @@ public slots: void onResetAudio(); void showRaiseWindow(); void on_qaFilterToggle_triggered(); + /// Alternates between showing and hiding video controls for animated images. + void toggleVideoControls(); /// Opens a save dialog for the image referenced by qtcSaveImageCursor. void saveImageAs(); /// Returns the path to the user's image directory, optionally with a