Draw the lock screen video from the OWE lock feed

The shell drops its own player: BackgroundMedia is image-only, and the
lock loads Owe.LockFeedSurface through a Loader, so a system without the
module shows no lock video instead of losing the whole lock screen. The
feed pauses per output when the panel blanks or power saver turns on.

The lock view keeps its still effect path and darkens the feed for
legibility. QtMultimedia and the shell video pause policy are gone, and
the base package list requires owe and owe-lockfeed instead.
This commit is contained in:
Bjarne Oeverli committed 2026-09-18 22:49:47 +02:00
1 parent 2e3142a105
commit eff410f91e
8 files changed
+92 -248

No files matched your search

+6 -50
View File
@@ -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
-111
View File
@@ -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()
}
}
}
-1
View File
@@ -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
-7
View File
@@ -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 = ""
+11
View File
@@ -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
}
+28 -7
View File
@@ -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"
}