Author SHA1 Message Date
Quinta 837a10b969 Show live wallpaper through workspace-switch animation and lock screen
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
2026-08-15 14:00:12 +02:00
QuintaandClaude Sonnet 5 603b9f9ded Defer initial playback-rate seek to ASYNC_DONE
An early seek right after set_state(PLAYING) raced the pipeline's
preroll and GStreamer silently dropped it, so a configured custom
rate only took effect after the first EOS loop-restart. Instead,
flag the pending seek and fire it from the first ASYNC_DONE bus
message, once the pipeline is actually ready to seek.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 17:41:35 +02:00
QuintaandClaude Sonnet 5 27f102d594 Fix EOS loop freeze and playback rate not sticking on start
Throttle the EOS loop-restart seek so a fast-looping clip can't fire
back-to-back blocking flush seeks on gnome-shell's main loop, which
was severe enough to freeze the whole desktop. Also apply the
configured playback rate immediately on start instead of only from
the first loop restart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 17:41:35 +02:00
Quinta e39d8973d5 custom playback speed fixed 2026-08-11 17:41:35 +02:00
Quinta 9876f98317 Add CodeQL analysis workflow configuration 2026-08-08 17:30:08 +02:00
Quinta 3795e9316c Update README with user attachments link
Added a link to user attachments in the README.
2026-08-07 15:00:31 +02:00
Quinta f8adc2594f edited the readme 2026-08-07 13:52:54 +02:00
Quinta faa3a7257f more oled features and system freeze fixes 2026-08-06 19:58:50 +02:00
QuintaandClaude Sonnet 5 40af1dad26 Fix potential login freeze: skip blocking GStreamer registry rescan
Gst.init() has no async variant. If the plugin registry cache is stale
(e.g. right after a system update), it synchronously rescans every
plugin, spawning gst-plugin-scanner per plugin and probing hardware
for codecs. Since gnome-shell is single-threaded and is the
compositor, that can freeze the whole screen for several seconds,
same as the blocking UPower proxy lookup fixed earlier.

