diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 0ec925d0..c292828f 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -107,12 +107,10 @@ python-poetry-core ttfx qemu-user-static-binfmt qrencode -# TODO: add owe here once omarchy-pkgs#519 lands. The shell keeps stills and -# the lock screen, and OWE owns desktop video backgrounds. Version 0.1.1 or -# newer plays the video's audio. +# OWE owns desktop video backgrounds and feeds the lock screen its frames. +owe +owe-lockfeed qt6-imageformats -qt6-multimedia -qt6-multimedia-ffmpeg quickshell ripgrep ruby diff --git a/shell/Ui/BackgroundMedia.qml b/shell/Ui/BackgroundMedia.qml index fa6b3345..aabde8d1 100644 --- a/shell/Ui/BackgroundMedia.qml +++ b/shell/Ui/BackgroundMedia.qml @@ -1,31 +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 - // The desktop turns this off because OWE owns video backgrounds. The lock - // screen leaves it on and keeps its own playback. - property bool videoEnabled: true - // 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) && videoEnabled ? 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 @@ -34,38 +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 && root.videoEnabled - 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 - } - Component { id: imageComponent diff --git a/shell/Ui/BackgroundVideo.qml b/shell/Ui/BackgroundVideo.qml deleted file mode 100644 index 0d0d34e1..00000000 --- a/shell/Ui/BackgroundVideo.qml +++ /dev/null @@ -1,111 +0,0 @@ -import QtQuick -import QtMultimedia - -// The lock screen is the only user. Playback is always silent, so this is 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. -Item { - id: root - - property url mediaSource: "" - property bool playbackEnabled: true - 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 - } - - // No audio output is built, so the lock never decodes an audio stream or - // opens an audio client. - MediaPlayer { - id: player - source: root.mediaSource - videoOutput: output - 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() - } - } -} diff --git a/shell/Ui/qmldir b/shell/Ui/qmldir index 092caf6c..464e25b7 100644 --- a/shell/Ui/qmldir +++ b/shell/Ui/qmldir @@ -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 diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index c51181ed..8c113e4b 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -16,7 +16,6 @@ Item { property string currentBackground: "" property string displayedBackground: "" - property int displayedReloads: 0 property string incomingBackground: "" property string oldBackground: "" property bool finishingTransition: false @@ -60,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 @@ -187,7 +183,6 @@ Item { } Component.onCompleted: { - oweStatusProc.running = true refreshBackground() } @@ -237,8 +232,6 @@ Item { id: base anchors.fill: parent path: root.displayedBackground - reloads: root.displayedReloads - videoEnabled: false onReadyChanged: { if (ready && root.finishingTransition) { root.incomingBackground = "" diff --git a/shell/plugins/lock/LockFeedSurface.qml b/shell/plugins/lock/LockFeedSurface.qml new file mode 100644 index 00000000..38bd4612 --- /dev/null +++ b/shell/plugins/lock/LockFeedSurface.qml @@ -0,0 +1,11 @@ +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 { + id: root + + property bool feedEnabled: true + active: root.feedEnabled +} diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index e7a430ac..9366b289 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -43,6 +43,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() @@ -89,15 +92,33 @@ Item { BackgroundMedia { id: wallpaper anchors.fill: parent - path: root.loadBackground ? root.backgroundPath : "" + path: root.video ? "" : root.backgroundPath version: root.backgroundVersion - playbackEnabled: root.loadBackground && !root.displaysBlank && !root.powerSaverActive + visible: !root.video + } + + // OWE decodes the video once and feeds these frames to every lock surface. + // The feed pauses when this output blanks or on battery power saver. + Loader { + id: feedLoader + anchors.fill: parent + active: root.video + source: "LockFeedSurface.qml" + visible: status === Loader.Ready + + Binding { + target: feedLoader.item + property: "feedEnabled" + value: root.feedActive + when: feedLoader.item !== null + restoreMode: Binding.RestoreNone + } } MultiEffect { anchors.fill: wallpaper - source: wallpaper.video ? null : wallpaper - visible: !wallpaper.video + source: root.video ? null : wallpaper + visible: !root.video autoPaddingEnabled: false blurEnabled: root.loadBackground && wallpaper.ready blur: 1.0 @@ -106,11 +127,11 @@ Item { contrast: -0.08 } - // Qt's video output cannot be sampled by MultiEffect on every renderer. + // 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" } diff --git a/test/shell.d/video-background-test.sh b/test/shell.d/video-background-test.sh index 12653721..31ac754d 100755 --- a/test/shell.d/video-background-test.sh +++ b/test/shell.d/video-background-test.sh @@ -9,15 +9,13 @@ const fs = require('fs') const utilQml = fs.readFileSync(path.join(root, 'shell/Commons/Util.qml'), 'utf8') const mediaQml = fs.readFileSync(path.join(root, 'shell/Ui/BackgroundMedia.qml'), 'utf8') -const videoQml = fs.readFileSync(path.join(root, 'shell/Ui/BackgroundVideo.qml'), 'utf8') const backgroundQml = fs.readFileSync(path.join(root, 'shell/plugins/background/Background.qml'), 'utf8') const lockQml = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockView.qml'), 'utf8') +const lockFeedQml = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockFeedSurface.qml'), 'utf8') const themeSwitcher = fs.readFileSync(path.join(root, 'bin/omarchy-theme-switcher'), 'utf8') const quattroUpgrade = fs.readFileSync(path.join(root, 'bin/omarchy-upgrade-to-quattro'), 'utf8') -const multimediaMigration = fs.readFileSync(path.join(root, 'migrations/1786609204.sh'), 'utf8') const barTextColor = fs.readFileSync(path.join(root, 'bin/omarchy-bar-text-color'), 'utf8') const menuImages = fs.readFileSync(path.join(root, 'bin/omarchy-menu-images'), 'utf8') -const lockView = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockView.qml'), 'utf8') const lockService = fs.readFileSync(path.join(root, 'shell/plugins/lock/Service.qml'), 'utf8') const batteryService = fs.readFileSync(path.join(root, 'shell/plugins/services/battery/Service.qml'), 'utf8') const themeSet = fs.readFileSync(path.join(root, 'bin/omarchy-theme-set'), 'utf8') @@ -29,50 +27,45 @@ assert( 'shared media helper identifies video paths without truncating valid local names' ) assert( - videoQml.includes('loops: MediaPlayer.Infinite') && - videoQml.includes('autoPlay: root.playbackEnabled') && - videoQml.includes('fillMode: VideoOutput.PreserveAspectCrop') && - /imageUrl: path && !Util\.isVideoPath\(path\) \? Util\.fileUrl\(path\) \+ \(version \? "\?v=" \+ version : ""\) : ""/.test(mediaQml) && - /videoUrl: path && Util\.isVideoPath\(path\) && videoEnabled \? Util\.fileUrl\(path\) : ""/.test(mediaQml), - 'background media plays aspect-cropped videos on a loop, and hands each loader only its own kind of file' + !fs.existsSync(path.join(root, 'shell/Ui/BackgroundVideo.qml')), + 'the shell keeps no video player of its own' ) assert( - videoQml.includes('MediaPlayer.LoadedMedia') && - videoQml.includes('primePauseTimer') && - videoQml.includes('mediaGeneration') && - videoQml.includes('videoSink') && - videoQml.includes('onVideoFrameChanged') && - videoQml.includes('MediaPlayer.BufferedMedia') && - videoQml.includes('mediaStatus !== MediaPlayer.BufferedMedia') && - videoQml.includes('interval: 1000') && - videoQml.includes('interval: 50') && - videoQml.includes('frameReceived') && - videoQml.includes('output.clearOutput()') && - !videoQml.includes('KeepLastFrame') && - /onPlaybackEnabledChanged:[\s\S]*?if \(playbackEnabled\) player\.play\(\)[\s\S]*?else player\.pause\(\)/.test(videoQml) && - videoQml.includes('primingGeneration') && - videoQml.includes('player.play()') && - videoQml.includes('player.pause()'), - 'paused video sources are primed to display their first frame' + /imageUrl: path && !video \? Util\.fileUrl\(path\) \+ \(version \? "\?v=" \+ version : ""\) : ""/.test(mediaQml) && + /active: root\.path !== "" && !root\.video/.test(mediaQml) && + !mediaQml.includes('videoUrl') && + !mediaQml.includes('videoLoader') && + !mediaQml.includes('videoEnabled'), + 'background media is image-only: it loads stills and leaves videos to OWE' ) assert( !/^\s*import QtMultimedia/m.test(mediaQml) && - mediaQml.includes('source: "BackgroundVideo.qml"'), - 'the still-image path never imports QtMultimedia, so image-only sessions do not map it' + !/^\s*import QtMultimedia/m.test(backgroundQml) && + !/^\s*import QtMultimedia/m.test(lockQml) && + !backgroundQml.includes('MediaPlayer') && + !lockQml.includes('MediaPlayer'), + 'no shell surface builds a Qt Multimedia pipeline' ) assert( - !/^\s*Video\s*\{/m.test(videoQml) && - !videoQml.includes('AudioOutput {') && - !videoQml.includes('audioOutput:') && - !mediaQml.includes('audioEnabled') && - !backgroundQml.includes('audioEnabled') && - !lockQml.includes('audioEnabled'), - 'the shell builds no audio output: OWE plays the desktop video audio and the lock stays silent' + /^import Owe\.LockFeed$/m.test(lockFeedQml) && + lockFeedQml.includes('LockFeed {') && + lockFeedQml.includes('active: root.feedEnabled') && + lockQml.includes('source: "LockFeedSurface.qml"') && + lockQml.includes('active: root.video') && + /feedActive: root\.video && root\.loadBackground && !root\.displaysBlank && !root\.powerSaverActive/.test(lockQml) && + /property: "feedEnabled"[\s\S]*?value: root\.feedActive/.test(lockQml), + 'the lock screen shows video through the OWE lock feed, loaded so a missing module costs only the video' ) assert( - /property: "mediaSource"[\s\S]*?when: videoLoader\.item !== null && Util\.isVideoPath\(root\.path\)\s*\n\s*restoreMode: Binding\.RestoreNone/.test(mediaQml) && - !videoQml.includes('Component.onDestruction'), - 'a player on its way out keeps its source, so nothing is left loading for its destructor to cancel' + !/^import Owe\./m.test(lockQml), + 'the lock view itself carries no foreign import, so the lock still loads without the feed module' +) +assert( + lockQml.includes('path: root.video ? "" : root.backgroundPath') && + lockQml.includes('visible: !root.video') && + lockQml.includes('visible: root.video') && + !lockQml.includes('wallpaper.video'), + 'the lock screen keeps its image effect for stills and shows the feed for videos' ) assert( !mediaQml.includes('mipmap'), @@ -82,13 +75,12 @@ assert( /instant \|\| !displayedBackground \|\| isVideo\(path\) \|\| isVideo\(displayedBackground\)[\s\S]*displayedBackground = finalPath/.test(backgroundQml), 'video switches bypass the image-only reveal stack and use the durable background path' ) -assert(backgroundQml.includes('BackgroundMedia {') && lockQml.includes('BackgroundMedia {'), 'desktop and lock screen share video-capable media rendering') +assert(backgroundQml.includes('BackgroundMedia {') && lockQml.includes('BackgroundMedia {'), 'desktop and lock screen share still rendering') assert( - backgroundQml.includes('videoEnabled: false') && - /property bool videoEnabled: true/.test(mediaQml) && - /active: root\.path !== "" && root\.video && !root\.reloading && root\.videoEnabled/.test(mediaQml) && - !lockQml.includes('videoEnabled'), - 'the desktop hands video backgrounds to OWE, and the lock keeps its own playback' + !backgroundQml.includes('videoEnabled') && + !backgroundQml.includes('displayedReloads') && + !mediaQml.includes('displayedReloads'), + 'the desktop hands video backgrounds to OWE with no player switch to carry' ) assert( !backgroundQml.includes('playbackEnabled') && @@ -99,19 +91,6 @@ assert( !backgroundQml.includes('omarchy.battery'), 'the desktop no longer carries the shell video pause policy' ) -assert( - lockQml.includes('source: wallpaper.video ? null : wallpaper') && - lockQml.includes('visible: !wallpaper.video') && - lockQml.includes('visible: wallpaper.video'), - 'lock screen bypasses its image effect for video output' -) -assert( - /if \(displayedBackground === finalPath\) displayedReloads \+= 1/.test(backgroundQml) && - backgroundQml.includes('reloads: root.displayedReloads') && - /active: root\.path !== "" && root\.video && !root\.reloading/.test(mediaQml) && - /onReloadsChanged: \{[\s\S]*?reloading = true/.test(mediaQml), - 'a theme switch that keeps the video path still reopens the replaced file' -) assert( barTextColor.includes('magick "$background_path[0]"'), 'bar colour sampling reads one frame instead of decoding a whole video' @@ -161,13 +140,12 @@ assert( 'a locked video wallpaper follows what each panel actually did, not only what the lock asked for' ) assert( - lockView.includes('playbackEnabled: root.loadBackground && !root.displaysBlank') && - lockView.includes('&& !root.powerSaverActive') && + /feedActive: root\.video && root\.loadBackground && !root\.displaysBlank && !root\.powerSaverActive/.test(lockQml) && /displaysBlank: root\.screenBlank\(/.test(lockService) && /powerSaverActive: root\.powerSaverActive/.test(lockService) && /function runBlank\(\) \{\s*\n\s*root\.displaysBlank = true/.test(lockService) && /function runWake\(\) \{\s*\n\s*root\.displaysBlank = false/.test(lockService), - 'the lock screen stops playback once displays go dark or power-saver is active' + 'the lock feed stops once displays go dark or power-saver is active' ) assert( batteryService.includes('property string activePowerProfile') && @@ -184,10 +162,6 @@ assert( 'theme switcher previews video-only themes, named preview files included' ) assert(quattroUpgrade.includes("-iname '*.mp4'"), 'Quattro upgrade can seed a video-only theme background') -assert( - multimediaMigration.includes('omarchy-pkg-add qt6-multimedia qt6-multimedia-ffmpeg'), - 'existing Quattro installations receive video playback dependencies' -) JS test_tmp=$(mktemp -d) @@ -301,8 +275,11 @@ timeout_rows=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$timeout_cache" \ timeout_marker=$(find "$timeout_cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.failed' -print -quit) [[ -z $timeout_marker ]] || fail "a timed out video is left to retry rather than remembered as failed" -grep -qx 'qt6-multimedia' "$ROOT/install/omarchy-base.packages" || fail "Qt Multimedia runtime is a base package" -grep -qx 'qt6-multimedia-ffmpeg' "$ROOT/install/omarchy-base.packages" || fail "Qt Multimedia FFmpeg backend is a base package" +grep -qx 'owe' "$ROOT/install/omarchy-base.packages" || fail "OWE is a base package" +grep -qx 'owe-lockfeed' "$ROOT/install/omarchy-base.packages" || fail "the OWE lock feed module is a base package" +if grep -qx 'qt6-multimedia' "$ROOT/install/omarchy-base.packages"; then + fail "Qt Multimedia is no longer needed by the shell" +fi pass "menu image generator creates thumbnails consumed by the picker" pass "direct picker generates and reuses still thumbnails" @@ -310,7 +287,7 @@ pass "direct picker omits videos whose thumbnails cannot be generated" pass "a rejected video is remembered so it costs nothing on the next open" pass "a timed out video is left to retry" pass "a locked video wallpaper follows the panels' real DPMS state" -pass "Qt Multimedia playback dependencies are declared" +pass "OWE and its lock feed module are declared" source <(awk ' /^(is_video_path|snapshot_background_path|background_transition_uses_snapshots|choose_theme_background|choose_staged_theme_background|set_theme_background)\(\) \{/ { copying=1 }