Inertial Chicken

This commit is contained in:
Daniel O'Neill 2025-12-22 19:41:13 -08:00
parent b1d070909f
commit 8462d4281e
27 changed files with 3278 additions and 0 deletions

60
Media.pro Normal file
View file

@ -0,0 +1,60 @@
QT += core quick widgets dbus
CONFIG += c++11
# The following define makes your compiler emit warnings if you use
# any feature of Qt which as been marked deprecated (the exact warnings
# depend on your compiler). Please consult the documentation of the
# deprecated API in order to know how to port your code away from it.
DEFINES += QT_DEPRECATED_WARNINGS
# You can also make your code fail to compile if you use deprecated APIs.
# In order to do so, uncomment the following line.
# You can also select to disable deprecated APIs only up to a certain version of Qt.
#DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs deprecated before Qt 6.0.0
SOURCES += \
src/clipboard.cpp \
src/file.cpp \
src/hash.cpp \
src/main.cpp \
src/screen.cpp \
src/settings.cpp \
src/wasm.cpp
HEADERS += \
src/clipboard.h \
src/file.h \
src/hash.h \
src/screen.h \
src/settings.h \
src/wasm.h
# Additional import path used to resolve QML modules in Qt Creator's code model
QML_IMPORT_PATH =
# Additional import path used to resolve QML modules just for Qt Quick Designer
QML_DESIGNER_IMPORT_PATH =
# Default rules for deployment.
qnx: target.path = /tmp/$${TARGET}/bin
else: unix:!android: target.path = /opt/$${TARGET}/bin
!isEmpty(target.path): INSTALLS += target
# FILES_SERVER = server/serve.js
DISTFILES = \
$$files(qml/*, true) \
$$files(svg/*, true) \
$$files(fonts/*, true) \
qml/TrackSelectorButton.qml \
qtquickcontrols2.conf
resources.prefix = /
resources.base = $$PWD
resources.files = \
$$files(qml/*, true) \
$$files(svg/*, true) \
$$files(fonts/*, true) \
qtquickcontrols2.conf
RESOURCES += resources

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,175 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtMultimedia
import QtQuick.Window
Window {
id: root
title: qsTr("FantIM Media Playback")
flags: Qt.FramelessWindowHint | Qt.Window
visibility: Window.Hidden
color: "black"
signal exitRequested()
property alias source: videoOut.source
property alias videoOutput: videoOut.videoOutput
required property MediaPlayer player
property var origSize
property QtObject normalOutput
onWidthChanged: printResized();
onHeightChanged: printResized();
function printResized() {
console.log(`Resized to ${width}x${height}`);
}
Component.onCompleted: {
let recalc = function() {
const sw = videoOut.videoOutput.sourceRect.width;
const sh = videoOut.videoOutput.sourceRect.height;
if( 0 >= sw || 0 >= sh )
return;
let w = sw;
let h = sh;
const rmh = Screen.desktopAvailableHeight * 0.75;
const rmw = Screen.desktopAvailableWidth * 0.75;
console.log(`Video size is ${sw}x${sh}, max is ${rmw}x${rmh}`);
if( w > rmw || h > rmh ) {
const scale = Math.min( (rmw / w), (rmh / h) );
w *= scale;
h *= scale;
//console.log(`Resizing to ${w}x${h} @ ${scale}`);
}
if( !root.origSize )
root.origSize = { "width":w, "height":h, "x":root.x, "y":root.y };
if( Window.FullScreen !== root.visibility ) {
root.width = w;
root.height = h;
}
};
videoOut.videoOutput.sourceRectChanged.connect( function() {
recalc();
});
recalc();
}
function showFullscreen() {
flags = Qt.FramelessWindowHint | Qt.Window;
visibility = Window.FullScreen;
videoOut.isFullscreen = true;
videoOut.isWindowed = false;
requestActivate();
}
function showWindowed() {
flags = Qt.Window;
visibility = Window.Windowed;
videoOut.isFullscreen = false;
videoOut.isWindowed = true;
Qt.callLater( function() {
if( root.origSize ) {
console.log(`Showing windowed at ${origSize.width}x${origSize.height}`);
root.width = origSize.width;
root.height = origSize.height;
Qt.callLater( function() {
root.x = origSize.x;
root.y = origSize.y;
} );
}
} );
requestActivate();
}
function toggleFullscreen() {
//exitRequested();
const rv = root.visibility;
root.hide();
if( Window.FullScreen === rv )
root.showWindowed();
else
root.showFullscreen();
}
Shortcut {
sequence: "Space"
context: Qt.ApplicationShortcut
onActivated: player.pausePlay();
}
Shortcut {
sequence: [StandardKey.Cancel, "F"]
context: Qt.ApplicationShortcut
onActivated: root.toggleFullscreen();
}
Shortcut {
sequence: "M"
context: Qt.ApplicationShortcut
onActivated: {
controlsBar.popup();
player.audioOutput.muted = !player.audioOutput.muted;
}
}
Shortcut {
sequence: "R"
context: Qt.ApplicationShortcut
onActivated: {
controlsBar.popup();
player.loopRequested = !player.loopRequested;
}
}
Shortcut {
sequence: "Home"
context: Qt.ApplicationShortcut
onActivated: {
controlsBar.popup();
player.position = 0;
}
}
Shortcut {
sequence: "Left"
context: Qt.ApplicationShortcut
onActivated: {
controlsBar.popup();
player.position -= 5000;
}
}
Shortcut {
sequence: "Right"
context: Qt.ApplicationShortcut
onActivated: {
controlsBar.popup();
player.position += 5000;
}
}
Shortcut {
sequence: "Up"
context: Qt.ApplicationShortcut
onActivated: {
controlsBar.popup();
player.audioOutput.volume += 0.10;
}
}
Shortcut {
sequence: "Down"
context: Qt.ApplicationShortcut
onActivated: {
controlsBar.popup();
player.audioOutput.volume -= 0.10;
}
}
VideoSegment {
id: videoOut
anchors.fill: parent
}
}

10
qml/ImageSegment.qml Normal file
View file

@ -0,0 +1,10 @@
import QtQuick
AnimatedImage {
id: img
anchors.fill: parent
asynchronous: true
playing: visible
//fillMode: Image.PreserveAspectFit
source: seg.source
}

651
qml/ImageViewer.qml Normal file
View file

@ -0,0 +1,651 @@
import QtQuick
import QtQuick.Controls
Rectangle {
id: overlayRoot
anchors.fill: parent
color: mainWindow.isFullscreen ? "black" : "#dd000000"
visible: opacity > 0
opacity: 0
focus: visible
property var images: []
property int currentIndex: 0
property string baseMode: "Full" // "Fit", "Fill", or "Full"
property real extraZoomPercent: 100 // 10400
signal closed()
signal thumbnailReady(variant entry, url thumbnail)
Behavior on opacity { NumberAnimation { duration: 100 } }
// ---- helpers ----
function resetZoom() {
extraZoomPercent = 100;
}
function toggleBaseMode() {
baseMode = (baseMode === "Fit") ? "Full" : (baseMode === "Full") ? "Fill" : "Fit";
resetZoom();
}
function toggleBaseOrReset() {
if (extraZoomPercent === 100)
toggleBaseMode();
else
resetZoom();
}
function stepZoom(steps) {
extraZoomPercent += steps * 10;
if (extraZoomPercent < 10) extraZoomPercent = 10;
if (extraZoomPercent > 400) extraZoomPercent = 400;
}
onCurrentIndexChanged: {
if (!images || images.length === 0)
return;
if (currentIndex < 0 || currentIndex >= images.length)
return;
videoSessionManager.pauseAll();
thumbView.currentIndex = currentIndex;
//thumbView.positionViewAtIndex(idx, ListView.Center);
imageView.snap();
}
function showGallery(index) {
imageMouseArea.snapping = true;
baseMode = "Full";
extraZoomPercent = 100;
const idx = Math.max(0, Math.min(index || 0, images.length - 1));
opacity = 1;
overlayRoot.forceActiveFocus();
currentIndex = idx;
Qt.callLater( function() {
imageMouseArea.snapping = false;
} );
}
function hide() {
opacity = 0;
videoSessionManager.pauseAll();
if( mainWindow.isFullscreen )
mainWindow.showNormal();
closed();
}
function prevImage() {
if (!images || images.length < 2)
return;
let nidx = currentIndex - 1;
if( nidx < 0 )
nidx = images.length-1;
currentIndex = nidx;
}
function nextImage() {
if (!images || images.length < 2)
return;
let nidx = currentIndex + 1;
if( nidx >= images.length )
nidx = 0;
currentIndex = nidx;
}
Keys.onReleased: function(event) {
if( Qt.Key_Escape === event.key ) {
hide();
event.accepted = true;
} else if( Qt.Key_Left === event.key ) {
prevImage();
event.accepted = true;
} else if( Qt.Key_Right === event.key ) {
nextImage();
event.accepted = true;
}
}
// background click to close (outside mainImageFrame)
MouseArea {
id: imageMouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
//preventStealing: false
//propagateComposedEvents: true
property bool insideMe: false
property bool dragging: false
property real pressX: 0
property real pressY: 0
property int startIndex: 0
property real dragFactor: 2.0 // perceived speed
property real thresholdFactor: 0.15 // 15% of width to change
property bool snapping: false
property bool doubleClicking: false
Timer {
id: doubleClickTimer
interval: 150
repeat: false
property variant ev
onTriggered: {
let item = contentRepeater.itemAt( overlayRoot.currentIndex );
if( !item || !item.clicked )
return;
console.log(`Sending click`);
item.clicked(ev);
}
}
onDoubleClicked: function(ev) {
ev.accepted = true;
doubleClicking = true;
doubleClickTimer.stop();
let item = contentRepeater.itemAt( overlayRoot.currentIndex );
if( !item || !item.doubleClicked )
return;
console.log(`Sending doubleClick`);
item.doubleClicked(ev);
}
onPressed: function(mouse) {
if (Qt.LeftButton === mouse.button) {
mouse.accepted = true;
insideMe = true;
pressX = mouse.x;
pressY = mouse.y;
startIndex = overlayRoot.currentIndex;
} else if (Qt.MiddleButton === mouse.button) {
overlayRoot.toggleBaseOrReset();
mouse.accepted = true;
}
}
onPositionChanged: function(mouse) {
if( Qt.LeftButton !== imageMouseArea.pressedButtons )
return;
const px = mouse.x - pressX;
const py = mouse.y - pressY;
if( !insideMe )
return;
if( !dragging ) {
if( Math.hypot(px, py) < 10 ) // 10px threshold
return;
else
dragging = true;
}
doubleClickTimer.stop();
mouse.accepted = true;
const pageWidth = mainImageFrame.width;
if (pageWidth <= 0 || !overlayRoot.images || overlayRoot.images.length <= 0)
return;
const dx = (mouse.x - pressX) * dragFactor;
const offset = Math.max(-pageWidth, Math.min(pageWidth, -dx));
const baseX = startIndex * pageWidth;
let newX = baseX + offset;
const maxX = Math.max(0, (overlayRoot.images.length - 1) * pageWidth);
if (newX < 0) newX = 0;
if (newX > maxX) newX = maxX;
imageView.contentX = newX;
}
onReleased: function(mouse) {
if( Qt.LeftButton !== mouse.button )
return;
if( !insideMe && !dragging )
return false;
mouse.accepted = true;
if( !dragging ) {
mouse.accepted = true;
if( doubleClicking || doubleClickTimer.running ) {
console.log(`Stopping doubleClickTimer`);
doubleClicking = false;
doubleClickTimer.stop();
return;
}
console.log(`Starting doubleClickTimer`);
doubleClickTimer.ev = mouse;
doubleClickTimer.start();
return true;
}
dragging = false;
if( Math.abs(pressX - mouse.x) < 5 && Math.abs(pressY - mouse.y) < 5 )
return
const pageWidth = mainImageFrame.width;
if (pageWidth <= 0 || !overlayRoot.images || overlayRoot.images.length <= 0) {
overlayRoot.currentIndex = 0;
imageView.snap();
return;
}
const dx = (mouse.x - pressX) * dragFactor;
const threshold = pageWidth * thresholdFactor;
let newIndex = startIndex;
if (Math.abs(dx) >= threshold) {
if (dx < 0 && startIndex < images.length - 1)
newIndex = startIndex + 1;
else if (dx > 0 && startIndex > 0)
newIndex = startIndex - 1;
}
overlayRoot.currentIndex = newIndex;
imageView.snap();
mouse.accepted = true;
}
onCanceled: {
dragging = false;
insideMe = false;
imageView.snap();
}
onWheel: function(event) {
event.accepted = true;
const delta = event.angleDelta.y || event.pixelDelta.y;
if (0 === delta)
return;
if (event.modifiers & Qt.ControlModifier)
overlayRoot.stepZoom(0 < delta ? 1 : -1);
else {
if (delta < 0)
overlayRoot.nextImage();
else
overlayRoot.prevImage();
}
}
}
Item {
id: mainImageFrame
anchors {
top: parent.top
left: parent.left
right: parent.right
bottom: thumbBar.top
}
clip: true
Item {
anchors.fill: parent
Row {
id: imageView
property real contentX: 0
x: 0 - contentX;
Behavior on x {
enabled: !imageMouseArea.dragging && !imageMouseArea.snapping
NumberAnimation {
duration: 150
easing.type: Easing.InOutQuad
}
}
onWidthChanged: snap();
function snap(instantly) {
if (!overlayRoot.images || overlayRoot.images.length <= 0)
return;
if (overlayRoot.currentIndex < 0 || overlayRoot.currentIndex >= overlayRoot.images.length)
return;
const pageWidth = mainImageFrame.width;
if (pageWidth <= 0)
return;
if( instantly ) imageMouseArea.snapping = true;
imageView.contentX = overlayRoot.currentIndex * pageWidth;
if( instantly ) imageMouseArea.snapping = false;
}
Repeater {
id: contentRepeater
model: images
delegate: Item {
id: largeDelegate
width: mainImageFrame.width
height: mainImageFrame.height
property alias item: segItem.item
function clicked() {
if( !segItem.item || !segItem.item.clicked ) {
console.log("No item or it lacks a click callback.");
return;
}
console.log('Calling segItem.item.clicked');
return segItem.item.clicked();
}
function doubleClicked() {
if( !segItem.item || !segItem.item.doubleClicked )
return;
return segItem.item.doubleClicked();
}
property real visualIndex: {
const w = mainImageFrame.width;
if (0 >= w || !overlayRoot.images || 0 >= overlayRoot.images.length)
return overlayRoot.currentIndex;
const vi = imageView.contentX / w;
if (!isFinite(vi))
return overlayRoot.currentIndex;
return vi;
}
// distance from this delegate's index to the visual center
property real dist: Math.abs(index - visualIndex)
// 1.0 when centered, fades to 0 by distance >= 1
opacity: 1.0 - Math.min(1.0, dist)
property real baseScale: {
const iw = segItem.segImplicitWidth;
const ih = segItem.segImplicitHeight;
const maxWidth = mainImageFrame.width;
const maxHeight = mainImageFrame.height;
if (0 >= iw || 0 >= ih ||
0 >= maxWidth ||
0 >= maxHeight)
return 1.0;
const sx = maxWidth / iw;
const sy = maxHeight / ih;
if ("Fit" === overlayRoot.baseMode || "Full" === overlayRoot.baseMode) {
const s = Math.min(sx, sy);
if( "Full" === overlayRoot.baseMode )
return s;
return (1.0 <= s) ? 1.0 : s; // prevent upscaling
}
console.log(`Max: ${maxWidth}x${maxHeight} / Implicit: ${iw}x${ih} / Ratio: sx=${sx}, sy=${sy}`);
return Math.max(sx, sy);
}
property real effScale: baseScale * (overlayRoot.extraZoomPercent / 100.0)
Loader {
id: segItem
anchors.centerIn: parent
property var seg: modelData
width: segImplicitWidth * parent.effScale
height: segImplicitHeight * parent.effScale
property real segImplicitWidth: implicitWidth
property real segImplicitHeight: implicitHeight
onStatusChanged: {
if (Loader.Ready !== status || !item)
return;
overlayRoot.images[index].previewObject = segItem.item;
/*
console.log(`Setting previewObject to ${segItem}`);
let thisObj = overlayRoot.images[index];
thisObj.previewObject = segItem.item;
overlayRoot.images.splice(index, 1, thisObj);
*/
if( seg.mime === "video" ) {
let cb = function(firstFrame) {
if( !overlayRoot )
return;
overlayRoot.images[index].thumbnail = firstFrame;
thumbView.model = overlayRoot.images;
overlayRoot.thumbnailReady(modelData, firstFrame);
};
if( segItem.item.firstFramePrimed )
cb(segItem.item.firstFrame);
else
segItem.item.firstFrameReady.connect( function(previewItem) {
cb(segItem.item.firstFrame);
} );
segItem.item.isWindowed = segItem.item.isFullscreen = false;
}
if( item.implicitWidth > 10 )
segImplicitWidth = item.implicitWidth;
if( item.implicitHeight > 10 )
segImplicitHeight = item.implicitHeight;
if (item.implicitWidthChanged)
item.implicitWidthChanged.connect(function() {
if( item.implicitWidth <= 10 ) return;
segImplicitWidth = item.implicitWidth;
console.log(`segment wants to be ${implicitWidth}px wide.`);
});
if (item.implicitHeightChanged)
item.implicitHeightChanged.connect(function() {
if( item.implicitHeight <= 10 ) return;
segImplicitHeight = item.implicitHeight;
console.log(`segment wants to be ${implicitHeight}px tall.`);
});
}
sourceComponent:
seg.mime === "image" ? imageSegment :
seg.mime === "video" ? videoSegment :
null
} // Loader
} // Delegate
} // Repeater
} // Row
Component {
id: imageSegment
ImageSegment {
}
}
Component {
id: videoSegment
VideoSegment {
anchors.fill: parent
source: seg.source
}
}
}
}
Rectangle {
id: thumbBar
anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
margins: 16
}
height: mainWindow.visibility === Window.FullScreen ? 0 : 96
visible: height > 0
color: "transparent"
clip: true
ListView {
id: thumbView
anchors.fill: parent
orientation: ListView.Horizontal
spacing: 8
clip: true
model: images
boundsBehavior: Flickable.StopAtBounds
preferredHighlightBegin: width / 3
preferredHighlightEnd: width * 2 / 3
highlightRangeMode: ListView.StrictlyEnforceRange
highlightFollowsCurrentItem: true
highlightMoveDuration: imageMouseArea.snapping ? 0 : 150
onWidthChanged: {
if (count > 0)
positionViewAtIndex(overlayRoot.currentIndex, ListView.Center)
}
Behavior on contentX {
enabled: !imageMouseArea.snapping
NumberAnimation {
duration: 180
easing.type: Easing.InOutQuad
}
}
delegate: Item {
width: 80
height: parent.height || 80
Item {
anchors.fill: parent
clip: true
Rectangle {
anchors.fill: parent
radius: 4
border.width: 3
border.color: "#80ffffff"
color: "transparent"
opacity: index === overlayRoot.currentIndex ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 150 } }
}
Image {
source: modelData["thumbnail"] ? modelData["thumbnail"] : modelData["mime"] === "image" ? modelData["source"] : ""
anchors.fill: parent
anchors.margins: 3
fillMode: Image.PreserveAspectFit
asynchronous: true
//visible: modelData.mime === "image"
//source: modelData.mime === "image" ? modelData["source"] : ""
}
/**
* Live preview. Still works, but it's hard on CPU:
**
VideoThumbnail {
anchors.centerIn: parent
visible: modelData.mime === "video"
sourceItem: modelData.mime === "video" && modelData.previewObject ? modelData.previewObject : null
//anchors.fill: parent
active: index === overlayRoot.currentIndex
//live: index === overlayRoot.currentIndex && visible
}
*/
MouseArea {
anchors.fill: parent
onClicked: {
overlayRoot.currentIndex = index;
imageView.snap();
}
}
}
}
}
}
// Zoom indicator (top, like Firefoxs address bar zoom)
Rectangle {
id: zoomIndicator
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 8
radius: 4
color: "#aa000000"
border.color: "#80ffffff"
border.width: 1
z: 2
opacity: thumbBar.visible ? 1 : 0
visible: opacity > 0
Row {
anchors.centerIn: parent
spacing: 4
Text {
text: Math.round(extraZoomPercent) + "% (" + baseMode + ")"
color: "white"
font.pixelSize: 12
}
Text {
text: "\u21BA" // reset glyph
color: "#cccccc"
font.pixelSize: 12
}
}
MouseArea {
anchors.fill: parent
onClicked: resetZoom()
}
}
// Close button (X)
Rectangle {
id: closeButton
width: 32
height: 32
radius: width / 2
color: "#aa000000"
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 8
border.color: "white"
border.width: 1
z: 2
property bool suppressed: mainWindow.isFullscreen
opacity: thumbBar.visible || !closeButton.suppressed ? 1 : 0
visible: opacity > 0
Text {
anchors.centerIn: parent
text: "\uf00d"
font.family: fa.font.family
font.pixelSize: 20
color: "white"
}
MouseArea {
anchors.fill: parent
onClicked: hide()
}
}
}

