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.
This commit is contained in:
Levi Neely 2026-07-18 18:46:35 +02:00
parent 513029005e
commit b9e02a972a
3 changed files with 129 additions and 2 deletions

View File

@ -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
)

View File

@ -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 {

View File

@ -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