The extension's own log showed rotation kept firing ("Wallpaper
changed to ...") after live wallpaper playback had already started.
Each rotation change writes org.gnome.desktop.background's
picture-uri, which makes GNOME Shell repaint its own background actor
into the same _backgroundGroup our live wallpaper actor lives in,
landing on top of it and hiding the video/GIF entirely.
LiveWallpaperManager now takes an onActiveChanged callback, invoked
only on actual start/stop transitions, which extension.js uses to
suspend/unsuspend RotationManager. This is tracked separately from
user-initiated pause()/resume() so turning live wallpaper off restores
whatever rotation state the user actually had, and next()/previous()
now no-op while suspended so manual/forced-OLED wallpaper changes
can't sneak one in either. The indicator's "Next Wallpaper" item is
greyed out to match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RDqbdjsiisSU7CbQke4g8
246 lines
8.2 KiB
JavaScript
246 lines
8.2 KiB
JavaScript
import GLib from 'gi://GLib';
|
|
import Gio from 'gi://Gio';
|
|
import St from 'gi://St';
|
|
import Clutter from 'gi://Clutter';
|
|
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
|
|
|
import {SettingsKey} from './settingsKeys.js';
|
|
import {listImagesInFolders} from './wallpaperSource.js';
|
|
import {ShuffleBag} from './shuffleBag.js';
|
|
|
|
const BACKGROUND_SCHEMA = 'org.gnome.desktop.background';
|
|
const SCREENSAVER_SCHEMA = 'org.gnome.desktop.screensaver';
|
|
const MIN_INTERVAL_SECONDS = 5;
|
|
|
|
/**
|
|
* Owns the wallpaper image list, the rotation timer, and applying the
|
|
* chosen image to both the desktop and (optionally) the lock screen via
|
|
* their standard GSettings schemas, with an optional crossfade overlay
|
|
* played on top while the change happens underneath.
|
|
*/
|
|
export class RotationManager {
|
|
constructor(settings, logger) {
|
|
this._settings = settings;
|
|
this._logger = logger;
|
|
this._backgroundSettings = new Gio.Settings({schema_id: BACKGROUND_SCHEMA});
|
|
this._screensaverSettings = new Gio.Settings({schema_id: SCREENSAVER_SCHEMA});
|
|
|
|
this._images = [];
|
|
this._sequentialIndex = -1;
|
|
this._shuffleBag = new ShuffleBag();
|
|
this._timeoutId = 0;
|
|
this._settingsSignals = [];
|
|
this._paused = false;
|
|
this._suspended = false;
|
|
this._lastChangeTime = GLib.get_monotonic_time();
|
|
this._currentPath = null;
|
|
this._transitionOverlays = [];
|
|
}
|
|
|
|
enable() {
|
|
this._settingsSignals.push(
|
|
this._settings.connect(`changed::${SettingsKey.WALLPAPER_FOLDERS}`, () => this._reloadImages()),
|
|
this._settings.connect(`changed::${SettingsKey.ROTATION_ENABLED}`, () => this._restartTimer()),
|
|
this._settings.connect(`changed::${SettingsKey.ROTATION_INTERVAL_SECONDS}`, () => this._restartTimer()),
|
|
this._settings.connect(`changed::${SettingsKey.ROTATION_MODE}`, () => this._onModeChanged())
|
|
);
|
|
|
|
this._reloadImages().catch(e => this._logger.error(e, 'Failed to load wallpaper folders'));
|
|
this._restartTimer();
|
|
}
|
|
|
|
disable() {
|
|
for (const id of this._settingsSignals)
|
|
this._settings.disconnect(id);
|
|
this._settingsSignals = [];
|
|
|
|
this._clearTimer();
|
|
this._clearTransitionOverlays();
|
|
}
|
|
|
|
get currentPath() {
|
|
return this._currentPath;
|
|
}
|
|
|
|
get hasImages() {
|
|
return this._images.length > 0;
|
|
}
|
|
|
|
get isPaused() {
|
|
return this._paused;
|
|
}
|
|
|
|
get secondsSinceLastChange() {
|
|
return (GLib.get_monotonic_time() - this._lastChangeTime) / GLib.USEC_PER_SEC;
|
|
}
|
|
|
|
async _reloadImages() {
|
|
const folders = this._settings.get_strv(SettingsKey.WALLPAPER_FOLDERS);
|
|
this._images = folders.length > 0 ? await listImagesInFolders(folders) : [];
|
|
this._shuffleBag.setItems(this._images);
|
|
this._sequentialIndex = -1;
|
|
this._logger.debug(`Loaded ${this._images.length} wallpaper(s) from ${folders.length} folder(s)`);
|
|
}
|
|
|
|
_onModeChanged() {
|
|
this._sequentialIndex = -1;
|
|
this._shuffleBag.setItems(this._images);
|
|
}
|
|
|
|
_restartTimer() {
|
|
this._clearTimer();
|
|
|
|
if (this._paused || this._suspended || !this._settings.get_boolean(SettingsKey.ROTATION_ENABLED))
|
|
return;
|
|
|
|
const interval = Math.max(
|
|
MIN_INTERVAL_SECONDS, this._settings.get_uint(SettingsKey.ROTATION_INTERVAL_SECONDS));
|
|
|
|
this._timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, interval, () => {
|
|
this.next().catch(e => this._logger.error(e, 'Automatic rotation failed'));
|
|
return GLib.SOURCE_CONTINUE;
|
|
});
|
|
}
|
|
|
|
_clearTimer() {
|
|
if (this._timeoutId) {
|
|
GLib.source_remove(this._timeoutId);
|
|
this._timeoutId = 0;
|
|
}
|
|
}
|
|
|
|
pause() {
|
|
this._paused = true;
|
|
this._clearTimer();
|
|
}
|
|
|
|
resume() {
|
|
this._paused = false;
|
|
this._restartTimer();
|
|
}
|
|
|
|
/**
|
|
* Distinct from user-initiated pause(): called while a live wallpaper
|
|
* is actually covering the desktop, so rotation doesn't keep changing
|
|
* a static image nobody can see (which also churns the shell's own
|
|
* background actor on top of the live wallpaper's). Resuming restores
|
|
* whatever the user's own pause() state was, rather than forcing
|
|
* rotation back on.
|
|
*/
|
|
suspend() {
|
|
this._suspended = true;
|
|
this._clearTimer();
|
|
}
|
|
|
|
unsuspend() {
|
|
this._suspended = false;
|
|
this._restartTimer();
|
|
}
|
|
|
|
async next({forced = false} = {}) {
|
|
if (this._suspended) {
|
|
this._logger.debug('Rotation suspended while live wallpaper is active');
|
|
return;
|
|
}
|
|
|
|
if (this._images.length === 0)
|
|
await this._reloadImages();
|
|
|
|
if (this._images.length === 0) {
|
|
this._logger.debug('No wallpapers available to rotate to');
|
|
return;
|
|
}
|
|
|
|
const mode = this._settings.get_string(SettingsKey.ROTATION_MODE);
|
|
let path;
|
|
if (mode === 'sequential') {
|
|
this._sequentialIndex = (this._sequentialIndex + 1) % this._images.length;
|
|
path = this._images[this._sequentialIndex];
|
|
} else {
|
|
path = this._shuffleBag.next();
|
|
}
|
|
|
|
if (path)
|
|
await this._applyWallpaper(path);
|
|
|
|
if (forced)
|
|
this._logger.debug('Wallpaper change forced (OLED protection)');
|
|
}
|
|
|
|
async previous() {
|
|
if (this._suspended || this._images.length === 0)
|
|
return;
|
|
|
|
const mode = this._settings.get_string(SettingsKey.ROTATION_MODE);
|
|
if (mode === 'sequential') {
|
|
this._sequentialIndex = (this._sequentialIndex - 1 + this._images.length) % this._images.length;
|
|
await this._applyWallpaper(this._images[this._sequentialIndex]);
|
|
} else {
|
|
await this.next();
|
|
}
|
|
}
|
|
|
|
async _applyWallpaper(path) {
|
|
const previousPath = this._currentPath;
|
|
const uri = GLib.filename_to_uri(path, null);
|
|
|
|
if (this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED) && previousPath)
|
|
this._playCrossfade(previousPath);
|
|
|
|
this._backgroundSettings.set_string('picture-uri', uri);
|
|
if (this._backgroundSettings.settings_schema.has_key('picture-uri-dark'))
|
|
this._backgroundSettings.set_string('picture-uri-dark', uri);
|
|
|
|
if (this._settings.get_boolean(SettingsKey.APPLY_TO_LOCK_SCREEN))
|
|
this._screensaverSettings.set_string('picture-uri', uri);
|
|
|
|
this._currentPath = path;
|
|
this._lastChangeTime = GLib.get_monotonic_time();
|
|
this._logger.debug(`Wallpaper changed to ${path}`);
|
|
}
|
|
|
|
/**
|
|
* The real background actor doesn't crossfade on its own, so we paint the
|
|
* *old* image full-screen on a throwaway overlay right as the new image
|
|
* is set underneath, then fade the overlay out to reveal it.
|
|
*/
|
|
_playCrossfade(previousPath) {
|
|
this._clearTransitionOverlays();
|
|
|
|
const durationMs = this._settings.get_uint(SettingsKey.TRANSITION_DURATION_MS);
|
|
const uri = GLib.filename_to_uri(previousPath, null).replace(/"/g, '%22');
|
|
|
|
for (const monitor of Main.layoutManager.monitors) {
|
|
const overlay = new St.Widget({
|
|
reactive: false,
|
|
x: monitor.x,
|
|
y: monitor.y,
|
|
width: monitor.width,
|
|
height: monitor.height,
|
|
style: `background-image: url("${uri}"); background-size: cover; background-position: center;`,
|
|
opacity: 255,
|
|
});
|
|
Main.layoutManager._backgroundGroup.add_child(overlay);
|
|
this._transitionOverlays.push(overlay);
|
|
|
|
overlay.ease({
|
|
opacity: 0,
|
|
duration: durationMs,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
onComplete: () => {
|
|
overlay.destroy();
|
|
const idx = this._transitionOverlays.indexOf(overlay);
|
|
if (idx >= 0)
|
|
this._transitionOverlays.splice(idx, 1);
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
_clearTransitionOverlays() {
|
|
for (const overlay of this._transitionOverlays)
|
|
overlay.destroy();
|
|
this._transitionOverlays = [];
|
|
}
|
|
}
|