View file

@ -0,0 +1,53 @@
import QtQuick
import QtQuick.Controls
ToolButton {
id: root
property var tracks: []
required property var player
property string activeProperty: "activeAudioTrack"
property string defaultLabel: ""
font.family: fa.font.family
//text: currentTrackLanguage()
onClicked: menu.open()
property int currentIndex: root.player[root.activeProperty]
onTracksChanged: currentIndex = root.player[root.activeProperty];
Menu {
id: menu
y: root.height
Repeater {
model: root.tracks || []
delegate: MenuItem {
readonly property int trackIndex: modelData.index
readonly property string trackLanguage: modelData.language
text: trackLanguage
checkable: true
checked: trackIndex === root.currentIndex
onTriggered: {
root.currentIndex = trackIndex;
root.player[root.activeProperty] = trackIndex;
}
}
}
}
function currentTrackLanguage() {
if (!tracks || !tracks.length || !player)
return defaultLabel;
const active = player[activeProperty];
for (let i = 0; i < tracks.length; ++i) {
if (tracks[i].index === active)
return tracks[i].language;
}
return defaultLabel;
}
}

391
qml/VideoSegment.qml Normal file
View file

@ -0,0 +1,391 @@
import QtQuick
import QtQuick.Controls
//import QtQuick.Controls.Material
import QtQuick.Layouts
import QtMultimedia
Item {
id: videoObject
implicitWidth: videoOutput.implicitWidth
implicitHeight: videoOutput.implicitHeight
property alias videoOutput: videoOutput
//property alias ratio: videoOutput.scale
property bool isWindowed: false
property bool isFullscreen: false
property string source
property string oldSource
property QtObject dumbPlayer: MediaPlayer {
property bool loopRequested: false
audioOutput: AudioOutput {
property real previousVolume
}
}
property QtObject videoMediaPlayer: dumbPlayer
property url firstFrame
property bool firstFramePrimed: false
signal firstFrameReady(url firstFrame)
onSourceChanged: recycleSource();
function recycleSource() {
videoMediaPlayer = dumbPlayer;
if( oldSource && oldSource.length > 0 )
videoSessionManager.releaseSession(oldSource, videoOutput);
oldSource = source;
if( source.length <= 0 )
return;
Qt.callLater( function() {
videoSessionManager.ensureSession(source, source, videoOutput, function(vso) {
//console.log(`Got our VSO: ${vso}`);
videoMediaPlayer = vso;
videoMediaPlayer.firstFrameReady.connect( function(previewItem) {
console.log(`Audio Tracks: ${JSON.stringify(videoMediaPlayer.audioTracks, null, 2)}`);
console.log(`Subtitle Tracks: ${JSON.stringify(videoMediaPlayer.subtitleTracks, null, 2)}`);
Qt.callLater( function() {
console.log(`Calling grabToImage on ${previewItem} which is ${previewItem.implicitWidth}x${previewItem.implicitHeight}`);
previewItem.grabToImage( function(res) {
videoObject.firstFrame = res.url;
videoObject.firstFramePrimed = true;
videoObject.firstFrameReady(videoObject.firstFrame);
}, Qt.size(previewItem.implicitWidth, previewItem.implicitHeight));
});
} );
});
});
}
function clicked(ev) {
console.log("VideoSegment clicked!");
videoMediaPlayer.pausePlay();
}
function doubleClicked(ev) {
videoObject.toggleFullscreen();
}
Component.onDestruction: {
//console.log("onDestruction");
videoMediaPlayer = dumbPlayer;
if( source && source.length > 0 )
videoSessionManager.releaseSession(source, videoOutput);
}
function toggleFullscreen() {
if( mainWindow.visibility === Window.FullScreen )
mainWindow.showNormal();
else
mainWindow.showFullScreen();
}
function restore() {
if( mainWindow.visibility === Window.FullScreen )
mainWindow.showNormal();
}
Shortcut {
sequence: "Space"
context: Qt.ApplicationShortcut
onActivated: videoMediaPlayer.pausePlay();
}
Shortcut {
sequence: StandardKey.Cancel
context: Qt.ApplicationShortcut
onActivated: videoObject.restore();
}
Shortcut {
sequence: "F"
context: Qt.ApplicationShortcut
onActivated: videoObject.toggleFullscreen();
}
Shortcut {
sequence: "M"
context: Qt.ApplicationShortcut
onActivated: {
controlsBar.popup();
videoMediaPlayer.audioOutput.muted = !videoMediaPlayer.audioOutput.muted;
}
}
Shortcut {
sequence: "R"
context: Qt.ApplicationShortcut
onActivated: {
controlsBar.popup();
videoMediaPlayer.loopRequested = !videoMediaPlayer.loopRequested;
}
}
Shortcut {
sequence: "Home"
context: Qt.ApplicationShortcut
onActivated: {
controlsBar.popup();
videoMediaPlayer.position = 0;
}
}
Shortcut {
sequence: "Left"
context: Qt.ApplicationShortcut
onActivated: {
controlsBar.popup();
videoMediaPlayer.position -= 5000;
}
}
Shortcut {
sequence: "Right"
context: Qt.ApplicationShortcut
onActivated: {
controlsBar.popup();
videoMediaPlayer.position += 5000;
}
}
Shortcut {
sequence: "Up"
context: Qt.ApplicationShortcut
onActivated: {
controlsBar.popup();
videoMediaPlayer.audioOutput.volume += 0.10;
}
}
Shortcut {
sequence: "Down"
context: Qt.ApplicationShortcut
onActivated: {
controlsBar.popup();
videoMediaPlayer.audioOutput.volume -= 0.10;
}
}
MouseArea {
// Black hole.
enabled: videoObject.visible
anchors.fill: parent
acceptedButtons: Qt.NoButton
hoverEnabled: true
}
VideoOutput {
id: videoOutput
anchors.fill: parent
fillMode: VideoOutput.PreserveAspectFit
endOfStreamPolicy: VideoOutput.KeepLastFrame
/*
readonly property real maxW: videoObject.width
// sanity-checked ratio
readonly property real ratio: {
if (implicitWidth <= 0 || implicitHeight <= 0)
return 1; // square fallback for "unknown", not 16:9
const r = implicitWidth / implicitHeight;
// clamp absurd aspect ratios
const minR = 1/4; // at most 4× taller than wide
const maxR = 4; // at most 4× wider than tall
return Math.max(minR, Math.min(maxR, r));
}
// scale to respect max width
readonly property real scale: maxW / ratio
property real myWidth: maxW
property real myHeight: scale
*/
//onMyWidthChanged: console.log(`myWidth: ${myWidth} / implicit: ${implicitWidth} x ${implicitHeight}`);
//onMyHeightChanged: console.log(`myHeight: ${myHeight} / implicit: ${implicitWidth} x ${implicitHeight}`);
}
// Placeholder image:
Rectangle {
id: rectPlaceholder
opacity: videoMediaPlayer.playing ? 0 : 1
anchors.fill: parent
//radius: 5
//border.width: 3
//border.color: 'white' // Material.accent
color: '#aa000000'
readonly property real fontscale: 2
Text {
id: iconPlay
anchors.centerIn: parent
font.pixelSize: rectPlaceholder.fontscale * 48
color: "white"
font.family: fa.font.family
text: "\uf04b"
}
Rectangle {
anchors {
top: iconPlay.bottom
horizontalCenter: parent.horizontalCenter
}
radius: 5
color: '#aa000000'
width: durationLabel.implicitWidth + 10
height: durationLabel.implicitHeight + 5
Text {
id: durationLabel
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
font.pixelSize: rectPlaceholder.fontscale * 6
color: 'white'
text: mainWindow.formatTime(videoMediaPlayer.duration)
}
}
}
MouseArea {
id: controlsBarMouse
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: controlsBar.height + 15
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
onEntered: {
controlsCloseLater.stop();
controlsBar.shouldBeVisible = true;
}
onExited: {
controlsCloseLater.interval = 2000;
controlsCloseLater.start();
}
Rectangle {
id: controlsBar
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 10
radius: 10
height: 60
color: "#CC212121" // semi-opaque dark
function popup(duration) {
if( !duration ) {
if( controlsBarMouse.containsMouse )
return;
duration = 1500;
}
controlsBar.shouldBeVisible = true;
controlsCloseLater.stop();
controlsCloseLater.interval = duration;
controlsCloseLater.start();
}
Timer {
id: controlsCloseLater
interval: 2000
repeat: false
onTriggered: {
controlsBar.shouldBeVisible = false;
}
}
opacity: !videoMediaPlayer.playing || shouldBeVisible ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
property bool shouldBeVisible: false
//visible: opacity > 0.02 // avoid click noise when invisible
RowLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 2
ToolButton {
id: playPauseButton
Layout.alignment: Qt.AlignVCenter
focus: false
font.family: fa.font.family
text: videoMediaPlayer.playbackState === MediaPlayer.PlayingState ? "\uf04c" : "\uf04b"
onClicked: {
videoMediaPlayer.pausePlay();
controlsCloseLater.stop();
}
}
Text {
id: currentTimeLabel
Layout.alignment: Qt.AlignVCenter
Layout.minimumWidth: implicitWidth
color: "#FFFFFF"
text: mainWindow.formatTime(videoMediaPlayer.position)
}
Slider {
id: videoPositionSlider
Layout.fillWidth: true
from: 0
to: videoMediaPlayer.duration
value: videoMediaPlayer.position
onMoved: videoMediaPlayer.position = value
}
Text {
id: totalTimeLabel
Layout.alignment: Qt.AlignVCenter
Layout.minimumWidth: implicitWidth
color: "#FFFFFF"
text: mainWindow.formatTime(videoMediaPlayer.duration)
}
ToolButton {
id: loopButton
focus: false
Layout.alignment: Qt.AlignVCenter
font.family: fa.font.family
text: "\uf0e2"
checkable: true
checked: videoMediaPlayer.loopRequested
onClicked: videoMediaPlayer.loopRequested = !videoMediaPlayer.loopRequested
}
VolumeButton {
id: buttonVolume
audioOutput: videoMediaPlayer.audioOutput
}
TrackSelectorButton {
id: audioTrackButton
visible: videoMediaPlayer.audioTracks.length > 1
player: videoMediaPlayer
tracks: videoMediaPlayer.audioTracks
activeProperty: "activeAudioTrack"
text: "\uf1ab"
}
TrackSelectorButton {
id: subtitleTrackButton
visible: videoMediaPlayer.subtitleTracks.length > 1
player: videoMediaPlayer
tracks: videoMediaPlayer.subtitleTracks
activeProperty: "activeSubtitleTrack"
text: "\uf20a"
}
ToolButton {
id: fullScreenButton
//visible: videoObject.isWindowed || videoObject.isFullscreen
Layout.alignment: Qt.AlignVCenter
text: "⛶"
onClicked: videoObject.toggleFullscreen();
}
} // RowLayout
} // Rectangle
} // MouseArea:constrolsBarMouse
}

