Both gnome-shell animations paint their own independent static copy of the desktop background on top of the live wallpaper actor: the workspace-switch swipe (WorkspaceBackground) and the lock screen (UnlockDialog, which already applies native blur). Patch both private _createBackground methods to hide the static clone / attach a shared-content actor so the live video shows through instead, gated on the existing apply-to-lock-screen settings
887 lines
35 KiB
JavaScript
887 lines
35 KiB
JavaScript
import GLib from 'gi://GLib';
|
|
import Gio from 'gi://Gio';
|
|
import Clutter from 'gi://Clutter';
|
|
import Graphene from 'gi://Graphene';
|
|
import St from 'gi://St';
|
|
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
|
import * as WorkspaceAnimation from 'resource:///org/gnome/shell/ui/workspaceAnimation.js';
|
|
import * as UnlockDialogModule from 'resource:///org/gnome/shell/ui/unlockDialog.js';
|
|
|
|
import {SettingsKey} from './settingsKeys.js';
|
|
import {loadGstreamerModules, GSTREAMER_INSTALL_HINT} from './gstreamerAvailability.js';
|
|
|
|
Gio._promisify(Gio.DBusProxy, 'new_for_bus', 'new_for_bus_finish');
|
|
|
|
/**
|
|
* Ways to get a decoded frame onto an actor, tried in order and "pinned"
|
|
* once one works. `new Clutter.Image()` failed with "is not a constructor"
|
|
* in real-world testing (Clutter.Image is apparently no longer directly
|
|
* constructible from GJS in current Mutter); St.ImageContent — the same
|
|
* mechanism gnome-shell's own code uses for uploading raw pixel buffers —
|
|
* is tried first, with the legacy Clutter.Image/set_data path kept only as
|
|
* a fallback for older shells.
|
|
*
|
|
* St.ImageContent.set_bytes() takes a Cogl.Context as its first argument
|
|
* (see js/ui/screenshot.js upstream) — omitting it is what produced
|
|
* "At least 6 arguments required, but only 5 passed".
|
|
*/
|
|
const FRAME_IMAGE_STRATEGIES = [
|
|
{
|
|
name: 'St.ImageContent',
|
|
create: (width, height) => St.ImageContent.new_with_preferred_size(width, height),
|
|
upload: (image, Cogl, data, width, height) =>
|
|
image.set_bytes(
|
|
global.stage.context.get_backend().get_cogl_context(),
|
|
GLib.Bytes.new(data), Cogl.PixelFormat.RGBA_8888, width, height, width * 4),
|
|
},
|
|
{
|
|
name: 'Clutter.Image',
|
|
create: () => new Clutter.Image(),
|
|
upload: (image, Cogl, data, width, height) =>
|
|
image.set_data(data, Cogl.PixelFormat.RGBA_8888, width, height, width * 4),
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Plays a video or animated GIF file as a looping animated background.
|
|
* `playbin` typefinds the source by content rather than extension, so GIFs
|
|
* are decoded through GStreamer's own GIF element (from the "good" plugin
|
|
* set) and handled exactly like any other video stream below this point.
|
|
*
|
|
* Mutter embeds its own private copy of Clutter, so GStreamer video sinks
|
|
* that hand back a Clutter actor from a *different* Clutter instance (e.g.
|
|
* clutterglsink / ClutterGst) cannot be attached to the shell's stage. To
|
|
* stay inside gnome-shell's own Clutter, this decodes frames with a plain
|
|
* GStreamer `appsink` (raw pixels only cross that boundary) and uploads
|
|
* each frame into a `Clutter.Image` set as the content of a normal actor
|
|
* that belongs to gnome-shell itself.
|
|
*/
|
|
export class LiveWallpaperManager {
|
|
constructor(settings, logger, {onActiveChanged} = {}) {
|
|
this._settings = settings;
|
|
this._logger = logger;
|
|
this._onActiveChanged = onActiveChanged ?? (() => {});
|
|
|
|
this._available = false;
|
|
this._active = false;
|
|
this._paused = false;
|
|
this._onBattery = false;
|
|
this._fullscreenActive = false;
|
|
|
|
this._Gst = null;
|
|
this._Cogl = null;
|
|
|
|
this._playbin = null;
|
|
this._appsink = null;
|
|
this._actor = null;
|
|
this._image = null;
|
|
this._videoWidth = 0;
|
|
this._videoHeight = 0;
|
|
|
|
this._settingsSignals = [];
|
|
this._busWatchId = 0;
|
|
this._eosSeekTimeoutId = 0;
|
|
this._lastEosSeekTimeMs = 0;
|
|
this._pendingInitialRateSeek = false;
|
|
this._monitorsChangedId = 0;
|
|
this._fullscreenChangedId = 0;
|
|
this._upowerProxy = null;
|
|
this._upowerSignalId = 0;
|
|
this._powerWatchGeneration = 0;
|
|
|
|
this._frameCount = 0;
|
|
this._pollCount = 0;
|
|
this._pollTimeoutId = 0;
|
|
this._noFrameWatchdogId = 0;
|
|
this._imageStrategyIndex = 0;
|
|
this._frameErrorCount = 0;
|
|
|
|
this._transitionOverlay = null;
|
|
this._fadeInPending = false;
|
|
|
|
this._patchedMethods = [];
|
|
this._lockScreenActors = [];
|
|
}
|
|
|
|
get isAvailable() {
|
|
return this._available;
|
|
}
|
|
|
|
get isActive() {
|
|
return this._active;
|
|
}
|
|
|
|
_setActive(active) {
|
|
if (this._active === active)
|
|
return;
|
|
this._active = active;
|
|
this._onActiveChanged(active);
|
|
}
|
|
|
|
async enable() {
|
|
await this._loadGstreamer();
|
|
|
|
this._settingsSignals.push(
|
|
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_ENABLED}`, () => this._sync()),
|
|
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_PATH}`, () => this._sync()),
|
|
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_MUTED}`, () => this._applyMute()),
|
|
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_DISPLAY_MODE}`, () => this._applyDisplayMode()),
|
|
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE}`, () => this._applyPlaybackRate()),
|
|
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_PAUSE_ON_BATTERY}`, () => this._updatePauseState()),
|
|
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_PAUSE_WHEN_FULLSCREEN}`, () => this._updatePauseState())
|
|
);
|
|
|
|
this._monitorsChangedId = Main.layoutManager.connect('monitors-changed', () => this._layoutActor());
|
|
|
|
this._patchShellIntegration();
|
|
this._sync();
|
|
}
|
|
|
|
disable() {
|
|
for (const id of this._settingsSignals)
|
|
this._settings.disconnect(id);
|
|
this._settingsSignals = [];
|
|
|
|
if (this._monitorsChangedId) {
|
|
Main.layoutManager.disconnect(this._monitorsChangedId);
|
|
this._monitorsChangedId = 0;
|
|
}
|
|
|
|
this._unpatchShellIntegration();
|
|
this._stop();
|
|
this._clearTransition();
|
|
}
|
|
|
|
/**
|
|
* Two of gnome-shell's own animations paint a *second*, independent
|
|
* background on top of ours, hiding the live wallpaper behind a frozen
|
|
* static image: the workspace-switch swipe (which clones the desktop
|
|
* picture per-workspace so it can slide, see WorkspaceBackground in
|
|
* workspaceAnimation.js) and the lock screen (which blurs its own
|
|
* separate copy of the desktop picture, see UnlockDialog in
|
|
* unlockDialog.js). Neither exposes a public hook for this, so both are
|
|
* patched at the prototype level — wrapped in try/catch per-patch since
|
|
* this reaches into shell-private methods that could rename across
|
|
* versions, and one patch failing shouldn't take down the other.
|
|
*/
|
|
_patchShellIntegration() {
|
|
const manager = this;
|
|
|
|
try {
|
|
const {WorkspaceBackground} = WorkspaceAnimation;
|
|
this._patchMethod(WorkspaceBackground.prototype, '_createBackground', original => function (...args) {
|
|
const result = original.call(this, ...args);
|
|
// Only the freshly-added static background clone is hidden
|
|
// (not the whole WorkspaceBackground group), so any real
|
|
// desktop-icon windows it also hosts stay visible.
|
|
const staticBackground = this.get_last_child();
|
|
if (manager.isActive && staticBackground)
|
|
staticBackground.visible = false;
|
|
return result;
|
|
});
|
|
} catch (e) {
|
|
this._logger.debug(`Workspace-switch live wallpaper integration unavailable (${e.message ?? e})`);
|
|
}
|
|
|
|
try {
|
|
const {UnlockDialog} = UnlockDialogModule;
|
|
this._patchMethod(UnlockDialog.prototype, '_createBackground', original => function (monitorIndex) {
|
|
const result = original.call(this, monitorIndex);
|
|
manager._attachLockScreenActor(this._backgroundGroup.get_last_child());
|
|
return result;
|
|
});
|
|
} catch (e) {
|
|
this._logger.debug(`Lock screen live wallpaper integration unavailable (${e.message ?? e})`);
|
|
}
|
|
}
|
|
|
|
_patchMethod(obj, name, wrap) {
|
|
const original = obj[name];
|
|
obj[name] = wrap(original);
|
|
this._patchedMethods.push({obj, name, original});
|
|
}
|
|
|
|
_unpatchShellIntegration() {
|
|
for (const {obj, name, original} of this._patchedMethods)
|
|
obj[name] = original;
|
|
this._patchedMethods = [];
|
|
this._lockScreenActors = [];
|
|
}
|
|
|
|
/**
|
|
* Adds an actor sharing our live frame's Content onto the lock screen's
|
|
* already-blurred per-monitor background widget (native Shell.BlurEffect
|
|
* radius 90, see UnlockDialog._createBackground) — reusing gnome-shell's
|
|
* own blur rather than applying a second one. Content is a step behind
|
|
* a frame: _updateFrame() re-points every tracked actor at `_image`
|
|
* each time a new one is decoded, so this needs no per-frame decode of
|
|
* its own and needs no update here if playback hasn't started yet
|
|
* (nothing to show is just transparent, revealing the native static
|
|
* background beneath).
|
|
*/
|
|
_attachLockScreenActor(container) {
|
|
if (!container || !this._available || !this._settings.get_boolean(SettingsKey.APPLY_TO_LOCK_SCREEN))
|
|
return;
|
|
|
|
const actor = new Clutter.Actor({
|
|
x: 0, y: 0,
|
|
width: container.width,
|
|
height: container.height,
|
|
content: this._image,
|
|
content_gravity: Clutter.ContentGravity.RESIZE_FILL,
|
|
});
|
|
container.add_child(actor);
|
|
this._lockScreenActors.push(actor);
|
|
actor.connect('destroy', () => {
|
|
this._lockScreenActors = this._lockScreenActors.filter(a => a !== actor);
|
|
});
|
|
}
|
|
|
|
async _loadGstreamer() {
|
|
try {
|
|
const {Gst, Cogl} = await loadGstreamerModules();
|
|
this._Gst = Gst;
|
|
this._Cogl = Cogl;
|
|
this._available = true;
|
|
} catch (e) {
|
|
this._available = false;
|
|
this._logger.warn(
|
|
`Live wallpaper unavailable: GStreamer/Cogl introspection bindings could not be loaded (${e.message ?? e}).\n${GSTREAMER_INSTALL_HINT}`);
|
|
}
|
|
}
|
|
|
|
_sync() {
|
|
const shouldRun = this._available &&
|
|
this._settings.get_boolean(SettingsKey.LIVE_WALLPAPER_ENABLED) &&
|
|
this._settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH) !== '';
|
|
|
|
if (shouldRun)
|
|
this._active ? this._restart() : this._start();
|
|
else if (this._active)
|
|
this._stop({animate: true});
|
|
}
|
|
|
|
/**
|
|
* Switching to a different file while already active. If transitions
|
|
* are on, the outgoing actor (already showing the old file's last
|
|
* frame) is detached before _stop() would destroy it, so it can be
|
|
* kept on screen and eased away by _playTransition() after _start()
|
|
* has the new one playing underneath — a crossfade between the two.
|
|
*/
|
|
_restart() {
|
|
const transitionsOn = this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED);
|
|
const outgoingActor = transitionsOn ? this._actor : null;
|
|
if (outgoingActor)
|
|
this._actor = null;
|
|
|
|
this._stop();
|
|
this._start();
|
|
|
|
if (outgoingActor)
|
|
this._playTransition(outgoingActor);
|
|
}
|
|
|
|
/**
|
|
* Builds videoconvert ! videoscale ! capsfilter(RGBA) ! appsink using
|
|
* explicit element creation, linking, and a ghost pad, rather than
|
|
* Gst.parse_bin_from_description()'s gst-launch mini-language. Both
|
|
* are meant to be equivalent, but a user's testing showed the
|
|
* string-parsed version's appsink never fired 'new-sample' even once
|
|
* inside gnome-shell's process while an equivalent standalone
|
|
* gst-launch-1.0 pipeline worked fine — this removes the string
|
|
* parsing (and its automatic ghost-pad detection) as a variable, and
|
|
* surfaces link() failures explicitly instead of failing silently.
|
|
*/
|
|
_buildSinkBin() {
|
|
const Gst = this._Gst;
|
|
|
|
const videoconvert = Gst.ElementFactory.make('videoconvert', 'benthicbloom-convert');
|
|
const videoscale = Gst.ElementFactory.make('videoscale', 'benthicbloom-scale');
|
|
const capsfilter = Gst.ElementFactory.make('capsfilter', 'benthicbloom-capsfilter');
|
|
const appsink = Gst.ElementFactory.make('appsink', 'benthicbloom-appsink');
|
|
|
|
if (!videoconvert || !videoscale || !capsfilter || !appsink) {
|
|
throw new Error(
|
|
'Failed to create one or more GStreamer elements ' +
|
|
'(videoconvert/videoscale/capsfilter/appsink) — a required plugin is likely missing');
|
|
}
|
|
|
|
capsfilter.set_property('caps', Gst.Caps.from_string('video/x-raw,format=RGBA'));
|
|
// emit-signals is deliberately left off: appsink's 'new-sample' fires
|
|
// from GStreamer's streaming thread, not gnome-shell's main thread,
|
|
// and a cross-thread call into the JS engine can be silently dropped
|
|
// rather than invoked. We poll with try_pull_sample() from a main
|
|
// thread GLib timer instead, which is safe to call from any thread.
|
|
appsink.set_property('max-buffers', 2);
|
|
appsink.set_property('drop', true);
|
|
appsink.set_property('sync', true);
|
|
|
|
const sinkBin = new Gst.Bin({name: 'benthicbloom-sinkbin'});
|
|
sinkBin.add(videoconvert);
|
|
sinkBin.add(videoscale);
|
|
sinkBin.add(capsfilter);
|
|
sinkBin.add(appsink);
|
|
|
|
if (!videoconvert.link(videoscale))
|
|
throw new Error('Failed to link videoconvert -> videoscale');
|
|
if (!videoscale.link(capsfilter))
|
|
throw new Error('Failed to link videoscale -> capsfilter');
|
|
if (!capsfilter.link(appsink))
|
|
throw new Error('Failed to link capsfilter -> appsink');
|
|
|
|
const sinkPad = videoconvert.get_static_pad('sink');
|
|
const ghostPad = Gst.GhostPad.new('sink', sinkPad);
|
|
if (!ghostPad)
|
|
throw new Error('Failed to create ghost pad for live wallpaper sink bin');
|
|
ghostPad.set_active(true);
|
|
sinkBin.add_pad(ghostPad);
|
|
|
|
this._appsink = appsink;
|
|
return sinkBin;
|
|
}
|
|
|
|
_start() {
|
|
const path = this._settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH);
|
|
if (!this._available || !path)
|
|
return;
|
|
|
|
const Gst = this._Gst;
|
|
|
|
try {
|
|
this._actor = new Clutter.Actor({
|
|
reactive: false,
|
|
pivot_point: new Graphene.Point({x: 0.5, y: 0.5}),
|
|
});
|
|
Main.layoutManager._backgroundGroup.add_child(this._actor);
|
|
this._applyDisplayMode();
|
|
|
|
this._fadeInPending = this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED);
|
|
if (this._fadeInPending)
|
|
this._actor.opacity = 0;
|
|
|
|
const sinkBin = this._buildSinkBin();
|
|
|
|
this._playbin = Gst.ElementFactory.make('playbin', 'benthicbloom-live-wallpaper');
|
|
this._playbin.set_property('video-sink', sinkBin);
|
|
this._playbin.set_property('uri', GLib.filename_to_uri(path, null));
|
|
|
|
const bus = this._playbin.get_bus();
|
|
bus.add_signal_watch();
|
|
this._busWatchId = bus.connect('message', (_bus, message) => this._onBusMessage(message));
|
|
|
|
this._applyMute();
|
|
const stateChangeResult = this._playbin.set_state(Gst.State.PLAYING);
|
|
this._logger.debug(`playbin.set_state(PLAYING) returned ${stateChangeResult}`);
|
|
// Seeking here immediately would race the pipeline's preroll —
|
|
// GStreamer can only seek once a state change has actually
|
|
// completed, so an early seek is silently dropped and playback
|
|
// sticks at native 1.0x. Instead, defer to the first ASYNC_DONE
|
|
// bus message (handled in _onBusMessage), which fires once the
|
|
// pipeline is actually ready to seek.
|
|
const initialRate = this._settings.get_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE);
|
|
this._pendingInitialRateSeek = initialRate !== 1.0;
|
|
this._logger.debug(
|
|
`Live wallpaper: configured playback rate ${initialRate}, ` +
|
|
`deferring initial seek to ASYNC_DONE: ${this._pendingInitialRateSeek}`);
|
|
|
|
this._frameCount = 0;
|
|
this._pollCount = 0;
|
|
this._imageStrategyIndex = 0;
|
|
this._frameErrorCount = 0;
|
|
// ~30fps polling of the appsink from the main thread. See the
|
|
// comment on appsink's properties above for why this replaces
|
|
// a 'new-sample' signal handler.
|
|
this._pollTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 33, () => this._pollForSample());
|
|
|
|
this._noFrameWatchdogId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 4, () => {
|
|
this._noFrameWatchdogId = 0;
|
|
if (this._active && this._frameCount === 0) {
|
|
this._logger.warn(
|
|
`Live wallpaper: no frames received 4s after starting playback ` +
|
|
`(polled appsink ${this._pollCount} times). If that count is 0, the poll timer ` +
|
|
'itself never ran; if it is nonzero, try_pull_sample() keeps returning nothing ' +
|
|
'— check for a "Failed to poll live wallpaper frame" error above.');
|
|
}
|
|
return GLib.SOURCE_REMOVE;
|
|
});
|
|
|
|
this._setActive(true);
|
|
this._paused = false;
|
|
this._connectPowerWatches();
|
|
this._logger.debug(`Live wallpaper started: ${path}`);
|
|
} catch (e) {
|
|
this._logger.error(e, 'Failed to start live wallpaper');
|
|
this._stop();
|
|
}
|
|
}
|
|
|
|
_pollForSample() {
|
|
if (!this._appsink)
|
|
return GLib.SOURCE_REMOVE;
|
|
|
|
const Gst = this._Gst;
|
|
this._pollCount++;
|
|
if (this._pollCount === 1)
|
|
this._logger.debug('Live wallpaper: appsink polling started');
|
|
|
|
try {
|
|
const sample = this._appsink.try_pull_sample(0);
|
|
if (!sample)
|
|
return GLib.SOURCE_CONTINUE;
|
|
|
|
const buffer = sample.get_buffer();
|
|
const structure = sample.get_caps().get_structure(0);
|
|
const [, width] = structure.get_int('width');
|
|
const [, height] = structure.get_int('height');
|
|
|
|
const [ok, mapInfo] = buffer.map(Gst.MapFlags.READ);
|
|
if (!ok) {
|
|
this._logger.debug('Live wallpaper: buffer.map() failed');
|
|
return GLib.SOURCE_CONTINUE;
|
|
}
|
|
|
|
try {
|
|
this._updateFrame(mapInfo.data, width, height);
|
|
this._frameCount++;
|
|
if (this._frameCount === 1) {
|
|
this._logger.debug(`Live wallpaper: first frame received (${width}x${height})`);
|
|
if (this._fadeInPending) {
|
|
this._fadeInPending = false;
|
|
this._actor.ease({
|
|
opacity: 255,
|
|
duration: this._settings.get_uint(SettingsKey.TRANSITION_DURATION_MS),
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
});
|
|
}
|
|
} else if (this._frameCount % 120 === 0)
|
|
this._logger.debug(`Live wallpaper: ${this._frameCount} frames rendered so far`);
|
|
} finally {
|
|
buffer.unmap(mapInfo);
|
|
}
|
|
} catch (e) {
|
|
// Rate-limited: this runs at ~30fps, so logging every failure
|
|
// would flood the journal (and this loop keeps retrying every
|
|
// frame, e.g. while cycling through FRAME_IMAGE_STRATEGIES).
|
|
this._frameErrorCount++;
|
|
if (this._frameErrorCount === 1 || this._frameErrorCount % 300 === 0) {
|
|
this._logger.error(
|
|
e, `Failed to poll live wallpaper frame (${this._frameErrorCount} failures so far)`);
|
|
}
|
|
}
|
|
|
|
return GLib.SOURCE_CONTINUE;
|
|
}
|
|
|
|
_updateFrame(data, width, height) {
|
|
if (!this._actor)
|
|
return;
|
|
|
|
const needsNewImage = !this._image || this._videoWidth !== width || this._videoHeight !== height;
|
|
|
|
while (this._imageStrategyIndex < FRAME_IMAGE_STRATEGIES.length) {
|
|
const strategy = FRAME_IMAGE_STRATEGIES[this._imageStrategyIndex];
|
|
try {
|
|
if (needsNewImage) {
|
|
this._image = strategy.create(width, height);
|
|
this._videoWidth = width;
|
|
this._videoHeight = height;
|
|
this._layoutActor();
|
|
}
|
|
strategy.upload(this._image, this._Cogl, data, width, height);
|
|
this._actor.set_content(this._image);
|
|
for (const lockActor of this._lockScreenActors) {
|
|
lockActor.set_content(this._image);
|
|
lockActor.visible = true;
|
|
}
|
|
return;
|
|
} catch (e) {
|
|
this._logger.warn(
|
|
`Live wallpaper: frame image strategy "${strategy.name}" failed ` +
|
|
`(${e.message ?? e}), trying the next one`);
|
|
this._imageStrategyIndex++;
|
|
this._image = null;
|
|
}
|
|
}
|
|
|
|
throw new Error('All live wallpaper frame image strategies failed');
|
|
}
|
|
|
|
_onBusMessage(message) {
|
|
const Gst = this._Gst;
|
|
switch (message.type) {
|
|
case Gst.MessageType.EOS:
|
|
this._loopPlayback();
|
|
break;
|
|
case Gst.MessageType.ASYNC_DONE:
|
|
this._logger.debug(
|
|
`Live wallpaper: ASYNC_DONE received (pending initial rate seek: ${this._pendingInitialRateSeek})`);
|
|
if (this._pendingInitialRateSeek) {
|
|
this._pendingInitialRateSeek = false;
|
|
this._seekToLoopStart();
|
|
}
|
|
break;
|
|
case Gst.MessageType.ERROR: {
|
|
const [error, debug] = message.parse_error();
|
|
this._logger.error(error, `Live wallpaper playback error (${debug ?? 'no debug info'})`);
|
|
this._stop();
|
|
break;
|
|
}
|
|
case Gst.MessageType.WARNING: {
|
|
const [warning, debug] = message.parse_warning();
|
|
this._logger.warn(`Live wallpaper GStreamer warning: ${warning.message} (${debug ?? 'no debug info'})`);
|
|
break;
|
|
}
|
|
case Gst.MessageType.STATE_CHANGED:
|
|
if (message.src === this._playbin) {
|
|
const [, newState] = message.parse_state_changed();
|
|
this._logger.debug(`Live wallpaper pipeline state changed to ${this._stateName(newState)}`);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restarts playback at EOS. `seek()` with GST_SEEK_FLAG_FLUSH blocks
|
|
* the calling thread until the pipeline flushes — and since this whole
|
|
* extension runs on gnome-shell's own main loop, that blocks the entire
|
|
* desktop for as long as it takes. At rate 1.0 that's one blocking call
|
|
* per full playthrough, tolerable; at higher rates a short clip loops
|
|
* (and EOS fires) many times a second, turning it into a sustained
|
|
* back-to-back stall that reads as a hard freeze. Throttling to at most
|
|
* one restart per EOS_SEEK_MIN_INTERVAL_MS gives the main loop room to
|
|
* breathe between them.
|
|
*/
|
|
_loopPlayback() {
|
|
const nowMs = GLib.get_monotonic_time() / 1000;
|
|
const elapsedMs = nowMs - this._lastEosSeekTimeMs;
|
|
const minIntervalMs = 150;
|
|
|
|
if (elapsedMs >= minIntervalMs) {
|
|
this._seekToLoopStart();
|
|
return;
|
|
}
|
|
|
|
if (this._eosSeekTimeoutId)
|
|
return;
|
|
this._eosSeekTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, minIntervalMs - elapsedMs, () => {
|
|
this._eosSeekTimeoutId = 0;
|
|
this._seekToLoopStart();
|
|
return GLib.SOURCE_REMOVE;
|
|
});
|
|
}
|
|
|
|
_seekToLoopStart() {
|
|
if (!this._playbin)
|
|
return;
|
|
|
|
this._lastEosSeekTimeMs = GLib.get_monotonic_time() / 1000;
|
|
const Gst = this._Gst;
|
|
const rate = this._settings.get_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE);
|
|
const ok = this._playbin.seek(
|
|
rate, Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT,
|
|
Gst.SeekType.SET, 0, Gst.SeekType.NONE, -1);
|
|
this._logger.debug(
|
|
`Live wallpaper: loop-restart seek to rate ${rate} ${ok ? 'succeeded' : 'FAILED (seek() returned false)'}`);
|
|
}
|
|
|
|
_stateName(state) {
|
|
const Gst = this._Gst;
|
|
return Object.keys(Gst.State).find(name => Gst.State[name] === state) ?? String(state);
|
|
}
|
|
|
|
_applyMute() {
|
|
if (this._playbin)
|
|
this._playbin.set_property('mute', this._settings.get_boolean(SettingsKey.LIVE_WALLPAPER_MUTED));
|
|
}
|
|
|
|
_applyPlaybackRate() {
|
|
if (!this._playbin || !this._active)
|
|
return;
|
|
|
|
const Gst = this._Gst;
|
|
const rate = this._settings.get_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE);
|
|
const [ok, position] = this._playbin.query_position(Gst.Format.TIME);
|
|
if (!ok) {
|
|
this._logger.debug('Live wallpaper: applyPlaybackRate aborted, query_position() failed');
|
|
return;
|
|
}
|
|
|
|
const seekOk = this._playbin.seek(
|
|
rate, Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE,
|
|
Gst.SeekType.SET, position, Gst.SeekType.NONE, -1);
|
|
this._logger.debug(
|
|
`Live wallpaper: applyPlaybackRate to ${rate} at position ${position} ` +
|
|
`${seekOk ? 'succeeded' : 'FAILED (seek() returned false)'}`);
|
|
}
|
|
|
|
/**
|
|
* Clutter's ContentGravity enum covers fit/stretch/center natively, but
|
|
* has no "cover" (scale-and-crop) value, so "fill" mode is done by hand:
|
|
* the actor is overscanned to the smallest size that fully covers the
|
|
* stage while preserving the video's aspect ratio, centered, and then
|
|
* clipped back down to the stage bounds.
|
|
*/
|
|
_applyDisplayMode() {
|
|
if (!this._actor)
|
|
return;
|
|
|
|
const mode = this._settings.get_string(SettingsKey.LIVE_WALLPAPER_DISPLAY_MODE);
|
|
switch (mode) {
|
|
case 'stretch':
|
|
this._actor.content_gravity = Clutter.ContentGravity.RESIZE_FILL;
|
|
break;
|
|
case 'center':
|
|
this._actor.content_gravity = Clutter.ContentGravity.CENTER;
|
|
break;
|
|
case 'fill':
|
|
this._actor.content_gravity = Clutter.ContentGravity.RESIZE_FILL;
|
|
break;
|
|
case 'fit':
|
|
default:
|
|
this._actor.content_gravity = Clutter.ContentGravity.RESIZE_ASPECT;
|
|
break;
|
|
}
|
|
|
|
this._layoutActor();
|
|
}
|
|
|
|
_layoutActor() {
|
|
if (!this._actor)
|
|
return;
|
|
|
|
const stageWidth = global.stage.width;
|
|
const stageHeight = global.stage.height;
|
|
const mode = this._settings.get_string(SettingsKey.LIVE_WALLPAPER_DISPLAY_MODE);
|
|
|
|
if (mode === 'fill' && this._videoWidth > 0 && this._videoHeight > 0) {
|
|
const scale = Math.max(stageWidth / this._videoWidth, stageHeight / this._videoHeight);
|
|
const scaledWidth = this._videoWidth * scale;
|
|
const scaledHeight = this._videoHeight * scale;
|
|
const offsetX = (stageWidth - scaledWidth) / 2;
|
|
const offsetY = (stageHeight - scaledHeight) / 2;
|
|
|
|
this._actor.set_position(offsetX, offsetY);
|
|
this._actor.set_size(scaledWidth, scaledHeight);
|
|
this._actor.set_clip(-offsetX, -offsetY, stageWidth, stageHeight);
|
|
return;
|
|
}
|
|
|
|
this._actor.remove_clip();
|
|
this._actor.set_position(0, 0);
|
|
this._actor.set_size(stageWidth, stageHeight);
|
|
}
|
|
|
|
_connectPowerWatches() {
|
|
this._disconnectPowerWatches();
|
|
|
|
try {
|
|
this._fullscreenChangedId = global.display.connect('in-fullscreen-changed', () => this._checkFullscreen());
|
|
this._checkFullscreen();
|
|
} catch (e) {
|
|
this._logger.debug(`Fullscreen tracking unavailable, pause-when-fullscreen disabled (${e.message ?? e})`);
|
|
}
|
|
|
|
// Deliberately async: the *_sync() variant blocks gnome-shell's
|
|
// single main thread (it *is* the compositor) until the system bus
|
|
// replies, which can take many seconds — or the full ~25s GDBus
|
|
// timeout — if upowerd is still starting up or the bus is busy,
|
|
// exactly the conditions right after login. That froze the entire
|
|
// screen (and could make the session look hung/crashed) since this
|
|
// runs every time live wallpaper starts, including automatically
|
|
// at login whenever it was left enabled.
|
|
const watchGeneration = ++this._powerWatchGeneration;
|
|
Gio.DBusProxy.new_for_bus(
|
|
Gio.BusType.SYSTEM, Gio.DBusProxyFlags.NONE, null,
|
|
'org.freedesktop.UPower', '/org/freedesktop/UPower', 'org.freedesktop.UPower', null)
|
|
.then(proxy => {
|
|
if (watchGeneration !== this._powerWatchGeneration)
|
|
return; // Superseded by a _stop()/_connectPowerWatches() while this was in flight.
|
|
this._upowerProxy = proxy;
|
|
this._upowerSignalId = proxy.connect('g-properties-changed', () => this._checkBattery());
|
|
this._checkBattery();
|
|
})
|
|
.catch(e => {
|
|
this._logger.debug(`UPower unavailable, pause-on-battery disabled (${e.message ?? e})`);
|
|
});
|
|
}
|
|
|
|
_disconnectPowerWatches() {
|
|
this._powerWatchGeneration++;
|
|
|
|
if (this._fullscreenChangedId) {
|
|
global.display.disconnect(this._fullscreenChangedId);
|
|
this._fullscreenChangedId = 0;
|
|
}
|
|
if (this._upowerProxy && this._upowerSignalId) {
|
|
this._upowerProxy.disconnect(this._upowerSignalId);
|
|
this._upowerSignalId = 0;
|
|
}
|
|
this._upowerProxy = null;
|
|
this._onBattery = false;
|
|
this._fullscreenActive = false;
|
|
}
|
|
|
|
_checkFullscreen() {
|
|
try {
|
|
const nMonitors = global.display.get_n_monitors();
|
|
this._fullscreenActive = Array.from({length: nMonitors}, (_, i) => i)
|
|
.some(i => global.display.get_monitor_in_fullscreen(i));
|
|
} catch (e) {
|
|
this._fullscreenActive = false;
|
|
}
|
|
this._updatePauseState();
|
|
}
|
|
|
|
_checkBattery() {
|
|
const value = this._upowerProxy?.get_cached_property('OnBattery');
|
|
this._onBattery = value ? value.get_boolean() : false;
|
|
this._updatePauseState();
|
|
}
|
|
|
|
_updatePauseState() {
|
|
const shouldPause =
|
|
(this._settings.get_boolean(SettingsKey.LIVE_WALLPAPER_PAUSE_ON_BATTERY) && this._onBattery) ||
|
|
(this._settings.get_boolean(SettingsKey.LIVE_WALLPAPER_PAUSE_WHEN_FULLSCREEN) && this._fullscreenActive);
|
|
this._setPaused(shouldPause);
|
|
}
|
|
|
|
_setPaused(paused) {
|
|
if (!this._playbin || this._paused === paused)
|
|
return;
|
|
this._paused = paused;
|
|
this._playbin.set_state(paused ? this._Gst.State.PAUSED : this._Gst.State.PLAYING);
|
|
}
|
|
|
|
/**
|
|
* `animate: true` is for a user-visible stop (toggling the setting off,
|
|
* or the path being cleared) — the last frame stays on screen and eases
|
|
* away per the shared Transitions setting, revealing the static
|
|
* wallpaper beneath. Left off for restarts (which hand the actor to
|
|
* _playTransition() themselves, see _restart()), errors, and
|
|
* disable(), where an immediate teardown is what's wanted.
|
|
*/
|
|
_stop({animate = false} = {}) {
|
|
this._disconnectPowerWatches();
|
|
|
|
if (this._noFrameWatchdogId) {
|
|
GLib.source_remove(this._noFrameWatchdogId);
|
|
this._noFrameWatchdogId = 0;
|
|
}
|
|
|
|
if (this._pollTimeoutId) {
|
|
GLib.source_remove(this._pollTimeoutId);
|
|
this._pollTimeoutId = 0;
|
|
}
|
|
|
|
if (this._eosSeekTimeoutId) {
|
|
GLib.source_remove(this._eosSeekTimeoutId);
|
|
this._eosSeekTimeoutId = 0;
|
|
}
|
|
|
|
if (this._playbin) {
|
|
const Gst = this._Gst;
|
|
const bus = this._playbin.get_bus();
|
|
if (this._busWatchId) {
|
|
bus.disconnect(this._busWatchId);
|
|
this._busWatchId = 0;
|
|
}
|
|
bus.remove_signal_watch();
|
|
|
|
this._playbin.set_state(Gst.State.NULL);
|
|
// Block briefly for the (normally fast) transition to actually
|
|
// finish before dropping our reference — otherwise the element
|
|
// can get disposed mid-transition, which GStreamer logs as
|
|
// "Trying to dispose element ..., but it is in PLAYING instead
|
|
// of the NULL state".
|
|
this._playbin.get_state(200 * Gst.MSECOND);
|
|
|
|
this._playbin = null;
|
|
this._appsink = null;
|
|
}
|
|
|
|
if (animate && this._actor && this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED)) {
|
|
this._playTransition(this._actor);
|
|
this._actor = null;
|
|
} else {
|
|
this._actor?.destroy();
|
|
this._actor = null;
|
|
}
|
|
this._image = null;
|
|
this._videoWidth = 0;
|
|
this._videoHeight = 0;
|
|
this._fadeInPending = false;
|
|
|
|
// Leaves the frozen last frame's Content in place rather than
|
|
// clearing it — a stopped/paused live wallpaper just hides its lock
|
|
// screen actor, revealing the native static blurred background
|
|
// beneath, without losing the tracked actor if playback resumes.
|
|
for (const lockActor of this._lockScreenActors)
|
|
lockActor.visible = false;
|
|
|
|
this._setActive(false);
|
|
this._paused = false;
|
|
}
|
|
|
|
/**
|
|
* Eases `overlay` — an actor that was just detached from active duty,
|
|
* still showing its last frame — off screen per the Transitions
|
|
* setting (fade/slide/zoom/random, same styles and duration as static
|
|
* rotation), then destroys it. Raising it above everything else first
|
|
* means this works whether it's revealing a freshly started live
|
|
* wallpaper actor underneath (switch) or the static desktop background
|
|
* (stop) — both are already in place by the time this runs.
|
|
*/
|
|
_playTransition(overlay) {
|
|
this._transitionOverlay?.destroy();
|
|
this._transitionOverlay = overlay;
|
|
|
|
Main.layoutManager._backgroundGroup.set_child_above_sibling(overlay, null);
|
|
|
|
const durationMs = this._settings.get_uint(SettingsKey.TRANSITION_DURATION_MS);
|
|
let style = this._settings.get_string(SettingsKey.TRANSITION_STYLE);
|
|
if (style === 'random')
|
|
style = ['fade', 'slide', 'zoom'][Math.floor(Math.random() * 3)];
|
|
|
|
const onComplete = () => {
|
|
if (this._transitionOverlay === overlay)
|
|
this._transitionOverlay = null;
|
|
overlay.destroy();
|
|
};
|
|
|
|
switch (style) {
|
|
case 'slide':
|
|
overlay.ease({
|
|
x: overlay.x - overlay.width,
|
|
duration: durationMs,
|
|
mode: Clutter.AnimationMode.EASE_IN_OUT_QUAD,
|
|
onComplete,
|
|
});
|
|
break;
|
|
case 'zoom':
|
|
overlay.ease({
|
|
scale_x: 1.15,
|
|
scale_y: 1.15,
|
|
opacity: 0,
|
|
duration: durationMs,
|
|
mode: Clutter.AnimationMode.EASE_IN_QUAD,
|
|
onComplete,
|
|
});
|
|
break;
|
|
case 'fade':
|
|
default:
|
|
overlay.ease({
|
|
opacity: 0,
|
|
duration: durationMs,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
onComplete,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
|
|
_clearTransition() {
|
|
this._transitionOverlay?.destroy();
|
|
this._transitionOverlay = null;
|
|
}
|
|
}
|