diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index edc3087d..d9f4f02e 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -497,6 +497,9 @@ Singleton { property string lockScreenActiveMonitor: "all" property string lockScreenInactiveColor: "#000000" property int lockScreenNotificationMode: 0 + property bool lockScreenVideoEnabled: false + property string lockScreenVideoPath: "" + property bool lockScreenVideoCycling: false property bool hideBrightnessSlider: false property int notificationTimeoutLow: 5000 diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index d347e271..ec38d87e 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -320,6 +320,9 @@ var SPEC = { lockScreenActiveMonitor: { def: "all" }, lockScreenInactiveColor: { def: "#000000" }, lockScreenNotificationMode: { def: 0 }, + lockScreenVideoEnabled: { def: false }, + lockScreenVideoPath: { def: "" }, + lockScreenVideoCycling: { def: false }, hideBrightnessSlider: { def: false }, notificationTimeoutLow: { def: 5000 }, diff --git a/quickshell/Modules/Lock/LockSurface.qml b/quickshell/Modules/Lock/LockSurface.qml index b8a5cf8b..9d1beaeb 100644 --- a/quickshell/Modules/Lock/LockSurface.qml +++ b/quickshell/Modules/Lock/LockSurface.qml @@ -2,8 +2,9 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell.Wayland +import qs.Common -Rectangle { +FocusScope { id: root required property WlSessionLock lock @@ -14,7 +15,17 @@ Rectangle { signal passwordChanged(string newPassword) signal unlockRequested - color: "transparent" + Keys.onPressed: event => { + if (videoScreensaver.active && videoScreensaver.inputEnabled) { + videoScreensaver.dismiss(); + event.accepted = true; + } + } + + Rectangle { + anchors.fill: parent + color: "transparent" + } LockScreenContent { id: lockContent @@ -23,17 +34,38 @@ Rectangle { demoMode: false passwordBuffer: root.sharedPasswordBuffer screenName: root.screenName + enabled: !videoScreensaver.active + focus: !videoScreensaver.active + opacity: videoScreensaver.active ? 0 : 1 onUnlockRequested: root.unlockRequested() onPasswordBufferChanged: { if (root.sharedPasswordBuffer !== passwordBuffer) { root.passwordChanged(passwordBuffer); } } + + Behavior on opacity { + NumberAnimation { + duration: 200 + } + } } + VideoScreensaver { + id: videoScreensaver + anchors.fill: parent + screenName: root.screenName + } + + Component.onCompleted: forceActiveFocus() + onIsLockedChanged: { if (isLocked) { + forceActiveFocus(); lockContent.resetLockState(); + if (SettingsData.lockScreenVideoEnabled) { + videoScreensaver.start(); + } return; } lockContent.unlocking = false; diff --git a/quickshell/Modules/Lock/VideoScreensaver.qml b/quickshell/Modules/Lock/VideoScreensaver.qml new file mode 100644 index 00000000..129f507b --- /dev/null +++ b/quickshell/Modules/Lock/VideoScreensaver.qml @@ -0,0 +1,200 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Io +import qs.Common +import qs.Services + +Item { + id: root + + required property string screenName + property bool active: false + property string videoSource: "" + property bool inputEnabled: false + property point lastMousePos: Qt.point(-1, -1) + property bool mouseInitialized: false + property var videoPlayer: null + + signal dismissed + + visible: active + z: 1000 + + Rectangle { + id: background + anchors.fill: parent + color: "black" + visible: root.active + } + + Timer { + id: inputEnableTimer + interval: 500 + onTriggered: root.inputEnabled = true + } + + Process { + id: videoPicker + property string result: "" + property string folder: "" + + command: ["sh", "-c", "find '" + folder + "' -maxdepth 1 -type f \\( " + "-iname '*.mp4' -o -iname '*.mkv' -o -iname '*.webm' -o " + "-iname '*.mov' -o -iname '*.avi' -o -iname '*.m4v' " + "\\) 2>/dev/null | shuf -n1"] + + stdout: SplitParser { + onRead: data => { + const path = data.trim(); + if (path) { + videoPicker.result = path; + root.videoSource = "file://" + path; + } + } + } + + onExited: exitCode => { + if (exitCode !== 0 || !videoPicker.result) { + console.warn("VideoScreensaver: no video found in folder"); + ToastService.showError(I18n.tr("Video Screensaver"), I18n.tr("No video found in folder")); + root.dismiss(); + } + } + } + + Process { + id: fileChecker + command: ["test", "-d", SettingsData.lockScreenVideoPath] + + onExited: exitCode => { + const isDir = exitCode === 0; + const videoPath = SettingsData.lockScreenVideoPath; + + if (isDir) { + videoPicker.folder = videoPath; + videoPicker.running = true; + } else if (SettingsData.lockScreenVideoCycling) { + const parentFolder = videoPath.substring(0, videoPath.lastIndexOf('/')); + videoPicker.folder = parentFolder; + videoPicker.running = true; + } else { + root.videoSource = "file://" + videoPath; + } + } + } + + function createVideoPlayer() { + if (videoPlayer) + return true; + + try { + videoPlayer = Qt.createQmlObject(` + import QtQuick + import QtMultimedia + Video { + anchors.fill: parent + fillMode: VideoOutput.PreserveAspectCrop + loops: MediaPlayer.Infinite + volume: 0 + } + `, background, "VideoScreensaver.VideoPlayer"); + + videoPlayer.errorOccurred.connect((error, errorString) => { + console.warn("VideoScreensaver: playback error:", errorString); + ToastService.showError(I18n.tr("Video Screensaver"), I18n.tr("Playback error: ") + errorString); + root.dismiss(); + }); + + return true; + } catch (e) { + console.warn("VideoScreensaver: Failed to create video player:", e); + return false; + } + } + + function destroyVideoPlayer() { + if (videoPlayer) { + videoPlayer.stop(); + videoPlayer.destroy(); + videoPlayer = null; + } + } + + function start() { + if (!SettingsData.lockScreenVideoEnabled || !SettingsData.lockScreenVideoPath) + return; + + if (!MultimediaService.available) { + ToastService.showError(I18n.tr("Video Screensaver"), I18n.tr("QtMultimedia is not available")); + return; + } + + if (!createVideoPlayer()) + return; + + videoPicker.result = ""; + videoPicker.folder = ""; + inputEnabled = false; + mouseInitialized = false; + lastMousePos = Qt.point(-1, -1); + active = true; + inputEnableTimer.start(); + fileChecker.running = true; + } + + function dismiss() { + if (!active) + return; + destroyVideoPlayer(); + inputEnabled = false; + active = false; + videoSource = ""; + dismissed(); + } + + onVideoSourceChanged: { + if (videoSource && active && videoPlayer) { + videoPlayer.source = videoSource; + videoPlayer.play(); + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + enabled: root.active && root.inputEnabled + hoverEnabled: true + propagateComposedEvents: false + + onPositionChanged: mouse => { + if (!root.mouseInitialized) { + root.lastMousePos = Qt.point(mouse.x, mouse.y); + root.mouseInitialized = true; + return; + } + var dx = Math.abs(mouse.x - root.lastMousePos.x); + var dy = Math.abs(mouse.y - root.lastMousePos.y); + if (dx > 5 || dy > 5) { + root.dismiss(); + } + } + onClicked: root.dismiss() + onPressed: root.dismiss() + onWheel: root.dismiss() + } + + Connections { + target: IdleService + + function onLockRequested() { + if (SettingsData.lockScreenVideoEnabled && !root.active) { + root.start(); + } + } + + function onFadeToLockRequested() { + if (SettingsData.lockScreenVideoEnabled && !root.active) { + IdleService.cancelFadeToLock(); + root.start(); + } + } + } +} diff --git a/quickshell/Modules/Settings/LockScreenTab.qml b/quickshell/Modules/Settings/LockScreenTab.qml index 98584555..8dd207dd 100644 --- a/quickshell/Modules/Settings/LockScreenTab.qml +++ b/quickshell/Modules/Settings/LockScreenTab.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import qs.Common +import qs.Modals.FileBrowser import qs.Services import qs.Widgets import qs.Modules.Settings.Widgets @@ -8,6 +9,16 @@ import qs.Modules.Settings.Widgets Item { id: root + FileBrowserModal { + id: videoBrowserModal + browserTitle: I18n.tr("Select Video or Folder") + browserIcon: "movie" + browserType: "video" + showHiddenFiles: false + fileExtensions: ["*.mp4", "*.mkv", "*.webm", "*.mov", "*.avi", "*.m4v"] + onFileSelected: path => SettingsData.set("lockScreenVideoPath", path) + } + DankFlickable { anchors.fill: parent clip: true @@ -168,6 +179,87 @@ Item { } } + SettingsCard { + width: parent.width + iconName: "movie" + title: I18n.tr("Video Screensaver") + settingKey: "videoScreensaver" + + StyledText { + visible: !MultimediaService.available + text: I18n.tr("QtMultimedia is not available - video screensaver requires qt multimedia services") + font.pixelSize: Theme.fontSizeSmall + color: Theme.warning + width: parent.width + wrapMode: Text.WordWrap + } + + SettingsToggleRow { + settingKey: "lockScreenVideoEnabled" + tags: ["lock", "screen", "video", "screensaver", "animation", "movie"] + text: I18n.tr("Enable Video Screensaver") + description: I18n.tr("Play a video when the screen locks.") + enabled: MultimediaService.available + checked: SettingsData.lockScreenVideoEnabled + onToggled: checked => SettingsData.set("lockScreenVideoEnabled", checked) + } + + Column { + width: parent.width + spacing: Theme.spacingXS + visible: SettingsData.lockScreenVideoEnabled && MultimediaService.available + + StyledText { + text: I18n.tr("Video Path") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + StyledText { + text: I18n.tr("Path to a video file or folder containing videos") + font.pixelSize: Theme.fontSizeXSmall + color: Theme.outlineVariant + wrapMode: Text.WordWrap + width: parent.width + } + + Row { + width: parent.width + spacing: Theme.spacingS + + DankTextField { + id: videoPathField + width: parent.width - browseVideoButton.width - Theme.spacingS + placeholderText: I18n.tr("/path/to/videos") + text: SettingsData.lockScreenVideoPath + backgroundColor: Theme.surfaceContainerHighest + onTextChanged: { + if (text !== SettingsData.lockScreenVideoPath) { + SettingsData.set("lockScreenVideoPath", text); + } + } + } + + DankButton { + id: browseVideoButton + text: I18n.tr("Browse") + onClicked: videoBrowserModal.open() + } + } + } + + SettingsToggleRow { + settingKey: "lockScreenVideoCycling" + tags: ["lock", "screen", "video", "screensaver", "cycling", "random", "shuffle"] + text: I18n.tr("Automatic Cycling") + description: I18n.tr("Pick a different random video each time from the same folder") + visible: SettingsData.lockScreenVideoEnabled && MultimediaService.available + enabled: MultimediaService.available + checked: SettingsData.lockScreenVideoCycling + onToggled: checked => SettingsData.set("lockScreenVideoCycling", checked) + } + } + SettingsCard { width: parent.width iconName: "monitor" diff --git a/quickshell/Services/AudioService.qml b/quickshell/Services/AudioService.qml index 20293d95..a1eabcc5 100644 --- a/quickshell/Services/AudioService.qml +++ b/quickshell/Services/AudioService.qml @@ -7,6 +7,7 @@ import Quickshell import Quickshell.Io import Quickshell.Services.Pipewire import qs.Common +import qs.Services Singleton { id: root @@ -14,7 +15,7 @@ Singleton { readonly property PwNode sink: Pipewire.defaultAudioSink readonly property PwNode source: Pipewire.defaultAudioSource - property bool soundsAvailable: false + readonly property bool soundsAvailable: MultimediaService.available property bool gsettingsAvailable: false property var availableSoundThemes: [] property string currentSoundTheme: "" @@ -312,24 +313,6 @@ EOFCONFIG } } - function detectSoundsAvailability() { - try { - const testObj = Qt.createQmlObject(` - import QtQuick - import QtMultimedia - Item {} - `, root, "AudioService.TestComponent"); - if (testObj) { - testObj.destroy(); - } - soundsAvailable = true; - return true; - } catch (e) { - soundsAvailable = false; - return false; - } - } - function checkGsettings() { Proc.runCommand("checkGsettings", ["sh", "-c", "gsettings get org.gnome.desktop.sound theme-name 2>/dev/null"], (output, exitCode) => { gsettingsAvailable = (exitCode === 0); @@ -1028,10 +1011,7 @@ EOFCONFIG } Component.onCompleted: { - if (!detectSoundsAvailability()) { - console.warn("AudioService: QtMultimedia not available - sound effects disabled"); - } else { - console.info("AudioService: Sound effects enabled"); + if (soundsAvailable) { checkGsettings(); Qt.callLater(createSoundPlayers); } diff --git a/quickshell/Services/MultimediaService.qml b/quickshell/Services/MultimediaService.qml new file mode 100644 index 00000000..f570c72a --- /dev/null +++ b/quickshell/Services/MultimediaService.qml @@ -0,0 +1,35 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell + +Singleton { + id: root + + property bool available: false + + function detectAvailability() { + try { + const testObj = Qt.createQmlObject(` + import QtQuick + import QtMultimedia + Item {} + `, root, "MultimediaService.TestComponent"); + if (testObj) { + testObj.destroy(); + } + available = true; + return true; + } catch (e) { + available = false; + return false; + } + } + + Component.onCompleted: { + if (!detectAvailability()) { + console.warn("MultimediaService: QtMultimedia not available"); + } + } +}