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:
parent
513029005e
commit
b9e02a972a
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Reference in New Issue