104
qml/VideoSessionManager.qml Normal file
View file

@ -0,0 +1,104 @@
//pragma Singleton
import QtQuick
import QtMultimedia
Item {
id: mgr
property var sessions: ({})
property var videoSessionComponent: Qt.createComponent("VideoSessionObject.qml");
signal closing();
Component.onDestruction: closing();
function sessionKey(messageId) {
return String(messageId);
}
function pauseAll() {
const keys = Object.keys(sessions);
for( let k of keys )
sessions[k].pause();
}
function ensureSession(messageId, url, videoOutput, callback) {
if( videoSessionComponent.status !== Component.Ready )
console.log(`videoSessionComponent not ready: ${videoSessionComponent.status} / ${videoSessionComponent.errorString()}`);
const key = sessionKey(messageId);
let integrate = function(obj, vo, isnew) {
if( isnew ) {
obj.source = url;
}
if( vo ) {
console.log(`Setting videoOutput to: ${vo}`)
obj.outputStack.push( vo );
obj.videoOutput = vo;
}
obj.refCount++;
sessions[key] = obj;
// console.log(`Video ${url} refcount++ =${obj.refCount}`);
return obj;
};
let s = sessions[key];
if( s )
return callback( integrate(s, videoOutput) );
try {
let props = {objectName: "VideoSession_"+key, resourceUrl:url, objectkey:key, manager:mgr};
if( videoOutput )
props.videoOutput = videoOutput;
const incubator = videoSessionComponent.incubateObject(mainWindow, props);
if (incubator.status !== Component.Ready) {
incubator.onStatusChanged = function(status) {
if (status === Component.Ready) {
callback( integrate(incubator.object, videoOutput, true) );
} else {
console.error(`Failed to incubate a new VideoSessionObject!`);
}
}
} else {
return callback( integrate(incubator.object, videoOutput, true) );
}
} catch(e) {
console.log("Failed: "+e);
}
return;
}
function releaseSession(messageId, videoOutput) {
const key = sessionKey(messageId);
let s = sessions[key];
if (!s)
return;
s.refCount--;
// console.log(`Video ${messageId} refcount-- =${s.refCount}`);
if (s.refCount <= 0) {
s.stop();
s.destroy();
Qt.callLater( function() {
delete sessions[key];
} );
return;
}
if( s.outputStack.length > 1 )
{
let foundIt = false;
for( let a=0; a < s.outputStack.length; a++ )
{
if( s.videoOutput === s.outputStack[a] )
{
s.outputStack.splice(a, 1);
}
}
}
s.videoOutput = s.outputStack[ s.outputStack.length-1 ];
}
}

