1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-07 14:08:29 -04:00

wallpaper: migrate auto-cycling scheduling to backend

This commit is contained in:
bbedward
2026-06-30 13:11:29 -04:00
parent 161118122e
commit d704a0ba3d
10 changed files with 799 additions and 364 deletions
+10
View File
@@ -23,6 +23,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes" 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/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
) )
@@ -56,6 +57,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
return 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 strings.HasPrefix(req.Method, "loginctl.") {
if loginctlManager == nil { if loginctlManager == nil {
models.RespondError(conn, req.ID, "loginctl manager not initialized") models.RespondError(conn, req.ID, "loginctl manager not initialized")
+66 -1
View File
@@ -33,13 +33,14 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery" "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/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
) )
const APIVersion = 26 const APIVersion = 27
var CLIVersion = "dev" var CLIVersion = "dev"
@@ -73,6 +74,7 @@ var clipboardManager *clipboard.Manager
var dbusManager *serverDbus.Manager var dbusManager *serverDbus.Manager
var wlContext *wlcontext.SharedContext var wlContext *wlcontext.SharedContext
var themeModeManager *thememode.Manager var themeModeManager *thememode.Manager
var wallpaperManager *wallpaper.Manager
var trayRecoveryManager *trayrecovery.Manager var trayRecoveryManager *trayrecovery.Manager
var locationManager *location.Manager var locationManager *location.Manager
var sysUpdateManager *sysupdate.Manager var sysUpdateManager *sysupdate.Manager
@@ -347,6 +349,13 @@ func InitializeThemeModeManager() error {
return nil return nil
} }
func InitializeWallpaperManager() error {
wallpaperManager = wallpaper.NewManager()
log.Info("Wallpaper rotation scheduler initialized")
return nil
}
func InitializeTrayRecoveryManager() error { func InitializeTrayRecoveryManager() error {
manager, err := trayrecovery.NewManager() manager, err := trayrecovery.NewManager()
if err != nil { if err != nil {
@@ -463,6 +472,10 @@ func getCapabilities() Capabilities {
caps = append(caps, "theme.auto") caps = append(caps, "theme.auto")
} }
if wallpaperManager != nil {
caps = append(caps, "wallpaper")
}
if dbusManager != nil { if dbusManager != nil {
caps = append(caps, "dbus") caps = append(caps, "dbus")
} }
@@ -529,6 +542,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "theme.auto") caps = append(caps, "theme.auto")
} }
if wallpaperManager != nil {
caps = append(caps, "wallpaper")
}
if locationManager != nil { if locationManager != nil {
caps = append(caps, "location") 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 { if shouldSubscribe("bluetooth") && bluezManager != nil {
wg.Add(1) wg.Add(1)
bluezChan := bluezManager.Subscribe(clientID + "-bluetooth") bluezChan := bluezManager.Subscribe(clientID + "-bluetooth")
@@ -1286,6 +1335,9 @@ func cleanupManagers() {
if themeModeManager != nil { if themeModeManager != nil {
themeModeManager.Close() themeModeManager.Close()
} }
if wallpaperManager != nil {
wallpaperManager.Close()
}
if trayRecoveryManager != nil { if trayRecoveryManager != nil {
trayRecoveryManager.Close() 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() { go func() {
<-loginctlReady <-loginctlReady
if loginctlManager == nil { if loginctlManager == nil {
@@ -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
}
}
}
+325
View File
@@ -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)
}
@@ -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")
}
}
}
+23
View File
@@ -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"`
}
+22 -6
View File
@@ -74,6 +74,9 @@ func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error
if config.Enabled { if config.Enabled {
m.post(func() { m.post(func() {
if m.controlsInitialized {
return
}
log.Info("Gamma control enabled at startup") log.Info("Gamma control enabled at startup")
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1) gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
m.availOutputsMu.RLock() m.availOutputsMu.RLock()
@@ -184,13 +187,26 @@ func (m *Manager) setupRegistry() error {
enabled := m.config.Enabled enabled := m.config.Enabled
m.configMutex.RUnlock() m.configMutex.RUnlock()
if enabled && m.controlsInitialized { if !enabled {
m.post(func() { return
if err := m.addOutputControl(output); err != nil {
log.Warnf("Failed to add output control: %v", err)
}
})
} }
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
})
} }
}) })
+15 -1
View File
@@ -129,9 +129,13 @@ Variants {
property bool useNextForEffect: false property bool useNextForEffect: false
property string pendingWallpaper: "" property string pendingWallpaper: ""
property string _deferredSource: "" 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 bool overviewBlurActive: CompositorService.isNiri && SettingsData.blurWallpaperOnOverview && NiriService.inOverview && currentWallpaper.source !== ""
readonly property var backingWindow: Window.window 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 property int _settleFrames: 3
function invalidate() { function invalidate() {
@@ -265,6 +269,7 @@ Variants {
root.useNextForEffect = false; root.useNextForEffect = false;
root.effectActive = false; root.effectActive = false;
root.transitionProgress = 0.0; root.transitionProgress = 0.0;
root.changePending = false;
currentWallpaper.layer.enabled = false; currentWallpaper.layer.enabled = false;
nextWallpaper.layer.enabled = false; nextWallpaper.layer.enabled = false;
nextWallpaper.source = ""; nextWallpaper.source = "";
@@ -502,6 +507,9 @@ Variants {
return; return;
} }
root.changePending = true;
invalidate();
const formattedSource = source.startsWith("file://") ? source : encodeFileUrl(source); const formattedSource = source.startsWith("file://") ? source : encodeFileUrl(source);
if (!isInitialized || !currentWallpaper.source) { if (!isInitialized || !currentWallpaper.source) {
@@ -688,6 +696,9 @@ Variants {
if (status === Image.Ready) { if (status === Image.Ready) {
imageMetrics.capture(implicitWidth, implicitHeight); imageMetrics.capture(implicitWidth, implicitHeight);
} }
if (status === Image.Ready || status === Image.Error) {
root.changePending = false;
}
} }
onSourceChanged: { onSourceChanged: {
@@ -890,6 +901,8 @@ Variants {
property real imageHeight2: modelData.height property real imageHeight2: modelData.height
property real screenWidth: modelData.width property real screenWidth: modelData.width
property real screenHeight: modelData.height property real screenHeight: modelData.height
property real scrollX: 50
property real scrollY: 50
fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wp_fade.frag.qsb") fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wp_fade.frag.qsb")
} }
} }
@@ -1048,6 +1061,7 @@ Variants {
currentWallpaper.layer.enabled = false; currentWallpaper.layer.enabled = false;
nextWallpaper.layer.enabled = false; nextWallpaper.layer.enabled = false;
root.effectActive = false; root.effectActive = false;
root.changePending = false;
if (!root.pendingWallpaper) if (!root.pendingWallpaper)
return; return;
+4 -1
View File
@@ -55,6 +55,7 @@ Singleton {
signal evdevStateUpdate(var data) signal evdevStateUpdate(var data)
signal gammaStateUpdate(var data) signal gammaStateUpdate(var data)
signal themeAutoStateUpdate(var data) signal themeAutoStateUpdate(var data)
signal wallpaperCycleUpdate(var data)
signal openUrlRequested(string url) signal openUrlRequested(string url)
signal appPickerRequested(var data) signal appPickerRequested(var data)
signal screensaverStateUpdate(var data) signal screensaverStateUpdate(var data)
@@ -68,7 +69,7 @@ Singleton {
property bool screensaverInhibited: false property bool screensaverInhibited: false
property var screensaverInhibitors: [] 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: { Component.onCompleted: {
if (socketPath && socketPath.length > 0) { if (socketPath && socketPath.length > 0) {
@@ -371,6 +372,8 @@ Singleton {
gammaStateUpdate(data); gammaStateUpdate(data);
} else if (service === "theme.auto") { } else if (service === "theme.auto") {
themeAutoStateUpdate(data); themeAutoStateUpdate(data);
} else if (service === "wallpaper") {
wallpaperCycleUpdate(data);
} else if (service === "browser.open_requested") { } else if (service === "browser.open_requested") {
if (data.target) { if (data.target) {
if (data.requestType === "url" || !data.requestType) { if (data.requestType === "url" || !data.requestType) {
+144 -355
View File
@@ -22,62 +22,37 @@ Singleton {
return false; return false;
} }
readonly property bool shouldPauseCycling: fullscreenShowing || SessionService.locked readonly property bool shouldPauseCycling: fullscreenShowing || SessionService.locked
property string cachedCyclingTime: SessionData.wallpaperCyclingTime readonly property bool serverSchedulingAvailable: DMSService.capabilities.includes("wallpaper")
property int cachedCyclingInterval: SessionData.wallpaperCyclingInterval property real lastCycleSeq: -1
property string lastTimeCheck: ""
property var monitorTimers: ({})
property var monitorLastTimeChecks: ({})
property var monitorProcesses: ({}) property var monitorProcesses: ({})
Component { Connections {
id: monitorTimerComponent target: DMSService
Timer {
property string targetScreen: "" function onWallpaperCycleUpdate(data) {
running: false if (!data)
repeat: true return;
onTriggered: { const seq = data.cycleSeq || 0;
if (typeof WallpaperCyclingService !== "undefined" && targetScreen !== "" && !WallpaperCyclingService.shouldPauseCycling) { if (lastCycleSeq < 0) {
WallpaperCyclingService.cycleNextForMonitor(targetScreen); lastCycleSeq = seq;
} return;
}
if (seq <= lastCycleSeq)
return;
lastCycleSeq = seq;
if (shouldPauseCycling)
return;
const target = data.target || "";
if (target === "") {
cycleToNextWallpaper();
} else {
cycleNextForMonitor(target);
} }
} }
}
Component { function onCapabilitiesReceived() {
id: monitorProcessComponent lastCycleSeq = -1;
Process { updateCyclingState();
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);
}
}
}
}
}
} }
} }
@@ -93,17 +68,11 @@ Singleton {
} }
function onWallpaperCyclingIntervalChanged() { function onWallpaperCyclingIntervalChanged() {
cachedCyclingInterval = SessionData.wallpaperCyclingInterval; updateCyclingState();
if (SessionData.wallpaperCyclingMode === "interval") {
updateCyclingState();
}
} }
function onWallpaperCyclingTimeChanged() { function onWallpaperCyclingTimeChanged() {
cachedCyclingTime = SessionData.wallpaperCyclingTime; updateCyclingState();
if (SessionData.wallpaperCyclingMode === "time") {
updateCyclingState();
}
} }
function onPerMonitorWallpaperChanged() { function onPerMonitorWallpaperChanged() {
@@ -119,225 +88,117 @@ Singleton {
target: SessionService target: SessionService
function onSessionUnlocked() { function onSessionUnlocked() {
if (SessionData.wallpaperCyclingEnabled || SessionData.perMonitorWallpaper) { updateCyclingState();
updateCyclingState();
}
} }
} }
function updateCyclingState() { function updateCyclingState() {
if (SessionData.perMonitorWallpaper) { cyclingActive = serverSchedulingAvailable && (SessionData.wallpaperCyclingEnabled || SessionData.perMonitorWallpaper);
stopCycling(); pushConfigToServer();
updatePerMonitorCycling();
} else if (SessionData.wallpaperCyclingEnabled && SessionData.wallpaperPath) {
startCycling();
stopAllMonitorCycling();
} else {
stopCycling();
stopAllMonitorCycling();
}
} }
function updatePerMonitorCycling() { function buildServerConfig() {
if (typeof Quickshell === "undefined") 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; return;
var screens = Quickshell.screens; DMSService.sendRequest("wallpaper.setConfig", {
for (var i = 0; i < screens.length; i++) { "config": buildServerConfig()
var screenName = screens[i].name; }, null);
var settings = SessionData.getMonitorCyclingSettings(screenName);
var wallpaper = SessionData.getMonitorWallpaper(screenName);
if (settings.enabled && wallpaper && !wallpaper.startsWith("#")) {
startMonitorCycling(screenName, settings);
} else {
stopMonitorCycling(screenName);
}
}
} }
function stopAllMonitorCycling() { function findCommand(wallpaperDir) {
var screenNames = Object.keys(monitorTimers); 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`];
for (var i = 0; i < screenNames.length; i++) {
stopMonitorCycling(screenNames[i]);
}
} }
function startCycling() { function monitorProcessFor(screenName) {
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;
}
var process = monitorProcesses[screenName]; var process = monitorProcesses[screenName];
if (process) { if (process)
process.destroy(); return process;
var newProcesses = Object.assign({}, monitorProcesses); var newProcesses = Object.assign({}, monitorProcesses);
delete newProcesses[screenName]; process = monitorProcessComponent.createObject(root);
monitorProcesses = newProcesses; 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); var globalProcess = goToPrevious ? prevCyclingProcess : cyclingProcess;
delete newChecks[screenName]; globalProcess.command = findCommand(wallpaperDir);
monitorLastTimeChecks = newChecks; globalProcess.targetScreenName = screenName || "";
globalProcess.currentWallpaper = currentWallpaper;
globalProcess.running = true;
} }
function cycleToNextWallpaper(screenName, wallpaperPath) { function cycleToNextWallpaper(screenName, wallpaperPath) {
const currentWallpaper = wallpaperPath || SessionData.wallpaperPath; cycle(screenName, wallpaperPath, false);
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;
}
} }
function cycleToPrevWallpaper(screenName, wallpaperPath) { function cycleToPrevWallpaper(screenName, wallpaperPath) {
const currentWallpaper = wallpaperPath || SessionData.wallpaperPath; cycle(screenName, wallpaperPath, true);
if (!currentWallpaper) }
function resetScheduleAfterManual() {
if (!serverSchedulingAvailable)
return; return;
const wallpaperDir = currentWallpaper.substring(0, currentWallpaper.lastIndexOf('/')); DMSService.sendRequest("wallpaper.trigger", {
"target": ""
if (screenName && monitorProcessComponent && monitorProcessComponent.status === Component.Ready) { }, null);
// 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;
}
} }
function cycleNextManually() { function cycleNextManually() {
if (SessionData.wallpaperPath) { if (!SessionData.wallpaperPath)
cycleToNextWallpaper(); return;
// Restart timers if cycling is active cycleToNextWallpaper();
if (cyclingActive && SessionData.wallpaperCyclingEnabled) { resetScheduleAfterManual();
if (SessionData.wallpaperCyclingMode === "interval") {
intervalTimer.interval = cachedCyclingInterval * 1000;
intervalTimer.restart();
}
}
}
} }
function cyclePrevManually() { function cyclePrevManually() {
if (SessionData.wallpaperPath) { if (!SessionData.wallpaperPath)
cycleToPrevWallpaper(); return;
// Restart timers if cycling is active cycleToPrevWallpaper();
if (cyclingActive && SessionData.wallpaperCyclingEnabled) { resetScheduleAfterManual();
if (SessionData.wallpaperCyclingMode === "interval") {
intervalTimer.interval = cachedCyclingInterval * 1000;
intervalTimer.restart();
}
}
}
} }
function cycleNextForMonitor(screenName) { function cycleNextForMonitor(screenName) {
@@ -358,138 +219,66 @@ Singleton {
} }
} }
function checkTimeBasedCycling() { function applyCycledWallpaper(text, currentPath, targetScreenName, goToPrevious) {
if (shouldPauseCycling) if (!text || !text.trim())
return; 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) { let targetIndex;
if (currentTime === cachedCyclingTime && currentTime !== lastTimeCheck) { if (goToPrevious) {
lastTimeCheck = currentTime; targetIndex = currentIndex === 0 ? wallpaperList.length - 1 : currentIndex - 1;
cycleToNextWallpaper();
} else if (currentTime !== cachedCyclingTime) {
lastTimeCheck = "";
}
} else { } else {
checkPerMonitorTimeBasedCycling(currentTime); targetIndex = (currentIndex + 1) % wallpaperList.length;
} }
} const targetWallpaper = wallpaperList[targetIndex];
if (!targetWallpaper || targetWallpaper === currentPath)
function checkPerMonitorTimeBasedCycling(currentTime) {
if (typeof Quickshell === "undefined")
return; 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("#")) { if (targetScreenName) {
var lastCheck = monitorLastTimeChecks[screenName] || ""; SessionData.setMonitorWallpaper(targetScreenName, targetWallpaper);
} else {
if (currentTime === settings.time && currentTime !== lastCheck) { SessionData.setWallpaper(targetWallpaper);
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;
}
}
} }
} }
Timer { Component {
id: intervalTimer id: monitorProcessComponent
interval: cachedCyclingInterval * 1000 Process {
running: false property string targetScreenName: ""
repeat: true property string currentWallpaper: ""
onTriggered: { property bool goToPrevious: false
if (shouldPauseCycling) running: false
return; stdout: StdioCollector {
cycleToNextWallpaper(); onStreamFinished: root.applyCycledWallpaper(text, currentWallpaper, targetScreenName, goToPrevious)
}
}
SystemClock {
id: systemClock
precision: SystemClock.Minutes
onDateChanged: {
if ((SessionData.wallpaperCyclingMode === "time" && cyclingActive) || SessionData.perMonitorWallpaper) {
checkTimeBasedCycling();
} }
} }
} }
Process { Process {
id: cyclingProcess id: cyclingProcess
property string targetScreenName: "" property string targetScreenName: ""
property string currentWallpaper: "" property string currentWallpaper: ""
property bool goToPrevious: false
running: false running: false
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: { onStreamFinished: root.applyCycledWallpaper(text, cyclingProcess.currentWallpaper, cyclingProcess.targetScreenName, cyclingProcess.goToPrevious)
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);
}
}
}
}
} }
} }
Process { Process {
id: prevCyclingProcess id: prevCyclingProcess
property string targetScreenName: "" property string targetScreenName: ""
property string currentWallpaper: "" property string currentWallpaper: ""
property bool goToPrevious: true
running: false running: false
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: { onStreamFinished: root.applyCycledWallpaper(text, prevCyclingProcess.currentWallpaper, prevCyclingProcess.targetScreenName, prevCyclingProcess.goToPrevious)
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);
}
}
}
}
} }
} }
} }