Inertial Chicken
This commit is contained in:
parent
b1d070909f
commit
8462d4281e
27 changed files with 3278 additions and 0 deletions
60
Media.pro
Normal file
60
Media.pro
Normal 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
|
||||
|
||||
BIN
fonts/Font Awesome 7 Free-Regular-400.otf
Normal file
BIN
fonts/Font Awesome 7 Free-Regular-400.otf
Normal file
Binary file not shown.
BIN
fonts/Font Awesome 7 Free-Solid-900.otf
Normal file
BIN
fonts/Font Awesome 7 Free-Solid-900.otf
Normal file
Binary file not shown.
175
qml/FullscreenVideoWindow.qml
Normal file
175
qml/FullscreenVideoWindow.qml
Normal 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
10
qml/ImageSegment.qml
Normal 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
651
qml/ImageViewer.qml
Normal 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 // 10–400
|
||||
|
||||
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 Firefox’s 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
53
qml/TrackSelectorButton.qml
Normal file
53
qml/TrackSelectorButton.qml
Normal 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
391
qml/VideoSegment.qml
Normal 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
104
qml/VideoSessionManager.qml
Normal 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
154
qml/VideoSessionObject.qml
Normal 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
78
qml/VideoThumbnail.qml
Normal 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
179
qml/VolumeButton.qml
Normal 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
273
qml/main.qml
Normal 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
13
qtquickcontrols2.conf
Normal 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
7
src/clipboard.cpp
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#include "clipboard.h"
|
||||
|
||||
Clipboard::Clipboard(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
clipboard = qApp->clipboard();
|
||||
}
|
||||
49
src/clipboard.h
Normal file
49
src/clipboard.h
Normal 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
66
src/file.cpp
Normal 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
27
src/file.h
Normal 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
18
src/hash.cpp
Normal 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
17
src/hash.h
Normal 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
51
src/main.cpp
Normal 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
5
src/screen.cpp
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#include "screen.h"
|
||||
|
||||
Screen::Screen(QObject *parent)
|
||||
: QObject{parent}
|
||||
{}
|
||||
86
src/screen.h
Normal file
86
src/screen.h
Normal 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
25
src/settings.cpp
Normal 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
25
src/settings.h
Normal 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
648
src/wasm.cpp
Normal 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
113
src/wasm.h
Normal 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
|
||||
Loading…
Reference in a new issue