154
qml/VideoSessionObject.qml Normal file
View file

@ -0,0 +1,154 @@
import QtQuick
import QtMultimedia
Item {
id: root
property int refCount: 0
required property string objectkey
required property url resourceUrl
required property VideoSessionManager manager
property alias player: player
property alias loopRequested: player.loopRequested
property alias source: player.source
property alias audioOutput: player.audioOutput
property alias videoOutput: player.videoOutput
property alias duration: player.duration
property alias position: player.position
property alias playbackState: player.playbackState
property alias playing: player.playing
property alias mediaStatus: player.mediaStatus
property alias activeAudioTrack: player.activeAudioTrack
property alias activeSubtitleTrack: player.activeSubtitleTrack
property var audioTracks: []
property var subtitleTracks: []
signal firstFrameReady(Item previewItem)
property variant outputStack: []
function play() { return player.play(); }
function pause() { return player.pause(); }
function stop() { return player.stop(); }
function pausePlay() { return player.pausePlay(); }
MediaPlayer {
id: player
property bool loopRequested: false
audioOutput: AudioOutput {
id: audioOut
property real previousVolume
function toggleMuted() {
if( !muted ) {
previousVolume = volume;
volume = 0;
} else
volume = previousVolume;
muted = !muted;
}
}
onPositionChanged: function() {
if( position > 0 && !firstFramePrimed )
{
player.pause();
firstFramePrimed = true;
player.position = 0;
audioOutput.muted = wasMuted;
// Gather info:
let atracks = [];
let aents = player.audioTracks;
for( let a=0; a < aents.length; a++ ) {
/*
let keys = aents[a].keys();
console.log(JSON.stringify(keys, null, 2));
for( let k of keys )
console.log(`${k} => ${aents[a].stringValue(k)}`);
*/
atracks.push( { "index":a, "language":aents[a].stringValue(6) } );
}
root.audioTracks = atracks;
let stracks = [ {"index":-1, "language":"Off"} ];
let sents = player.subtitleTracks;
for( let s=0; s < sents.length; s++ ) {
let keys = sents[s].keys();
//console.log(JSON.stringify(keys, null, 2));
console.log("---");
for( let k of keys )
console.log(`${k} => ${sents[s].stringValue(k)}`);
stracks.push( { "index":s, "language":sents[s].stringValue(0) } );
}
root.subtitleTracks = stracks;
root.firstFrameReady(player.videoOutput);
}
if( position < duration )
return;
position = 0;
if( !loopRequested )
pause();
else
Qt.callLater( function() { play(); } );
}
function pausePlay() {
if (playbackState === MediaPlayer.PlayingState)
pause()
else {
if( duration === position )
position = 0;
play()
}
}
/** just to grab first frame: **/
property bool firstFramePrimed: false
property bool wasMuted
onMediaStatusChanged: {
if (!firstFramePrimed &&
(mediaStatus === MediaPlayer.BufferedMedia ||
mediaStatus === MediaPlayer.LoadedMedia)) {
if( !playing ) {
wasMuted = audioOutput.muted;
console.log(`wasMuted:${wasMuted}`);
audioOutput.muted = true;
play();
}
}
}
onPlaybackStateChanged: function() {
if( !firstFramePrimed )
return;
if( playbackState !== MediaPlayer.PlayingState )
MediaScreen.setInhibited(false);
else
Qt.callLater( function() {
MediaScreen.setInhibited(true);
} );
}
}
Timer {
id: firstFrameTimer
interval: 22
repeat: false
onTriggered: root.firstFrameReady(player.videoOutput);
}
}

78
qml/VideoThumbnail.qml Normal file
View file

@ -0,0 +1,78 @@
import QtQuick
import QtQuick.Effects
Item {
id: root
property Item sourceItem
// Set only width OR height; the other is derived from aspect ratio.
property real targetWidth: 0
property real targetHeight: 90
property bool active: true
property bool live: true
// Maintain aspect ratio based on sourceItem
readonly property real aspectRatio: (
sourceItem && sourceItem.height > 0
? sourceItem.width / sourceItem.height
: 16 / 9
)
width: targetWidth > 0 ? targetWidth :
targetHeight > 0 ? targetHeight * aspectRatio : 160
height: targetHeight > 0 ? targetHeight :
targetWidth > 0 ? targetWidth / aspectRatio : width / aspectRatio
/*
// Background card
Rectangle {
anchors.fill: parent
radius: 8
color: "#202020"
border.color: active ? "#ffffff" : "#404040"
border.width: active ? 2 : 1
}
*/
onSourceItemChanged: {
console.log(`sourceItem: ${sourceItem}`);
}
// Texture of the live video item
ShaderEffectSource {
id: src
anchors.fill: parent
sourceItem: root.sourceItem
live: root.live
recursive: true // so it sees child items too (e.g. overlays)
hideSource: false // set true if you want to only see the thumb
mipmap: true
}
// Modern multi-effect: crop, round corners, subtle blur/shadow if you want
MultiEffect {
anchors.fill: root
source: src
// Corners / clipping
maskEnabled: true
maskSource: Rectangle {
width: root.width
height: root.height
radius: 8
}
// Subtle darken / desaturate for non-active thumbs
brightness: active ? 0.0 : -0.05
contrast: 0.05
saturation: active ? 0.0 : -0.2
// Optional slight shadow to make thumbnails pop
shadowEnabled: true
shadowBlur: 0.25
shadowVerticalOffset: 2
}
}

