Files
benthicbloom/prefs.js
Claude f246d6a4fe Add live wallpaper source folders and bundle preferences page icons
Live Wallpaper page gains a "Wallpaper Folders" list (mirroring the
static rotation one) plus an "Available Media" list of the videos/GIFs
found inside them to pick from, backed by a new live-wallpaper-folders
GSettings key and a generalized wallpaperSource.js extension scan.

The preferences page-switcher icons (General, Rotation, Live Wallpaper,
OLED Protection, About) were showing as broken-image placeholders since
they depended on exact symbolic icon names being present in the user's
system icon theme. Bundle a small set of custom SVG icons under icons/
and register them via Gtk.IconTheme.add_search_path so they always
resolve regardless of the system theme.
2026-08-04 02:08:14 +00:00

458 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Adw from 'gi://Adw';
import Gtk from 'gi://Gtk';
import Gio from 'gi://Gio';
import Gdk from 'gi://Gdk';
import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
import {SettingsKey} from './lib/settingsKeys.js';
import {checkGstreamerBaseAvailable, GSTREAMER_INSTALL_HINT} from './lib/gstreamerAvailability.js';
import {listMediaFilesInFolders} from './lib/wallpaperSource.js';
export default class BenthicBloomPreferences extends ExtensionPreferences {
async fillPreferencesWindow(window) {
const settings = this.getSettings();
const gstreamerError = await checkGstreamerBaseAvailable().then(() => null, e => e.message ?? String(e));
// Bundled rather than relying on the system icon theme having these
// exact names — a missing symbolic icon otherwise renders as a
// blank/broken-image placeholder in the page switcher.
Gtk.IconTheme.get_for_display(window.get_display()).add_search_path(`${this.path}/icons`);
window.set_default_size(640, 720);
window.add(this._buildGeneralPage(settings));
window.add(this._buildRotationPage(settings));
window.add(this._buildLiveWallpaperPage(settings, gstreamerError));
window.add(this._buildOledPage(settings));
window.add(this._buildAboutPage());
}
_switchRow(settings, key, title, subtitle) {
const row = new Adw.SwitchRow({title, subtitle});
settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT);
return row;
}
_spinRow({title, subtitle, lower, upper, step, page, digits = 0}, getValue, setValue) {
const row = new Adw.SpinRow({
title,
subtitle,
digits,
adjustment: new Gtk.Adjustment({lower, upper, step_increment: step, page_increment: page ?? step}),
});
row.value = getValue();
row.connect('notify::value', () => setValue(row.value));
return row;
}
// --- General page ------------------------------------------------
_buildGeneralPage(settings) {
const page = new Adw.PreferencesPage({title: _('General'), icon_name: 'bb-general-symbolic'});
const behaviorGroup = new Adw.PreferencesGroup({title: _('Behavior')});
page.add(behaviorGroup);
behaviorGroup.add(this._switchRow(
settings, SettingsKey.SHOW_INDICATOR,
_('Show Panel Indicator'), _('Display a quick-access icon in the top bar')));
behaviorGroup.add(this._switchRow(
settings, SettingsKey.APPLY_TO_LOCK_SCREEN,
_('Apply to Lock Screen'), _('Also use the current wallpaper as the lock screen background')));
behaviorGroup.add(this._switchRow(
settings, SettingsKey.DEBUG_LOGGING,
_('Debug Logging'), _('Print verbose diagnostics to the system log (journalctl -f)')));
const foldersGroup = new Adw.PreferencesGroup({
title: _('Wallpaper Folders'),
description: _('Images found directly inside these folders are used for rotation'),
});
page.add(foldersGroup);
const folderList = this._buildFolderListBox(settings, SettingsKey.WALLPAPER_FOLDERS, _('No folders added yet'));
foldersGroup.add(folderList);
const addButton = new Gtk.Button({
label: _('Add Folder…'),
halign: Gtk.Align.START,
margin_top: 6,
css_classes: ['flat'],
});
addButton.connect('clicked', () => this._pickFolderForList(
settings, SettingsKey.WALLPAPER_FOLDERS, folderList, _('No folders added yet')));
foldersGroup.add(addButton);
return page;
}
// --- Shared folder-list widgets (used by the General and Live Wallpaper pages) --
_buildFolderListBox(settings, key, emptyText, onChange) {
const listBox = new Gtk.ListBox({selection_mode: Gtk.SelectionMode.NONE, css_classes: ['boxed-list']});
this._refreshFolderListBox(listBox, settings, key, emptyText, onChange);
return listBox;
}
_refreshFolderListBox(listBox, settings, key, emptyText, onChange) {
let child = listBox.get_first_child();
while (child) {
const next = child.get_next_sibling();
listBox.remove(child);
child = next;
}
const folders = settings.get_strv(key);
if (folders.length === 0) {
listBox.append(new Adw.ActionRow({title: emptyText}));
return;
}
for (const folder of folders) {
const row = new Adw.ActionRow({title: folder});
const removeButton = new Gtk.Button({
icon_name: 'user-trash-symbolic',
valign: Gtk.Align.CENTER,
css_classes: ['flat'],
});
removeButton.connect('clicked', () => {
const current = settings.get_strv(key);
settings.set_strv(key, current.filter(f => f !== folder));
this._refreshFolderListBox(listBox, settings, key, emptyText, onChange);
onChange?.();
});
row.add_suffix(removeButton);
listBox.append(row);
}
}
_pickFolderForList(settings, key, listBox, emptyText, onChange) {
const dialog = new Gtk.FileDialog({title: _('Select Folder')});
dialog.select_folder(listBox.get_root(), null, (source, result) => {
try {
const folder = dialog.select_folder_finish(result);
const path = folder.get_path();
if (!path)
return;
const current = settings.get_strv(key);
if (!current.includes(path)) {
settings.set_strv(key, [...current, path]);
this._refreshFolderListBox(listBox, settings, key, emptyText, onChange);
onChange?.();
}
} catch (e) {
// Dialog was dismissed; nothing to do.
}
});
}
// --- Rotation page -------------------------------------------------
_buildRotationPage(settings) {
const page = new Adw.PreferencesPage({title: _('Rotation'), icon_name: 'bb-rotation-symbolic'});
const group = new Adw.PreferencesGroup({title: _('Automatic Rotation')});
page.add(group);
group.add(this._switchRow(
settings, SettingsKey.ROTATION_ENABLED,
_('Enable Rotation'), _('Automatically change the wallpaper on a timer')));
group.add(this._spinRow(
{title: _('Interval'), subtitle: _('Minutes between wallpaper changes'), lower: 1, upper: 1440, step: 1, page: 10},
() => settings.get_uint(SettingsKey.ROTATION_INTERVAL_SECONDS) / 60,
value => settings.set_uint(SettingsKey.ROTATION_INTERVAL_SECONDS, Math.round(value) * 60)));
const modeRow = new Adw.ComboRow({
title: _('Order'),
model: new Gtk.StringList({strings: [_('Shuffle'), _('Sequential')]}),
});
modeRow.selected = settings.get_string(SettingsKey.ROTATION_MODE) === 'sequential' ? 1 : 0;
modeRow.connect('notify::selected', () => {
settings.set_string(SettingsKey.ROTATION_MODE, modeRow.selected === 1 ? 'sequential' : 'shuffle');
});
group.add(modeRow);
const transitionGroup = new Adw.PreferencesGroup({title: _('Transitions')});
page.add(transitionGroup);
transitionGroup.add(this._switchRow(
settings, SettingsKey.TRANSITION_ENABLED,
_('Crossfade'), _('Smoothly fade between wallpapers instead of switching instantly')));
transitionGroup.add(this._spinRow(
{title: _('Fade Duration'), subtitle: _('Milliseconds'), lower: 200, upper: 5000, step: 100, page: 500},
() => settings.get_uint(SettingsKey.TRANSITION_DURATION_MS),
value => settings.set_uint(SettingsKey.TRANSITION_DURATION_MS, Math.round(value))));
return page;
}
// --- Live wallpaper page --------------------------------------------
_buildLiveWallpaperPage(settings, gstreamerError) {
const page = new Adw.PreferencesPage({title: _('Live Wallpaper'), icon_name: 'bb-live-wallpaper-symbolic'});
if (gstreamerError) {
const warningGroup = new Adw.PreferencesGroup();
const warningRow = new Adw.ActionRow({
title: _('GStreamer Not Found'),
subtitle: `${_('Live wallpapers will stay disabled until this is fixed:')} ${gstreamerError}\n\n${GSTREAMER_INSTALL_HINT}`,
css_classes: ['warning'],
});
warningRow.subtitle_lines = 0;
warningGroup.add(warningRow);
page.add(warningGroup);
}
const group = new Adw.PreferencesGroup({
title: _('Video Wallpaper'),
description: _(
'Play a looping video or animated GIF as your desktop background instead of a static image. ' +
'Requires GStreamer (with its "good" and "base" plugin sets, which provide GIF decoding) ' +
'to be installed on your system. Note: this page can only detect GStreamer being ' +
'completely missing — the rendering path it also needs is private to gnome-shell and can’t ' +
'be checked from here, so the absence of a warning below isn’t a full guarantee.'),
});
page.add(group);
group.add(this._switchRow(
settings, SettingsKey.LIVE_WALLPAPER_ENABLED,
_('Enable Live Wallpaper'), _('Overrides the static wallpaper while active')));
const fileRow = new Adw.ActionRow({
title: _('Video / GIF File'),
subtitle: settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH) || _('None selected'),
});
const chooseButton = new Gtk.Button({label: _('Choose…'), valign: Gtk.Align.CENTER, css_classes: ['flat']});
chooseButton.connect('clicked', () => {
const dialog = new Gtk.FileDialog({title: _('Select Wallpaper Video or GIF')});
const mediaFilter = new Gtk.FileFilter({name: _('Videos and animated GIFs')});
mediaFilter.add_mime_type('video/*');
mediaFilter.add_mime_type('image/gif');
mediaFilter.add_pattern('*.gif');
const allFilter = new Gtk.FileFilter({name: _('All files')});
allFilter.add_pattern('*');
const filterList = new Gio.ListStore({item_type: Gtk.FileFilter});
filterList.append(mediaFilter);
filterList.append(allFilter);
dialog.filters = filterList;
dialog.default_filter = mediaFilter;
dialog.open(fileRow.get_root(), null, (source, result) => {
try {
const file = dialog.open_finish(result);
const path = file.get_path();
settings.set_string(SettingsKey.LIVE_WALLPAPER_PATH, path);
fileRow.subtitle = path;
this._refreshLiveMediaList(settings, fileRow);
} catch (e) {
// Dialog was dismissed; nothing to do.
}
});
});
fileRow.add_suffix(chooseButton);
group.add(fileRow);
const muteRow = this._switchRow(
settings, SettingsKey.LIVE_WALLPAPER_MUTED,
_('Mute Audio'), _('Play video wallpapers without sound'));
group.add(muteRow);
const rateRow = this._spinRow(
{
title: _('Playback Speed'),
subtitle: _('Multiplier, e.g. 0.5 for half speed, 2.0 for double speed'),
lower: 0.1, upper: 4.0, step: 0.1, page: 0.5, digits: 1,
},
() => settings.get_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE),
value => settings.set_double(SettingsKey.LIVE_WALLPAPER_PLAYBACK_RATE, value));
group.add(rateRow);
const foldersGroup = new Adw.PreferencesGroup({
title: _('Wallpaper Folders'),
description: _('Videos and animated GIFs found directly inside these folders can be picked below'),
});
page.add(foldersGroup);
const mediaGroup = new Adw.PreferencesGroup({title: _('Available Media')});
page.add(mediaGroup);
this._liveMediaList = new Gtk.ListBox({selection_mode: Gtk.SelectionMode.NONE, css_classes: ['boxed-list']});
mediaGroup.add(this._liveMediaList);
this._liveMediaGeneration = 0;
const refreshMedia = () => this._refreshLiveMediaList(settings, fileRow);
const liveFolderList = this._buildFolderListBox(
settings, SettingsKey.LIVE_WALLPAPER_FOLDERS, _('No folders added yet'), refreshMedia);
foldersGroup.add(liveFolderList);
const addFolderButton = new Gtk.Button({
label: _('Add Folder…'),
halign: Gtk.Align.START,
margin_top: 6,
css_classes: ['flat'],
});
addFolderButton.connect('clicked', () => this._pickFolderForList(
settings, SettingsKey.LIVE_WALLPAPER_FOLDERS, liveFolderList, _('No folders added yet'), refreshMedia));
foldersGroup.add(addFolderButton);
refreshMedia();
const powerGroup = new Adw.PreferencesGroup({title: _('Power Saving')});
page.add(powerGroup);
powerGroup.add(this._switchRow(
settings, SettingsKey.LIVE_WALLPAPER_PAUSE_ON_BATTERY,
_('Pause on Battery'), _('Stop video playback while running on battery power')));
powerGroup.add(this._switchRow(
settings, SettingsKey.LIVE_WALLPAPER_PAUSE_WHEN_FULLSCREEN,
_('Pause When Fullscreen'), _('Stop video playback while a window is fullscreen')));
// The enable switch itself stays usable so the setting can be
// prepared ahead of time, but everything that only matters once
// GStreamer is actually driving playback is greyed out.
if (gstreamerError) {
fileRow.sensitive = false;
muteRow.sensitive = false;
rateRow.sensitive = false;
foldersGroup.sensitive = false;
mediaGroup.sensitive = false;
powerGroup.sensitive = false;
}
return page;
}
/** Re-scans the configured live wallpaper folders and repopulates the "Available Media" list. */
_refreshLiveMediaList(settings, fileRow) {
const listBox = this._liveMediaList;
const generation = ++this._liveMediaGeneration;
let child = listBox.get_first_child();
while (child) {
const next = child.get_next_sibling();
listBox.remove(child);
child = next;
}
const folders = settings.get_strv(SettingsKey.LIVE_WALLPAPER_FOLDERS);
if (folders.length === 0) {
listBox.append(new Adw.ActionRow({title: _('Add a folder above to browse its videos and GIFs')}));
return;
}
listBox.append(new Adw.ActionRow({title: _('Scanning…')}));
listMediaFilesInFolders(folders).then(paths => {
if (generation !== this._liveMediaGeneration)
return; // A folder changed again before this scan finished; a newer one is in flight.
let c = listBox.get_first_child();
while (c) {
const next = c.get_next_sibling();
listBox.remove(c);
c = next;
}
if (paths.length === 0) {
listBox.append(new Adw.ActionRow({title: _('No videos or GIFs found in those folders')}));
return;
}
const currentPath = settings.get_string(SettingsKey.LIVE_WALLPAPER_PATH);
for (const path of paths) {
const row = new Adw.ActionRow({
title: Gio.File.new_for_path(path).get_basename(),
subtitle: path,
activatable: true,
});
if (path === currentPath)
row.add_suffix(new Gtk.Image({icon_name: 'object-select-symbolic'}));
row.connect('activated', () => {
settings.set_string(SettingsKey.LIVE_WALLPAPER_PATH, path);
fileRow.subtitle = path;
this._refreshLiveMediaList(settings, fileRow);
});
listBox.append(row);
}
}).catch(() => {});
}
// --- OLED protection page --------------------------------------------
_buildOledPage(settings) {
const page = new Adw.PreferencesPage({title: _('OLED Protection'), icon_name: 'bb-oled-symbolic'});
const group = new Adw.PreferencesGroup({
title: _('Burn-in Protection'),
description: _('Reduces the risk of permanent image retention on OLED displays'),
});
page.add(group);
group.add(this._switchRow(
settings, SettingsKey.OLED_PROTECTION_ENABLED,
_('Enable OLED Protection'), _('Master switch for all burn-in protection features')));
const shiftGroup = new Adw.PreferencesGroup({title: _('Pixel Shifting')});
page.add(shiftGroup);
shiftGroup.add(this._switchRow(
settings, SettingsKey.OLED_PIXEL_SHIFT_ENABLED,
_('Enable Pixel Shifting'), _('Periodically nudge the background by a few pixels')));
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),
value => settings.set_uint(SettingsKey.OLED_PIXEL_SHIFT_INTERVAL_SECONDS, Math.round(value))));
shiftGroup.add(this._spinRow(
{title: _('Shift Amount'), subtitle: _('Pixels'), lower: 1, upper: 10, step: 1, page: 1},
() => settings.get_uint(SettingsKey.OLED_PIXEL_SHIFT_AMOUNT_PX),
value => settings.set_uint(SettingsKey.OLED_PIXEL_SHIFT_AMOUNT_PX, Math.round(value))));
const dimGroup = new Adw.PreferencesGroup({title: _('Idle Dimming')});
page.add(dimGroup);
dimGroup.add(this._switchRow(
settings, SettingsKey.OLED_DIM_ON_IDLE_ENABLED,
_('Dim When Idle'), _('Lower brightness after a period of inactivity')));
dimGroup.add(this._spinRow(
{title: _('Idle Delay'), subtitle: _('Seconds of inactivity before dimming'), lower: 10, upper: 3600, step: 10, page: 60},
() => settings.get_uint(SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS),
value => settings.set_uint(SettingsKey.OLED_DIM_IDLE_DELAY_SECONDS, Math.round(value))));
dimGroup.add(this._spinRow(
{
title: _('Dimmed Brightness'), subtitle: _('0.0 = black, 0.9 = barely dimmed'),
lower: 0.0, upper: 0.9, step: 0.05, page: 0.1, digits: 2,
},
() => settings.get_double(SettingsKey.OLED_DIM_BRIGHTNESS),
value => settings.set_double(SettingsKey.OLED_DIM_BRIGHTNESS, value)));
const forceGroup = new Adw.PreferencesGroup({title: _('Forced Rotation')});
page.add(forceGroup);
forceGroup.add(this._switchRow(
settings, SettingsKey.OLED_FORCE_ROTATION_ENABLED,
_('Force Periodic Change'),
_('Change the wallpaper even if automatic rotation is off, to avoid prolonged static images')));
forceGroup.add(this._spinRow(
{title: _('Maximum Static Duration'), subtitle: _('Hours before a change is forced'), lower: 1, upper: 48, step: 1, page: 4},
() => settings.get_uint(SettingsKey.OLED_MAX_STATIC_DURATION_SECONDS) / 3600,
value => settings.set_uint(SettingsKey.OLED_MAX_STATIC_DURATION_SECONDS, Math.round(value) * 3600)));
return page;
}
// --- About page -----------------------------------------------------
_buildAboutPage() {
const page = new Adw.PreferencesPage({title: _('About'), icon_name: 'bb-about-symbolic'});
const group = new Adw.PreferencesGroup();
page.add(group);
group.add(new Adw.ActionRow({title: this.metadata.name, subtitle: this.metadata.description}));
group.add(new Adw.ActionRow({
title: _('Version'),
subtitle: this.metadata['version-name'] ?? String(this.metadata.version ?? ''),
}));
if (this.metadata.url) {
const linkRow = new Adw.ActionRow({title: _('Source Code'), subtitle: this.metadata.url, activatable: true});
linkRow.connect('activated', () => Gtk.show_uri(null, this.metadata.url, Gdk.CURRENT_TIME));
group.add(linkRow);
}
return page;
}
}