Files
omarchy/shell/services/BackgroundIntro.qml
T

331 lines
11 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Io
import qs.Commons
import qs.Commons as Commons
// Keep startup and theme covers alive across background service handoffs.
Item {
id: root
property var host: null
property bool cover: true
readonly property bool sessionConsumed: String(sessionMarker.text() || "").trim() === (Quickshell.env("OMARCHY_SESSION_ID") || Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE"))
property bool startupSettled: sessionConsumed
property bool startupPending: !sessionConsumed
property bool startupMediaReady: false
property real startupOpacity: startupPending ? 1 : 0
readonly property bool desktopReady: !host || (!host.pluginReloading && (!host.pluginRegistry || !host.pluginRegistry.scanning) && (!("bar" in host) || !!host.bar))
property bool checked: false
property string themeToken: ""
property string transitionToken: ""
readonly property string startupBackgroundPath: Quickshell.env("OMARCHY_STARTUP_BACKGROUND")
property string themeBackground: sessionConsumed && !Util.isVideoPath(startupBackgroundPath) ? startupBackgroundPath : ""
property var themeNativeSize: null
property string themeColors: ""
property string themeShell: ""
property real themeOpacity: 1
readonly property var backgroundService: host && host.services ? host.firstPartyServiceFor("omarchy.background") : null
readonly property bool backgroundActive: !!backgroundService && !backgroundService.suspended
readonly property bool backgroundReady: backgroundActive && backgroundService.ready !== false
function restoreStartupCursor() {
Quickshell.execDetached(["hyprctl", "eval", "if omarchy_startup_cursor_restore then omarchy_startup_cursor_restore() end"])
}
function finishStartup() {
if (!cover || !startupSettled) return
var registry = host ? host.pluginRegistry : null
var backgroundId = registry ? registry.resolveEnabledId("omarchy.background") : ""
var disabled = registry && registry.installedPlugins[backgroundId] && !registry.isEnabled(backgroundId)
if (!backgroundReady && !disabled) return
cover = false
if (!themeToken) themeBackground = ""
startupMediaReady = true
revealStartup()
}
function revealStartup() {
if (!startupPending || startupFade.running || !startupMediaReady || !desktopReady) return
if (!startupCovers.instances.length) return
for (var panel of startupCovers.instances)
if (panel.readyFrames < 2) return
startupFade.start()
}
onDesktopReadyChanged: revealStartup()
onBackgroundReadyChanged: finishStartup()
Connections {
target: root.host && root.host.pluginRegistry ? root.host.pluginRegistry : null
function onPluginsChanged() { root.finishStartup() }
}
function prepareTheme(fromPath, token, colors, shell) {
framePoll.stop()
themeFade.stop()
themeToken = token
transitionToken = token
if (themeBackground !== fromPath) {
themeNativeSize = backgroundService && backgroundService.nativeSizes ? backgroundService.nativeSizes[backgroundService.displayedBackground] : null
}
themeBackground = fromPath
themeColors = colors
themeShell = shell
themeOpacity = 1
themeFallback.restart()
}
function revealTheme() {
if (!themeToken && !cover) return
framePoll.stop()
themeFallback.stop()
if (themeToken) {
Commons.Color.loadColors(Util.decodeBase64(themeColors))
Commons.Color.loadShell(Util.decodeBase64(themeShell))
Style.scheduleRefresh()
}
cover = false
themeToken = ""
themeColors = ""
themeShell = ""
if (startupPending) {
// The login reveal starts on moving video, without showing its final still.
themeBackground = ""
startupMediaReady = true
revealStartup()
} else {
themeFade.restart()
}
}
function finishTheme(token) {
if (token === themeToken) revealTheme()
}
function themeStatus(token) {
return token === transitionToken && (themeToken || themeFade.running || startupFade.running) ? "pending" : "ready"
}
function themeCoverStatus(token) {
if (token !== themeToken) return "superseded"
if (!themeBackground) return "ready"
for (var panel of covers.instances) {
if (panel.coverFailed) return "error"
if (!panel.coverReady) return "loading"
}
return covers.instances.length > 0 ? "ready" : "loading"
}
function cancelTheme() {
framePoll.stop()
themeFallback.stop()
themeFade.stop()
themeToken = ""
transitionToken = ""
themeBackground = ""
themeColors = ""
themeShell = ""
}
NumberAnimation {
id: startupFade
target: root
property: "startupOpacity"
to: 0
duration: Style.duration(420)
easing.type: Easing.OutCubic
onStarted: root.restoreStartupCursor()
onFinished: root.startupPending = false
}
// A missing wallpaper or failed plugin must not leave applications hidden.
Timer {
interval: 10000
running: root.startupPending
onTriggered: {
root.cover = false
if (!root.themeToken) root.themeBackground = ""
root.startupMediaReady = true
startupFade.start()
}
}
NumberAnimation {
id: themeFade
target: root
property: "themeOpacity"
to: 0
duration: Style.duration(420)
easing.type: Easing.OutCubic
onFinished: root.themeBackground = ""
}
Timer {
id: themeFallback
interval: 10000
onTriggered: root.revealTheme()
}
// A renderer left over from another intro can first fade from its still.
// Keep that preparation hidden until the actual video is fully revealed.
Timer {
id: framePoll
interval: 16
repeat: true
onTriggered: if (!frameStatus.running) frameStatus.running = true
}
Process {
id: frameStatus
command: ["owe", "render-status"]
property string token: ""
onStarted: token = root.themeToken
stdout: StdioCollector { id: frameStatusOut }
onExited: function(exitCode) {
if (exitCode !== 0 || token !== root.themeToken || (!token && !root.cover)) return
try {
var status = JSON.parse(frameStatusOut.text)
if (status.kind === "video" && status.ready && !status.has_transition && status.time_pos > 0)
root.revealTheme()
} catch (e) {}
}
}
// A shell restart shares this compositor session; a new login does not.
FileView {
id: sessionMarker
path: Quickshell.env("HOME") + "/.local/state/omarchy/background-intro.session-id"
blockLoading: true
watchChanges: false
printErrors: false
}
Process {
id: startupBackground
command: ["readlink", "-f", Quickshell.env("HOME") + "/.local/state/omarchy/current/background"]
stdout: StdioCollector { id: startupBackgroundOut }
onExited: function(exitCode) {
var path = String(startupBackgroundOut.text || "").trim()
if (exitCode === 0 && root.cover && !root.startupPending && !root.themeToken && !Util.isVideoPath(path))
root.themeBackground = path
}
}
Component.onCompleted: {
checked = true
if (sessionConsumed) restoreStartupCursor()
if (!startupBackgroundPath && !startupPending) startupBackground.running = true
introProc.running = true
}
Component.onDestruction: if (startupPending) restoreStartupCursor()
onBackgroundActiveChanged: {
if (!backgroundActive && checked) {
if (cover || themeToken) {
framePoll.start()
if (!frameStatus.running) frameStatus.running = true
}
}
}
// Cover the bar as well as the wallpaper on a new login. Shell restarts
// retain the current desktop and never map this startup cover.
Variants {
id: startupCovers
model: Quickshell.screens
PanelWindow {
required property var modelData
property int readyFrames: 0
screen: modelData
visible: root.startupPending
color: "transparent"
mask: Region {}
anchors { top: true; bottom: true; left: true; right: true }
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.namespace: "omarchy-background"
Rectangle {
anchors.fill: parent
color: "black"
opacity: root.startupOpacity
}
FrameAnimation {
running: root.startupPending && root.desktopReady && readyFrames < 2
onTriggered: {
readyFrames += 1
if (readyFrames === 2) root.revealStartup()
}
}
}
}
Variants {
id: covers
model: Quickshell.screens
PanelWindow {
id: panel
required property var modelData
property int coverFrames: 0
readonly property bool coverReady: outgoingFrame.status === Image.Ready && coverFrames >= 2
readonly property bool coverFailed: outgoingFrame.status === Image.Error
screen: modelData
visible: (!root.startupPending && root.cover) || root.themeBackground !== ""
// Keep the window transparent throughout the fade. Paint the startup
// color inside it so changing the window format cannot flash black.
color: "transparent"
mask: Region {}
anchors { top: true; bottom: true; left: true; right: true }
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Bottom
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.namespace: "omarchy-background"
Rectangle {
anchors.fill: parent
visible: root.cover
color: Commons.Color.background
}
Image {
id: outgoingFrame
anchors.fill: parent
source: root.themeBackground ? Util.fileUrl(root.themeBackground) : ""
sourceSize: {
var w = Math.ceil((parent.width || modelData.width) * modelData.devicePixelRatio)
var h = Math.ceil((parent.height || modelData.height) * modelData.devicePixelRatio)
var native = root.themeNativeSize
return native && (native.width < w || native.height < h) ? Qt.size(native.width, native.height) : Qt.size(w, h)
}
fillMode: Image.PreserveAspectCrop
opacity: root.themeOpacity
// Start decoding while the remaining theme configs render.
asynchronous: true
onStatusChanged: panel.coverFrames = 0
}
// Give the ready image a frame to reach the compositor before OWE
// releases the shell's wallpaper underneath this cover.
FrameAnimation {
running: outgoingFrame.status === Image.Ready && panel.coverFrames < 2
onTriggered: panel.coverFrames += 1
}
}
}
Process {
id: introProc
command: ["omarchy-theme-bg-boot-intro"]
onExited: {
root.startupSettled = true
root.finishStartup()
}
}
}