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