From b9e02a972a2e6f7700ad79dfdb6a622e091eea04 Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:46:35 +0200 Subject: [PATCH] gui: inline video embeds in chat Detect video URLs (YouTube, Vimeo, Dailymotion, Twitch, direct media) in chat responses. Direct .mp4/.webm/.ogv files play inline via QtMultimedia Video. Other video sites show a card with play button that opens in the system browser. Adds Qt6::Multimedia dependency. --- CMakeLists.txt | 3 +- gui/ChatPane.qml | 78 ++++++++++++++++++++++++++++++++++++++++++ gui/chatblockmodel.cpp | 50 ++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 66e22e0..040c28b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -153,7 +153,7 @@ if(OLLIE_KF5) else() # --- Qt6 / KF6 --- - find_package(Qt6 REQUIRED COMPONENTS Core DBus Widgets Quick QuickControls2) + find_package(Qt6 REQUIRED COMPONENTS Core DBus Widgets Quick QuickControls2 Multimedia) find_package(KF6StatusNotifierItem REQUIRED) find_package(KF6 REQUIRED COMPONENTS Runner Notifications CoreAddons Config) find_package(Plasma REQUIRED) @@ -242,6 +242,7 @@ else() Qt6::Widgets Qt6::Quick Qt6::QuickControls2 + Qt6::Multimedia KF6::ConfigCore KF6::SyntaxHighlighting ) diff --git a/gui/ChatPane.qml b/gui/ChatPane.qml index 549ce24..5278c1e 100644 --- a/gui/ChatPane.qml +++ b/gui/ChatPane.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import QtMultimedia import org.ollie.gui 1.0 import "emojis.js" as Emojis @@ -558,6 +559,7 @@ Item { sourceComponent: subBlock.type === "code" ? codeSubDelegate : subBlock.type === "diff" ? diffSubDelegate : subBlock.type === "interrupt" ? interruptSubDelegate + : subBlock.type === "video" ? videoSubDelegate : proseSubDelegate } } @@ -622,6 +624,7 @@ Item { sourceComponent: subBlock.type === "code" ? codeSubDelegate : subBlock.type === "diff" ? diffSubDelegate : subBlock.type === "interrupt" ? interruptSubDelegate + : subBlock.type === "video" ? videoSubDelegate : proseSubDelegate } } @@ -721,6 +724,7 @@ Item { sourceComponent: subBlock.type === "code" ? codeSubDelegate : subBlock.type === "diff" ? diffSubDelegate : subBlock.type === "interrupt" ? interruptSubDelegate + : subBlock.type === "video" ? videoSubDelegate : plainSubDelegate } } @@ -782,6 +786,7 @@ Item { sourceComponent: subBlock.type === "code" ? codeSubDelegate : subBlock.type === "diff" ? diffSubDelegate : subBlock.type === "interrupt" ? interruptSubDelegate + : subBlock.type === "video" ? videoSubDelegate : plainSubDelegate } } @@ -859,6 +864,79 @@ Item { } } + Component { + id: videoSubDelegate + Item { + width: parent ? parent.width : 400 + height: Math.min(width * 9/16, 360) + + property string videoUrl: subBlock.content || "" + property bool isDirectMedia: /\.(mp4|webm|ogv)(\?|$)/i.test(videoUrl) + + // Direct media: inline player + Video { + id: videoPlayer + anchors.fill: parent + source: parent.isDirectMedia ? parent.videoUrl : "" + visible: parent.isDirectMedia + fillMode: VideoOutput.PreserveAspectFit + + MouseArea { + anchors.fill: parent + onClicked: { + if (videoPlayer.playbackState === MediaPlayer.PlayingState) + videoPlayer.pause() + else + videoPlayer.play() + } + } + } + + // Non-direct (YouTube, Vimeo, etc): link card with play button + Rectangle { + anchors.fill: parent + visible: !parent.isDirectMedia + color: "#181818" + radius: 8 + + ColumnLayout { + anchors.centerIn: parent + spacing: 8 + + Rectangle { + Layout.alignment: Qt.AlignHCenter + width: 64; height: 64 + radius: 32 + color: "#cc222222" + border.color: "#666" + border.width: 1 + Text { + anchors.centerIn: parent + text: "\u25B6" + font.pixelSize: 32 + color: "white" + } + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: parent.parent.parent.videoUrl + color: "#aaa" + font.pixelSize: 11 + elide: Text.ElideMiddle + Layout.maximumWidth: parent.parent.parent.width - 40 + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Qt.openUrlExternally(parent.parent.videoUrl) + } + } + } + } + Component { id: interruptSubDelegate Rectangle { diff --git a/gui/chatblockmodel.cpp b/gui/chatblockmodel.cpp index de62ad6..627c0c4 100644 --- a/gui/chatblockmodel.cpp +++ b/gui/chatblockmodel.cpp @@ -365,7 +365,55 @@ QVariantList ChatBlockModel::parseSubBlocks(const QString &content) const result.append(m); } - return result; + // Post-pass: extract video URLs from prose blocks into video sub-blocks + // Matches: YouTube, Vimeo, Dailymotion, Twitch clips, PeerTube, and direct media files + static const QRegularExpression videoUrlRe( + R"((https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/|vimeo\.com/|dailymotion\.com/video/|dai\.ly/|twitch\.tv/\w+/clip/|clips\.twitch\.tv/)[^\s)\]]+|https?://[^\s)\]]+\.(?:mp4|webm|ogv|m3u8)))"); + QVariantList final; + for (const QVariant &v : result) { + QVariantMap block = v.toMap(); + if (block["type"].toString() != "prose") { + final.append(block); + continue; + } + QString prose = block["content"].toString(); + auto vit = videoUrlRe.globalMatch(prose); + int lastPos = 0; + bool hasVideo = false; + while (vit.hasNext()) { + hasVideo = true; + auto vmatch = vit.next(); + if (vmatch.capturedStart() > lastPos) { + QString before = prose.mid(lastPos, vmatch.capturedStart() - lastPos).trimmed(); + if (!before.isEmpty()) { + QVariantMap pm; + pm["type"] = "prose"; + pm["content"] = before; + final.append(pm); + } + } + QVariantMap vm; + vm["type"] = "video"; + vm["content"] = vmatch.captured(1); + final.append(vm); + lastPos = vmatch.capturedEnd(); + } + if (hasVideo) { + if (lastPos < prose.size()) { + QString after = prose.mid(lastPos).trimmed(); + if (!after.isEmpty()) { + QVariantMap pm; + pm["type"] = "prose"; + pm["content"] = after; + final.append(pm); + } + } + } else { + final.append(block); + } + } + + return final; } // Custom highlighter that builds HTML string directly