Set GST_REGISTRY_UPDATE=no when a cache already exists so Gst.init()
trusts it and returns immediately, then refresh the cache in a
throwaway subprocess afterward so it doesn't go stale forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 12:11:11 +02:00
Quinta d7278ee56d Merge pull request #2 from Quinta0/claude/gnome-wallpaper-auto-rotation-r1ailq
Fix login freeze: make the UPower proxy lookup async, not blocking
2026-08-06 11:21:08 +02:00
Quinta 5761d37145 Merge pull request #1 from Quinta0/claude/gnome-wallpaper-auto-rotation-r1ailq
Claude/gnome wallpaper auto rotation r1ailq
2026-08-04 20:53:28 +02:00
13 changed files with 810 additions and 125 deletions
+99
View File
@@ -0,0 +1,99 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '15 7 * * 2'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v7
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- name: Run manual build steps
if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
+74 -62
View File
@@ -1,45 +1,53 @@
# BenthicBloom # BenthicBloom
A GNOME Shell 50 extension that automatically rotates your wallpaper,
supports looping video (live) wallpapers, and includes built-in https://github.com/user-attachments/assets/cf95d723-7c0d-44b1-9b8f-01e03f88850b
protection against OLED burn-in.
A GNOME Shell 50 extension that rotates your wallpaper, supports looping
video (live) wallpapers, and protects OLED displays from burn-in.
## Features ## Features
- **Auto rotation** — pick one or more folders and BenthicBloom cycles - **Auto rotation**: pick one or more folders and BenthicBloom cycles
through the images inside them on a timer, in sequential or shuffled through the images inside them on a timer, sequential or shuffled, with
order, with an optional crossfade between changes. The chosen wallpaper an optional crossfade. Can also apply to the lock screen.
can also be applied to the lock screen. - **Live wallpapers**: play a looping video or animated GIF as your
- **Live wallpapers** — play a looping video *or animated GIF* as your
desktop background. Frames are decoded with GStreamer and rendered desktop background. Frames are decoded with GStreamer and rendered
directly by GNOME Shell's own Clutter stage (no external, incompatible directly on GNOME Shell's own Clutter stage. Playback can pause on
Clutter build involved). Playback can automatically pause on battery battery power or while a window is fullscreen. Auto rotation suspends
power or while a window is fullscreen. Automatic rotation is while a live wallpaper is on screen and resumes after.
automatically suspended while a live wallpaper is actually on screen - **OLED burn-in protection**, three independent toggles:
(and resumes afterward), since changing the static wallpaper would
otherwise repaint over the video.
- **OLED burn-in protection** — three independent, individually toggled
techniques:
- *Pixel shifting*: nudges the background a few pixels on a slow drift - *Pixel shifting*: nudges the background a few pixels on a slow drift
cycle so the same subpixels aren't lit continuously. cycle so the same subpixels aren't lit continuously.
- *Idle dimming*: fades the background to a configurable lower - *Idle dimming*: fades the background after the system has been idle
brightness after the system has been idle for a while, and restores for a while, restores it when you're back.
it the moment you're back. - *Forced rotation*: guarantees a wallpaper change after a maximum
- *Forced rotation*: guarantees the wallpaper changes after a maximum static duration, even if auto rotation is off.
static duration, even if automatic rotation is otherwise switched
off.
- A top-bar indicator for quick access to rotation, live wallpaper, and - A top-bar indicator for quick access to rotation, live wallpaper, and
OLED protection toggles, plus a "Next Wallpaper" action, and a full OLED protection toggles, a "Next Wallpaper" action, and a full
libadwaita preferences window. libadwaita preferences window.
## Requirements ## Requirements
- GNOME Shell 50. - GNOME Shell 50.
- For live wallpapers: GStreamer with its `good` and `base` plugin sets - For live wallpapers: GStreamer's `base` and `good` plugin sets (the
(e.g. `gstreamer1.0-plugins-good` and `gstreamer1.0-plugins-base`, or `good` set also provides GIF decoding), with GObject-Introspection data.
your distribution's equivalent) — the `good` set is also what provides Without them the live wallpaper toggle stays disabled; everything else
animated GIF decoding. If these aren't installed, the live wallpaper works normally.
toggle stays disabled and BenthicBloom's other features work normally.
```sh
# Arch
sudo pacman -S gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav
# Debian/Ubuntu
sudo apt install gstreamer1.0-plugins-base gstreamer1.0-plugins-good gir1.2-gst-plugins-base-1.0
# Fedora
sudo dnf install gstreamer1-plugins-base gstreamer1-plugins-good gobject-introspection
```
Restart GNOME Shell after installing (log out and back in on Wayland).
## Installation ## Installation
@@ -51,9 +59,8 @@ cd benthicbloom
make install make install
``` ```
Then reload GNOME Shell — press <kbd>Alt</kbd>+<kbd>F2</kbd>, type `r`, Reload GNOME Shell (<kbd>Alt</kbd>+<kbd>F2</kbd>, type `r`, <kbd>Enter</kbd>
press <kbd>Enter</kbd> on X11, or log out and back in on Wayland — and on X11; log out and back in on Wayland), then enable the extension:
enable the extension:
```sh ```sh
gnome-extensions enable benthicbloom@quinta0.github.io gnome-extensions enable benthicbloom@quinta0.github.io
@@ -70,55 +77,60 @@ installable via `gnome-extensions install <file>` or the Extensions app.
## Configuration ## Configuration
Open preferences from the panel indicator's "Wallpaper Settings…" entry, Open preferences from the panel indicator's "Wallpaper Settings..." entry,
or run: or run:
```sh ```sh
gnome-extensions prefs benthicbloom@quinta0.github.io gnome-extensions prefs benthicbloom@quinta0.github.io
``` ```
- **General** — panel indicator visibility, lock screen syncing, debug - **General**: panel indicator visibility, lock screen syncing, debug
logging, and the folders scanned for wallpapers. logging, and the folders scanned for wallpapers.
- **Rotation** — enable/disable, interval, shuffle vs. sequential order, - **Rotation**: enable/disable, interval, shuffle vs. sequential order,
and crossfade transition settings. crossfade settings.
- **Live Wallpaper** — enable/disable, video file, source folders to pick - **Live Wallpaper**: enable/disable, video file, source folders, mute,
a video/GIF from, mute, playback speed, and power-saving pause behavior. playback speed, power-saving pause behavior.
- **OLED Protection** — master switch plus independent controls for - **OLED Protection**: master switch plus independent controls for pixel
pixel shifting, idle dimming, and forced periodic rotation. shifting, idle dimming, and forced periodic rotation.
## Architecture ## Architecture
``` ```
extension.js Entry point: wires up the three managers + indicator extension.js Entry point: wires up the three managers + indicator
prefs.js libadwaita preferences window prefs.js libadwaita preferences window
lib/settingsKeys.js GSettings key name constants lib/settingsKeys.js GSettings key name constants
lib/logger.js Small logging wrapper gated by the debug-logging setting lib/logger.js Small logging wrapper gated by the debug-logging setting
lib/wallpaperSource.js Async folder scanning for image files lib/wallpaperSource.js Async folder scanning for image files
lib/shuffleBag.js No-immediate-repeat random ordering for shuffle mode lib/shuffleBag.js No-immediate-repeat random ordering for shuffle mode
lib/rotationManager.js Timer-driven wallpaper rotation + crossfade overlay lib/rotationManager.js Timer-driven wallpaper rotation + crossfade overlay
lib/liveWallpaper.js GStreamer video playback rendered onto a Clutter actor lib/gstreamerAvailability.js Non-blocking GStreamer init/registry check + availability probe
lib/oledProtection.js Pixel shifting, idle dimming, forced rotation lib/liveWallpaper.js GStreamer video playback rendered onto a Clutter actor
lib/indicator.js Top-bar quick-access menu lib/oledProtection.js Pixel shifting, idle dimming, forced rotation
icons/ Bundled symbolic icons for the preferences window lib/indicator.js Top-bar quick-access menu
schemas/ GSettings schema icons/ Bundled symbolic icons for the preferences window
schemas/ GSettings schema
``` ```
## Known limitations ## Known limitations
- The live wallpaper currently renders across the full stage as a single - The live wallpaper renders across the full stage as a single layer, so
layer, so on multi-monitor setups the video spans across all monitors on multi-monitor setups the video spans all monitors as one canvas
as one canvas rather than being tiled per-monitor. instead of being tiled per-monitor.
- Pixel shifting and idle dimming rely on GNOME Shell's private - Pixel shifting and idle dimming rely on GNOME Shell's private
`Main.layoutManager._backgroundGroup` and `Meta.IdleMonitor` APIs, which `Main.layoutManager._backgroundGroup` and `Meta.IdleMonitor` APIs, not
are not part of the stable extension API and could change in future part of the stable extension API, and could change in future shell
shell versions. versions.
- GStreamer's registry scan is skipped (trusting the existing cache)
unless no cache exists yet, to avoid blocking gnome-shell's main thread,
and therefore the whole screen, for several seconds right after login.
A background subprocess refreshes the cache afterward so newly
installed plugins still become visible eventually.
## Testing ## Testing
This was developed and validated (JSON, GSettings schema compilation, This was developed and validated (JSON, GSettings schema compilation, and
and JavaScript syntax) without a live GNOME Shell 50 session available in JavaScript syntax) without a live GNOME Shell 50 session available in the
the development environment. Before relying on it, test in a nested development environment. Before relying on it, test in a nested session:
session:
```sh ```sh
dbus-run-session -- gnome-shell --nested --wayland dbus-run-session -- gnome-shell --nested --wayland
@@ -129,4 +141,4 @@ you hit.
## License ## License
GPL-3.0-or-later — see [LICENSE](LICENSE). GPL-3.0-or-later, see [LICENSE](LICENSE).
+4 -4
View File
@@ -1,10 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g fill="none" stroke="#2e3436" stroke-width="1.3" stroke-linecap="round"> <g fill="none" stroke="#2e3436" stroke-width="1.6" stroke-linecap="round">
<line x1="3.5" y1="2" x2="3.5" y2="14"/> <line x1="3.5" y1="2" x2="3.5" y2="14"/>
<line x1="8" y1="2" x2="8" y2="14"/> <line x1="8" y1="2" x2="8" y2="14"/>
<line x1="12.5" y1="2" x2="12.5" y2="14"/> <line x1="12.5" y1="2" x2="12.5" y2="14"/>
</g> </g>
<circle cx="3.5" cy="5.5" r="1.7" fill="#2e3436"/> <circle cx="3.5" cy="5.3" r="2" fill="#2e3436"/>
<circle cx="8" cy="10.5" r="1.7" fill="#2e3436"/> <circle cx="8" cy="10.7" r="2" fill="#2e3436"/>
<circle cx="12.5" cy="7" r="1.7" fill="#2e3436"/> <circle cx="12.5" cy="7" r="2" fill="#2e3436"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 465 B

After

Width:  |  Height:  |  Size: 459 B

+2 -2
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<rect x="1.5" y="3" width="13" height="10" rx="1.8" fill="none" stroke="#2e3436" stroke-width="1.3"/> <rect x="1.3" y="2.8" width="13.4" height="10.4" rx="2" fill="none" stroke="#2e3436" stroke-width="1.5"/>
<path d="M 6.6 5.8 L 6.6 10.2 L 10.4 8 Z" fill="#2e3436"/> <path d="M 6.2 5.3 L 6.2 10.7 L 10.7 8 Z" fill="#2e3436"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 256 B

After

Width:  |  Height:  |  Size: 260 B