179
qml/VolumeButton.qml Normal file
View file

@ -0,0 +1,179 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtMultimedia
Control {
id: root
// Hook this up from outside
required property AudioOutput audioOutput
implicitWidth: volButton.implicitWidth
implicitHeight: volButton.implicitHeight
// Convenience accessors
property real volume: audioOutput ? audioOutput.volume : 0.5
property bool muted: audioOutput ? audioOutput.muted : false
function volumeIcon() : string {
if (!audioOutput || audioOutput.muted || audioOutput.volume <= 0.0001)
return "🔇"
if (audioOutput.volume < 0.34)
return "🔈"
if (audioOutput.volume < 0.67)
return "🔉"
return "🔊"
}
function setVolumeFromWheel(delta) {
if (!audioOutput)
return
const step = 0.05
let v = audioOutput.volume + (delta > 0 ? step : -step)
if (v < 0) v = 0
if (v > 1) v = 1
audioOutput.volume = v
if (v > 0 && audioOutput.muted)
audioOutput.muted = false
popup.open();
if( !popupHover.containsMouse )
{
closeTimer.stop();
closeTimer.start();
}
}
ToolButton {
id: volButton
anchors.fill: parent
text: root.volumeIcon()
focusPolicy: Qt.NoFocus
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onClicked: function(ev) {
if (!root.audioOutput)
return
const button = ev.button;
if (button === Qt.MiddleButton) {
ev.accepted = true
root.audioOutput.toggleMuted();
popup.open();
if( !popupHover.containsMouse )
{
closeTimer.stop();
closeTimer.start();
}
}
else if (button === Qt.LeftButton)
{
ev.accepted = true
popup.open()
}
}
onWheel: function(ev) {
root.setVolumeFromWheel(ev.angleDelta.y)
ev.accepted = true
}
}
}
Popup {
id: popup
x: volButton.x + volButton.width / 2 - width / 2
y: volButton.y - height - 8
modal: false
focus: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
enter: Transition {
NumberAnimation {
property: "opacity"
from: 0
to: 1
duration: 60
easing.type: Easing.OutQuad
}
}
exit: Transition {
NumberAnimation {
property: "opacity"
from: 1
to: 0
duration: 120
easing.type: Easing.InQuad
}
}
contentItem: MouseArea {
id: popupHover
hoverEnabled: true
anchors.fill: parent
onEntered: closeTimer.stop();
onExited: closeTimer.start();
onWheel: function(ev) {
root.setVolumeFromWheel(ev.angleDelta.y)
ev.accepted = true
}
Timer {
id: closeTimer
interval: 1500
repeat: false
onTriggered: popup.close();
}
ColumnLayout {
id: popupColumn
anchors.fill: parent
anchors.margins: 6
spacing: 6
// Mute toggle
ToolButton {
id: muteButton
Layout.alignment: Qt.AlignHCenter
checkable: true
checked: root.muted
highlighted: checked
text: "🔇"
onClicked: root.audioOutput.toggleMuted();
}
Slider {
id: volSlider
Layout.alignment: Qt.AlignHCenter
orientation: Qt.Vertical
from: 0.0
to: 1.0
stepSize: 0.01
implicitHeight: 110
Layout.fillHeight: true
value: root.volume
onMoved: {
if (!root.audioOutput)
return
if( root.audioOutput.volume > 0 )
root.audioOutput.muted = false;
else
root.audioOutput.muted = true;
root.audioOutput.volume = value
}
} // Slider
} // ColumnLayout
} // contentItem:MouseArea
}
}

273
qml/main.qml Normal file
View file

