From d704a0ba3dfdf6d6cdfc90959995748191a5de20 Mon Sep 17 00:00:00 2001 From: bbedward Date: Tue, 30 Jun 2026 13:11:29 -0400 Subject: [PATCH] wallpaper: migrate auto-cycling scheduling to backend --- core/internal/server/router.go | 10 + core/internal/server/server.go | 67 ++- core/internal/server/wallpaper/handlers.go | 84 +++ core/internal/server/wallpaper/manager.go | 325 ++++++++++++ .../internal/server/wallpaper/manager_test.go | 106 ++++ core/internal/server/wallpaper/types.go | 23 + core/internal/server/wayland/manager.go | 28 +- quickshell/Modules/WallpaperBackground.qml | 16 +- quickshell/Services/DMSService.qml | 5 +- .../Services/WallpaperCyclingService.qml | 499 +++++------------- 10 files changed, 799 insertions(+), 364 deletions(-) create mode 100644 core/internal/server/wallpaper/handlers.go create mode 100644 core/internal/server/wallpaper/manager.go create mode 100644 core/internal/server/wallpaper/manager_test.go create mode 100644 core/internal/server/wallpaper/types.go diff --git a/core/internal/server/router.go b/core/internal/server/router.go index df7fee2ef..d07485678 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -23,6 +23,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wallpaper" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" ) @@ -56,6 +57,15 @@ func RouteRequest(conn net.Conn, req models.Request) { return } + if strings.HasPrefix(req.Method, "wallpaper.") { + if wallpaperManager == nil { + models.RespondError(conn, req.ID, "wallpaper manager not initialized") + return + } + wallpaper.HandleRequest(conn, req, wallpaperManager) + return + } + if strings.HasPrefix(req.Method, "loginctl.") { if loginctlManager == nil { models.RespondError(conn, req.ID, "loginctl manager not initialized") diff --git a/core/internal/server/server.go b/core/internal/server/server.go index c239f24be..c3d9a7631 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -33,13 +33,14 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wallpaper" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" ) -const APIVersion = 26 +const APIVersion = 27 var CLIVersion = "dev" @@ -73,6 +74,7 @@ var clipboardManager *clipboard.Manager var dbusManager *serverDbus.Manager var wlContext *wlcontext.SharedContext var themeModeManager *thememode.Manager +var wallpaperManager *wallpaper.Manager var trayRecoveryManager *trayrecovery.Manager var locationManager *location.Manager var sysUpdateManager *sysupdate.Manager @@ -347,6 +349,13 @@ func InitializeThemeModeManager() error { return nil } +func InitializeWallpaperManager() error { + wallpaperManager = wallpaper.NewManager() + + log.Info("Wallpaper rotation scheduler initialized") + return nil +} + func InitializeTrayRecoveryManager() error { manager, err := trayrecovery.NewManager() if err != nil { @@ -463,6 +472,10 @@ func getCapabilities() Capabilities { caps = append(caps, "theme.auto") } + if wallpaperManager != nil { + caps = append(caps, "wallpaper") + } + if dbusManager != nil { caps = append(caps, "dbus") } @@ -529,6 +542,10 @@ func getServerInfo() ServerInfo { caps = append(caps, "theme.auto") } + if wallpaperManager != nil { + caps = append(caps, "wallpaper") + } + if locationManager != nil { caps = append(caps, "location") } @@ -841,6 +858,38 @@ func handleSubscribe(conn net.Conn, req models.Request) { }() } + if shouldSubscribe("wallpaper") && wallpaperManager != nil { + wg.Add(1) + wallpaperChan := wallpaperManager.Subscribe(clientID + "-wallpaper") + go func() { + defer wg.Done() + defer wallpaperManager.Unsubscribe(clientID + "-wallpaper") + + initialState := wallpaperManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "wallpaper", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-wallpaperChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "wallpaper", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + if shouldSubscribe("bluetooth") && bluezManager != nil { wg.Add(1) bluezChan := bluezManager.Subscribe(clientID + "-bluetooth") @@ -1286,6 +1335,9 @@ func cleanupManagers() { if themeModeManager != nil { themeModeManager.Close() } + if wallpaperManager != nil { + wallpaperManager.Close() + } if trayRecoveryManager != nil { trayRecoveryManager.Close() } @@ -1578,6 +1630,19 @@ func Start(printDocs bool) error { }() } + if err := InitializeWallpaperManager(); err != nil { + log.Warnf("Wallpaper scheduler unavailable: %v", err) + } else { + notifyCapabilityChange() + go func() { + <-loginctlReady + if loginctlManager == nil { + return + } + wallpaperManager.WatchLoginctl(loginctlManager) + }() + } + go func() { <-loginctlReady if loginctlManager == nil { diff --git a/core/internal/server/wallpaper/handlers.go b/core/internal/server/wallpaper/handlers.go new file mode 100644 index 000000000..f7f4dffaa --- /dev/null +++ b/core/internal/server/wallpaper/handlers.go @@ -0,0 +1,84 @@ +package wallpaper + +import ( + "encoding/json" + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + if manager == nil { + models.RespondError(conn, req.ID, "wallpaper manager not initialized") + return + } + + switch req.Method { + case "wallpaper.getState": + handleGetState(conn, req, manager) + case "wallpaper.setConfig": + handleSetConfig(conn, req, manager) + case "wallpaper.trigger": + handleTrigger(conn, req, manager) + case "wallpaper.subscribe": + handleSubscribe(conn, req, manager) + default: + models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) + } +} + +func handleGetState(conn net.Conn, req models.Request, manager *Manager) { + models.Respond(conn, req.ID, manager.GetState()) +} + +func handleSetConfig(conn net.Conn, req models.Request, manager *Manager) { + raw, ok := params.Any(req.Params, "config") + if !ok { + models.RespondError(conn, req.ID, "missing or invalid 'config' parameter") + return + } + + data, err := json.Marshal(raw) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + var config Config + if err := json.Unmarshal(data, &config); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + manager.SetConfig(config) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "wallpaper schedule set"}) +} + +func handleTrigger(conn net.Conn, req models.Request, manager *Manager) { + manager.ResetSchedule(params.StringOpt(req.Params, "target", "")) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "wallpaper schedule reset"}) +} + +func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) { + clientID := fmt.Sprintf("client-%p", conn) + stateChan := manager.Subscribe(clientID) + defer manager.Unsubscribe(clientID) + + initialState := manager.GetState() + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + ID: req.ID, + Result: &initialState, + }); err != nil { + return + } + + for state := range stateChan { + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + Result: &state, + }); err != nil { + return + } + } +} diff --git a/core/internal/server/wallpaper/manager.go b/core/internal/server/wallpaper/manager.go new file mode 100644 index 000000000..c8059a9bf --- /dev/null +++ b/core/internal/server/wallpaper/manager.go @@ -0,0 +1,325 @@ +package wallpaper + +import ( + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +type activeSchedule struct { + cfg ScheduleConfig + nextFire time.Time +} + +type Manager struct { + config Config + configMutex sync.RWMutex + + state *State + stateMutex sync.RWMutex + + subscribers syncmap.Map[string, chan State] + + stopChan chan struct{} + updateTrigger chan struct{} + resetReq chan string + wg sync.WaitGroup +} + +func NewManager() *Manager { + m := &Manager{ + config: Config{ + Global: ScheduleConfig{Mode: "interval", IntervalSec: 300, Time: "06:00"}, + Monitors: map[string]ScheduleConfig{}, + }, + stopChan: make(chan struct{}), + updateTrigger: make(chan struct{}, 1), + resetReq: make(chan string, 8), + } + m.state = &State{Config: m.getConfig()} + + m.wg.Add(1) + go m.schedulerLoop() + + return m +} + +func (m *Manager) GetState() State { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + if m.state == nil { + return State{Config: m.getConfig()} + } + return *m.state +} + +func (m *Manager) Subscribe(id string) chan State { + ch := make(chan State, 64) + m.subscribers.Store(id, ch) + return ch +} + +func (m *Manager) Unsubscribe(id string) { + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) SetConfig(config Config) { + if config.Monitors == nil { + config.Monitors = map[string]ScheduleConfig{} + } + m.configMutex.Lock() + if reflect.DeepEqual(m.config, config) { + m.configMutex.Unlock() + return + } + m.config = config + m.configMutex.Unlock() + m.TriggerUpdate() +} + +func (m *Manager) ResetSchedule(target string) { + select { + case m.resetReq <- target: + default: + } +} + +func (m *Manager) TriggerUpdate() { + select { + case m.updateTrigger <- struct{}{}: + default: + } +} + +func (m *Manager) Close() { + select { + case <-m.stopChan: + return + default: + close(m.stopChan) + } + m.wg.Wait() + m.subscribers.Range(func(key string, ch chan State) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) +} + +func (m *Manager) WatchLoginctl(lm *loginctl.Manager) { + ch := lm.Subscribe("wallpaper") + m.wg.Add(1) + go func() { + defer m.wg.Done() + defer lm.Unsubscribe("wallpaper") + for { + select { + case <-m.stopChan: + return + case state, ok := <-ch: + if !ok { + return + } + if state.PreparingForSleep { + continue + } + m.TriggerUpdate() + } + } + }() +} + +func (m *Manager) schedulerLoop() { + defer m.wg.Done() + + schedules := map[string]*activeSchedule{} + resets := map[string]bool{} + var seq uint64 + var timer *time.Timer + + for { + now := time.Now() + config := m.getConfig() + active := activeSchedules(config) + + for key := range schedules { + if _, ok := active[key]; !ok { + delete(schedules, key) + } + } + for key, cfg := range active { + s, ok := schedules[key] + switch { + case !ok: + schedules[key] = &activeSchedule{cfg: cfg, nextFire: computeNext(now, cfg)} + case s.cfg != cfg || resets[key]: + s.cfg = cfg + s.nextFire = computeNext(now, cfg) + } + delete(resets, key) + } + + var dueKeys []string + for key, s := range schedules { + if !s.nextFire.After(now) { + dueKeys = append(dueKeys, key) + s.nextFire = computeNext(now, s.cfg) + } + } + + next, hasNext := soonest(schedules) + if len(dueKeys) == 0 { + m.setState(config, next, seq, "") + } + for _, key := range dueKeys { + seq++ + m.setState(config, next, seq, key) + } + + waitDur := 24 * time.Hour + if hasNext { + waitDur = time.Until(next) + if waitDur < time.Second { + waitDur = time.Second + } + } + + if timer != nil { + timer.Stop() + } + timer = time.NewTimer(waitDur) + + select { + case <-m.stopChan: + timer.Stop() + return + case <-m.updateTrigger: + timer.Stop() + case key := <-m.resetReq: + timer.Stop() + resets[key] = true + case <-timer.C: + } + } +} + +func (m *Manager) setState(config Config, next time.Time, seq uint64, target string) { + newState := State{Config: config, NextRotation: next, CycleSeq: seq, Target: target} + + m.stateMutex.Lock() + if m.state != nil && statesEqual(m.state, &newState) { + m.stateMutex.Unlock() + return + } + m.state = &newState + m.stateMutex.Unlock() + + m.notifySubscribers() +} + +func (m *Manager) notifySubscribers() { + state := m.GetState() + m.subscribers.Range(func(key string, ch chan State) bool { + select { + case ch <- state: + default: + } + return true + }) +} + +func (m *Manager) getConfig() Config { + m.configMutex.RLock() + defer m.configMutex.RUnlock() + return m.config +} + +func activeSchedules(config Config) map[string]ScheduleConfig { + out := map[string]ScheduleConfig{} + if config.PerMonitor { + for name, cfg := range config.Monitors { + if cfg.Enabled { + out[name] = cfg + } + } + return out + } + if config.Global.Enabled { + out[""] = config.Global + } + return out +} + +func computeNext(now time.Time, cfg ScheduleConfig) time.Time { + switch cfg.Mode { + case "time": + return nextDailyTime(now, cfg.Time) + default: + sec := cfg.IntervalSec + if sec < 1 { + sec = 1 + } + return now.Add(time.Duration(sec) * time.Second) + } +} + +func nextDailyTime(now time.Time, hhmm string) time.Time { + hour, minute, ok := parseHHMM(hhmm) + if !ok { + return now.Add(24 * time.Hour) + } + next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location()) + if !next.After(now) { + next = next.Add(24 * time.Hour) + } + return next +} + +func parseHHMM(hhmm string) (int, int, bool) { + parts := strings.Split(hhmm, ":") + if len(parts) != 2 { + return 0, 0, false + } + hour, err := strconv.Atoi(parts[0]) + if err != nil || hour < 0 || hour > 23 { + return 0, 0, false + } + minute, err := strconv.Atoi(parts[1]) + if err != nil || minute < 0 || minute > 59 { + return 0, 0, false + } + return hour, minute, true +} + +func soonest(schedules map[string]*activeSchedule) (time.Time, bool) { + var best time.Time + found := false + for _, s := range schedules { + if !found || s.nextFire.Before(best) { + best = s.nextFire + found = true + } + } + return best, found +} + +func statesEqual(a, b *State) bool { + switch { + case a == nil || b == nil: + return a == b + case a.CycleSeq != b.CycleSeq: + return false + case a.Target != b.Target: + return false + case !a.NextRotation.Equal(b.NextRotation): + return false + } + return reflect.DeepEqual(a.Config, b.Config) +} diff --git a/core/internal/server/wallpaper/manager_test.go b/core/internal/server/wallpaper/manager_test.go new file mode 100644 index 000000000..db63ec577 --- /dev/null +++ b/core/internal/server/wallpaper/manager_test.go @@ -0,0 +1,106 @@ +package wallpaper + +import ( + "testing" + "time" +) + +func TestParseHHMM(t *testing.T) { + cases := []struct { + in string + hour int + minute int + ok bool + }{ + {"06:00", 6, 0, true}, + {"23:59", 23, 59, true}, + {"00:00", 0, 0, true}, + {"24:00", 0, 0, false}, + {"6:5", 6, 5, true}, + {"bad", 0, 0, false}, + {"12", 0, 0, false}, + {"12:60", 0, 0, false}, + } + for _, c := range cases { + hour, minute, ok := parseHHMM(c.in) + if ok != c.ok || (ok && (hour != c.hour || minute != c.minute)) { + t.Errorf("parseHHMM(%q) = (%d, %d, %v), want (%d, %d, %v)", c.in, hour, minute, ok, c.hour, c.minute, c.ok) + } + } +} + +func TestComputeNextInterval(t *testing.T) { + now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + got := computeNext(now, ScheduleConfig{Mode: "interval", IntervalSec: 300}) + if want := now.Add(300 * time.Second); !got.Equal(want) { + t.Errorf("interval next = %v, want %v", got, want) + } + + clamped := computeNext(now, ScheduleConfig{Mode: "interval", IntervalSec: 0}) + if want := now.Add(time.Second); !clamped.Equal(want) { + t.Errorf("interval clamp = %v, want %v", clamped, want) + } +} + +func TestComputeNextTime(t *testing.T) { + now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + + later := computeNext(now, ScheduleConfig{Mode: "time", Time: "18:00"}) + if want := time.Date(2026, 6, 30, 18, 0, 0, 0, time.UTC); !later.Equal(want) { + t.Errorf("time next (today) = %v, want %v", later, want) + } + + tomorrow := computeNext(now, ScheduleConfig{Mode: "time", Time: "06:00"}) + if want := time.Date(2026, 7, 1, 6, 0, 0, 0, time.UTC); !tomorrow.Equal(want) { + t.Errorf("time next (tomorrow) = %v, want %v", tomorrow, want) + } +} + +func TestActiveSchedules(t *testing.T) { + global := activeSchedules(Config{ + Global: ScheduleConfig{Enabled: true, Mode: "interval", IntervalSec: 60}, + }) + if len(global) != 1 { + t.Fatalf("global active = %d, want 1", len(global)) + } + if _, ok := global[""]; !ok { + t.Errorf("global active missing global key") + } + + perMonitor := activeSchedules(Config{ + PerMonitor: true, + Global: ScheduleConfig{Enabled: true}, + Monitors: map[string]ScheduleConfig{ + "DP-1": {Enabled: true, Mode: "interval", IntervalSec: 60}, + "DP-2": {Enabled: false}, + }, + }) + if len(perMonitor) != 1 { + t.Fatalf("per-monitor active = %d, want 1", len(perMonitor)) + } + if _, ok := perMonitor["DP-1"]; !ok { + t.Errorf("per-monitor active missing DP-1") + } +} + +func TestSchedulerEmitsCycle(t *testing.T) { + m := NewManager() + defer m.Close() + + sub := m.Subscribe("test") + defer m.Unsubscribe("test") + + m.SetConfig(Config{Global: ScheduleConfig{Enabled: true, Mode: "interval", IntervalSec: 1}}) + + deadline := time.After(3 * time.Second) + for { + select { + case state := <-sub: + if state.CycleSeq > 0 && state.Target == "" { + return + } + case <-deadline: + t.Fatal("scheduler did not emit a cycle event within 3s") + } + } +} diff --git a/core/internal/server/wallpaper/types.go b/core/internal/server/wallpaper/types.go new file mode 100644 index 000000000..a97c093ca --- /dev/null +++ b/core/internal/server/wallpaper/types.go @@ -0,0 +1,23 @@ +package wallpaper + +import "time" + +type ScheduleConfig struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + IntervalSec int `json:"intervalSec"` + Time string `json:"time"` +} + +type Config struct { + PerMonitor bool `json:"perMonitor"` + Global ScheduleConfig `json:"global"` + Monitors map[string]ScheduleConfig `json:"monitors"` +} + +type State struct { + Config Config `json:"config"` + NextRotation time.Time `json:"nextRotation"` + CycleSeq uint64 `json:"cycleSeq"` + Target string `json:"target"` +} diff --git a/core/internal/server/wayland/manager.go b/core/internal/server/wayland/manager.go index 3540e3db4..2380b1f27 100644 --- a/core/internal/server/wayland/manager.go +++ b/core/internal/server/wayland/manager.go @@ -74,6 +74,9 @@ func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error if config.Enabled { m.post(func() { + if m.controlsInitialized { + return + } log.Info("Gamma control enabled at startup") gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1) m.availOutputsMu.RLock() @@ -184,13 +187,26 @@ func (m *Manager) setupRegistry() error { enabled := m.config.Enabled m.configMutex.RUnlock() - if enabled && m.controlsInitialized { - m.post(func() { - if err := m.addOutputControl(output); err != nil { - log.Warnf("Failed to add output control: %v", err) - } - }) + if !enabled { + return } + m.post(func() { + if err := m.addOutputControl(output); err != nil { + log.Warnf("gamma: failed to add output control: %v", err) + return + } + if m.controlsInitialized { + return + } + // All outputs had been torn down (monitor sleep/disconnect), + // clearing controlsInitialized. Mark it ready again so the + // gamma_size event that follows control creation drives the + // reapply, instead of waiting for a manual toggle. No explicit + // apply here: applyGamma's dedup would suppress a no-change + // write anyway, and the new control isn't ready until gamma_size. + log.Info("gamma: output returned, re-establishing controls") + m.controlsInitialized = true + }) } }) diff --git a/quickshell/Modules/WallpaperBackground.qml b/quickshell/Modules/WallpaperBackground.qml index d1e564e0c..8719f7f17 100644 --- a/quickshell/Modules/WallpaperBackground.qml +++ b/quickshell/Modules/WallpaperBackground.qml @@ -129,9 +129,13 @@ Variants { property bool useNextForEffect: false property string pendingWallpaper: "" property string _deferredSource: "" + // Held true from the moment a wallpaper change is initiated until it + // settles, so the paused render loop wakes and drives the async load + // and transition even when nothing else marks the window dirty. + property bool changePending: false readonly property bool overviewBlurActive: CompositorService.isNiri && SettingsData.blurWallpaperOnOverview && NiriService.inOverview && currentWallpaper.source !== "" readonly property var backingWindow: Window.window - readonly property bool renderActive: !source || effectActive || overviewBlurActive || pendingWallpaper !== "" || _deferredSource !== "" || frameAnim.running || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading + readonly property bool renderActive: !source || effectActive || overviewBlurActive || pendingWallpaper !== "" || _deferredSource !== "" || changePending || frameAnim.running || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading property int _settleFrames: 3 function invalidate() { @@ -265,6 +269,7 @@ Variants { root.useNextForEffect = false; root.effectActive = false; root.transitionProgress = 0.0; + root.changePending = false; currentWallpaper.layer.enabled = false; nextWallpaper.layer.enabled = false; nextWallpaper.source = ""; @@ -502,6 +507,9 @@ Variants { return; } + root.changePending = true; + invalidate(); + const formattedSource = source.startsWith("file://") ? source : encodeFileUrl(source); if (!isInitialized || !currentWallpaper.source) { @@ -688,6 +696,9 @@ Variants { if (status === Image.Ready) { imageMetrics.capture(implicitWidth, implicitHeight); } + if (status === Image.Ready || status === Image.Error) { + root.changePending = false; + } } onSourceChanged: { @@ -890,6 +901,8 @@ Variants { property real imageHeight2: modelData.height property real screenWidth: modelData.width property real screenHeight: modelData.height + property real scrollX: 50 + property real scrollY: 50 fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wp_fade.frag.qsb") } } @@ -1048,6 +1061,7 @@ Variants { currentWallpaper.layer.enabled = false; nextWallpaper.layer.enabled = false; root.effectActive = false; + root.changePending = false; if (!root.pendingWallpaper) return; diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml index 9a75b34ff..e1ef3b6e4 100644 --- a/quickshell/Services/DMSService.qml +++ b/quickshell/Services/DMSService.qml @@ -55,6 +55,7 @@ Singleton { signal evdevStateUpdate(var data) signal gammaStateUpdate(var data) signal themeAutoStateUpdate(var data) + signal wallpaperCycleUpdate(var data) signal openUrlRequested(string url) signal appPickerRequested(var data) signal screensaverStateUpdate(var data) @@ -68,7 +69,7 @@ Singleton { property bool screensaverInhibited: false property var screensaverInhibitors: [] - property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "bluetooth", "bluetooth.pairing", "brightness", "wlroutput", "evdev", "browser", "dbus", "clipboard", "location", "sysupdate"] + property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "wallpaper", "bluetooth", "bluetooth.pairing", "brightness", "wlroutput", "evdev", "browser", "dbus", "clipboard", "location", "sysupdate"] Component.onCompleted: { if (socketPath && socketPath.length > 0) { @@ -371,6 +372,8 @@ Singleton { gammaStateUpdate(data); } else if (service === "theme.auto") { themeAutoStateUpdate(data); + } else if (service === "wallpaper") { + wallpaperCycleUpdate(data); } else if (service === "browser.open_requested") { if (data.target) { if (data.requestType === "url" || !data.requestType) { diff --git a/quickshell/Services/WallpaperCyclingService.qml b/quickshell/Services/WallpaperCyclingService.qml index a07c563f6..2d58230a3 100644 --- a/quickshell/Services/WallpaperCyclingService.qml +++ b/quickshell/Services/WallpaperCyclingService.qml @@ -22,62 +22,37 @@ Singleton { return false; } readonly property bool shouldPauseCycling: fullscreenShowing || SessionService.locked - property string cachedCyclingTime: SessionData.wallpaperCyclingTime - property int cachedCyclingInterval: SessionData.wallpaperCyclingInterval - property string lastTimeCheck: "" - property var monitorTimers: ({}) - property var monitorLastTimeChecks: ({}) + readonly property bool serverSchedulingAvailable: DMSService.capabilities.includes("wallpaper") + property real lastCycleSeq: -1 property var monitorProcesses: ({}) - Component { - id: monitorTimerComponent - Timer { - property string targetScreen: "" - running: false - repeat: true - onTriggered: { - if (typeof WallpaperCyclingService !== "undefined" && targetScreen !== "" && !WallpaperCyclingService.shouldPauseCycling) { - WallpaperCyclingService.cycleNextForMonitor(targetScreen); - } + Connections { + target: DMSService + + function onWallpaperCycleUpdate(data) { + if (!data) + return; + const seq = data.cycleSeq || 0; + if (lastCycleSeq < 0) { + lastCycleSeq = seq; + return; + } + if (seq <= lastCycleSeq) + return; + lastCycleSeq = seq; + if (shouldPauseCycling) + return; + const target = data.target || ""; + if (target === "") { + cycleToNextWallpaper(); + } else { + cycleNextForMonitor(target); } } - } - Component { - id: monitorProcessComponent - Process { - property string targetScreenName: "" - property string currentWallpaper: "" - property bool goToPrevious: false - running: false - stdout: StdioCollector { - onStreamFinished: { - if (text && text.trim()) { - const files = text.trim().split('\n').filter(file => file.length > 0); - if (files.length <= 1) - return; - const wallpaperList = files.sort(); - const currentPath = currentWallpaper; - let currentIndex = wallpaperList.findIndex(path => path === currentPath); - if (currentIndex === -1) - currentIndex = 0; - let targetIndex; - if (goToPrevious) { - targetIndex = currentIndex === 0 ? wallpaperList.length - 1 : currentIndex - 1; - } else { - targetIndex = (currentIndex + 1) % wallpaperList.length; - } - const targetWallpaper = wallpaperList[targetIndex]; - if (targetWallpaper && targetWallpaper !== currentPath) { - if (targetScreenName) { - SessionData.setMonitorWallpaper(targetScreenName, targetWallpaper); - } else { - SessionData.setWallpaper(targetWallpaper); - } - } - } - } - } + function onCapabilitiesReceived() { + lastCycleSeq = -1; + updateCyclingState(); } } @@ -93,17 +68,11 @@ Singleton { } function onWallpaperCyclingIntervalChanged() { - cachedCyclingInterval = SessionData.wallpaperCyclingInterval; - if (SessionData.wallpaperCyclingMode === "interval") { - updateCyclingState(); - } + updateCyclingState(); } function onWallpaperCyclingTimeChanged() { - cachedCyclingTime = SessionData.wallpaperCyclingTime; - if (SessionData.wallpaperCyclingMode === "time") { - updateCyclingState(); - } + updateCyclingState(); } function onPerMonitorWallpaperChanged() { @@ -119,225 +88,117 @@ Singleton { target: SessionService function onSessionUnlocked() { - if (SessionData.wallpaperCyclingEnabled || SessionData.perMonitorWallpaper) { - updateCyclingState(); - } + updateCyclingState(); } } function updateCyclingState() { - if (SessionData.perMonitorWallpaper) { - stopCycling(); - updatePerMonitorCycling(); - } else if (SessionData.wallpaperCyclingEnabled && SessionData.wallpaperPath) { - startCycling(); - stopAllMonitorCycling(); - } else { - stopCycling(); - stopAllMonitorCycling(); - } + cyclingActive = serverSchedulingAvailable && (SessionData.wallpaperCyclingEnabled || SessionData.perMonitorWallpaper); + pushConfigToServer(); } - function updatePerMonitorCycling() { - if (typeof Quickshell === "undefined") + function buildServerConfig() { + var monitors = {}; + if (SessionData.perMonitorWallpaper && typeof Quickshell !== "undefined") { + var screens = Quickshell.screens; + for (var i = 0; i < screens.length; i++) { + var name = screens[i].name; + var s = SessionData.getMonitorCyclingSettings(name); + var wp = SessionData.getMonitorWallpaper(name); + monitors[name] = { + "enabled": !!(s.enabled && wp && !wp.startsWith("#")), + "mode": s.mode || "interval", + "intervalSec": s.interval || 300, + "time": s.time || "06:00" + }; + } + } + return { + "perMonitor": SessionData.perMonitorWallpaper, + "global": { + "enabled": !!(SessionData.wallpaperCyclingEnabled && SessionData.wallpaperPath), + "mode": SessionData.wallpaperCyclingMode, + "intervalSec": SessionData.wallpaperCyclingInterval, + "time": SessionData.wallpaperCyclingTime + }, + "monitors": monitors + }; + } + + function pushConfigToServer() { + if (!serverSchedulingAvailable) return; - var screens = Quickshell.screens; - for (var i = 0; i < screens.length; i++) { - var screenName = screens[i].name; - var settings = SessionData.getMonitorCyclingSettings(screenName); - var wallpaper = SessionData.getMonitorWallpaper(screenName); - - if (settings.enabled && wallpaper && !wallpaper.startsWith("#")) { - startMonitorCycling(screenName, settings); - } else { - stopMonitorCycling(screenName); - } - } + DMSService.sendRequest("wallpaper.setConfig", { + "config": buildServerConfig() + }, null); } - function stopAllMonitorCycling() { - var screenNames = Object.keys(monitorTimers); - for (var i = 0; i < screenNames.length; i++) { - stopMonitorCycling(screenNames[i]); - } + function findCommand(wallpaperDir) { + return ["sh", "-c", `find -L "${wallpaperDir}" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.bmp" -o -iname "*.gif" -o -iname "*.webp" -o -iname "*.jxl" -o -iname "*.avif" -o -iname "*.heif" -o -iname "*.exr" \\) 2>/dev/null | sort`]; } - function startCycling() { - switch (SessionData.wallpaperCyclingMode) { - case "interval": - lastTimeCheck = ""; - intervalTimer.interval = cachedCyclingInterval * 1000; - intervalTimer.start(); - cyclingActive = true; - break; - case "time": - intervalTimer.stop(); - cyclingActive = true; - checkTimeBasedCycling(); - break; - } - } - - function stopCycling() { - intervalTimer.stop(); - cyclingActive = false; - } - - function startMonitorCycling(screenName, settings) { - switch (settings.mode) { - case "interval": - { - var newChecks = Object.assign({}, monitorLastTimeChecks); - delete newChecks[screenName]; - monitorLastTimeChecks = newChecks; - - var timer = monitorTimers[screenName]; - if (!timer && monitorTimerComponent && monitorTimerComponent.status === Component.Ready) { - var newTimers = Object.assign({}, monitorTimers); - var newTimer = monitorTimerComponent.createObject(root); - newTimer.targetScreen = screenName; - newTimers[screenName] = newTimer; - monitorTimers = newTimers; - timer = newTimer; - } - if (timer) { - timer.interval = settings.interval * 1000; - timer.start(); - } - break; - } - case "time": - { - var existingTimer = monitorTimers[screenName]; - if (existingTimer) { - existingTimer.stop(); - existingTimer.destroy(); - var newTimers = Object.assign({}, monitorTimers); - delete newTimers[screenName]; - monitorTimers = newTimers; - } - - var newChecks = Object.assign({}, monitorLastTimeChecks); - newChecks[screenName] = ""; - monitorLastTimeChecks = newChecks; - break; - } - } - } - - function stopMonitorCycling(screenName) { - var timer = monitorTimers[screenName]; - if (timer) { - timer.stop(); - timer.destroy(); - var newTimers = Object.assign({}, monitorTimers); - delete newTimers[screenName]; - monitorTimers = newTimers; - } - + function monitorProcessFor(screenName) { var process = monitorProcesses[screenName]; - if (process) { - process.destroy(); - var newProcesses = Object.assign({}, monitorProcesses); - delete newProcesses[screenName]; - monitorProcesses = newProcesses; + if (process) + return process; + var newProcesses = Object.assign({}, monitorProcesses); + process = monitorProcessComponent.createObject(root); + newProcesses[screenName] = process; + monitorProcesses = newProcesses; + return process; + } + + function cycle(screenName, wallpaperPath, goToPrevious) { + const currentWallpaper = wallpaperPath || SessionData.wallpaperPath; + if (!currentWallpaper) + return; + const wallpaperDir = currentWallpaper.substring(0, currentWallpaper.lastIndexOf('/')); + + if (screenName && monitorProcessComponent.status === Component.Ready) { + var process = monitorProcessFor(screenName); + process.command = findCommand(wallpaperDir); + process.targetScreenName = screenName; + process.currentWallpaper = currentWallpaper; + process.goToPrevious = goToPrevious; + process.running = true; + return; } - var newChecks = Object.assign({}, monitorLastTimeChecks); - delete newChecks[screenName]; - monitorLastTimeChecks = newChecks; + var globalProcess = goToPrevious ? prevCyclingProcess : cyclingProcess; + globalProcess.command = findCommand(wallpaperDir); + globalProcess.targetScreenName = screenName || ""; + globalProcess.currentWallpaper = currentWallpaper; + globalProcess.running = true; } function cycleToNextWallpaper(screenName, wallpaperPath) { - const currentWallpaper = wallpaperPath || SessionData.wallpaperPath; - if (!currentWallpaper) - return; - const wallpaperDir = currentWallpaper.substring(0, currentWallpaper.lastIndexOf('/')); - - if (screenName && monitorProcessComponent && monitorProcessComponent.status === Component.Ready) { - // Use per-monitor process - var process = monitorProcesses[screenName]; - if (!process) { - var newProcesses = Object.assign({}, monitorProcesses); - var newProcess = monitorProcessComponent.createObject(root); - newProcesses[screenName] = newProcess; - monitorProcesses = newProcesses; - process = newProcess; - } - - if (process) { - process.command = ["sh", "-c", `find -L "${wallpaperDir}" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.bmp" -o -iname "*.gif" -o -iname "*.webp" -o -iname "*.jxl" -o -iname "*.avif" -o -iname "*.heif" -o -iname "*.exr" \\) 2>/dev/null | sort`]; - process.targetScreenName = screenName; - process.currentWallpaper = currentWallpaper; - process.goToPrevious = false; - process.running = true; - } - } else { - // Use global process for fallback - cyclingProcess.command = ["sh", "-c", `find -L "${wallpaperDir}" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.bmp" -o -iname "*.gif" -o -iname "*.webp" -o -iname "*.jxl" -o -iname "*.avif" -o -iname "*.heif" -o -iname "*.exr" \\) 2>/dev/null | sort`]; - cyclingProcess.targetScreenName = screenName || ""; - cyclingProcess.currentWallpaper = currentWallpaper; - cyclingProcess.running = true; - } + cycle(screenName, wallpaperPath, false); } function cycleToPrevWallpaper(screenName, wallpaperPath) { - const currentWallpaper = wallpaperPath || SessionData.wallpaperPath; - if (!currentWallpaper) + cycle(screenName, wallpaperPath, true); + } + + function resetScheduleAfterManual() { + if (!serverSchedulingAvailable) return; - const wallpaperDir = currentWallpaper.substring(0, currentWallpaper.lastIndexOf('/')); - - if (screenName && monitorProcessComponent && monitorProcessComponent.status === Component.Ready) { - // Use per-monitor process (same as next, but with prev flag) - var process = monitorProcesses[screenName]; - if (!process) { - var newProcesses = Object.assign({}, monitorProcesses); - var newProcess = monitorProcessComponent.createObject(root); - newProcesses[screenName] = newProcess; - monitorProcesses = newProcesses; - process = newProcess; - } - - if (process) { - process.command = ["sh", "-c", `find -L "${wallpaperDir}" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.bmp" -o -iname "*.gif" -o -iname "*.webp" -o -iname "*.jxl" -o -iname "*.avif" -o -iname "*.heif" -o -iname "*.exr" \\) 2>/dev/null | sort`]; - process.targetScreenName = screenName; - process.currentWallpaper = currentWallpaper; - process.goToPrevious = true; - process.running = true; - } - } else { - // Use global process for fallback - prevCyclingProcess.command = ["sh", "-c", `find -L "${wallpaperDir}" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.bmp" -o -iname "*.gif" -o -iname "*.webp" -o -iname "*.jxl" -o -iname "*.avif" -o -iname "*.heif" -o -iname "*.exr" \\) 2>/dev/null | sort`]; - prevCyclingProcess.targetScreenName = screenName || ""; - prevCyclingProcess.currentWallpaper = currentWallpaper; - prevCyclingProcess.running = true; - } + DMSService.sendRequest("wallpaper.trigger", { + "target": "" + }, null); } function cycleNextManually() { - if (SessionData.wallpaperPath) { - cycleToNextWallpaper(); - // Restart timers if cycling is active - if (cyclingActive && SessionData.wallpaperCyclingEnabled) { - if (SessionData.wallpaperCyclingMode === "interval") { - intervalTimer.interval = cachedCyclingInterval * 1000; - intervalTimer.restart(); - } - } - } + if (!SessionData.wallpaperPath) + return; + cycleToNextWallpaper(); + resetScheduleAfterManual(); } function cyclePrevManually() { - if (SessionData.wallpaperPath) { - cycleToPrevWallpaper(); - // Restart timers if cycling is active - if (cyclingActive && SessionData.wallpaperCyclingEnabled) { - if (SessionData.wallpaperCyclingMode === "interval") { - intervalTimer.interval = cachedCyclingInterval * 1000; - intervalTimer.restart(); - } - } - } + if (!SessionData.wallpaperPath) + return; + cycleToPrevWallpaper(); + resetScheduleAfterManual(); } function cycleNextForMonitor(screenName) { @@ -358,138 +219,66 @@ Singleton { } } - function checkTimeBasedCycling() { - if (shouldPauseCycling) + function applyCycledWallpaper(text, currentPath, targetScreenName, goToPrevious) { + if (!text || !text.trim()) return; - const currentTime = Qt.formatTime(systemClock.date, "hh:mm"); + const files = text.trim().split('\n').filter(file => file.length > 0); + if (files.length <= 1) + return; + const wallpaperList = files.sort(); + let currentIndex = wallpaperList.findIndex(path => path === currentPath); + if (currentIndex === -1) + currentIndex = 0; - if (!SessionData.perMonitorWallpaper) { - if (currentTime === cachedCyclingTime && currentTime !== lastTimeCheck) { - lastTimeCheck = currentTime; - cycleToNextWallpaper(); - } else if (currentTime !== cachedCyclingTime) { - lastTimeCheck = ""; - } + let targetIndex; + if (goToPrevious) { + targetIndex = currentIndex === 0 ? wallpaperList.length - 1 : currentIndex - 1; } else { - checkPerMonitorTimeBasedCycling(currentTime); + targetIndex = (currentIndex + 1) % wallpaperList.length; } - } - - function checkPerMonitorTimeBasedCycling(currentTime) { - if (typeof Quickshell === "undefined") + const targetWallpaper = wallpaperList[targetIndex]; + if (!targetWallpaper || targetWallpaper === currentPath) return; - var screens = Quickshell.screens; - for (var i = 0; i < screens.length; i++) { - var screenName = screens[i].name; - var settings = SessionData.getMonitorCyclingSettings(screenName); - var wallpaper = SessionData.getMonitorWallpaper(screenName); - if (settings.enabled && settings.mode === "time" && wallpaper && !wallpaper.startsWith("#")) { - var lastCheck = monitorLastTimeChecks[screenName] || ""; - - if (currentTime === settings.time && currentTime !== lastCheck) { - var newChecks = Object.assign({}, monitorLastTimeChecks); - newChecks[screenName] = currentTime; - monitorLastTimeChecks = newChecks; - cycleNextForMonitor(screenName); - } else if (currentTime !== settings.time) { - var newChecks = Object.assign({}, monitorLastTimeChecks); - newChecks[screenName] = ""; - monitorLastTimeChecks = newChecks; - } - } + if (targetScreenName) { + SessionData.setMonitorWallpaper(targetScreenName, targetWallpaper); + } else { + SessionData.setWallpaper(targetWallpaper); } } - Timer { - id: intervalTimer - interval: cachedCyclingInterval * 1000 - running: false - repeat: true - onTriggered: { - if (shouldPauseCycling) - return; - cycleToNextWallpaper(); - } - } - - SystemClock { - id: systemClock - precision: SystemClock.Minutes - onDateChanged: { - if ((SessionData.wallpaperCyclingMode === "time" && cyclingActive) || SessionData.perMonitorWallpaper) { - checkTimeBasedCycling(); + Component { + id: monitorProcessComponent + Process { + property string targetScreenName: "" + property string currentWallpaper: "" + property bool goToPrevious: false + running: false + stdout: StdioCollector { + onStreamFinished: root.applyCycledWallpaper(text, currentWallpaper, targetScreenName, goToPrevious) } } } Process { id: cyclingProcess - property string targetScreenName: "" property string currentWallpaper: "" - + property bool goToPrevious: false running: false - stdout: StdioCollector { - onStreamFinished: { - if (text && text.trim()) { - const files = text.trim().split('\n').filter(file => file.length > 0); - if (files.length <= 1) - return; - const wallpaperList = files.sort(); - const currentPath = cyclingProcess.currentWallpaper; - let currentIndex = wallpaperList.findIndex(path => path === currentPath); - if (currentIndex === -1) - currentIndex = 0; - - const nextIndex = (currentIndex + 1) % wallpaperList.length; - const nextWallpaper = wallpaperList[nextIndex]; - - if (nextWallpaper && nextWallpaper !== currentPath) { - if (cyclingProcess.targetScreenName) { - SessionData.setMonitorWallpaper(cyclingProcess.targetScreenName, nextWallpaper); - } else { - SessionData.setWallpaper(nextWallpaper); - } - } - } - } + onStreamFinished: root.applyCycledWallpaper(text, cyclingProcess.currentWallpaper, cyclingProcess.targetScreenName, cyclingProcess.goToPrevious) } } Process { id: prevCyclingProcess - property string targetScreenName: "" property string currentWallpaper: "" - + property bool goToPrevious: true running: false - stdout: StdioCollector { - onStreamFinished: { - if (text && text.trim()) { - const files = text.trim().split('\n').filter(file => file.length > 0); - if (files.length <= 1) - return; - const wallpaperList = files.sort(); - const currentPath = prevCyclingProcess.currentWallpaper; - let currentIndex = wallpaperList.findIndex(path => path === currentPath); - if (currentIndex === -1) - currentIndex = 0; - - const prevIndex = currentIndex === 0 ? wallpaperList.length - 1 : currentIndex - 1; - const prevWallpaper = wallpaperList[prevIndex]; - - if (prevWallpaper && prevWallpaper !== currentPath) { - if (prevCyclingProcess.targetScreenName) { - SessionData.setMonitorWallpaper(prevCyclingProcess.targetScreenName, prevWallpaper); - } else { - SessionData.setWallpaper(prevWallpaper); - } - } - } - } + onStreamFinished: root.applyCycledWallpaper(text, prevCyclingProcess.currentWallpaper, prevCyclingProcess.targetScreenName, prevCyclingProcess.goToPrevious) } } }