diff --git a/GlobalStates.qml b/GlobalStates.qml index a902355e2..66a643b4e 100644 --- a/GlobalStates.qml +++ b/GlobalStates.qml @@ -15,6 +15,9 @@ Singleton { // Deferred panel loading gate — non-critical panels wait for this before activating property bool deferredPanelsReady: false // Boot greeting lifecycle — singleton preserves across hot-reload so greeting shows once per session + property bool videoEditorPopupOpen: false + property bool videoEditorOpen: false + property string videoEditorPath: "" property bool bootGreetingOpen: false property bool bootGreetingDone: false property bool barOpen: true diff --git a/modules/background/widgets/clock/CookieQuote.qml b/modules/background/widgets/clock/CookieQuote.qml index 23a77ca2c..9335109dd 100644 --- a/modules/background/widgets/clock/CookieQuote.qml +++ b/modules/background/widgets/clock/CookieQuote.qml @@ -3,7 +3,6 @@ import qs.modules.common.widgets import QtQuick import Qt5Compat.GraphicalEffects - Item { id: root @@ -30,14 +29,24 @@ Item { Rectangle { id: quoteBox - implicitWidth: quoteStyledText.width + 16 // for spacing on both sides - implicitHeight: quoteStyledText.height + 8 + implicitWidth: quoteRow.implicitWidth + 16 + implicitHeight: quoteRow.implicitHeight + 8 radius: Appearance.rounding.small color: Appearance.colors.colSecondaryContainer Row { + id: quoteRow anchors.centerIn: parent - spacing: 0 + spacing: 4 + + MaterialSymbol { + id: quoteIcon + anchors.top: parent.top + iconSize: Appearance.font.pixelSize.huge + text: "format_quote" + color: Appearance.colors.colOnSecondaryContainer + } + StyledText { id: quoteStyledText horizontalAlignment: Text.AlignLeft @@ -51,4 +60,4 @@ Item { } } } -} \ No newline at end of file +} diff --git a/modules/regionSelector/CursorGuide.qml b/modules/regionSelector/CursorGuide.qml new file mode 100755 index 000000000..a31e77112 --- /dev/null +++ b/modules/regionSelector/CursorGuide.qml @@ -0,0 +1,115 @@ +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick + +Item { + id: root + property var action + property var selectionMode + + property int duration: 1000 + + property string description: switch (root.action) { + case RegionSelection.SnipAction.Copy: + case RegionSelection.SnipAction.Edit: + return Translation.tr("Copy region (LMB) or annotate (RMB)"); + case RegionSelection.SnipAction.Search: + root.duration = 1500; + return Translation.tr("Use Google Lens (LMB) or ask AI (RMB)"); + case RegionSelection.SnipAction.CharRecognition: + return Translation.tr("Recognize text"); + case RegionSelection.SnipAction.Record: + case RegionSelection.SnipAction.RecordWithSound: + return Translation.tr("Record region"); + } + property string materialSymbol: switch (root.action) { + case RegionSelection.SnipAction.Copy: + case RegionSelection.SnipAction.Edit: + return "content_cut"; + case RegionSelection.SnipAction.Search: + return "image_search"; + case RegionSelection.SnipAction.CharRecognition: + return "document_scanner"; + case RegionSelection.SnipAction.Record: + case RegionSelection.SnipAction.RecordWithSound: + return "videocam"; + default: + return ""; + } + + property bool showDescription: true + function hideDescription() { + root.showDescription = false + } + Timer { + id: descTimeout + interval: root.duration + running: true + onTriggered: { + root.hideDescription() + } + } + onActionChanged: { + root.showDescription = true + descTimeout.restart() + } + + property int margins: 8 + implicitWidth: content.implicitWidth + margins * 2 + implicitHeight: content.implicitHeight + margins * 2 + + Rectangle { + id: content + anchors.centerIn: parent + + property real padding: 8 + implicitHeight: 38 + implicitWidth: root.showDescription ? contentRow.implicitWidth + padding * 2 : implicitHeight + clip: true + + topLeftRadius: 6 + bottomLeftRadius: implicitHeight - topLeftRadius + bottomRightRadius: bottomLeftRadius + topRightRadius: bottomLeftRadius + + color: Appearance.colors.colPrimary + + Behavior on topLeftRadius { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + Behavior on implicitWidth { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + Row { + id: contentRow + anchors { + verticalCenter: parent.verticalCenter + left: parent.left + leftMargin: content.padding + } + spacing: 12 + + MaterialSymbol { + anchors.verticalCenter: parent.verticalCenter + iconSize: 22 + color: Appearance.colors.colOnPrimary + animateChange: true + text: root.materialSymbol + } + + FadeLoader { + id: descriptionLoader + anchors.verticalCenter: parent.verticalCenter + shown: root.showDescription + sourceComponent: StyledText { + color: Appearance.colors.colOnPrimary + text: root.description + anchors.right: parent.right + anchors.rightMargin: 6 + } + } + } + } +} diff --git a/modules/regionSelector/RegionSelection.qml b/modules/regionSelector/RegionSelection.qml index 2058a5b36..b8fa57e40 100644 --- a/modules/regionSelector/RegionSelection.qml +++ b/modules/regionSelector/RegionSelection.qml @@ -519,6 +519,16 @@ PanelWindow { overlayColor: root.overlayColor points: root.points } + } + + // The thing to the bottom-right with an icon + CursorGuide { + z: 9999 + visible: true + x: root.dragging ? root.regionX + root.regionWidth : mouseArea.mouseX + y: root.dragging ? root.regionY + root.regionHeight : mouseArea.mouseY + action: root.action + selectionMode: root.selectionMode } // Window regions diff --git a/modules/videoEditor/VideoEditor.qml b/modules/videoEditor/VideoEditor.qml new file mode 100755 index 000000000..282c74418 --- /dev/null +++ b/modules/videoEditor/VideoEditor.qml @@ -0,0 +1,418 @@ +pragma ComponentBehavior: Bound + +import qs +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import qs.services +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtMultimedia +import Quickshell +import Quickshell.Wayland + +PanelWindow { + id: root + visible: GlobalStates.videoEditorOpen + + color: "transparent" + + WlrLayershell.namespace: "quickshell:videoEditor" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + exclusionMode: ExclusionMode.Ignore + + anchors { + top: true + bottom: true + left: true + right: true + } + + MediaPlayer { + id: player + source: GlobalStates.videoEditorPath !== "" ? "file://" + GlobalStates.videoEditorPath : "" + videoOutput: videoOutput + audioOutput: AudioOutput {} + loops: MediaPlayer.Infinite + + onPositionChanged: { + if (player.position >= root.effectiveEndTime - 50) { + player.position = root.startTime + } + if (player.position < root.startTime) { + player.position = root.startTime + } + } + } + + onVisibleChanged: { + if (visible) { + player.play() + cropW = -1 + startTime = 0 + endTime = -1 + } else { + player.stop() + } + } + + property real cropX: 0 + property real cropY: 0 + property real cropW: -1 + property real cropH: -1 + property real startTime: 0 + property real endTime: -1 + readonly property real effectiveEndTime: endTime === -1 ? player.duration : endTime + + function applyPreset(ratio) { + let vW = videoOutput.contentRect.width + let vH = videoOutput.contentRect.height + if (vW <= 0 || vH <= 0) return + + if (ratio === -1) { + cropW = vW + cropH = vH + cropX = 0 + cropY = 0 + return + } + + if (vW / vH > ratio) { + cropH = vH + cropW = vH * ratio + } else { + cropW = vW + cropH = vW / ratio + } + cropX = (vW - cropW) / 2 + cropY = (vH - cropH) / 2 + } + + function save(replace) { + if (videoOutput.contentRect.width <= 0) return + + let args = [ + Quickshell.shellPath("scripts/videos/process_video.sh"), + GlobalStates.videoEditorPath, + Math.round(cropW), + Math.round(cropH), + Math.round(cropX), + Math.round(cropY), + Math.round(startTime), + Math.round(effectiveEndTime), + Math.round(videoOutput.contentRect.width), + Math.round(videoOutput.contentRect.height), + replace ? "1" : "0" + ] + Quickshell.execDetached(args) + GlobalStates.videoEditorOpen = false + } + + Rectangle { + id: mainContainer + anchors.centerIn: parent + width: Math.min(parent.width * 0.9, 1400) + height: Math.min(parent.height * 0.9, 900) + radius: Appearance.rounding.windowRounding + + color: Config.options.appearance.transparency.enable ? Appearance.colors.colLayer1 : Appearance.m3colors.m3surfaceContainer + border.width: 1 + border.color: Appearance.colors.colLayer0Border + + Keys.onSpacePressed: { + if (player.playbackState === MediaPlayer.PlayingState) player.pause() + else player.play() + } + Keys.onEscapePressed: GlobalStates.videoEditorOpen = false + focus: root.visible + + ColumnLayout { + anchors.fill: parent + anchors.margins: 30 + spacing: 20 + + RowLayout { + Layout.fillWidth: true + spacing: 16 + MaterialSymbol { + text: "movie_edit" + iconSize: 42 + color: Appearance.colors.colPrimary + } + StyledText { + text: Translation.tr("Video Editor") + font.pixelSize: 32 + font.weight: Font.Bold + color: Appearance.colors.colOnSurface + } + Item { Layout.fillWidth: true } + + RippleButton { + id: closeBtn + width: 52 + height: 52 + buttonRadius: 26 + colBackground: Appearance.colors.colSurfaceContainerHighest + contentItem: Item { + MaterialSymbol { + anchors.centerIn: parent + text: "close" + iconSize: 24 + color: Appearance.colors.colOnSurface + } + } + onClicked: GlobalStates.videoEditorOpen = false + } + } + + Item { + id: videoContainer + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + VideoOutput { + id: videoOutput + anchors.centerIn: parent + width: parent.width + height: parent.height + fillMode: VideoOutput.PreserveAspectFit + + Item { + anchors.fill: parent + visible: root.cropW !== -1 + Rectangle { x: videoOutput.contentRect.x; y: videoOutput.contentRect.y; width: videoOutput.contentRect.width; height: root.cropY; color: "#aa000000" } + Rectangle { x: videoOutput.contentRect.x; y: videoOutput.contentRect.y + root.cropY + root.cropH; width: videoOutput.contentRect.width; height: videoOutput.contentRect.height - (root.cropY + root.cropH); color: "#aa000000" } + Rectangle { x: videoOutput.contentRect.x; y: videoOutput.contentRect.y + root.cropY; width: root.cropX; height: root.cropH; color: "#aa000000" } + Rectangle { x: videoOutput.contentRect.x + root.cropX + root.cropW; y: videoOutput.contentRect.y + root.cropY; width: videoOutput.contentRect.width - (root.cropX + root.cropW); height: root.cropH; color: "#aa000000" } + } + + Rectangle { + id: cropBox + visible: root.cropW !== -1 + x: videoOutput.contentRect.x + root.cropX + y: videoOutput.contentRect.y + root.cropY + width: root.cropW + height: root.cropH + color: "transparent" + border.color: Appearance.colors.colPrimary + border.width: 2 + + MouseArea { + anchors.fill: parent + onPositionChanged: (mouse) => { + if (pressed) { + let newX = Math.max(videoOutput.contentRect.x, Math.min(videoOutput.contentRect.x + videoOutput.contentRect.width - parent.width, parent.x + mouse.x - width/2)) + let newY = Math.max(videoOutput.contentRect.y, Math.min(videoOutput.contentRect.y + videoOutput.contentRect.height - parent.height, parent.y + mouse.y - height/2)) + root.cropX = newX - videoOutput.contentRect.x + root.cropY = newY - videoOutput.contentRect.y + } + } + } + + Rectangle { + anchors.right: parent.right + anchors.bottom: parent.bottom + width: 32 + height: 32 + radius: 16 + color: Appearance.colors.colPrimary + MaterialSymbol { anchors.centerIn: parent; text: "expand_content"; iconSize: 20; color: Appearance.colors.colOnPrimary } + + MouseArea { + anchors.fill: parent + onPositionChanged: (mouse) => { + if (pressed) { + let newW = Math.max(50, parent.parent.width + mouse.x) + let newH = Math.max(50, parent.parent.height + mouse.y) + if (parent.parent.x + newW <= videoOutput.contentRect.x + videoOutput.contentRect.width) root.cropW = newW + if (parent.parent.y + newH <= videoOutput.contentRect.y + videoOutput.contentRect.height) root.cropH = newH + } + } + } + } + } + } + + RippleButton { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: 16 + width: 56 + height: 56 + buttonRadius: 28 + colBackground: "#aa000000" + contentItem: Item { + MaterialSymbol { + anchors.centerIn: parent + text: player.playbackState === MediaPlayer.PlayingState ? "pause" : "play_arrow" + iconSize: 32 + color: "white" + } + } + onClicked: { + if (player.playbackState === MediaPlayer.PlayingState) player.pause() + else player.play() + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 24 + + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + StyledText { text: Translation.tr("Trim Video"); font.weight: Font.Medium; color: Appearance.colors.colOnSurface } + Item { + id: timeline + Layout.fillWidth: true + Layout.preferredHeight: 50 + Rectangle { + anchors.fill: parent + radius: 12 + color: Appearance.colors.colSurfaceContainer + border.width: 1 + border.color: Appearance.colors.colLayer0Border + Rectangle { anchors.fill: parent; anchors.margins: 4; radius: 8; color: Appearance.colors.colLayer1 } + MouseArea { + anchors.fill: parent + onPressed: (mouse) => { + let pos = Math.max(0, Math.min(1, mouse.x / width)) + player.position = pos * player.duration + } + } + } + Rectangle { + x: (root.startTime / player.duration) * parent.width + width: ((root.effectiveEndTime - root.startTime) / player.duration) * parent.width + height: parent.height + color: ColorUtils.transparentize(Appearance.colors.colPrimary, 0.3) + } + Rectangle { + x: (player.position / player.duration) * parent.width - 2 + width: 4; height: parent.height; color: Appearance.colors.colSecondary + } + Rectangle { + id: startHandle + x: (root.startTime / player.duration) * parent.width - 15 + width: 30; height: parent.height; radius: 6; color: Appearance.colors.colPrimary + MaterialSymbol { anchors.centerIn: parent; text: "chevron_right"; iconSize: 18; color: Appearance.colors.colOnPrimary } + MouseArea { + anchors.fill: parent + onPositionChanged: (mouse) => { + if (pressed) { + let newX = Math.max(-15, Math.min(endHandle.x - 40, parent.x + mouse.x - width/2)) + root.startTime = Math.max(0, (newX + 15) / timeline.width * player.duration) + player.position = root.startTime + } + } + onPressed: player.pause(); onReleased: player.play() + } + } + Rectangle { + id: endHandle + x: (root.effectiveEndTime / player.duration) * parent.width - 15 + width: 30; height: parent.height; radius: 6; color: Appearance.colors.colPrimary + MaterialSymbol { anchors.centerIn: parent; text: "chevron_left"; iconSize: 18; color: Appearance.colors.colOnPrimary } + MouseArea { + anchors.fill: parent + onPositionChanged: (mouse) => { + if (pressed) { + let newX = Math.max(startHandle.x + 40, Math.min(timeline.width - 15, parent.x + mouse.x - width/2)) + root.endTime = Math.min(player.duration, (newX + 15) / timeline.width * player.duration) + player.position = root.endTime + } + } + onPressed: player.pause(); onReleased: player.play() + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 20 + Layout.alignment: Qt.AlignBottom + + ColumnLayout { + spacing: 8 + StyledText { text: Translation.tr("Aspect Ratio"); font.weight: Font.Medium; color: Appearance.colors.colOnSurface } + RowLayout { + spacing: 8 + Repeater { + model: [ + { name: "Free", ratio: -1, icon: "aspect_ratio" }, + { name: "16:9", ratio: 1.7777777777777777, icon: "rectangle" }, + { name: "9:16", ratio: 0.5625, icon: "smartphone" }, + { name: "4:3", ratio: 1.3333333333333333, icon: "desktop_windows" }, + { name: "1:1", ratio: 1, icon: "square" } + ] + delegate: RippleButton { + id: ratioBtn + required property var modelData + implicitWidth: 100 + implicitHeight: 44 + buttonRadius: 22 + property bool isActive: root.cropW !== -1 && Math.abs((root.cropW/root.cropH) - ratioBtn.modelData.ratio) < 0.01 || (root.cropW === videoOutput.contentRect.width && ratioBtn.modelData.ratio === -1) + colBackground: isActive ? Appearance.colors.colPrimary : Appearance.colors.colSurfaceContainerHighest + contentItem: Item { + RowLayout { + anchors.centerIn: parent + spacing: 8 + MaterialSymbol { text: ratioBtn.modelData.icon; iconSize: 18; color: ratioBtn.isActive ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSurface } + StyledText { text: ratioBtn.modelData.name; font.weight: Font.Medium; color: ratioBtn.isActive ? Appearance.colors.colOnPrimary : Appearance.colors.colOnSurface } + } + } + onClicked: root.applyPreset(ratioBtn.modelData.ratio) + } + } + } + } + + Item { Layout.fillWidth: true } + + RowLayout { + spacing: 12 + Layout.alignment: Qt.AlignBottom + + RippleButton { + implicitWidth: 180 + implicitHeight: 56 + buttonRadius: 28 + colBackground: Appearance.colors.colSurfaceContainerHighest + contentItem: Item { + RowLayout { + anchors.centerIn: parent + spacing: 12 + MaterialSymbol { text: "content_copy"; iconSize: 24; color: Appearance.colors.colOnSurface } + StyledText { text: Translation.tr("Save Copy"); font.pixelSize: 16; font.weight: Font.Bold; color: Appearance.colors.colOnSurface } + } + } + onClicked: root.save(false) + } + + RippleButton { + implicitWidth: 220 + implicitHeight: 56 + buttonRadius: 28 + colBackground: Appearance.colors.colPrimary + contentItem: Item { + RowLayout { + anchors.centerIn: parent + spacing: 12 + MaterialSymbol { text: "check_circle"; iconSize: 24; color: Appearance.colors.colOnPrimary } + StyledText { text: Translation.tr("Save and Replace"); font.pixelSize: 16; font.weight: Font.Bold; color: Appearance.colors.colOnPrimary } + } + } + onClicked: root.save(true) + } + } + } + } + } + } +} diff --git a/modules/videoEditor/VideoEditorPopup.qml b/modules/videoEditor/VideoEditorPopup.qml new file mode 100755 index 000000000..92a75ff33 --- /dev/null +++ b/modules/videoEditor/VideoEditorPopup.qml @@ -0,0 +1,67 @@ +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +Scope { + id: root + + LazyLoader { + id: popupLoader + active: GlobalStates.videoEditorPopupOpen + + component: PanelWindow { + id: popupWindow + color: "transparent" + visible: true + screen: { + const focused = Quickshell.Hyprland?.focusedMonitor?.name + if (focused) { + const s = Quickshell.screens.find(s => s.name === focused) + if (s) return s + } + return Quickshell.screens[0] + } + + WlrLayershell.namespace: "quickshell:videoEditorPopup" + WlrLayershell.layer: WlrLayer.Overlay + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 0 + + anchors { + top: !Config.options.bar.vertical && !Config.options.bar.bottom + bottom: !Config.options.bar.vertical && Config.options.bar.bottom + left: Config.options.bar.vertical && !Config.options.bar.bottom + right: (!Config.options.bar.vertical) || (Config.options.bar.vertical && Config.options.bar.bottom) + } + + margins { + top: Config.options.bar.vertical ? 0 : Appearance.sizes.barHeight + bottom: Config.options.bar.vertical ? 0 : Appearance.sizes.barHeight + left: Config.options.bar.vertical ? Appearance.sizes.verticalBarWidth : 0 + right: Config.options.bar.vertical ? Appearance.sizes.verticalBarWidth : Appearance.sizes.hyprlandGapsOut + 4 + } + + implicitWidth: popupContent.implicitWidth + implicitHeight: popupContent.implicitHeight + + mask: Region { + item: popupContent.contentBackground + } + + VideoEditorPopupContent { + id: popupContent + onDismissed: GlobalStates.videoEditorPopupOpen = false + onEditRequested: { + GlobalStates.videoEditorPopupOpen = false + GlobalStates.videoEditorOpen = true + } + } + } + } +} diff --git a/modules/videoEditor/VideoEditorPopupContent.qml b/modules/videoEditor/VideoEditorPopupContent.qml new file mode 100755 index 000000000..519738277 --- /dev/null +++ b/modules/videoEditor/VideoEditorPopupContent.qml @@ -0,0 +1,135 @@ +pragma ComponentBehavior: Bound + +import qs.modules.common +import qs.modules.common.widgets +import qs.services +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + signal dismissed() + signal editRequested() + + readonly property bool isHovered: backgroundMa.containsMouse + + Timer { + id: dismissTimer + interval: 8000 + repeat: false + running: !isHovered + onTriggered: root.dismissed() + } + + onIsHoveredChanged: { + if (isHovered) dismissTimer.stop() + else dismissTimer.restart() + } + + property alias contentBackground: contentBackground + + property real popupWidth: 300 + property real horizontalPadding: 20 + property real verticalPadding: 20 + + implicitWidth: popupWidth + 2 * Appearance.sizes.elevationMargin + implicitHeight: contentLayout.implicitHeight + verticalPadding * 2 + 2 * Appearance.sizes.elevationMargin + + NumberAnimation on opacity { from: 0; to: 1; duration: 350; easing.type: Easing.OutCubic } + NumberAnimation on scale { from: 0.85; to: 1; duration: 400; easing.type: Easing.OutBack; easing.overshoot: 1.2 } + + transformOrigin: Item.TopRight + + StyledRectangularShadow { target: contentBackground } + + Rectangle { + id: contentBackground + anchors { + fill: parent + margins: Appearance.sizes.elevationMargin + } + radius: Appearance.rounding.large + color: Config.options.appearance.transparency.popups ? Appearance.colors.colLayer0 : Appearance.m3colors.m3surfaceContainer + border.width: 1 + border.color: Appearance.colors.colLayer0Border + + ColumnLayout { + id: contentLayout + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: root.horizontalPadding + topMargin: root.verticalPadding + bottomMargin: root.verticalPadding + } + spacing: 16 + + RowLayout { + spacing: 12 + MaterialSymbol { + text: "movie_edit" + iconSize: 32 + color: Appearance.colors.colPrimary + } + ColumnLayout { + spacing: 2 + StyledText { + text: Translation.tr("Recording Finished") + font.pixelSize: 18 + font.weight: Font.Bold + color: Appearance.colors.colOnSurface + } + StyledText { + text: Translation.tr("Do you want to edit it?") + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colOnSurfaceVariant + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 40 + radius: Appearance.rounding.full + color: editMa.containsMouse ? Appearance.colors.colPrimaryContainerHover : Appearance.colors.colPrimaryContainer + + scale: editMa.pressed ? 0.95 : (editMa.containsMouse ? 1.02 : 1.0) + Behavior on scale { NumberAnimation { duration: 150 } } + Behavior on color { animation: Appearance.animation.elementMoveFast.colorAnimation.createObject(this) } + + RowLayout { + anchors.centerIn: parent + spacing: 8 + MaterialSymbol { + text: "edit" + iconSize: 20 + color: Appearance.colors.colOnPrimaryContainer + } + StyledText { + text: Translation.tr("Edit Video") + font.weight: Font.Medium + color: Appearance.colors.colOnPrimaryContainer + } + } + + MouseArea { + id: editMa + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: root.editRequested() + } + } + } + } + + MouseArea { + id: backgroundMa + anchors.fill: parent + z: -1 + hoverEnabled: true + onClicked: root.dismissed() + } +} diff --git a/modules/videoEditor/qmldir b/modules/videoEditor/qmldir new file mode 100755 index 000000000..709bc0bb9 --- /dev/null +++ b/modules/videoEditor/qmldir @@ -0,0 +1,4 @@ +module qs.modules.ii.videoEditor +VideoEditor 1.0 VideoEditor.qml +VideoEditorPopup 1.0 VideoEditorPopup.qml +VideoEditorPopupContent 1.0 VideoEditorPopupContent.qml diff --git a/scripts/videos/process_video.sh b/scripts/videos/process_video.sh new file mode 100755 index 000000000..8bcf4217e --- /dev/null +++ b/scripts/videos/process_video.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +INPUT="$1" +CROP_W_UI="$2" +CROP_H_UI="$3" +CROP_X_UI="$4" +CROP_Y_UI="$5" +START_MS="$6" +END_MS="$7" +UI_W="$8" +UI_H="$9" +REPLACE="${10}" + +# Log for debugging +echo "Processing $INPUT" > /tmp/video_edit.log +echo "UI: ${UI_W}x${UI_H}, Crop: ${CROP_W_UI}x${CROP_H_UI} at ${CROP_X_UI},${CROP_Y_UI}" >> /tmp/video_edit.log +echo "Time: ${START_MS}ms to ${END_MS}ms" >> /tmp/video_edit.log +echo "Replace: $REPLACE" >> /tmp/video_edit.log + +# Get original dimensions +DIM=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "$INPUT") +ORIG_W=$(echo $DIM | cut -dx -f1) +ORIG_H=$(echo $DIM | cut -dx -f2) + +echo "Original: ${ORIG_W}x${ORIG_H}" >> /tmp/video_edit.log + +# Convert ms to seconds with leading zero +START_S=$(echo "scale=3; $START_MS / 1000" | bc -l | sed 's/^\./0./; s/^-\./-0./') +END_S=$(echo "scale=3; $END_MS / 1000" | bc -l | sed 's/^\./0./; s/^-\./-0./') + +# Handle case where bc returns just 0 +[[ "$START_S" == "0" ]] || [[ -z "$START_S" ]] && START_S="0.000" +[[ "$END_S" == "0" ]] || [[ -z "$END_S" ]] && END_S="0.000" + +FILTER="" +# Only apply crop if it's not the full UI area (with some tolerance) +W_RATIO=$(echo "scale=4; $CROP_W_UI / $UI_W" | bc -l) +if [[ "$CROP_W_UI" != "-1" ]] && (( $(echo "$W_RATIO < 0.99" | bc -l) )); then + # Scale UI coordinates to original dimensions + W=$(echo "($CROP_W_UI * $ORIG_W) / $UI_W" | bc) + H=$(echo "($CROP_H_UI * $ORIG_H) / $UI_H" | bc) + X=$(echo "($CROP_X_UI * $ORIG_W) / $UI_W" | bc) + Y=$(echo "($CROP_Y_UI * $ORIG_H) / $UI_H" | bc) + + # Ensure values are even + W=$(( (W / 2) * 2 )) + H=$(( (H / 2) * 2 )) + X=$(( (X / 2) * 2 )) + Y=$(( (Y / 2) * 2 )) + + FILTER="-vf crop=$W:$H:$X:$Y" + echo "Applying filter: $FILTER" >> /tmp/video_edit.log +fi + +DIR=$(dirname "$INPUT") +BASE=$(basename "$INPUT") +EXT="${BASE##*.}" +NAME="${BASE%.*}" + +if [[ "$REPLACE" == "1" ]]; then + OUTPUT="${INPUT}.edited.${EXT}" +else + # Find a unique name + i=1 + while [[ -f "${DIR}/${NAME}_edited_${i}.${EXT}" ]]; do + i=$((i+1)) + done + OUTPUT="${DIR}/${NAME}_edited_${i}.${EXT}" +fi + +notify-send "Editing Video..." "Applying crop and cut..." -a 'Video Editor' -i video-x-generic & + +# Run ffmpeg +ffmpeg -i "$INPUT" -ss "$START_S" -to "$END_S" $FILTER -preset fast -crf 18 -y "$OUTPUT" >> /tmp/video_edit.log 2>&1 + +if [[ -f "$OUTPUT" ]]; then + if [[ "$REPLACE" == "1" ]]; then + mv "$OUTPUT" "$INPUT" + notify-send "Video Edited" "Saved to $INPUT" -a 'Video Editor' -i video-x-generic & + else + notify-send "Video Saved" "New copy at $OUTPUT" -a 'Video Editor' -i video-x-generic & + fi +else + notify-send "Video Edit Failed" "Check /tmp/video_edit.log" -a 'Video Editor' -u critical & +fi diff --git a/scripts/videos/record.sh b/scripts/videos/record.sh index 58b53033b..ca3d438c1 100755 --- a/scripts/videos/record.sh +++ b/scripts/videos/record.sh @@ -525,10 +525,29 @@ for ((i=0;i<${#ARGS[@]};i++)); do FULLSCREEN_FLAG=1 fi done - if pgrep wf-recorder > /dev/null; then if is_truthy "$SHOW_NOTIFICATIONS"; then notify-send "Recording Stopped" "Stopped" -a 'Recorder' & fi - pkill wf-recorder & + + pkill wf-recorder + + # Wait for the process to fully exit memory + while pgrep -x wf-recorder > /dev/null; do + sleep 0.1 + done + + # Allow kernel I/O flush to disk before reading + sleep 0.4 + + # Read all matching files to prevent SIGPIPE under set -euo pipefail + ALL_VIDEOS=$(ls -1t "$SAVE_PATH" 2>/dev/null | grep -E '\.(mp4|mkv|flv|mov)$' || true) + + # Extract only the first entry safely + LATEST_FILE="${ALL_VIDEOS%%$'\n'*}" + + if [[ -n "$LATEST_FILE" ]]; then + quickshell -c inir ipc call launchVideoEditor handle "$SAVE_PATH/$LATEST_FILE" + fi + exit 0 else output_base="$(date +"$RECORDING_NAME_FORMAT")" output_file="./${output_base}.mp4" diff --git a/shell.qml b/shell.qml index d130736e5..90786e01a 100644 --- a/shell.qml +++ b/shell.qml @@ -309,7 +309,15 @@ ShellRoot { function toggle(): void { open() } + } + + IpcHandler { + target: "launchVideoEditor" + function handle(path: string): void { + GlobalStates.videoEditorPath = path + GlobalStates.videoEditorPopupOpen = true } +} // Settings overlay panel (loaded only when overlay mode is enabled) LazyLoader { @@ -327,7 +335,7 @@ ShellRoot { LazyLoader { active: Config.ready && (Config.options?.panelFamily ?? "ii") !== "waffle" source: "ShellIiPanels.qml" - } + } LazyLoader { active: Config.ready && (Config.options?.panelFamily ?? "ii") === "waffle" @@ -337,6 +345,9 @@ ShellRoot { // Close confirmation dialog (always loaded, handles IPC) LazyLoader { active: Config.ready; component: CloseConfirm {} } +// Video Editor + LazyLoader { active: Config.ready; source: "modules/videoEditor/VideoEditorPopup.qml" } + LazyLoader { active: Config.ready; source: "modules/videoEditor/VideoEditor.qml" } // Shared (always loaded via ToastManager) ToastManager {}