From 1bb4cb3c1c44594f533ab48203515e9db193161f 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 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 To turn on caching most notably boosts performance when jumping to frames that are sequentially far apart due to `QMovie` only being able to switch frame in order from start to end and around when caching is off, though it usually plays fine regardless except for when playing in reverse in an animation with many frames. Reverse playback is also implemented here, so when decreasing the speed to less than zero it will play at that speed in reverse as expected, taking a speed-step that's twice as big if the speed would otherwise become zero so that the animation only pauses when it's not playing. As for loop mode, "Unchanged" is to use the in-built setting in the `gif` image file for how many times it is to repeat, whereas "Loop" and "No loop" override this behavior accordingly. The main limitation is the character limit on text messages for images. Currently 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 Lastly, if settings were to be added to this feature, then here are some suggestions for those settings: - Play animations by default - Show video controls by default - Cache all frames by default - Specify the default loop mode - Specify the default playback speed --- src/mumble/CustomElements.cpp | 624 +++++++++++++++++++++++++++++++++- src/mumble/CustomElements.h | 53 ++- src/mumble/Log.cpp | 189 +++++++++- src/mumble/Log.h | 3 + src/mumble/MainWindow.cpp | 74 +++- src/mumble/MainWindow.h | 4 + 6 files changed, 930 insertions(+), 17 deletions(-) diff --git a/src/mumble/CustomElements.cpp b/src/mumble/CustomElements.cpp index 2fe2474f62c..9ba1627f17d 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,600 @@ 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); + } +} + +void AnimationTextObject::stopAllPlayback(QMovie *animation) { + animation->stop(); + animation->setProperty("isPlayingInReverse", false); +} + +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 of the animation if its vertical position changes, + // such as if content above it increases in height by text wrapping, + // or if its horizontal position changes, such as if the window resizes + // when the animation is centered and dependent on available space: + if (propertyRect.y() != rect.y() || propertyRect.x() != rect.x()) { + 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()); +} + +int AnimationTextObject::getOffset(int offset, int start, int length) { + return start + offset + (offset < 0 ? length : 0); +} + +void AnimationTextObject::setRectAndVideoControlPositioning(QObject *propertyHolder, QRect rect, int videoBarHeight, int underVideoBarHeight, + int cacheX, int loopModeX, int frameTraversalX, int 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 wasPaused, bool wasCached, 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 videoControlsHeight = videoBarHeight + underVideoBarHeight; + QSize sizeMin(rect.width() - videoControlsHeight, rect.height() - videoControlsHeight); + int xWithWidth = rect.x() + sizeMin.width(); + auto getOffsetX = [rect, xWithWidth](int offset) { + return offset < 0 ? xWithWidth + offset : rect.x() + offset; + }; + int cacheX = getOffsetX(propertyHolder->property("cacheX").toInt()); + int loopModeX = getOffsetX(propertyHolder->property("loopModeX").toInt()); + int frameTraversalX = getOffsetX(propertyHolder->property("frameTraversalX").toInt()); + int speedX = getOffsetX(propertyHolder->property("speedX").toInt()); + + int videoBarX = rect.x(); + int videoBarY = rect.y() + rect.height() - videoControlsHeight; + int underVideoBarY = videoBarY + videoBarHeight; + int underVideoBarWithMarginY = underVideoBarY + 14; + + auto convertUnit = [](int integer, int exponent, int decimalAmount = 0) -> double { + 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) -> QString { + qsizetype decimalMarkIndex = numberStr.lastIndexOf('.'); + bool isDecimal = decimalMarkIndex != -1 && decimalMarkIndex < numberStr.length() - 1; + qsizetype currentDecimalAmount = isDecimal ? numberStr.sliced(decimalMarkIndex + 1).length() : 0; + qsizetype decimalFillerAmount = decimalAmount - currentDecimalAmount; + + QString decimalFillers = QString('0').repeated(decimalFillerAmount); + return decimalFillerAmount > 0 ? numberStr.append((decimalFillerAmount == 1 ? "." : "") + decimalFillers) : numberStr; + }; + auto padNumber = [](QString numberStr, int digitAmount) -> QString { + 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) -> QString { + auto getTimeNumbers = [](double seconds) -> QList< double > { + 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 { + int digitAmount = 0; + do { + number /= 10; + ++digitAmount; + } while (number != 0); + return digitAmount; + }; + + QList< double > timeNumbers = getTimeNumbers(seconds); + QList< double > totalTimeNumbers = totalSeconds == 0 ? timeNumbers : getTimeNumbers(totalSeconds); + qsizetype timeNumberAmount = timeNumbers.length(); + int decimalAmount = 1; + + qsizetype lastTimeNumberIndex = 0; + for (qsizetype i = timeNumberAmount - 1; i >= 0; --i) { + if (totalTimeNumbers[i] > 0) { + lastTimeNumberIndex = i; + break; + } + } + + QString timeStr; + for (qsizetype 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 ? (int)(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(); + qsizetype loopModeStrLength = loopModeStr.length(); + int loopModeStrOffset = loopModeStrLength > 7 ? 12 : loopModeStrLength > 4 ? 5 : 0; + 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 - (double)loopModeStrOffset, underVideoBarY + 17), loopModeStr); + + QString cachedStr = QString::fromStdString(wasCached ? "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 (wasPaused) { + // 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 videoControlsHeight = videoBarHeight + underVideoBarHeight; + QSize sizeMin(rect.width() - videoControlsHeight, rect.height() - videoControlsHeight); + int xWithWidth = rect.x() + sizeMin.width(); + auto getOffsetX = [rect, xWithWidth](int offset) { + return offset < 0 ? xWithWidth + offset : rect.x() + offset; + }; + int cacheX = getOffsetX(propertyHolder->property("cacheX").toInt()); + int loopModeX = getOffsetX(propertyHolder->property("loopModeX").toInt()); + int frameTraversalX = getOffsetX(propertyHolder->property("frameTraversalX").toInt()); + int speedX = getOffsetX(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 videoControlsY = rect.y() + rect.height() - videoControlsHeight; + int underVideoBarY = videoControlsY + videoBarHeight; + + QRect viewRect(rect.topLeft(), sizeMin); + 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; + int speedMinusHeight = 9; + int speedPlusHeight = 11; + QRect speedResetRect(speedX - 28, underVideoBarY, speedWidth, underVideoBarHeight); + QRect speedMinusRect(speedX - 14, underVideoBarY + speedPlusHeight, speedWidth, speedMinusHeight); + QRect speedPlusRect(speedX - 14, underVideoBarY, speedWidth, speedPlusHeight); + + 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 setSpeed = [animation, updateVideoControls](int percentage) { + // Pausing the animation should only be done via the play state to avoid confusion: + if (percentage == 0) { + return; + } + animation->setSpeed(percentage); + updateVideoControls(); + // `QMovie` does not itself support reverse playback but this can be and is implemented using it: + bool wasPlayingInReverse = animation->property("isPlayingInReverse").toBool(); + bool wasRunning = animation->state() == QMovie::Running || wasPlayingInReverse; + if (percentage < 0) { + if (wasPlayingInReverse) { + return; + } + animation->setPaused(true); + animation->setProperty("isPlayingInReverse", wasRunning); + if (wasRunning) { + // Trigger the signal `frameChanged` where the handler also supports reverse playback: + emit animation->frameChanged(animation->currentFrameNumber()); + } + } else if (wasPlayingInReverse) { + animation->setProperty("isPlayingInReverse", false); + animation->setPaused(!wasRunning); + } + }; + 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 percentageStep) { + int speed = animation->speed(); + int nextPercentage = speed + percentageStep; + setSpeed(speed + percentageStep * (nextPercentage != 0 ? 1 : 2)); + }; + auto resetPlayback = [animation, thisSetFrame]() { + // Show the first frame that the animation would continue from if started again + // without caching anyway, indicating that the animation was stopped instead of paused: + thisSetFrame(0); + stopAllPlayback(animation); + }; + auto togglePause = [animation, thisSetFrame, lastFrameIndex]() { + QMovie::MovieState state = animation->state(); + bool wasStoppedOrNeverStarted = state == QMovie::NotRunning; + if (animation->speed() < 0) { + int frameIndex = animation->currentFrameNumber(); + int frameIndexToSwitchTo = wasStoppedOrNeverStarted && frameIndex == 0 ? lastFrameIndex : frameIndex; + if (wasStoppedOrNeverStarted) { + animation->start(); + animation->setPaused(true); + thisSetFrame(frameIndexToSwitchTo); + } + bool wasPlayingInReverse = animation->property("isPlayingInReverse").toBool(); + animation->setProperty("isPlayingInReverse", !wasPlayingInReverse); + if (wasPlayingInReverse) { + emit animation->stateChanged(state); + } else { + emit animation->frameChanged(frameIndexToSwitchTo); + } + } else if (wasStoppedOrNeverStarted) { + 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 by pausing it if it is not at the start or end: + thisSetFrame(previousFrame); + if (wasPaused || (!wasRunning && previousFrame != 0 && previousFrame != lastFrameIndex)) { + animation->setPaused(true); + } else if (!wasRunning) { + animation->stop(); + } + updateVideoControls(); + }; + + 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 wasRunning = animation->state() == QMovie::Running || animation->property("isPlayingInReverse").toBool(); + bool wasCached = animation->cacheMode() == QMovie::CacheAll; + int frameIndex = animation->currentFrameNumber(); + int speedPercentage = animation->speed(); + updatePropertiesIfChanged(animation, rect); + + painter->setRenderHint(QPainter::Antialiasing); + if (areVideoControlsOn) { + drawVideoControls(painter, animation, frame, !wasRunning, wasCached, frameIndex, speedPercentage); + return; + } + painter->drawPixmap(rect, frame); + if (!wasRunning) { + 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..9b8d3a5a65a 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,50 @@ 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 stopAllPlayback(QMovie *animation); + 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 int getOffset(int offset, int start, int length); + 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 wasPaused, bool wasCached, 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..ab5fe9ee837 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,186 @@ QString Log::imageToImg(QImage img, int maxSize) { return QString(); } +bool Log::htmlWithAnimations(const QString &html, QTextCursor *tc) { + bool isAnyAnimation = false; + + qsizetype htmlEndIndex = html.length() - 1; + auto isAnimationHeader = [html, htmlEndIndex](qsizetype previousImgEndIndex) -> std::tuple { + qsizetype imgStartIndex = html.indexOf(" 0 && htmlEndIndex > base64StartIndex + 2 ? + qvariant_cast< QString >(QByteArray::fromBase64(qvariant_cast< QByteArray >(html.sliced(base64StartIndex, 4)))) : + ""; + return std::make_tuple(imageFirstThreeBytes == "GIF", imgStartIndex, base64StartIndex); + }; + auto getIndexOfDoubleOrSingleQuote = [html](qsizetype startIndex) -> qsizetype { + qsizetype index = html.indexOf('\"', startIndex); + if (index == -1) { + index = html.indexOf('\'', startIndex); + } + return index; + }; + + // Track if there are more animations to insert after the current one: + bool isAnotherAnimation; + // Track the end of the previous animation and thereby where to move forward the start of the search to + // as well as what HTML precedes the currently processed animation but succeeds the previous animation: + qsizetype previousImgEndIndex = -1; + do { + qsizetype imgStartIndex; + qsizetype base64StartIndex; + std::tie(isAnotherAnimation, imgStartIndex, base64StartIndex) = isAnimationHeader(previousImgEndIndex); + qsizetype base64EndIndex = getIndexOfDoubleOrSingleQuote(base64StartIndex) - 1; + qsizetype imgEndIndex = html.indexOf('>', base64EndIndex); + if (!isAnotherAnimation || base64EndIndex == -2 || imgEndIndex == -1) { + previousImgEndIndex = imgEndIndex; + continue; + } + qsizetype 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; + previousImgEndIndex = imgEndIndex; + continue; + } + animation->setProperty("LoopMode", QVariant::fromValue(AnimationTextObject::Unchanged)); + // Track when the animation is playing in reverse instead of using the in-built play-controls which do not support it: + animation->setProperty("isPlayingInReverse", false); + // Block further signals during sequential traversal until reaching the preceding frame: + animation->setProperty("isTraversingFrames", false); + // 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 used 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(); + qsizetype frameDelayAmount = frameDelays.length(); + 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); + // Refresh the image 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` and + // play the animation in reverse if the property for it is `true`: + connect(animation, &QMovie::frameChanged, [animation, getLoopMode, frameDelays, frameDelayAmount, lastFrameIndex](int frameIndex) { + auto getFrameDelay = [animation, frameDelays, frameDelayAmount](int targetFrameIndex) -> int { + double speed = abs(animation->speed() / (double)100); + bool isIndexInBoundsForDelay = targetFrameIndex >= 0 && targetFrameIndex < frameDelayAmount; + return (int)round(frameDelays[isIndexInBoundsForDelay ? targetFrameIndex : frameDelayAmount - 1].toInt() / speed); + }; + auto isAtFrameAndRunning = [animation](int targetFrameIndex) -> bool { + int frameIndex = animation->currentFrameNumber(); + bool wasRunning = animation->state() == QMovie::Running || animation->property("isPlayingInReverse").toBool(); + return frameIndex == targetFrameIndex && wasRunning; + }; + auto isAtFrameAndRunningWithNoLoop = [animation, getLoopMode, isAtFrameAndRunning](int targetFrameIndex) { + return isAtFrameAndRunning(targetFrameIndex) && getLoopMode() == AnimationTextObject::NoLoop; + }; + auto stopAtEndOfFrame = [animation, isAtFrameAndRunningWithNoLoop, getFrameDelay](int targetFrameIndex) { + int delay = getFrameDelay(targetFrameIndex); + QTimer::singleShot(delay, Qt::PreciseTimer, animation, [animation, isAtFrameAndRunningWithNoLoop, targetFrameIndex]() { + if (!isAtFrameAndRunningWithNoLoop(targetFrameIndex)) { + return; + } + AnimationTextObject::setFrame(animation, targetFrameIndex); + AnimationTextObject::stopAllPlayback(animation); + }); + }; + + if (animation->property("isPlayingInReverse").toBool()) { + if (animation->property("isTraversingFrames").toBool()) { + return; + } + int precedingFrameIndex = frameIndex <= 0 ? lastFrameIndex : frameIndex - 1; + int precedingFrameDelay = getFrameDelay(precedingFrameIndex); + if (isAtFrameAndRunningWithNoLoop(0)) { + stopAtEndOfFrame(frameIndex); + return; + } + QTimer::singleShot(precedingFrameDelay, Qt::PreciseTimer, animation, + [animation, isAtFrameAndRunning, frameIndex, precedingFrameIndex]() { + if (!isAtFrameAndRunning(frameIndex)) { + return; + } + bool wasCached = animation->cacheMode() == QMovie::CacheAll; + if (!wasCached) { + animation->setProperty("isTraversingFrames", true); + } + AnimationTextObject::setFrame(animation, precedingFrameIndex); + if (!wasCached) { + animation->setProperty("isTraversingFrames", false); + emit animation->frameChanged(precedingFrameIndex); + } + }); + } else if (isAtFrameAndRunningWithNoLoop(lastFrameIndex)) { + stopAtEndOfFrame(frameIndex); + } + }); + + QTextCharFormat fmt = Global::get().mw->qteLog->currentCharFormat(); + fmt.setObjectType(Animation); + fmt.setProperty(1, QVariant::fromValue(animation)); + + isAnotherAnimation = std::get<0>(isAnimationHeader(imgEndIndex)); + qsizetype htmlBeforeImgLength = imgStartIndex - 1 - previousImgEndIndex; + QString htmlBeforeImg = imgStartIndex - 1 > previousImgEndIndex ? html.sliced(previousImgEndIndex + 1, htmlBeforeImgLength) : ""; + QString htmlAfterImg = !isAnotherAnimation && imgEndIndex < htmlEndIndex ? html.sliced(imgEndIndex + 1) : ""; + tc->insertHtml(htmlBeforeImg); + tc->insertText(QString(QChar::ObjectReplacementCharacter), fmt); + tc->insertHtml(htmlAfterImg); + + previousImgEndIndex = imgEndIndex; + isAnyAnimation = true; + } while (isAnotherAnimation); + return isAnyAnimation; +} + QString Log::validHtml(const QString &html, QTextCursor *tc) { LogDocument qtd; @@ -648,7 +831,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 animated image files along with the rest of the HTML + // if a tag, a header and valid data for any is detected, otherwise log the HTML as usual: + if (!tc || !htmlWithAnimations(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..c1627d5bbde 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 htmlWithAnimations(const QString &html, QTextCursor *tc); public: Log(QObject *p = nullptr); diff --git a/src/mumble/MainWindow.cpp b/src/mumble/MainWindow.cpp index 0b533d3e919..56cf3f7f37f 100644 --- a/src/mumble/MainWindow.cpp +++ b/src/mumble/MainWindow.cpp @@ -69,6 +69,7 @@ #include #include #include +#include #include #include #include @@ -1064,38 +1065,85 @@ 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))); + menu->addAction(tr("Clear"), this, SLOT(clearDocument(void))); menu->exec(qteLog->mapToGlobal(mpos)); delete menu; } +void MainWindow::clearDocument() { + QList< QTextFormat > fmts = qteLog->document()->allFormats(); + for (auto fmt : fmts) { + if (fmt.objectType() == Log::Animation) { + QMovie *animation = qvariant_cast< QMovie* >(fmt.property(1)); + AnimationTextObject::stopAllPlayback(animation); + animation->deleteLater(); + } + } + qteLog->clear(); +} + +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 +3962,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..7a65aacfde4 100644 --- a/src/mumble/MainWindow.h +++ b/src/mumble/MainWindow.h @@ -372,6 +372,10 @@ public slots: void onResetAudio(); void showRaiseWindow(); void on_qaFilterToggle_triggered(); + /// Removes the content of the client's log and deletes the objects used by text objects. + void clearDocument(); + /// 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