@ -0,0 +1,273 @@
import QtCore
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material
import QtQuick.Layouts
Window {
id: mainWindow
width: 500
height: 900
visible: true
title: qsTr("Media")
Material.theme: Material.Dark
readonly property bool isFullscreen: mainWindow.visibility === Window.FullScreen
Component.onCompleted: {
//let locs = StandardPaths.standardLocations(StandardPaths.PicturesLocation);
let locs = StandardPaths.standardLocations(StandardPaths.MoviesLocation);
navigator.m_home = locs[0];
navigator.goHome();
}
FontLoader {
id: fa
source: "../fonts/Font Awesome 7 Free-Solid-900.otf"
}
function formatTime(ms) {
if (ms <= 0 || ms === undefined)
return "0:00";
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes + ":" + (seconds < 10 ? "0" + seconds : seconds);
}
function showImage(images, idx) {
imageViewer.showGallery(images, idx);
}
QtObject {
id: navigator
property var m_model
property var m_files
property string m_home
property string m_path
function goHome() {
setPath( m_home );
}
function goUp() {
let parts = m_path.split(/\//g);
parts.pop();
let npath = parts.join('/');
if( "file://" === npath )
npath = "file:///";
setPath(npath);
}
function setPath(path) {
m_path = path;
navPath.text = m_path;
m_model = File.readDir(m_path);
/** Can't use filter/map here cuz race on idx set, so... **/
let files = [];
for( let a=0, b=0; a < m_model.length; a++ )
{
let obj = m_model[a];
if( obj.type === "dir" )
continue;
obj["source"] = m_path + "/" + obj["name"];
obj["index"] = a;
obj["fileindex"] = b++;
files.push(obj);
}
m_files = files;
flowRepeater.model = m_model;
imageViewer.images = m_files; // should be model, but we used to use ListView, so...
}
function open(obj) {
if( "dir" === obj.type ) {
const npath = m_path + "/" + obj.name;
return setPath( npath );
}
imageViewer.showGallery(obj["fileindex"]);
}
}
VideoSessionManager {
id: videoSessionManager
}
Rectangle {
anchors.fill: parent
color: Material.background
}
Rectangle {
id: topBar
anchors {
top: parent.top
left: parent.left
right: parent.right
}
height: barLayout.implicitHeight + 20
color: 'black'
RowLayout {
id: barLayout
anchors.fill: parent
anchors.margins: 10
RoundButton {
Layout.alignment: Qt.AlignVCenter
font.family: fa.font.family
text: "\uf015"
onClicked: navigator.goHome();
}
RoundButton {
Layout.alignment: Qt.AlignVCenter
font.family: fa.font.family
text: "\uf062"
onClicked: navigator.goUp();
}
TextField {
id: navPath
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
onAccepted: navigator.setPath(text);
}
RoundButton {
Layout.alignment: Qt.AlignVCenter
font.family: fa.font.family
text: "\uf061"
onClicked: navigator.setPath(navPath.text);
}
}
}
Flickable {
id: flowFlicker
anchors {
top: topBar.bottom
left: parent.left
right: parent.right
bottom: parent.bottom
margins: 10
}
contentHeight: flow.height
contentWidth: flow.width
clip: true
GridLayout {
id: flow
width: flowFlicker.width
height: implicitHeight
columnSpacing: 10
rowSpacing: 10
columns: flowFlicker.width / (128+10)
Repeater {
id: flowRepeater
Item {
Layout.minimumWidth: 128
Layout.minimumHeight: 128
Layout.fillWidth: true
//width: 128
//height: 128
clip: true
property alias imageThumb: imageThumb
//property alias videoThumb: videoThumb
Image {
id: imageThumb
anchors.fill: parent
anchors.margins: 5
fillMode: Image.PreserveAspectFit
source: modelData.mime === "image" ? modelData.fullpath : ""
onSourceChanged: {
if( source.length === 0 )
return;
console.log("Fullpath:" + modelData.fullpath);
}
}
/*
VideoThumbnail {
id: videoThumb
visible: sourceItem !== undefined && sourceItem !== null
active: visible
anchors.centerIn: parent
anchors.margins: 5
targetHeight: parent.height
//targetWidth: parent.width
live: true
}*/
Rectangle {
id: labelNameBG
anchors.fill: labelName
color: "#aa000000"
radius: 4
}
Label {
id: labelName
anchors {
top: parent.top
left: parent.left
right: parent.right
margins: 10
}
text: modelData.name
wrapMode: Text.Wrap
color: "white"
}
Label {
anchors {
bottom: parent.bottom
right: parent.right
margins: 10
}
font.pixelSize: 24
font.family: fa.font.family
text: modelData.mime === "dir" ? "\uf07b"
: modelData.mime === "image" ? "\uf1c5"
: modelData.mime === "video" ? "\uf1c8"
: "\uf15b"
}
Rectangle {
border.color: Material.accent
border.width: 2
radius: 5
anchors.fill: parent
color: "transparent"
}
MouseArea {
anchors.fill: parent
onClicked: navigator.open(modelData);
}
}
}
}
}
ImageViewer {
id: imageViewer
onThumbnailReady: function(entry, thumb) {
console.log(`thumbnailReady: ${JSON.stringify(entry,null,4)}`);
if( "video" !== entry["mime"] )
return;
let ctrl = flowRepeater.itemAt(entry["index"]);
ctrl.imageThumb.source = thumb;
}
}
}

13
qtquickcontrols2.conf Normal file
View file

@ -0,0 +1,13 @@
; This file can be edited to change the style of the application
; Read "Qt Quick Controls 2 Configuration File" for details:
; http://doc.qt.io/qt-5/qtquickcontrols2-configuration.html
[Controls]
Style=Material
[Material]
Theme=Dark
;Accent=BlueGrey
;Primary=BlueGray
;Foreground=Brown
;Background=Grey

7
src/clipboard.cpp Normal file
View file

@ -0,0 +1,7 @@
#include "clipboard.h"
Clipboard::Clipboard(QObject *parent)
: QObject(parent)
{
clipboard = qApp->clipboard();
}

49
src/clipboard.h Normal file
View file

@ -0,0 +1,49 @@
#ifndef CLIPBOARD_H
#define CLIPBOARD_H
#include <QGuiApplication>
#include <QClipboard>
#include <QObject>
#include <QImage>
/*
class MimeData : public QObject
{
Q_OBJECT
public:
Q_PROPERTY(QStringList formats READ getData WRITE setData NOTIFY dataChanged)
public slots:
QStringList getData();
void setData();
signals:
void dataChanged();
};
*/
class Clipboard : public QObject
{
Q_OBJECT
public:
Clipboard(QObject *parent=nullptr);
Q_INVOKABLE void setText(QString text){
clipboard->setText(text, QClipboard::Clipboard);
}
Q_INVOKABLE QString text() {
return clipboard->text();
}
Q_INVOKABLE void setImage(QImage img)
{
clipboard->setImage(img);
}
Q_INVOKABLE QImage image() {
return clipboard->image();
}
private:
QClipboard *clipboard;
};
#endif // CLIPBOARD_H

66
src/file.cpp Normal file
View file

@ -0,0 +1,66 @@
#include <QQmlEngine>
#include "file.h"
File::File(QObject *parent)
: QObject{parent}
{
}
QVariantList File::readDir(const QString &path)
{
QString filepath = QUrl(path).toLocalFile();
QDir d(filepath);
QStringList nameFilters;
nameFilters << "*.webm" << "*.mkv" << "*.mp4" << "*.jpg" << "*.png" << "*.gif";
QFileInfoList ents = d.entryInfoList(nameFilters, QDir::Drives | QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot, QDir::DirsFirst | QDir::Name);
QVariantList results;
for( const QFileInfo &ent : std::as_const(ents) ) {
QString filename = ent.fileName();
QVariantMap vent;
vent["type"] = ent.isDir() ? "dir" : "file";
vent["name"] = filename;
vent["fullpath"] = "file://" + ent.absoluteFilePath();
vent["dirpath"] = "file://" + ent.absolutePath();
vent["size"] = ent.size();
if( filename.endsWith(".webm") || filename.endsWith(".mkv") || filename.endsWith(".mp4") )
vent["mime"] = "video";
else if( filename.endsWith(".jpg") || filename.endsWith(".png") || filename.endsWith(".gif") )
vent["mime"] = "image";
else
vent["mime"] = ent.isDir() ? "dir" : "file";
results.append(vent);
}
return results;
}
QByteArray File::readFile(const QString &path)
{
QString filepath = QUrl(path).toLocalFile();
QFile f(filepath);
if( !f.open(QIODevice::ReadOnly) )
{
qDebug() << "Failed to read file: " << filepath;
//qmlEngine(this)->throwError(tr("Failed to read file!"));
return QByteArray();
}
QByteArray r = f.readAll();
f.close();
return r;
}
QString File::toBase64(const QByteArray &data)
{
return QString::fromLatin1( data.toBase64() );
}
QByteArray File::fromBase64(const QString &data)
{
return QByteArray::fromBase64(data.toLatin1());
}

27
src/file.h Normal file
View file

@ -0,0 +1,27 @@
#ifndef FILE_H
#define FILE_H
#include <QByteArray>
#include <QDir>
#include <QFile>
#include <QObject>
#include <QString>
class File : public QObject
{
Q_OBJECT
public:
explicit File(QObject *parent = nullptr);
Q_INVOKABLE QVariantList readDir(const QString &path);
Q_INVOKABLE QByteArray readFile(const QString &path);
Q_INVOKABLE QString toBase64(const QByteArray &data);
Q_INVOKABLE QByteArray fromBase64(const QString &data);
signals:
};
#endif // FILE_H

18
src/hash.cpp Normal file
View file

@ -0,0 +1,18 @@
#include "hash.h"
#include <QUuid>
#include <QCryptographicHash>
Hash::Hash(QObject *parent) : QObject(parent)
{
}
QString Hash::uuid()
{
return QUuid::createUuid().toString();
}
QString Hash::sha256(const QByteArray &data)
{
return QString::fromLatin1( QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex() );
}

17
src/hash.h Normal file
View file

@ -0,0 +1,17 @@
#ifndef HASH_H
#define HASH_H
#include <QObject>
class Hash : public QObject
{
Q_OBJECT
public:
explicit Hash(QObject *parent = nullptr);
public:
Q_INVOKABLE QString uuid();
Q_INVOKABLE QString sha256(const QByteArray &data);
};
#endif // HASH_H

51
src/main.cpp Normal file
View file

@ -0,0 +1,51 @@
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include "clipboard.h"
#include "settings.h"
#include "hash.h"
#include "file.h"
#include "wasm.h"
#include "screen.h"
int main(int argc, char *argv[])
{
#if QT_VERSION < 0x060900
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
#endif
QGuiApplication app(argc, argv);
app.setApplicationDisplayName("Media");
app.setApplicationName("Media");
app.setApplicationVersion("1.0.0");
app.setOrganizationName("ONeill Codesmithing");
app.setOrganizationDomain("oneill.app");
app.setDesktopFileName("Media.desktop");
QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty("Clipboard", new Clipboard());
engine.rootContext()->setContextProperty("Settings", new Settings());
engine.rootContext()->setContextProperty("Hash", new Hash());
engine.rootContext()->setContextProperty("File", new File());
engine.rootContext()->setContextProperty("MediaScreen", new Screen());
engine.rootContext()->setContextProperty("WASM", new WASM(&engine));
QObject::connect(
&engine,
&QQmlApplicationEngine::objectCreationFailed,
&app,
[]() { QCoreApplication::exit(-1); },
Qt::QueuedConnection);
QUrl path = QUrl(QStringLiteral("qrc:/qml/main.qml"));
if( argc > 1 )
path = QUrl(QString::fromLocal8Bit(argv[1]));
engine.load(path);
if (engine.rootObjects().isEmpty())
return -1;
return app.exec();
}

5
src/screen.cpp Normal file
View file

@ -0,0 +1,5 @@
#include "screen.h"
Screen::Screen(QObject *parent)
: QObject{parent}
{}

86
src/screen.h Normal file
View file

@ -0,0 +1,86 @@
#ifndef SCREEN_H
#define SCREEN_H
#include <QGuiApplication>
#include <QObject>
#include <QDBusInterface>
#include <QDBusReply>
class Screen : public QObject
{
Q_OBJECT
public:
explicit Screen(QObject *parent = nullptr);
Q_INVOKABLE void setInhibited(bool on)
{
if (on == m_inhibited)
return;
if (on) {
enableInhibit();
} else {
disableInhibit();
}
}
private:
bool m_inhibited = false;
uint m_cookie = 0;
QString m_service;
QString m_path;
QString m_iface;
void enableInhibit()
{
// Try org.freedesktop.ScreenSaver first
if (tryInhibit("org.freedesktop.ScreenSaver",
"/ScreenSaver",
"org.freedesktop.ScreenSaver"))
return;
// Fallback: KDE power management
tryInhibit("org.freedesktop.PowerManagement",
"/org/freedesktop/PowerManagement/Inhibit",
"org.freedesktop.PowerManagement.Inhibit");
}
bool tryInhibit(const QString &service,
const QString &path,
const QString &ifaceName)
{
QDBusInterface iface(service, path, ifaceName, QDBusConnection::sessionBus());
if (!iface.isValid())
return false;
QDBusReply<uint> reply = iface.call("Inhibit",
qApp->applicationName(),
QStringLiteral("Playing video"));
if (!reply.isValid())
return false;
m_cookie = reply.value();
m_service = service;
m_path = path;
m_iface = ifaceName;
m_inhibited = true;
return true;
}
void disableInhibit()
{
if (!m_inhibited || m_cookie == 0)
return;
QDBusInterface iface(m_service, m_path, m_iface, QDBusConnection::sessionBus());
if (iface.isValid())
iface.call("UnInhibit", m_cookie);
m_cookie = 0;
m_inhibited = false;
}
};
#endif // SCREEN_H

25
src/settings.cpp Normal file
View file

@ -0,0 +1,25 @@
#include "settings.h"
Settings::Settings(QObject *parent) : QObject(parent)
{
m_settings = new QSettings("ONeill Codesmithing", "FantIM", this);
}
Settings::~Settings()
{
m_settings->deleteLater();
}
void Settings::setValue(const QString &key, const QVariant &value)
{
if( m_settings->value(key) == value )
return;
m_settings->setValue(key, value);
emit settingChanged(key, value);
}
QVariant Settings::value(const QString &key, const QVariant &defaultValue)
{
return m_settings->value(key, defaultValue);
}

25
src/settings.h Normal file
View file

@ -0,0 +1,25 @@
#ifndef SETTINGS_H
#define SETTINGS_H
#include <QObject>
#include <QSettings>
class Settings : public QObject
{
Q_OBJECT
QSettings *m_settings;
public:
explicit Settings(QObject *parent = nullptr);
~Settings();
signals:
void settingChanged(const QString &key, const QVariant &value);
public slots:
void setValue(const QString &key, const QVariant &value);
QVariant value(const QString &key, const QVariant &defaultValue=QVariant());
};
#endif // SETTINGS_H

648
src/wasm.cpp Normal file
View file

@ -0,0 +1,648 @@
#include "wasm.h"
#include <QDebug>
#include <QApplication>
#include <QBuffer>
#include <QByteArray>
#include <QCryptographicHash>
#include <QDataStream>
#include <QFileDialog>
#include <QIODevice>
#include <QJsonDocument>
#include <QJSEngine>
#include <QMimeData>
#include <QPair>
#include <QQmlEngine>
#ifndef QT_NO_SSL
# include <QSslError>
#endif
#include <QUrlQuery>
#include <QUuid>
#ifndef BUILD_VERSION
# define BUILD_VERSION "dev-0.1"
#endif
#ifdef Q_OS_WASM
#include <emscripten.h>
EM_JS(char*, __js_location, (void), {
const locstr = window.location.href;
const len = lengthBytesUTF16(locstr) + 1;
let strbuf = _malloc(len);
stringToUTF16(locstr, strbuf, len+1);
return strbuf;
});
EM_JS(char*, __js_eval, (const char *str, size_t len), {
const jsstr = UTF16ToString(str, len);
console.log("Eval: "+jsstr);
const resstr = eval(jsstr);
if( !resstr )
return null;
const olen = lengthBytesUTF16(resstr) + 1;
let strbuf = _malloc(olen);
stringToUTF16(resstr, strbuf, olen+1);
return strbuf;
});
EM_JS(char *, __js_loadsettings, (void), {
let name = "settings=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for( let i = 0; i < ca.length; i++ )
{
let c = ca[i];
while( c.charAt(0) === ' ' )
{
c = c.substring(1);
}
if( c.indexOf(name) === 0 )
{
const contents = c.substring(name.length, c.length);
const len = lengthBytesUTF16(contents) + 1;
let strbuf = _malloc(len);
stringToUTF16(contents, strbuf, len+1);
return strbuf;
}
}
return 0;
});
EM_JS(void, __js_savesettings, (const char *str, size_t len), {
const string = UTF16ToString(str, len);
const encoded = encodeURIComponent(string);
let now = new Date();
now.setFullYear( now.getFullYear() + 1 );
const expires = now.toUTCString();
document.cookie = "settings="+encoded+"; expires="+expires+"; SameSite=Strict";
});
EM_JS(void, __js_log, (const char *formatstr, size_t formatlen, const char *argsstr, size_t argslen), {
const jsformat = UTF16ToString(formatstr, formatlen);
const jsargstr = UTF16ToString(argsstr, argslen);
try {
const jsargs = JSON.parse(jsargstr);
console.log( jsformat, ...jsargs );
} catch(e) {
console.log("Failed to log message: "+e);
}
});
#else
# include <QDir>
#endif
WASM::WASM(QQmlEngine *parent)
: QObject{parent},
m_engine{parent},
m_clipboard{QApplication::clipboard()},
m_translator{nullptr}
{
#ifndef __EMSCRIPTEN__
m_qsettings = new QSettings();
#endif
loadCache();
}
WASM::~WASM()
{
#ifndef Q_OS_WASM
m_qsettings->deleteLater();
#endif
}
QVariantMap WASM::queryItems()
{
QUrlQuery q = QUrlQuery( QUrl(location()) );
QVariantMap result;
for( QPair<QString,QString> pair : q.queryItems() )
{
result[ pair.first ] = pair.second;
}
return result;
}
QString WASM::location()
{
#ifdef Q_OS_WASM
char *jsloc = (char *)__js_location();
QString result = QString::fromUtf16( (const char16_t *)jsloc );
::free((void*)jsloc);
return result;
#else
//return QDir::currentPath();
return qApp->applicationDirPath();
#endif
}
QString WASM::assetsLocation()
{
#ifdef Q_OS_WASM
return location();
#else
//QString ap = QString("%1%2/").arg("file://").arg( QDir( QString("%1/../www").arg(location()) ).absolutePath() );
QString ap = QString("qrc:/");
//qDebug() << "Got ap: " << ap;
return ap;
#endif
}
QVariant WASM::value(const QString &key, const QVariant &defval)
{
return m_settings.value(key, defval);
}
void WASM::setValue(const QString &key, const QVariant &value)
{
m_settings[key] = value;
saveCache();
}
bool WASM::setIfUndef(const QString &key, const QVariant &value)
{
if( m_settings.contains(key) )
return false;
setValue(key, value);
return true;
}
QString WASM::sha256( const QVariant &data )
{
return QString( QCryptographicHash::hash( data.toByteArray(), QCryptographicHash::Sha256 ).toHex() );
}
QString WASM::uuid()
{
return QUuid::createUuid().toString();
}
QString WASM::toBase64(const QByteArray &data)
{
return QString::fromLatin1( data.toBase64() );
}
QByteArray WASM::fromBase64(const QString &data)
{
return QByteArray::fromBase64( data.toLatin1() );
}
QVariant WASM::xmlToJSON(const QString &xml)
{
QXmlStreamReader xmlReader;
xmlReader.clear();
xmlReader.addData(xml);
QVariant result = xmlStreamToVariant(xmlReader, "", 128);
return result;
}
QVariant WASM::xmlStreamToVariant(QXmlStreamReader &xml, const QString &prefix, const int maxDepth)
{
if (maxDepth < 0) {
qWarning() << QObject::tr("max depth exceeded");
return QVariantMap();
}
if (xml.hasError()) {
qWarning() << xml.errorString();
return QVariantMap();
}
if (xml.tokenType() == QXmlStreamReader::NoToken)
xml.readNext();
if ((xml.tokenType() != QXmlStreamReader::StartDocument) &&
(xml.tokenType() != QXmlStreamReader::StartElement)) {
qWarning() << QObject::tr("unexpected XML tokenType %1 (%2)")
.arg(xml.tokenString()).arg(xml.tokenType());
return QVariantMap();
}
QVariantMap map;
if (xml.tokenType() == QXmlStreamReader::StartDocument) {
map.insert(prefix + QLatin1String("DocumentEncoding"), xml.documentEncoding().toString());
map.insert(prefix + QLatin1String("DocumentVersion"), xml.documentVersion().toString());
map.insert(prefix + QLatin1String("StandaloneDocument"), xml.isStandaloneDocument());
} else {
if (!xml.namespaceUri().isEmpty())
map.insert(prefix + QLatin1String("NamespaceUri"), xml.namespaceUri().toString());
foreach (const QXmlStreamAttribute &attribute, xml.attributes()) {
QVariantMap attributeMap;
attributeMap.insert(QLatin1String("Value"), attribute.value().toString());
if (!attribute.namespaceUri().isEmpty())
attributeMap.insert(QLatin1String("NamespaceUri"), attribute.namespaceUri().toString());
if (!attribute.prefix().isEmpty())
attributeMap.insert(QLatin1String("Prefix"), attribute.prefix().toString());
attributeMap.insert(QLatin1String("QualifiedName"), attribute.qualifiedName().toString());
map.insert(prefix + attribute.name().toString(), attributeMap);
}
}
QString str;
QVariant recursed;
for (xml.readNext(); (!xml.atEnd()) && (xml.tokenType() != QXmlStreamReader::EndElement)
&& (xml.tokenType() != QXmlStreamReader::EndDocument); xml.readNext()) {
switch (xml.tokenType()) {
case QXmlStreamReader::Characters:
case QXmlStreamReader::Comment:
case QXmlStreamReader::DTD:
case QXmlStreamReader::EntityReference:
str = xml.text().toString().trimmed();
if( str.length() > 0 )
map.insert(prefix + xml.tokenString(), str);
break;
case QXmlStreamReader::ProcessingInstruction:
map.insert(prefix + xml.processingInstructionTarget().toString(),
xml.processingInstructionData().toString());
break;
case QXmlStreamReader::StartElement:
str = xml.name().toString();
recursed = xmlStreamToVariant(xml, prefix, maxDepth-1);
if( !map.contains(str) )
{
map.insert(str, recursed);
break;
}
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
if( map.value(str).type() != QVariant::List )
#else
if( map.value(str).typeId() != QMetaType::QVariantList )
#endif
{
QVariantList vals;
vals.append(map.value(str));
vals.append(recursed);
map.insert(str, vals);
} else {
QVariantList vals = map.value(str).toList();
vals.append(recursed);
map.insert(str, vals);
}
break;
case QXmlStreamReader::EndDocument:
return map;
default:
qWarning() << QObject::tr("unexpected XML tokenType %1 (%2)")
.arg(xml.tokenString()).arg(xml.tokenType());
}
}
return map;
}
#ifdef Q_OS_WASM
void WASM::log(const QString &format, const QVariantList &args)
{
QJsonDocument jsonArgs = QJsonDocument::fromVariant(args);
QString asJson = QString::fromUtf8(jsonArgs.toJson(QJsonDocument::Compact));
__js_log( (const char *)format.utf16(), format.length()*2, (const char*)asJson.utf16(), asJson.length()*2 );
}
#endif
bool WASM::setLanguage(const QString &langcode)
{
// Already on that language?
if( m_translator && langcode == m_translator->language() )
return true;
// Switching to "no language"?
if( langcode.compare("C") == 0 )
{
if( !m_translator )
return true;
replaceTranslator(nullptr);
return true;
}
#if QT_VERSION >= 0x060400
// en_CA => en, CA
QStringList langTerr = langcode.split('_');
if( langTerr.length() != 2 )
return false;
// en => QLocale::English, CA => QLocale::Canada
QLocale::Language lang = QLocale::codeToLanguage( langTerr.at(0) );
QLocale::Territory terr = QLocale::codeToTerritory( langTerr.at(1) );
QLocale locale(lang, terr);
#else
QLocale locale(langcode);
#endif
QTranslator *translator = new QTranslator();
bool res = translator->load(locale, "mars_", "", ":/i18n/", ".qm");
if( !res )
{
#if QT_VERSION >= 0x060400
qDebug() << "Load failed for language:" << langcode << ":" << langTerr.join('_');
#else
qDebug() << "Load failed for language:" << langcode;
#endif
translator->deleteLater();
return false;
}
replaceTranslator(translator);
return true;
}
void WASM::replaceTranslator(QTranslator *translator)
{
if( m_translator )
{
qApp->removeTranslator(m_translator);
m_translator->deleteLater();
}
m_translator = translator;
if( m_translator )
{
if( qApp->installTranslator(m_translator) )
emit translated();
} else
emit translated();
}
QString WASM::version()
{
#ifdef BUILD_VERSION
return QString::fromLatin1((const char *)BUILD_VERSION);
#else
return QString("unknown");
#endif
}
QString WASM::buildTime()
{
#ifdef BUILD_TIME
return QString::fromLatin1((const char *)BUILD_TIME);
#else
return QString("unknown");
#endif
}
void WASM::remoteTranslation(const QString &langcode)
{
QNetworkRequest request;
QString rooturl = QUrl(location()).adjusted(QUrl::RemoveQuery | QUrl::RemoveFilename).toString();
QString qmloc = QString("%1/qm/mars_%2.qm").arg(rooturl).arg(langcode);
request.setUrl( QUrl(qmloc).adjusted(QUrl::NormalizePathSegments) );
qDebug() << "Requesting " << request.url().toString();
QNetworkReply *reply = m_qnam.get(request);
QObject::connect( reply, &QNetworkReply::errorOccurred, [reply]() {
qDebug() << "An error occured: " << reply->errorString();
reply->deleteLater();
});
QObject::connect( reply, &QNetworkReply::finished, [this, reply]() {
QByteArray data = reply->readAll();
reply->deleteLater();
if( data.length() == 0 )
return;
qDebug() << "Download complete: " << data.length() << "bytes";
QTranslator *translator = new QTranslator();
bool res = translator->load( (const uchar*)data.constData(), data.length() );
if( !res )
{
qDebug() << "Failed to load translation data.";
translator->deleteLater();
return;
}
replaceTranslator(translator);
});
}
QByteArray WASM::clipboardImage()
{
QImage image = m_clipboard->image();
if( image.isNull() )
return QByteArray();
QByteArray ba;
QBuffer buffer(&ba);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
buffer.close();
return ba;
}
bool WASM::clipboardHasImage()
{
const QMimeData *md = m_clipboard->mimeData();
if( !md )
return false;
return md->hasImage();
}
#ifdef Q_OS_WASM
bool WASM::loadCache()
{
const char *encoded = __js_loadsettings();
if( !encoded )
{
qWarning() << "WASM::loadCache(): No previous settings found.";
return false;
}
size_t len = ::strlen(encoded);
QByteArray ba = QByteArray::fromBase64( QByteArray::fromRawData(encoded, len) );
::free((void*)encoded);
if( ba.length() == 0 )
{
qWarning() << "WASM::loadCache(): Failed to decode settings blob, corrupt Base64?";
return false;
}
QDataStream ds(ba);
ds >> m_settings;
return true;
}
void WASM::saveCache()
{
QByteArray ba;
QDataStream bs(&ba, QIODevice::WriteOnly);
bs << m_settings;
QByteArray b64 = ba.toBase64();
__js_savesettings( b64.constData(), b64.length() );
}
QString WASM::eval(const QString &code)
{
const ushort *aslatin = code.utf16();
size_t codelen;
for( codelen=0; codelen < 8192; codelen++ )
{
if( aslatin[codelen] == '\0' )
break;
}
const char *encoded = __js_eval( (const char *)aslatin, codelen*2 );
if( !encoded )
return QString();
return QString::fromUtf16((const char16_t *)encoded);
}
#else
bool WASM::loadCache()
{
m_settings = m_qsettings->value("wasm_settings", QVariant()).toMap();
return true;
}
void WASM::saveCache()
{
m_qsettings->setValue("wasm_settings", m_settings);
}
#endif
void WASM::handleUpload(QJSValue callback, const QString &filter)
{
QJSEngine *jse = qjsEngine(this);
if( !jse )
{
qCritical() << "Couldn't get a QJSEngine handle.";
return;
}
jse->collectGarbage();
//auto fileContentReady = [jse, callback](const QString &fileName, const QByteArray &fileContent) mutable {
auto fileContentReady = [callback](const QString &fileName, const QByteArray &fileContent) mutable {
if (fileName.isEmpty()) {
// No file was selected
} else {
QString b64str = QString::fromLatin1( fileContent.toBase64() );
QJSValueList args;
args << fileName;
args << QJSValue( b64str );
args << QJSValue( (uint)fileContent.length() );
//qDebug() << "File name:" << fileName << "/" << fileContent.length() << "/" << b64str.length();
callback.call(args);
}
};
QFileDialog::getOpenFileContent(filter, fileContentReady);
}
bool WASM::get(const QString &url, QJSValue callback)
{
QJSEngine *jse = qjsEngine(this);
if( !jse )
{
qCritical() << "Couldn't get a QJSEngine handle.";
return false;
}
jse->collectGarbage();
QNetworkRequest request = QNetworkRequest(QUrl(url));
QNetworkReply *reply = m_qnam.get(request);
connect( reply, &QNetworkReply::finished, this, [jse, reply, callback]() mutable {
// Convert headers to simpler map for JS
QVariantMap headers;
for( QNetworkReply::RawHeaderPair pair : reply->rawHeaderPairs() )
{
headers[ QString::fromUtf8(pair.first) ] = QString::fromUtf8(pair.second);
}
int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
QByteArray data = reply->readAll();
QJSValueList args;
args << statusCode;
args << jse->toScriptValue<QVariant>(headers);
args << jse->toScriptValue<QByteArray>(data);
callback.call(args);
reply->deleteLater();
});
#ifndef QT_NO_SSL
connect( reply, &QNetworkReply::sslErrors, [](const QList<QSslError> &errors) {
for( QSslError err : errors )
qDebug() << "SSL Error: " << err.errorString();
//reply->deleteLater();
});
#endif
return reply->isRunning();
}
QVariant WASM::readFile(const QString &path)
{
qDebug() << "WASM::readFile: " << path;
QFile f(path);
if( !f.open(QIODevice::ReadOnly) )
{
qDebug() << "WASM::readFile: Failed to read file: " << f.errorString();
return false;
}
QByteArray buf = f.readAll();
f.close();
return buf;
}
QVariant WASM::writeFile(const QString &path, const QByteArray &data)
{
QFile f(path);
if( !f.open(QIODevice::WriteOnly | QIODevice::Truncate) )
{
qDebug() << "WASM::writeFile: Failed to open file: " << f.errorString();
return false;
}
qint64 res = f.write(data);
f.close();
return res;
}
void WASM::clearComponentCache()
{
if( !m_engine )
{
qCritical() << "Couldn't get a QQmlEngine handle.";
return;
}
m_engine->clearComponentCache();
}
Watcher *WASM::watcher()
{
Watcher *w = new Watcher(this);
return w;
}
Watcher::Watcher(QObject *parent)
: QObject(parent)
{
QObject::connect( &m_watcher, &QFileSystemWatcher::fileChanged, [this](const QString &path) {
this->fileChanged(path);
this->m_watcher.addPath(path);
});
}
bool Watcher::watch(const QString &path)
{
return m_watcher.addPath(path);
}

113
src/wasm.h Normal file
View file

@ -0,0 +1,113 @@
#ifndef WASM_H
#define WASM_H
#include <QClipboard>
#include <QFileSystemWatcher>
#include <QJSValue>
#include <QObject>
#include <QLocale>
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QTranslator>
#include <QVariantMap>
#include <QXmlStreamReader>
#include <QtSystemDetection>
#ifndef Q_OS_WASM
# include <QSettings>
#endif
class Watcher : public QObject {
Q_OBJECT
QFileSystemWatcher m_watcher;
public:
Watcher(QObject *parent=nullptr);
Q_INVOKABLE bool watch(const QString &path);
signals:
void fileChanged(const QString &path);
};
class QQmlEngine;
class WASM : public QObject
{
Q_OBJECT
QQmlEngine *m_engine;
QNetworkAccessManager m_qnam;
QClipboard *m_clipboard;
QTranslator *m_translator;
QVariantMap m_settings;
#ifndef Q_OS_WASM
QSettings *m_qsettings;
#endif
Q_PROPERTY(QString buildTime READ buildTime CONSTANT)
Q_PROPERTY(QString version READ version CONSTANT)
public:
explicit WASM(QQmlEngine *parent);
~WASM();
Q_INVOKABLE QVariantMap queryItems();
Q_INVOKABLE QString location();
Q_INVOKABLE QString assetsLocation();
Q_INVOKABLE QVariant value(const QString &key, const QVariant &defval=QVariant());
Q_INVOKABLE void setValue(const QString &key, const QVariant &value);
Q_INVOKABLE bool setIfUndef(const QString &key, const QVariant &value);
Q_INVOKABLE static QString sha256(const QVariant &data );
Q_INVOKABLE static QString uuid();
Q_INVOKABLE static QString toBase64(const QByteArray &data);
Q_INVOKABLE static QByteArray fromBase64(const QString &data);
Q_INVOKABLE static QVariant xmlToJSON(const QString &xml);
#ifdef Q_OS_WASM
Q_INVOKABLE static void log(const QString &format, const QVariantList &args);
#endif
Q_INVOKABLE bool setLanguage(const QString &langcode);
Q_INVOKABLE void remoteTranslation(const QString &langcode);
Q_INVOKABLE Watcher *watcher();
Q_INVOKABLE void clearComponentCache();
Q_INVOKABLE void handleUpload(QJSValue callback, const QString &filter=QString("Images (*.png *.gif *.jpg *.jpeg *.webp)"));
Q_INVOKABLE bool get(const QString &url, QJSValue callback);
Q_INVOKABLE QVariant readFile(const QString &path);
Q_INVOKABLE QVariant writeFile(const QString &path, const QByteArray &data);
#ifdef Q_OS_WASM
Q_INVOKABLE QString eval(const QString &code);
#endif
// Clipboard related:
Q_INVOKABLE QByteArray clipboardImage();
Q_INVOKABLE bool clipboardHasImage();
private:
bool loadCache();
void saveCache();
void replaceTranslator(QTranslator *translator);
static QVariant xmlStreamToVariant(QXmlStreamReader &xml, const QString &prefix = QLatin1String("."), const int maxDepth = 1024);
QString buildTime();
QString version();
signals:
void translated();
};
#endif // WASM_H