Merge quattro into use-omasnap-for-screenshots
This commit is contained in:
commit
39d1cb956d
21 files changed
+384
-324
No files matched your search
@@ -1,29 +1,19 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
// The shell draws stills only. OWE owns video backgrounds on the desktop, and
|
||||
// it feeds the lock screen through its own socket.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string path: ""
|
||||
property int version: 0
|
||||
property bool playbackEnabled: true
|
||||
property bool audioEnabled: false
|
||||
// Bumped when the file behind an unchanged path may have been replaced.
|
||||
// Images cache-bust through version; a video is rebuilt, since FFmpeg
|
||||
// would read a query as part of the filename.
|
||||
property int reloads: 0
|
||||
property bool reloading: false
|
||||
readonly property var current: video ? videoLoader.item : imageLoader.item
|
||||
readonly property var current: imageLoader.item
|
||||
readonly property bool ready: current ? current.ready : false
|
||||
readonly property bool video: Util.isVideoPath(path)
|
||||
// Cache-bust images selected in a running lock session. FFmpeg treats the
|
||||
// query as part of a local filename, so videos must keep their plain URL.
|
||||
// Each URL is empty for the other kind, so a switch never hands the still
|
||||
// loader a video, or the player a still, in the moment before it unloads.
|
||||
// Both test the path directly: going through `video` lets a URL evaluate
|
||||
// against the stale flag and leak the wrong file for one pass.
|
||||
readonly property url imageUrl: path && !Util.isVideoPath(path) ? Util.fileUrl(path) + (version ? "?v=" + version : "") : ""
|
||||
readonly property url videoUrl: path && Util.isVideoPath(path) ? Util.fileUrl(path) : ""
|
||||
// Cache-bust images selected in a running lock session, so a theme switch
|
||||
// that replaces the file behind an unchanged path shows the new pixels.
|
||||
readonly property url imageUrl: path && !video ? Util.fileUrl(path) + (version ? "?v=" + version : "") : ""
|
||||
|
||||
Loader {
|
||||
id: imageLoader
|
||||
@@ -32,45 +22,6 @@ Item {
|
||||
sourceComponent: imageComponent
|
||||
}
|
||||
|
||||
// Loaded by URL rather than from a Component here, so QtMultimedia and its
|
||||
// audio dependency closure never map into a session that only shows images.
|
||||
Loader {
|
||||
id: videoLoader
|
||||
anchors.fill: parent
|
||||
active: root.path !== "" && root.video && !root.reloading
|
||||
source: "BackgroundVideo.qml"
|
||||
}
|
||||
|
||||
onReloadsChanged: {
|
||||
if (!video) return
|
||||
reloading = true
|
||||
Qt.callLater(function() { root.reloading = false })
|
||||
}
|
||||
|
||||
// A player on its way out keeps its source: pushing an empty one starts a
|
||||
// load of nothing that its destructor then cancels, which FFmpeg logs.
|
||||
Binding {
|
||||
target: videoLoader.item
|
||||
property: "mediaSource"
|
||||
value: root.videoUrl
|
||||
when: videoLoader.item !== null && Util.isVideoPath(root.path)
|
||||
restoreMode: Binding.RestoreNone
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: videoLoader.item
|
||||
property: "playbackEnabled"
|
||||
value: root.playbackEnabled
|
||||
when: videoLoader.item !== null
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: videoLoader.item
|
||||
property: "audioEnabled"
|
||||
value: root.audioEnabled
|
||||
when: videoLoader.item !== null
|
||||
}
|
||||
|
||||
Component {
|
||||
id: imageComponent
|
||||
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import QtQuick
|
||||
import QtMultimedia
|
||||
|
||||
// Deliberately a bare MediaPlayer and VideoOutput rather than the Video
|
||||
// convenience type: Video always builds an AudioOutput, and a muted sink still
|
||||
// decodes the audio stream and opens an audio client on every output.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property url mediaSource: ""
|
||||
property bool playbackEnabled: true
|
||||
property bool audioEnabled: false
|
||||
property int mediaGeneration: 0
|
||||
property bool priming: false
|
||||
property int primingGeneration: -1
|
||||
property bool frameReceived: false
|
||||
readonly property bool ready: player.hasVideo
|
||||
|
||||
onMediaSourceChanged: {
|
||||
mediaGeneration += 1
|
||||
priming = false
|
||||
primingGeneration = -1
|
||||
frameReceived = false
|
||||
primePauseTimer.stop()
|
||||
framePauseTimer.stop()
|
||||
output.clearOutput()
|
||||
}
|
||||
|
||||
onPlaybackEnabledChanged: {
|
||||
priming = false
|
||||
frameReceived = false
|
||||
primePauseTimer.stop()
|
||||
framePauseTimer.stop()
|
||||
if (playbackEnabled) player.play()
|
||||
else player.pause()
|
||||
}
|
||||
|
||||
function pauseAfterPrimedFrame() {
|
||||
if (!priming
|
||||
|| root.playbackEnabled
|
||||
|| primingGeneration !== root.mediaGeneration) return
|
||||
|
||||
priming = false
|
||||
primePauseTimer.stop()
|
||||
framePauseTimer.stop()
|
||||
player.pause()
|
||||
}
|
||||
|
||||
// A paused MediaPlayer can load a source without presenting its first frame.
|
||||
// Prime it until VideoOutput receives a frame, with a timeout so a stalled
|
||||
// decoder cannot keep running indefinitely on battery.
|
||||
Timer {
|
||||
id: primePauseTimer
|
||||
interval: 1000
|
||||
repeat: false
|
||||
|
||||
onTriggered: root.pauseAfterPrimedFrame()
|
||||
}
|
||||
|
||||
// Receiving a frame means the decoder has produced it, but the scene graph
|
||||
// may not have committed it yet. Give VideoOutput a render cycle before
|
||||
// pausing so the first frame is not lost on a source switch.
|
||||
Timer {
|
||||
id: framePauseTimer
|
||||
interval: 50
|
||||
repeat: false
|
||||
|
||||
onTriggered: root.pauseAfterPrimedFrame()
|
||||
}
|
||||
|
||||
VideoOutput {
|
||||
id: output
|
||||
anchors.fill: parent
|
||||
fillMode: VideoOutput.PreserveAspectCrop
|
||||
}
|
||||
|
||||
// Sound is opted into per output: with a player per monitor, every output
|
||||
// playing the track would layer copies of it. The sink is only built once
|
||||
// the media reports a sound track, so a silent file never opens an audio
|
||||
// client or its threads. Priming a paused player must not be heard.
|
||||
Loader {
|
||||
id: audioLoader
|
||||
active: root.audioEnabled && player.hasAudio
|
||||
sourceComponent: AudioOutput {
|
||||
muted: root.priming || !root.playbackEnabled
|
||||
}
|
||||
}
|
||||
|
||||
MediaPlayer {
|
||||
id: player
|
||||
source: root.mediaSource
|
||||
videoOutput: output
|
||||
audioOutput: audioLoader.item
|
||||
loops: MediaPlayer.Infinite
|
||||
autoPlay: root.playbackEnabled
|
||||
onMediaStatusChanged: {
|
||||
if (mediaStatus !== MediaPlayer.LoadedMedia) return
|
||||
|
||||
if (!root.playbackEnabled) {
|
||||
root.priming = true
|
||||
root.primingGeneration = root.mediaGeneration
|
||||
root.frameReceived = false
|
||||
primePauseTimer.restart()
|
||||
}
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: output.videoSink
|
||||
function onVideoFrameChanged() {
|
||||
if (player.mediaStatus !== MediaPlayer.BufferedMedia) return
|
||||
if (!root.priming
|
||||
|| root.playbackEnabled
|
||||
|| root.primingGeneration !== root.mediaGeneration
|
||||
|| root.frameReceived) return
|
||||
|
||||
root.frameReceived = true
|
||||
framePauseTimer.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ BarIndicator 1.0 BarIndicator.qml
|
||||
BarIconButton 1.0 BarIconButton.qml
|
||||
BarWidget 1.0 BarWidget.qml
|
||||
BackgroundMedia 1.0 BackgroundMedia.qml
|
||||
BackgroundVideo 1.0 BackgroundVideo.qml
|
||||
BorderOverlay 1.0 BorderOverlay.qml
|
||||
BorderSurface 1.0 BorderSurface.qml
|
||||
Button 1.0 Button.qml
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
@@ -17,7 +16,6 @@ Item {
|
||||
|
||||
property string currentBackground: ""
|
||||
property string displayedBackground: ""
|
||||
property int displayedReloads: 0
|
||||
property string incomingBackground: ""
|
||||
property string oldBackground: ""
|
||||
property bool finishingTransition: false
|
||||
@@ -28,23 +26,6 @@ Item {
|
||||
property string pendingShellRaw: ""
|
||||
property real revealProgress: 1
|
||||
|
||||
// Injected by the first-party service loader; used to reach the lock and idle
|
||||
// services so playback can stop whenever nothing can see the wallpaper.
|
||||
property var shell: null
|
||||
|
||||
// Stop a video wallpaper's decoding whenever it is covered. Qt's FFmpeg
|
||||
// engine drives its own clock, so an unseen player keeps decoding until it
|
||||
// is told not to — a locked laptop would otherwise decode until it died.
|
||||
readonly property var lockService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.lock") : null
|
||||
readonly property var idleService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.idle") : null
|
||||
readonly property var batteryService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.battery") : null
|
||||
readonly property bool lockActive: lockService ? lockService.locked : false
|
||||
readonly property bool screensaverActive: idleService ? idleService.screensaverWindowCount > 0 : false
|
||||
readonly property bool powerSaverActive: batteryService ? batteryService.powerSaverOnBattery : false
|
||||
// A lock or a screensaver covers every output, so it is decided once here.
|
||||
// Fullscreen is decided per output below, because it only covers its own.
|
||||
readonly property bool sessionObscured: lockActive || screensaverActive
|
||||
|
||||
function isVideo(path) {
|
||||
return Util.isVideoPath(path)
|
||||
}
|
||||
@@ -78,9 +59,6 @@ Item {
|
||||
if (instant || !displayedBackground || isVideo(path) || isVideo(displayedBackground)) {
|
||||
oldBackground = ""
|
||||
incomingBackground = ""
|
||||
// A theme switch can replace the file behind an unchanged path, which
|
||||
// an unchanged property would never pick up.
|
||||
if (displayedBackground === finalPath) displayedReloads += 1
|
||||
displayedBackground = finalPath
|
||||
revealProgress = 1
|
||||
return
|
||||
@@ -225,23 +203,9 @@ Item {
|
||||
// Keep render updates enabled. The background layer has been observed to
|
||||
// lose its committed buffer while parked with updatesEnabled=false,
|
||||
// leaving a black desktop until omarchy-shell is restarted. A still
|
||||
// wallpaper costs nothing to keep enabled, and a video one is throttled
|
||||
// by pausing playback rather than by parking the layer.
|
||||
// wallpaper costs nothing to keep enabled. OWE manages video layers.
|
||||
updatesEnabled: true
|
||||
|
||||
// Pausing every wallpaper for one fullscreen window would freeze the one
|
||||
// still on show next to it, which costs a viewer more than it saves. The
|
||||
// workspace on show here knows whether a fullscreen window covers it,
|
||||
// wherever focus happens to be.
|
||||
readonly property var hyprlandMonitor: Hyprland.monitorFor(modelData)
|
||||
readonly property var visibleWorkspace: hyprlandMonitor ? hyprlandMonitor.activeWorkspace : null
|
||||
readonly property bool fullscreenHere: visibleWorkspace ? visibleWorkspace.hasFullscreen : false
|
||||
|
||||
// A sound track plays from one output only, or every monitor would
|
||||
// layer its own copy of it.
|
||||
readonly property bool firstScreen: Quickshell.screens.length > 0
|
||||
&& String(Quickshell.screens[0].name || "") === String(modelData.name || "")
|
||||
|
||||
property bool maskReady: false
|
||||
|
||||
function maybeStartReveal() {
|
||||
@@ -259,13 +223,12 @@ Item {
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// OWE owns video backgrounds. This layer draws stills, and stays empty
|
||||
// behind a video so OWE's own layer shows through.
|
||||
BackgroundMedia {
|
||||
id: base
|
||||
anchors.fill: parent
|
||||
path: root.displayedBackground
|
||||
reloads: root.displayedReloads
|
||||
playbackEnabled: !root.sessionObscured && !root.powerSaverActive && !panel.fullscreenHere
|
||||
audioEnabled: panel.firstScreen
|
||||
onReadyChanged: {
|
||||
if (ready && root.finishingTransition) {
|
||||
root.incomingBackground = ""
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import QtQuick
|
||||
import Owe.LockFeed
|
||||
|
||||
// Loaded through a Loader from LockView, so a system without the module shows
|
||||
// no lock video instead of losing the whole lock screen.
|
||||
LockFeed {
|
||||
}
|
||||
@@ -7,6 +7,7 @@ Item {
|
||||
id: root
|
||||
|
||||
property string backgroundPath: ""
|
||||
property string videoPosterPath: ""
|
||||
property int backgroundVersion: 0
|
||||
property bool fingerprintConfigured: false
|
||||
property bool authenticatingPassword: false
|
||||
@@ -43,6 +44,9 @@ Item {
|
||||
? Border.surfaceSpec("lock", "border-error", Color.lock.borderError, root.outlineThickness, "border-alpha")
|
||||
: Border.surfaceSpec("lock", "border-active", Color.lock.borderActive, root.outlineThickness, "border-alpha")
|
||||
|
||||
readonly property bool video: Util.isVideoPath(root.backgroundPath)
|
||||
readonly property bool feedActive: root.video && root.loadBackground && !root.displaysBlank && !root.powerSaverActive
|
||||
|
||||
signal submitPassword(string password)
|
||||
signal passwordTextEdited(string password)
|
||||
signal clearFailureRequested()
|
||||
@@ -88,16 +92,15 @@ Item {
|
||||
|
||||
BackgroundMedia {
|
||||
id: wallpaper
|
||||
objectName: "lockWallpaper"
|
||||
anchors.fill: parent
|
||||
path: root.loadBackground ? root.backgroundPath : ""
|
||||
path: root.loadBackground ? (root.video ? root.videoPosterPath : root.backgroundPath) : ""
|
||||
version: root.backgroundVersion
|
||||
playbackEnabled: root.loadBackground && !root.displaysBlank && !root.powerSaverActive
|
||||
}
|
||||
|
||||
MultiEffect {
|
||||
anchors.fill: wallpaper
|
||||
source: wallpaper.video ? null : wallpaper
|
||||
visible: !wallpaper.video
|
||||
source: wallpaper
|
||||
autoPaddingEnabled: false
|
||||
blurEnabled: root.loadBackground && wallpaper.ready
|
||||
blur: 1.0
|
||||
@@ -106,11 +109,22 @@ Item {
|
||||
contrast: -0.08
|
||||
}
|
||||
|
||||
// Qt's video output cannot be sampled by MultiEffect on every renderer.
|
||||
// The cached poster stays behind the feed when policy pauses playback,
|
||||
// the module is unavailable, or a new connection has not received a frame.
|
||||
Loader {
|
||||
id: feedLoader
|
||||
objectName: "lockFeedLoader"
|
||||
anchors.fill: parent
|
||||
active: root.feedActive
|
||||
source: "LockFeedSurface.qml"
|
||||
visible: status === Loader.Ready
|
||||
}
|
||||
|
||||
// The feed item cannot be sampled by MultiEffect on every renderer.
|
||||
// Keep video wallpapers visible and darken them slightly for legibility.
|
||||
Rectangle {
|
||||
anchors.fill: wallpaper
|
||||
visible: wallpaper.video
|
||||
anchors.fill: feedLoader
|
||||
visible: root.video
|
||||
color: "#22000000"
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ Item {
|
||||
property string failureMessage: ""
|
||||
property int failedAttempts: 0
|
||||
property string backgroundPath: ""
|
||||
property string videoPosterPath: ""
|
||||
property int backgroundVersion: 0
|
||||
property string lastEvent: "init"
|
||||
property string lastEventAt: ""
|
||||
@@ -113,6 +114,16 @@ Item {
|
||||
if (!readlinkProc.running) readlinkProc.running = true
|
||||
}
|
||||
|
||||
function refreshPoster() {
|
||||
if (!root.videoBackground) {
|
||||
root.videoPosterPath = ""
|
||||
return
|
||||
}
|
||||
if (posterProc.running) return
|
||||
posterProc.sourcePath = root.backgroundPath
|
||||
posterProc.running = true
|
||||
}
|
||||
|
||||
function refreshFingerprintStatus() {
|
||||
if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true
|
||||
}
|
||||
@@ -307,6 +318,7 @@ Item {
|
||||
id: lockView
|
||||
anchors.fill: parent
|
||||
backgroundPath: root.backgroundPath
|
||||
videoPosterPath: root.videoPosterPath
|
||||
backgroundVersion: root.backgroundVersion
|
||||
fingerprintConfigured: root.fingerprintConfigured
|
||||
authenticatingPassword: root.authenticatingPassword
|
||||
@@ -339,6 +351,7 @@ Item {
|
||||
LockView {
|
||||
anchors.fill: parent
|
||||
backgroundPath: root.backgroundPath
|
||||
videoPosterPath: root.videoPosterPath
|
||||
backgroundVersion: root.backgroundVersion
|
||||
fingerprintConfigured: root.fingerprintConfigured
|
||||
authenticatingPassword: false
|
||||
@@ -409,9 +422,25 @@ Item {
|
||||
onStreamFinished: {
|
||||
var next = String(text || "").trim()
|
||||
if (next !== root.backgroundPath) {
|
||||
root.videoPosterPath = ""
|
||||
root.backgroundPath = next
|
||||
root.backgroundVersion += 1
|
||||
}
|
||||
root.refreshPoster()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: posterProc
|
||||
property string sourcePath: ""
|
||||
command: ["bash", Quickshell.env("OMARCHY_PATH") + "/shell/plugins/lock/poster.sh", sourcePath]
|
||||
stdout: StdioCollector { id: posterOutput; waitForEnd: true }
|
||||
onExited: function(exitCode) {
|
||||
if (sourcePath !== root.backgroundPath) {
|
||||
root.refreshPoster()
|
||||
} else {
|
||||
root.videoPosterPath = exitCode === 0 ? String(posterOutput.text || "").trim() : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
# One cached frame shared by all lock outputs, including when OWE is paused.
|
||||
set -euo pipefail
|
||||
|
||||
source_path=$1
|
||||
cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/lock-poster"
|
||||
signature=$(stat -Lc '%s:%y:%z' "$source_path")
|
||||
key=$(printf '%s\n%s' "$source_path" "$signature" | sha256sum | cut -d ' ' -f 1)
|
||||
poster="$cache_dir/poster-$key.jpg"
|
||||
mkdir -p "$cache_dir"
|
||||
exec {lock_fd}>"$cache_dir/.lock"
|
||||
flock -w 10 "$lock_fd"
|
||||
|
||||
if [[ ! -s $poster ]]; then
|
||||
temporary=$(mktemp "$cache_dir/.poster-XXXXXX.jpg")
|
||||
trap 'rm -f "$temporary"' EXIT
|
||||
timeout -k 1 5 ffmpegthumbnailer -i "$source_path" -o "$temporary" -s 1920 -q 8 {lock_fd}>&-
|
||||
[[ -s $temporary ]]
|
||||
mv -f "$temporary" "$poster"
|
||||
find "$cache_dir" -maxdepth 1 -type f -name 'poster-*.jpg' ! -name "poster-$key.jpg" -delete
|
||||
fi
|
||||
|
||||
printf '%s\n' "$poster"
|
||||
Reference in new issue
Block a user