+2 -4
View File
@@ -1,6 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M 3.11 6.22 A 5.2 5.2 0 0 1 13.12 7.10" fill="none" stroke="#2e3436" stroke-width="1.4" stroke-linecap="round"/> <path d="M 12.8 5.6 A 5.3 5.3 0 1 1 11.4 2.9" fill="none" stroke="#2e3436" stroke-width="1.6" stroke-linecap="round"/>
<path d="M 12.89 9.78 A 5.2 5.2 0 0 1 2.88 8.90" fill="none" stroke="#2e3436" stroke-width="1.4" stroke-linecap="round"/> <path d="M 13.9,2.6 13.5,6.4 10.2,4.5 Z" fill="#2e3436"/>
<path d="M 13.52,9.36 11.38,7.00 14.73,6.41 Z" fill="#2e3436"/>
<path d="M 2.48,6.64 4.62,9.00 1.27,9.59 Z" fill="#2e3436"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 468 B

After

Width:  |  Height:  |  Size: 272 B

+72
View File
@@ -1,3 +1,67 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
/**
* True if a GStreamer plugin registry cache already exists for this user.
* A plain local directory listing — fast, unlike the scan below.
*/
function hasGstRegistryCache() {
const cacheDir = Gio.File.new_for_path(
GLib.build_filenamev([GLib.get_user_cache_dir(), 'gstreamer-1.0']));
try {
const enumerator = cacheDir.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NONE, null);
const hasEntry = enumerator.next_file(null) !== null;
enumerator.close(null);
return hasEntry;
} catch (e) {
return false;
}
}
/**
* Gst.init() has no async variant. If its plugin registry cache is stale
* (e.g. right after a system update touched GStreamer packages), it
* synchronously rescans every plugin — spawning gst-plugin-scanner per
* plugin, and probing hardware for codec plugins — which can block
* gnome-shell's single main thread (it *is* the compositor) for many
* seconds, exactly the conditions right after login. That froze the entire
* screen the same way the blocking UPower proxy lookup did.
*
* GST_REGISTRY_UPDATE=no makes Gst.init() trust the existing cache
* unconditionally instead of validating it, so it returns immediately. Only
* set when a cache already exists — an extension-lifetime-first-ever
* GStreamer run on this machine still needs one real scan to have anything
* to trust.
*/
function keepGstInitFast() {
if (hasGstRegistryCache())
GLib.setenv('GST_REGISTRY_UPDATE', 'no', true);
}
/**
* Refreshes the on-disk registry cache in a throwaway subprocess, so a
* stale cache (skipped above) doesn't stay stale forever and newly
* installed plugins eventually become visible. Runs fully out-of-process:
* even if a hardware probe inside it hangs, gnome-shell is unaffected.
*/
function refreshGstRegistryInBackground() {
try {
const launcher = new Gio.SubprocessLauncher(
Gio.SubprocessFlags.STDOUT_SILENCE | Gio.SubprocessFlags.STDERR_SILENCE);
launcher.setenv('GST_REGISTRY_UPDATE', 'yes', true);
const proc = launcher.spawnv(['gst-inspect-1.0', '--exists', 'playbin']);
proc.wait_check_async(null, (source, result) => {
try {
source.wait_check_finish(result);
} catch (e) {
// Best-effort refresh; a missing binary or nonzero exit just leaves the cache as-is.
}
});
} catch (e) {
// Best-effort refresh; a missing gst-inspect-1.0 just leaves the cache as-is.
}
}
/** /**
* Loads the GObject-Introspection bindings the live wallpaper feature needs * Loads the GObject-Introspection bindings the live wallpaper feature needs
* (Gst, GstApp for the appsink signals, Cogl for uploading decoded frames) * (Gst, GstApp for the appsink signals, Cogl for uploading decoded frames)
@@ -13,6 +77,8 @@
* checkGstreamerBaseAvailable() there instead. * checkGstreamerBaseAvailable() there instead.
*/ */
export async function loadGstreamerModules() { export async function loadGstreamerModules() {
keepGstInitFast();
const [{default: Gst}, , {default: Cogl}] = await Promise.all([ const [{default: Gst}, , {default: Cogl}] = await Promise.all([
import('gi://Gst?version=1.0'), import('gi://Gst?version=1.0'),
import('gi://GstApp?version=1.0'), import('gi://GstApp?version=1.0'),
@@ -22,6 +88,8 @@ export async function loadGstreamerModules() {
if (!Gst.is_initialized()) if (!Gst.is_initialized())
Gst.init(null); Gst.init(null);
refreshGstRegistryInBackground();
return {Gst, Cogl}; return {Gst, Cogl};
} }
@@ -33,6 +101,8 @@ export async function loadGstreamerModules() {
* failure here is a genuine, actionable problem, unlike a Cogl probe. * failure here is a genuine, actionable problem, unlike a Cogl probe.
*/ */
export async function checkGstreamerBaseAvailable() { export async function checkGstreamerBaseAvailable() {
keepGstInitFast();
const [{default: Gst}] = await Promise.all([ const [{default: Gst}] = await Promise.all([
import('gi://Gst?version=1.0'), import('gi://Gst?version=1.0'),
import('gi://GstApp?version=1.0'), import('gi://GstApp?version=1.0'),
@@ -40,6 +110,8 @@ export async function checkGstreamerBaseAvailable() {
if (!Gst.is_initialized()) if (!Gst.is_initialized())
Gst.init(null); Gst.init(null);
refreshGstRegistryInBackground();
} }
export const GSTREAMER_INSTALL_HINT = export const GSTREAMER_INSTALL_HINT =
+351 -13
View File
@@ -1,8 +1,11 @@
import GLib from 'gi://GLib'; import GLib from 'gi://GLib';
import Gio from 'gi://Gio'; import Gio from 'gi://Gio';
import Clutter from 'gi://Clutter'; import Clutter from 'gi://Clutter';
import Graphene from 'gi://Graphene';
import St from 'gi://St'; import St from 'gi://St';
import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 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 {SettingsKey} from './settingsKeys.js';
import {loadGstreamerModules, GSTREAMER_INSTALL_HINT} from './gstreamerAvailability.js'; import {loadGstreamerModules, GSTREAMER_INSTALL_HINT} from './gstreamerAvailability.js';
@@ -77,6 +80,9 @@ export class LiveWallpaperManager {
this._settingsSignals = []; this._settingsSignals = [];
this._busWatchId = 0; this._busWatchId = 0;
this._eosSeekTimeoutId = 0;
this._lastEosSeekTimeMs = 0;
this._pendingInitialRateSeek = false;
this._monitorsChangedId = 0; this._monitorsChangedId = 0;
this._fullscreenChangedId = 0; this._fullscreenChangedId = 0;
this._upowerProxy = null; this._upowerProxy = null;
@@ -89,6 +95,12 @@ export class LiveWallpaperManager {
this._noFrameWatchdogId = 0; this._noFrameWatchdogId = 0;
this._imageStrategyIndex = 0; this._imageStrategyIndex = 0;
this._frameErrorCount = 0; this._frameErrorCount = 0;
this._transitionOverlay = null;
this._fadeInPending = false;
this._patchedMethods = [];
this._lockScreenActors = [];
} }
get isAvailable() { get isAvailable() {
@@ -113,6 +125,7 @@ export class LiveWallpaperManager {
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_ENABLED}`, () => this._sync()), 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_PATH}`, () => this._sync()),
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_MUTED}`, () => this._applyMute()), 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_PLAYBACK_RATE}`, () => this._applyPlaybackRate()),
this._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_PAUSE_ON_BATTERY}`, () => this._updatePauseState()), 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._settings.connect(`changed::${SettingsKey.LIVE_WALLPAPER_PAUSE_WHEN_FULLSCREEN}`, () => this._updatePauseState())
@@ -120,6 +133,7 @@ export class LiveWallpaperManager {
this._monitorsChangedId = Main.layoutManager.connect('monitors-changed', () => this._layoutActor()); this._monitorsChangedId = Main.layoutManager.connect('monitors-changed', () => this._layoutActor());
this._patchShellIntegration();
this._sync(); this._sync();
} }
@@ -133,7 +147,94 @@ export class LiveWallpaperManager {
this._monitorsChangedId = 0; this._monitorsChangedId = 0;
} }
this._unpatchShellIntegration();
this._stop(); 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() { async _loadGstreamer() {
@@ -157,12 +258,27 @@ export class LiveWallpaperManager {
if (shouldRun) if (shouldRun)
this._active ? this._restart() : this._start(); this._active ? this._restart() : this._start();
else if (this._active) else if (this._active)
this._stop(); 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() { _restart() {
const transitionsOn = this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED);
const outgoingActor = transitionsOn ? this._actor : null;
if (outgoingActor)
this._actor = null;
this._stop(); this._stop();
this._start(); this._start();
if (outgoingActor)
this._playTransition(outgoingActor);
} }
/** /**
@@ -233,11 +349,15 @@ export class LiveWallpaperManager {
try { try {
this._actor = new Clutter.Actor({ this._actor = new Clutter.Actor({
content_gravity: Clutter.ContentGravity.RESIZE_ASPECT,
reactive: false, reactive: false,
pivot_point: new Graphene.Point({x: 0.5, y: 0.5}),
}); });
Main.layoutManager._backgroundGroup.add_child(this._actor); Main.layoutManager._backgroundGroup.add_child(this._actor);
this._layoutActor(); this._applyDisplayMode();
this._fadeInPending = this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED);
if (this._fadeInPending)
this._actor.opacity = 0;
const sinkBin = this._buildSinkBin(); const sinkBin = this._buildSinkBin();
@@ -252,6 +372,17 @@ export class LiveWallpaperManager {
this._applyMute(); this._applyMute();
const stateChangeResult = this._playbin.set_state(Gst.State.PLAYING); const stateChangeResult = this._playbin.set_state(Gst.State.PLAYING);
this._logger.debug(`playbin.set_state(PLAYING) returned ${stateChangeResult}`); 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._frameCount = 0;
this._pollCount = 0; this._pollCount = 0;
@@ -312,9 +443,17 @@ export class LiveWallpaperManager {
try { try {
this._updateFrame(mapInfo.data, width, height); this._updateFrame(mapInfo.data, width, height);
this._frameCount++; this._frameCount++;
if (this._frameCount === 1) if (this._frameCount === 1) {
this._logger.debug(`Live wallpaper: first frame received (${width}x${height})`); this._logger.debug(`Live wallpaper: first frame received (${width}x${height})`);
else if (this._frameCount % 120 === 0) 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`); this._logger.debug(`Live wallpaper: ${this._frameCount} frames rendered so far`);
} finally { } finally {
buffer.unmap(mapInfo); buffer.unmap(mapInfo);
@@ -346,9 +485,14 @@ export class LiveWallpaperManager {
this._image = strategy.create(width, height); this._image = strategy.create(width, height);
this._videoWidth = width; this._videoWidth = width;
this._videoHeight = height; this._videoHeight = height;
this._layoutActor();
} }
strategy.upload(this._image, this._Cogl, data, width, height); strategy.upload(this._image, this._Cogl, data, width, height);
this._actor.set_content(this._image); this._actor.set_content(this._image);
for (const lockActor of this._lockScreenActors) {
lockActor.set_content(this._image);
lockActor.visible = true;
}
return; return;
} catch (e) { } catch (e) {
this._logger.warn( this._logger.warn(
@@ -366,8 +510,15 @@ export class LiveWallpaperManager {
const Gst = this._Gst; const Gst = this._Gst;
switch (message.type) { switch (message.type) {
case Gst.MessageType.EOS: case Gst.MessageType.EOS:
this._playbin.seek_simple( this._loopPlayback();
Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, 0); 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; break;
case Gst.MessageType.ERROR: { case Gst.MessageType.ERROR: {
const [error, debug] = message.parse_error(); const [error, debug] = message.parse_error();
@@ -389,6 +540,50 @@ export class LiveWallpaperManager {
} }
} }
/**
* 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) { _stateName(state) {
const Gst = this._Gst; const Gst = this._Gst;
return Object.keys(Gst.State).find(name => Gst.State[name] === state) ?? String(state); return Object.keys(Gst.State).find(name => Gst.State[name] === state) ?? String(state);
@@ -406,19 +601,74 @@ export class LiveWallpaperManager {
const Gst = this._Gst; const Gst = this._Gst;
const rate = this._settings.get_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE); const rate = this._settings.get_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE);
const [ok, position] = this._playbin.query_position(Gst.Format.TIME); const [ok, position] = this._playbin.query_position(Gst.Format.TIME);
if (!ok) if (!ok) {
this._logger.debug('Live wallpaper: applyPlaybackRate aborted, query_position() failed');
return; return;
}
this._playbin.seek( const seekOk = this._playbin.seek(
rate, Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE, rate, Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE,
Gst.SeekType.SET, position, Gst.SeekType.NONE, -1); 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() { _layoutActor() {
if (!this._actor) if (!this._actor)
return; 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_position(0, 0);
this._actor.set_size(global.stage.width, global.stage.height); this._actor.set_size(stageWidth, stageHeight);
} }
_connectPowerWatches() { _connectPowerWatches() {
@@ -502,7 +752,15 @@ export class LiveWallpaperManager {
this._playbin.set_state(paused ? this._Gst.State.PAUSED : this._Gst.State.PLAYING); this._playbin.set_state(paused ? this._Gst.State.PAUSED : this._Gst.State.PLAYING);
} }
_stop() { /**
* `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(); this._disconnectPowerWatches();
if (this._noFrameWatchdogId) { if (this._noFrameWatchdogId) {
@@ -515,6 +773,11 @@ export class LiveWallpaperManager {
this._pollTimeoutId = 0; this._pollTimeoutId = 0;
} }
if (this._eosSeekTimeoutId) {
GLib.source_remove(this._eosSeekTimeoutId);
this._eosSeekTimeoutId = 0;
}
if (this._playbin) { if (this._playbin) {
const Gst = this._Gst; const Gst = this._Gst;
const bus = this._playbin.get_bus(); const bus = this._playbin.get_bus();
@@ -536,13 +799,88 @@ export class LiveWallpaperManager {
this._appsink = null; this._appsink = null;
} }
this._actor?.destroy(); if (animate && this._actor && this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED)) {
this._actor = null; this._playTransition(this._actor);
this._actor = null;
} else {
this._actor?.destroy();
this._actor = null;
}
this._image = null; this._image = null;
this._videoWidth = 0; this._videoWidth = 0;
this._videoHeight = 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._setActive(false);
this._paused = 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;
}
} }
+31
View File
@@ -1,6 +1,7 @@
import GLib from 'gi://GLib'; import GLib from 'gi://GLib';
import Meta from 'gi://Meta'; import Meta from 'gi://Meta';
import Clutter from 'gi://Clutter'; import Clutter from 'gi://Clutter';
import Graphene from 'gi://Graphene';
import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import {SettingsKey} from './settingsKeys.js'; import {SettingsKey} from './settingsKeys.js';
@@ -13,6 +14,11 @@ const SHIFT_PATTERN = [
const FORCE_ROTATION_CHECK_SECONDS = 60; const FORCE_ROTATION_CHECK_SECONDS = 60;
// Extra margin, in pixels, added on top of the configured shift amount when
// overscanning background actors — rounding/timing slack so a step never
// exposes a sliver of empty stage at the trailing edge.
const OVERSCAN_SAFETY_PX = 2;
/** /**
* Reduces OLED burn-in risk through three independent techniques: * Reduces OLED burn-in risk through three independent techniques:
* - pixel shifting: nudges the background actors a few px on a slow cycle * - pixel shifting: nudges the background actors a few px on a slow cycle
@@ -105,6 +111,7 @@ export class OledProtectionManager {
const [dx, dy] = SHIFT_PATTERN[this._shiftStep]; const [dx, dy] = SHIFT_PATTERN[this._shiftStep];
for (const actor of this._backgroundActors()) { for (const actor of this._backgroundActors()) {
this._ensureOverscan(actor, amount);
actor.ease({ actor.ease({
translation_x: dx * amount, translation_x: dx * amount,
translation_y: dy * amount, translation_y: dy * amount,
@@ -114,12 +121,36 @@ export class OledProtectionManager {
} }
} }
/**
* Background actors (the real desktop background, and our own live
* wallpaper actor in most display modes) are sized to exactly cover
* the stage with no slack, so nudging one by a few pixels for burn-in
* protection would drag its edge inward and expose bare stage on the
* side it moved away from. Scaling the actor up slightly around its
* center — by just enough margin to cover the configured shift amount
* — keeps every edge overscanned past the stage bounds no matter which
* step of the shift pattern is active.
*/
_ensureOverscan(actor, amount) {
const width = actor.width;
const height = actor.height;
if (width <= 0 || height <= 0)
return;
const margin = amount + OVERSCAN_SAFETY_PX;
actor.pivot_point = new Graphene.Point({x: 0.5, y: 0.5});
actor.scale_x = 1 + (2 * margin) / width;
actor.scale_y = 1 + (2 * margin) / height;
}
_resetShift() { _resetShift() {
this._shiftStep = 0; this._shiftStep = 0;
for (const actor of this._backgroundActors()) { for (const actor of this._backgroundActors()) {
actor.ease({ actor.ease({
translation_x: 0, translation_x: 0,
translation_y: 0, translation_y: 0,
scale_x: 1,
scale_y: 1,
duration: 500, duration: 500,
mode: Clutter.AnimationMode.EASE_OUT_QUAD, mode: Clutter.AnimationMode.EASE_OUT_QUAD,
}); });
+48 -16
View File
@@ -2,6 +2,7 @@ import GLib from 'gi://GLib';
import Gio from 'gi://Gio'; import Gio from 'gi://Gio';
import St from 'gi://St'; import St from 'gi://St';
import Clutter from 'gi://Clutter'; import Clutter from 'gi://Clutter';
import Graphene from 'gi://Graphene';
import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import {SettingsKey} from './settingsKeys.js'; import {SettingsKey} from './settingsKeys.js';
@@ -40,6 +41,7 @@ export class RotationManager {
enable() { enable() {
this._settingsSignals.push( this._settingsSignals.push(
this._settings.connect(`changed::${SettingsKey.WALLPAPER_FOLDERS}`, () => this._reloadImages()), this._settings.connect(`changed::${SettingsKey.WALLPAPER_FOLDERS}`, () => this._reloadImages()),
this._settings.connect(`changed::${SettingsKey.WALLPAPER_RESCAN_REQUEST}`, () => this._reloadImages()),
this._settings.connect(`changed::${SettingsKey.ROTATION_ENABLED}`, () => this._restartTimer()), 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_INTERVAL_SECONDS}`, () => this._restartTimer()),
this._settings.connect(`changed::${SettingsKey.ROTATION_MODE}`, () => this._onModeChanged()) this._settings.connect(`changed::${SettingsKey.ROTATION_MODE}`, () => this._onModeChanged())
@@ -185,7 +187,7 @@ export class RotationManager {
const uri = GLib.filename_to_uri(path, null); const uri = GLib.filename_to_uri(path, null);
if (this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED) && previousPath) if (this._settings.get_boolean(SettingsKey.TRANSITION_ENABLED) && previousPath)
this._playCrossfade(previousPath); this._playTransition(previousPath);
this._backgroundSettings.set_string('picture-uri', uri); this._backgroundSettings.set_string('picture-uri', uri);
if (this._backgroundSettings.settings_schema.has_key('picture-uri-dark')) if (this._backgroundSettings.settings_schema.has_key('picture-uri-dark'))
@@ -200,15 +202,19 @@ export class RotationManager {
} }
/** /**
* The real background actor doesn't crossfade on its own, so we paint the * The real background actor doesn't animate changes on its own, so we
* *old* image full-screen on a throwaway overlay right as the new image * paint the *old* image full-screen on a throwaway overlay right as the
* is set underneath, then fade the overlay out to reveal it. * new image is set underneath, then animate the overlay away to reveal
* it. Which animation runs is picked per monitor by style.
*/ */
_playCrossfade(previousPath) { _playTransition(previousPath) {
this._clearTransitionOverlays(); this._clearTransitionOverlays();
const durationMs = this._settings.get_uint(SettingsKey.TRANSITION_DURATION_MS); const durationMs = this._settings.get_uint(SettingsKey.TRANSITION_DURATION_MS);
const uri = GLib.filename_to_uri(previousPath, null).replace(/"/g, '%22'); const uri = GLib.filename_to_uri(previousPath, null).replace(/"/g, '%22');
let style = this._settings.get_string(SettingsKey.TRANSITION_STYLE);
if (style === 'random')
style = ['fade', 'slide', 'zoom'][Math.floor(Math.random() * 3)];
for (const monitor of Main.layoutManager.monitors) { for (const monitor of Main.layoutManager.monitors) {
const overlay = new St.Widget({ const overlay = new St.Widget({
@@ -217,23 +223,49 @@ export class RotationManager {
y: monitor.y, y: monitor.y,
width: monitor.width, width: monitor.width,
height: monitor.height, height: monitor.height,
pivot_point: new Graphene.Point({x: 0.5, y: 0.5}),
style: `background-image: url("${uri}"); background-size: cover; background-position: center;`, style: `background-image: url("${uri}"); background-size: cover; background-position: center;`,
opacity: 255, opacity: 255,
}); });
Main.layoutManager._backgroundGroup.add_child(overlay); Main.layoutManager._backgroundGroup.add_child(overlay);
this._transitionOverlays.push(overlay); this._transitionOverlays.push(overlay);
overlay.ease({ const onComplete = () => {
opacity: 0, overlay.destroy();
duration: durationMs, const idx = this._transitionOverlays.indexOf(overlay);
mode: Clutter.AnimationMode.EASE_OUT_QUAD, if (idx >= 0)
onComplete: () => { this._transitionOverlays.splice(idx, 1);
overlay.destroy(); };
const idx = this._transitionOverlays.indexOf(overlay);
if (idx >= 0) switch (style) {
this._transitionOverlays.splice(idx, 1); case 'slide':
}, overlay.ease({
}); x: monitor.x - monitor.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;
}
} }
} }
+3
View File
@@ -2,6 +2,7 @@ export const SettingsKey = Object.freeze({
SHOW_INDICATOR: 'show-indicator', SHOW_INDICATOR: 'show-indicator',
DEBUG_LOGGING: 'debug-logging', DEBUG_LOGGING: 'debug-logging',
WALLPAPER_FOLDERS: 'wallpaper-folders', WALLPAPER_FOLDERS: 'wallpaper-folders',
WALLPAPER_RESCAN_REQUEST: 'wallpaper-rescan-request',
APPLY_TO_LOCK_SCREEN: 'apply-to-lock-screen', APPLY_TO_LOCK_SCREEN: 'apply-to-lock-screen',
ROTATION_ENABLED: 'rotation-enabled', ROTATION_ENABLED: 'rotation-enabled',
@@ -10,10 +11,12 @@ export const SettingsKey = Object.freeze({
CURRENT_INDEX: 'current-index', CURRENT_INDEX: 'current-index',
TRANSITION_ENABLED: 'transition-enabled', TRANSITION_ENABLED: 'transition-enabled',
TRANSITION_DURATION_MS: 'transition-duration-ms', TRANSITION_DURATION_MS: 'transition-duration-ms',
TRANSITION_STYLE: 'transition-style',
LIVE_WALLPAPER_ENABLED: 'live-wallpaper-enabled', LIVE_WALLPAPER_ENABLED: 'live-wallpaper-enabled',
LIVE_WALLPAPER_PATH: 'live-wallpaper-path', LIVE_WALLPAPER_PATH: 'live-wallpaper-path',
LIVE_WALLPAPER_FOLDERS: 'live-wallpaper-folders', LIVE_WALLPAPER_FOLDERS: 'live-wallpaper-folders',
LIVE_WALLPAPER_DISPLAY_MODE: 'live-wallpaper-display-mode',
LIVE_WALLPAPER_MUTED: 'live-wallpaper-muted', LIVE_WALLPAPER_MUTED: 'live-wallpaper-muted',
LIVE_WALLPAPER_PLAYBACK_RATE: 'live-wallpaper-playback-rate', LIVE_WALLPAPER_PLAYBACK_RATE: 'live-wallpaper-playback-rate',
LIVE_WALLPAPER_PAUSE_ON_BATTERY: 'live-wallpaper-pause-on-battery', LIVE_WALLPAPER_PAUSE_ON_BATTERY: 'live-wallpaper-pause-on-battery',
+1 -1
View File
@@ -7,5 +7,5 @@
"settings-schema": "org.gnome.shell.extensions.benthicbloom", "settings-schema": "org.gnome.shell.extensions.benthicbloom",
"gettext-domain": "benthicbloom", "gettext-domain": "benthicbloom",
"version": 1, "version": 1,
"version-name": "1.0.0" "version-name": "1.0.1"
} }
+93 -23
View File
@@ -9,6 +9,25 @@ import {SettingsKey} from './lib/settingsKeys.js';
import {checkGstreamerBaseAvailable, GSTREAMER_INSTALL_HINT} from './lib/gstreamerAvailability.js'; import {checkGstreamerBaseAvailable, GSTREAMER_INSTALL_HINT} from './lib/gstreamerAvailability.js';
import {listMediaFilesInFolders} from './lib/wallpaperSource.js'; import {listMediaFilesInFolders} from './lib/wallpaperSource.js';
const BACKGROUND_SCHEMA = 'org.gnome.desktop.background';
// GNOME's own picture-options values, exposed here so wallpaper fill mode
// can be controlled without inventing a parallel setting of our own.
// Built lazily (not at module top level) because gettext can only be
// called once the extension is registered, which hasn't happened yet
// while this module is still being evaluated on import.
function pictureOptions() {
return [
{value: 'zoom', label: _('Fill (Crop to Zoom)')},
{value: 'scaled', label: _('Fit (No Cropping)')},
{value: 'stretched', label: _('Stretch')},
{value: 'centered', label: _('Centered')},
{value: 'wallpaper', label: _('Tiled')},
{value: 'spanned', label: _('Spanned Across Monitors')},
{value: 'none', label: _('None (Background Color Only)')},
];
}
export default class BenthicBloomPreferences extends ExtensionPreferences { export default class BenthicBloomPreferences extends ExtensionPreferences {
async fillPreferencesWindow(window) { async fillPreferencesWindow(window) {
const settings = this.getSettings(); const settings = this.getSettings();
@@ -19,8 +38,10 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
// blank/broken-image placeholder in the page switcher. // blank/broken-image placeholder in the page switcher.
Gtk.IconTheme.get_for_display(window.get_display()).add_search_path(`${this.path}/icons`); Gtk.IconTheme.get_for_display(window.get_display()).add_search_path(`${this.path}/icons`);
const backgroundSettings = new Gio.Settings({schema_id: BACKGROUND_SCHEMA});
window.set_default_size(640, 720); window.set_default_size(640, 720);
window.add(this._buildGeneralPage(settings)); window.add(this._buildGeneralPage(settings, backgroundSettings));
window.add(this._buildRotationPage(settings)); window.add(this._buildRotationPage(settings));
window.add(this._buildLiveWallpaperPage(settings, gstreamerError)); window.add(this._buildLiveWallpaperPage(settings, gstreamerError));
window.add(this._buildOledPage(settings)); window.add(this._buildOledPage(settings));
@@ -47,7 +68,7 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
// --- General page ------------------------------------------------ // --- General page ------------------------------------------------
_buildGeneralPage(settings) { _buildGeneralPage(settings, backgroundSettings) {
const page = new Adw.PreferencesPage({title: _('General'), icon_name: 'bb-general-symbolic'}); const page = new Adw.PreferencesPage({title: _('General'), icon_name: 'bb-general-symbolic'});
const behaviorGroup = new Adw.PreferencesGroup({title: _('Behavior')}); const behaviorGroup = new Adw.PreferencesGroup({title: _('Behavior')});
@@ -62,6 +83,23 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
settings, SettingsKey.DEBUG_LOGGING, settings, SettingsKey.DEBUG_LOGGING,
_('Debug Logging'), _('Print verbose diagnostics to the system log (journalctl -f)'))); _('Debug Logging'), _('Print verbose diagnostics to the system log (journalctl -f)')));
const displayGroup = new Adw.PreferencesGroup({
title: _('Display'),
description: _('How the wallpaper image is fitted to your screen, e.g. cropped to fill it'),
});
page.add(displayGroup);
const pictureOptionsList = pictureOptions();
const fillModeRow = new Adw.ComboRow({
title: _('Fill Mode'),
model: new Gtk.StringList({strings: pictureOptionsList.map(o => o.label)}),
});
const currentOption = backgroundSettings.get_string('picture-options');
fillModeRow.selected = Math.max(0, pictureOptionsList.findIndex(o => o.value === currentOption));
fillModeRow.connect('notify::selected', () => {
backgroundSettings.set_string('picture-options', pictureOptionsList[fillModeRow.selected].value);
});
displayGroup.add(fillModeRow);
const foldersGroup = new Adw.PreferencesGroup({ const foldersGroup = new Adw.PreferencesGroup({
title: _('Wallpaper Folders'), title: _('Wallpaper Folders'),
description: _('Images found directly inside these folders are used for rotation'), description: _('Images found directly inside these folders are used for rotation'),
@@ -71,15 +109,18 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
const folderList = this._buildFolderListBox(settings, SettingsKey.WALLPAPER_FOLDERS, _('No folders added yet')); const folderList = this._buildFolderListBox(settings, SettingsKey.WALLPAPER_FOLDERS, _('No folders added yet'));
foldersGroup.add(folderList); foldersGroup.add(folderList);
const addButton = new Gtk.Button({ const buttonRow = new Gtk.Box({orientation: Gtk.Orientation.HORIZONTAL, spacing: 6, margin_top: 6});
label: _('Add Folder…'), foldersGroup.add(buttonRow);
halign: Gtk.Align.START,
margin_top: 6, const addButton = new Gtk.Button({label: _('Add Folder…'), css_classes: ['flat']});
css_classes: ['flat'],
});
addButton.connect('clicked', () => this._pickFolderForList( addButton.connect('clicked', () => this._pickFolderForList(
settings, SettingsKey.WALLPAPER_FOLDERS, folderList, _('No folders added yet'))); settings, SettingsKey.WALLPAPER_FOLDERS, folderList, _('No folders added yet')));
foldersGroup.add(addButton); buttonRow.append(addButton);
const refreshButton = new Gtk.Button({label: _('Refresh'), css_classes: ['flat']});
refreshButton.connect('clicked', () => settings.set_uint(
SettingsKey.WALLPAPER_RESCAN_REQUEST, settings.get_uint(SettingsKey.WALLPAPER_RESCAN_REQUEST) + 1));
buttonRow.append(refreshButton);
return page; return page;
} }
@@ -174,7 +215,17 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
page.add(transitionGroup); page.add(transitionGroup);
transitionGroup.add(this._switchRow( transitionGroup.add(this._switchRow(
settings, SettingsKey.TRANSITION_ENABLED, settings, SettingsKey.TRANSITION_ENABLED,
_('Crossfade'), _('Smoothly fade between wallpapers instead of switching instantly'))); _('Enable Transition'), _('Play an animation between wallpapers instead of switching instantly')));
const styles = ['fade', 'slide', 'zoom', 'random'];
const styleLabels = [_('Fade'), _('Slide'), _('Zoom'), _('Random')];
const styleRow = new Adw.ComboRow({title: _('Style'), model: new Gtk.StringList({strings: styleLabels})});
styleRow.selected = Math.max(0, styles.indexOf(settings.get_string(SettingsKey.TRANSITION_STYLE)));
styleRow.connect('notify::selected', () => {
settings.set_string(SettingsKey.TRANSITION_STYLE, styles[styleRow.selected]);
});
transitionGroup.add(styleRow);
transitionGroup.add(this._spinRow( transitionGroup.add(this._spinRow(
{title: _('Fade Duration'), subtitle: _('Milliseconds'), lower: 200, upper: 5000, step: 100, page: 500}, {title: _('Fade Duration'), subtitle: _('Milliseconds'), lower: 200, upper: 5000, step: 100, page: 500},
() => settings.get_uint(SettingsKey.TRANSITION_DURATION_MS), () => settings.get_uint(SettingsKey.TRANSITION_DURATION_MS),
@@ -251,6 +302,19 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
fileRow.add_suffix(chooseButton); fileRow.add_suffix(chooseButton);
group.add(fileRow); group.add(fileRow);
const liveDisplayModes = ['fit', 'fill', 'stretch', 'center'];
const liveDisplayLabels = [_('Fit (Letterboxed)'), _('Fill (Crop to Cover)'), _('Stretch'), _('Center')];
const displayModeRow = new Adw.ComboRow({
title: _('Display Mode'),
model: new Gtk.StringList({strings: liveDisplayLabels}),
});
displayModeRow.selected = Math.max(
0, liveDisplayModes.indexOf(settings.get_string(SettingsKey.LIVE_WALLPAPER_DISPLAY_MODE)));
displayModeRow.connect('notify::selected', () => {
settings.set_string(SettingsKey.LIVE_WALLPAPER_DISPLAY_MODE, liveDisplayModes[displayModeRow.selected]);
});
group.add(displayModeRow);
const muteRow = this._switchRow( const muteRow = this._switchRow(
settings, SettingsKey.LIVE_WALLPAPER_MUTED, settings, SettingsKey.LIVE_WALLPAPER_MUTED,
_('Mute Audio'), _('Play video wallpapers without sound')); _('Mute Audio'), _('Play video wallpapers without sound'));
@@ -283,15 +347,17 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
settings, SettingsKey.LIVE_WALLPAPER_FOLDERS, _('No folders added yet'), refreshMedia); settings, SettingsKey.LIVE_WALLPAPER_FOLDERS, _('No folders added yet'), refreshMedia);
foldersGroup.add(liveFolderList); foldersGroup.add(liveFolderList);
const addFolderButton = new Gtk.Button({ const liveButtonRow = new Gtk.Box({orientation: Gtk.Orientation.HORIZONTAL, spacing: 6, margin_top: 6});
label: _('Add Folder…'), foldersGroup.add(liveButtonRow);
halign: Gtk.Align.START,
margin_top: 6, const addFolderButton = new Gtk.Button({label: _('Add Folder…'), css_classes: ['flat']});
css_classes: ['flat'],
});
addFolderButton.connect('clicked', () => this._pickFolderForList( addFolderButton.connect('clicked', () => this._pickFolderForList(
settings, SettingsKey.LIVE_WALLPAPER_FOLDERS, liveFolderList, _('No folders added yet'), refreshMedia)); settings, SettingsKey.LIVE_WALLPAPER_FOLDERS, liveFolderList, _('No folders added yet'), refreshMedia));
foldersGroup.add(addFolderButton); liveButtonRow.append(addFolderButton);
const refreshMediaButton = new Gtk.Button({label: _('Refresh'), css_classes: ['flat']});
refreshMediaButton.connect('clicked', refreshMedia);
liveButtonRow.append(refreshMediaButton);
refreshMedia(); refreshMedia();
@@ -309,6 +375,7 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
// GStreamer is actually driving playback is greyed out. // GStreamer is actually driving playback is greyed out.
if (gstreamerError) { if (gstreamerError) {
fileRow.sensitive = false; fileRow.sensitive = false;
displayModeRow.sensitive = false;
muteRow.sensitive = false; muteRow.sensitive = false;
rateRow.sensitive = false; rateRow.sensitive = false;
foldersGroup.sensitive = false; foldersGroup.sensitive = false;
@@ -394,9 +461,12 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
settings, SettingsKey.OLED_PIXEL_SHIFT_ENABLED, settings, SettingsKey.OLED_PIXEL_SHIFT_ENABLED,
_('Enable Pixel Shifting'), _('Periodically nudge the background by a few pixels'))); _('Enable Pixel Shifting'), _('Periodically nudge the background by a few pixels')));
shiftGroup.add(this._spinRow( shiftGroup.add(this._spinRow(
{title: _('Shift Interval'), subtitle: _('Seconds between each shift step'), lower: 10, upper: 600, step: 5, page: 30}, {
() => settings.get_uint(SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS), title: _('Shift Interval'), subtitle: _('Minutes between each shift step'),
value => settings.set_uint(SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS, Math.round(value)))); lower: 0.5, upper: 10, step: 0.5, page: 1, digits: 1,
},
() => settings.get_uint(SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS) / 60,
value => settings.set_uint(SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS, Math.round(value * 60))));
shiftGroup.add(this._spinRow( shiftGroup.add(this._spinRow(
{title: _('Shift Amount'), subtitle: _('Pixels'), lower: 1, upper: 10, step: 1, page: 1}, {title: _('Shift Amount'), subtitle: _('Pixels'), lower: 1, upper: 10, step: 1, page: 1},
() => settings.get_uint(SettingsKey.OLED_PIXEL_SHIFT_AMOUNT_PX), () => settings.get_uint(SettingsKey.OLED_PIXEL_SHIFT_AMOUNT_PX),
@@ -408,9 +478,9 @@ export default class BenthicBloomPreferences extends ExtensionPreferences {
settings, SettingsKey.OLED_DIM_ON_IDLE_ENABLED, settings, SettingsKey.OLED_DIM_ON_IDLE_ENABLED,
_('Dim When Idle'), _('Lower brightness after a period of inactivity'))); _('Dim When Idle'), _('Lower brightness after a period of inactivity')));
dimGroup.add(this._spinRow( dimGroup.add(this._spinRow(
{title: _('Idle Delay'), subtitle: _('Seconds of inactivity before dimming'), lower: 10, upper: 3600, step: 10, page: 60}, {title: _('Idle Delay'), subtitle: _('Minutes of inactivity before dimming'), lower: 1, upper: 60, step: 1, page: 5},
() => settings.get_uint(SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS), () => settings.get_uint(SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS) / 60,
value => settings.set_uint(SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS, Math.round(value)))); value => settings.set_uint(SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS, Math.round(value * 60))));
dimGroup.add(this._spinRow( dimGroup.add(this._spinRow(
{ {
title: _('Dimmed Brightness'), subtitle: _('0.0 = black, 0.9 = barely dimmed'), title: _('Dimmed Brightness'), subtitle: _('0.0 = black, 0.9 = barely dimmed'),
@@ -21,6 +21,12 @@
<description>List of folder paths scanned for wallpaper images.</description> <description>List of folder paths scanned for wallpaper images.</description>
</key> </key>
<key name="wallpaper-rescan-request" type="u">
<default>0</default>
<summary>Wallpaper folder rescan request</summary>
<description>Internal counter bumped by the preferences UI to ask the running extension to rescan wallpaper folders for new files.</description>
</key>
<key name="apply-to-lock-screen" type="b"> <key name="apply-to-lock-screen" type="b">
<default>true</default> <default>true</default>
<summary>Apply wallpaper to lock screen</summary> <summary>Apply wallpaper to lock screen</summary>
@@ -67,6 +73,18 @@
<description>Duration, in milliseconds, of the crossfade animation between wallpapers.</description> <description>Duration, in milliseconds, of the crossfade animation between wallpapers.</description>
</key> </key>
<key name="transition-style" type="s">
<choices>
<choice value="fade"/>
<choice value="slide"/>
<choice value="zoom"/>
<choice value="random"/>
</choices>
<default>"fade"</default>
<summary>Transition style</summary>
<description>Visual effect used when the wallpaper changes.</description>
</key>
<key name="live-wallpaper-enabled" type="b"> <key name="live-wallpaper-enabled" type="b">
<default>false</default> <default>false</default>
<summary>Enable live wallpaper</summary> <summary>Enable live wallpaper</summary>
@@ -85,6 +103,18 @@
<description>List of folder paths scanned for videos and animated GIFs to pick a live wallpaper from.</description> <description>List of folder paths scanned for videos and animated GIFs to pick a live wallpaper from.</description>
</key> </key>
<key name="live-wallpaper-display-mode" type="s">
<choices>
<choice value="fit"/>
<choice value="fill"/>
<choice value="stretch"/>
<choice value="center"/>
</choices>
<default>"fit"</default>
<summary>Live wallpaper display mode</summary>
<description>How the video/GIF is fitted to the screen: fit (letterboxed), fill (cropped to cover), stretch (distorted to fill) or center (original size).</description>
</key>
<key name="live-wallpaper-muted" type="b"> <key name="live-wallpaper-muted" type="b">
<default>true</default> <default>true</default>
<summary>Mute live wallpaper audio</summary> <summary>Mute live wallpaper audio</summary>