Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Assets/Translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1783,6 +1783,8 @@
"settings-clear-cache-label": "Wallpaper cache",
"settings-clear-cache-toast": "Wallpaper cache cleared",
"settings-desc": "Control how wallpapers are managed and displayed.",
"live-paper-enable-description": "Use projectM milkdrop visualizer as desktop and lockscreen background. Disables static wallpaper management.",
"live-paper-enable-label": "Live paper background",
"settings-enable-management-description": "Manage wallpapers with Noctalia. Uncheck if you prefer using another application.",
"settings-enable-management-label": "Enable wallpaper management",
"settings-enable-overview-description": "Applies a blurred and dimmed wallpaper to the overview screen.",
Expand Down
3 changes: 2 additions & 1 deletion Assets/settings-default.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,8 @@
"wallhavenResolutionWidth": "",
"wallhavenResolutionHeight": "",
"sortOrder": "name",
"favorites": []
"favorites": [],
"livePaperEnabled": false
},
"appLauncher": {
"enableClipboardHistory": false,
Expand Down
2 changes: 2 additions & 0 deletions Commons/Settings.qml
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,8 @@ Singleton {
property list<var> favorites: []
// Format: [{ "path": "...", "appearance": "light"|"dark", "colorScheme": "...", "darkMode": bool, "useWallpaperColors": bool, "generationMethod": "...", "paletteColors": [...] }]
// Legacy entries omit "appearance" and use darkMode to infer light vs dark slot.

property bool livePaperEnabled: false
}

// applauncher
Expand Down
2 changes: 1 addition & 1 deletion Modules/Background/Background.qml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Variants {

required property ShellScreen modelData

active: modelData && Settings.data.wallpaper.enabled && (!PowerProfileService.noctaliaPerformanceMode || !Settings.data.noctaliaPerformance.disableWallpaper)
active: modelData && Settings.data.wallpaper.enabled && !Settings.data.wallpaper.livePaperEnabled && (!PowerProfileService.noctaliaPerformanceMode || !Settings.data.noctaliaPerformance.disableWallpaper)

sourceComponent: PanelWindow {
id: root
Expand Down
65 changes: 65 additions & 0 deletions Modules/Background/LivePaper.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Multimedia
import qs.Services.UI

/**
* LivePaper — projectM Milkdrop visualization as desktop background.
*
* One PanelWindow per screen at WlrLayer.Background.
* Preset selection is driven by ProjectMService (shared across all screens
* and the lock screen for coherent visualization).
*/
Variants {
id: livePaperVariants
model: Quickshell.screens

delegate: Loader {
required property ShellScreen modelData

active: modelData && Settings.data.wallpaper.livePaperEnabled

sourceComponent: PanelWindow {
id: win
screen: modelData

WlrLayershell.layer: WlrLayer.Background
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "noctalia-livepaper-" + (screen?.name || "unknown")

anchors {
bottom: true
top: true
right: true
left: true
}

color: "transparent"

ProjectMItem {
id: projM
anchors.fill: parent
running: true
autoPresets: false
darken: 0.6
meshWidth: 24
meshHeight: 18
fps: 30

Component.onCompleted: {
if (ProjectMService.currentPreset !== "")
requestPreset(ProjectMService.currentPreset)
}
}

Connections {
target: ProjectMService
function onCurrentPresetChanged() {
projM.requestPreset(ProjectMService.currentPreset)
}
}
}
}
}
1 change: 1 addition & 0 deletions Modules/LockScreen/LockScreen.qml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Loader {
} else {
LockKeysService.unregisterComponent("lockscreen");
}

}

onNeedsSpectrumChanged: {
Expand Down
35 changes: 32 additions & 3 deletions Modules/LockScreen/LockScreenBackground.qml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Effects
import Quickshell
import qs.Commons
import qs.Multimedia
import qs.Services.Compositor
import qs.Services.Power
import qs.Services.UI
Expand Down Expand Up @@ -103,15 +104,43 @@ Item {
});
}

// Background - solid color or black fallback
// projectM rendered directly via QQuickFramebufferObject — zero IPC, synced to vsync.
// Preset is driven by ProjectMService (shared with desktop backgrounds for coherence).
ProjectMItem {
id: lockProjM
anchors.fill: parent
visible: Settings.data.wallpaper.livePaperEnabled
running: Settings.data.wallpaper.livePaperEnabled
autoPresets: false
darken: 0.0
meshWidth: 24
meshHeight: 18
fps: 30

onRunningChanged: {
if (running && ProjectMService.currentPreset !== "")
requestPreset(ProjectMService.currentPreset)
}
}

Connections {
target: ProjectMService
function onCurrentPresetChanged() {
if (lockProjM.running)
lockProjM.requestPreset(ProjectMService.currentPreset)
}
}

// Background - solid color or black fallback; hidden when live wallpaper is active.
Rectangle {
anchors.fill: parent
visible: !Settings.data.wallpaper.livePaperEnabled
color: Settings.data.wallpaper.useSolidColor ? Settings.data.wallpaper.solidColor : "#000000"
}

Image {
id: lockBgImage
visible: source !== "" && Settings.data.wallpaper.enabled && !Settings.data.wallpaper.useSolidColor && (!PowerProfileService.noctaliaPerformanceMode || !Settings.data.noctaliaPerformance.disableWallpaper)
visible: source !== "" && Settings.data.wallpaper.enabled && !Settings.data.wallpaper.livePaperEnabled && !Settings.data.wallpaper.useSolidColor && (!PowerProfileService.noctaliaPerformanceMode || !Settings.data.noctaliaPerformance.disableWallpaper)
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
source: resolvedWallpaperPath
Expand All @@ -137,7 +166,7 @@ Item {
}

Rectangle {
visible: !Settings.data.wallpaper.useSolidColor
visible: !Settings.data.wallpaper.useSolidColor && !Settings.data.wallpaper.livePaperEnabled
anchors.fill: parent
gradient: Gradient {
GradientStop {
Expand Down
11 changes: 10 additions & 1 deletion Modules/Panels/Settings/Tabs/Wallpaper/GeneralSubTab.qml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ ColumnLayout {
signal openMonitorFolderPicker(string monitorName)

NToggle {
label: I18n.tr("panels.wallpaper.live-paper-enable-label")
description: I18n.tr("panels.wallpaper.live-paper-enable-description")
checked: Settings.data.wallpaper.livePaperEnabled
onToggled: checked => Settings.data.wallpaper.livePaperEnabled = checked
defaultValue: Settings.getDefaultValue("wallpaper.livePaperEnabled")
}

NToggle {
enabled: !Settings.data.wallpaper.livePaperEnabled
label: I18n.tr("panels.wallpaper.settings-enable-management-label")
description: I18n.tr("panels.wallpaper.settings-enable-management-description")
checked: Settings.data.wallpaper.enabled
Expand All @@ -26,7 +35,7 @@ ColumnLayout {
}

ColumnLayout {
enabled: Settings.data.wallpaper.enabled
enabled: Settings.data.wallpaper.enabled && !Settings.data.wallpaper.livePaperEnabled
spacing: Style.marginL
Layout.fillWidth: true

Expand Down
134 changes: 134 additions & 0 deletions Services/UI/ProjectMService.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
pragma Singleton

import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
import qs.Commons

/**
* ProjectMService — manages preset selection and rotation for all ProjectMItem
* instances (desktop backgrounds + lock screen).
*
* Instead of each ProjectMItem running its own preset timer and MPRIS handler,
* this singleton broadcasts the current preset path via `currentPreset`.
* Instances set `autoPresets: false` and call `requestPreset()` on changes.
*/
Singleton {
id: root

// Presets dir is symlinked by the home-module into XDG_DATA_HOME.
readonly property string presetsDir: (Quickshell.env("XDG_DATA_HOME") || (Quickshell.env("HOME") + "/.local/share")) + "/waylivepaper/presets"

// Currently active preset path — bind ProjectMItem.requestPreset() to this.
property string currentPreset: ""

// Preset rotation interval in seconds.
readonly property int presetInterval: 120

// -------------------------------------------------------
function init() {
Logger.i("ProjectMService", "Service started");
if (Settings.data.wallpaper.livePaperEnabled) {
_scanPresets();
}
}

// -------------------------------------------------------
function _scanPresets() {
_presets = [];
presetScanner.running = true;
}

function _advancePreset() {
if (_presets.length === 0) return;
var idx = Math.floor(Math.random() * _presets.length);
currentPreset = _presets[idx];
Logger.d("ProjectMService", "Preset:", currentPreset);
}

property var _presets: []

// -------------------------------------------------------
Connections {
target: Settings.data.wallpaper
function onLivePaperEnabledChanged() {
if (Settings.data.wallpaper.livePaperEnabled) {
if (root._presets.length === 0) {
root._scanPresets();
} else {
root._advancePreset();
presetTimer.start();
}
} else {
presetTimer.stop();
}
}
}

// -------------------------------------------------------
// MPRIS: advance preset on track change.
property var mprisPlayer: (Mpris.players && Mpris.players.values && Mpris.players.values.length > 0)
? Mpris.players.values[0] : null

property string currentMprisTrack: mprisPlayer ? (mprisPlayer.trackTitle ?? "") : ""
property string _lastMprisTrack: ""

onCurrentMprisTrackChanged: {
if (currentMprisTrack !== "" && currentMprisTrack !== _lastMprisTrack) {
_lastMprisTrack = currentMprisTrack
Logger.d("ProjectMService", "MPRIS track changed → advancing preset")
_advancePreset()
presetTimer.restart()
}
}

// -------------------------------------------------------
// Scan presets directory via `find`.
Process {
id: presetScanner
running: false
command: ["find", "-L", root.presetsDir, "-type", "f",
"(", "-name", "*.milk", "-o", "-name", "*.prjm", ")"]

stdout: SplitParser {
onRead: (line) => {
var trimmed = line.trim()
if (trimmed !== "")
root._presets.push(trimmed)
}
}

stderr: StdioCollector {
onStreamFinished: {}
}

onExited: (code, status) => {
if (root._presets.length === 0) {
Logger.w("ProjectMService", "No presets found in", root.presetsDir);
return;
}
// Fisher-Yates shuffle for random rotation
var arr = root._presets
for (var i = arr.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1))
var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp
}
root._presets = arr
Logger.i("ProjectMService", "Scanned", root._presets.length, "presets");
root._advancePreset()
if (Settings.data.wallpaper.livePaperEnabled) {
presetTimer.start()
}
}
}

// -------------------------------------------------------
Timer {
id: presetTimer
interval: root.presetInterval * 1000
repeat: true
running: false
onTriggered: root._advancePreset()
}
}
Loading