diff --git a/Makefile b/Makefile index 827c2ed..efa0595 100644 --- a/Makefile +++ b/Makefile @@ -13,9 +13,10 @@ schemas: build: schemas install: build - mkdir -p $(INSTALL_DIR)/lib $(INSTALL_DIR)/schemas + mkdir -p $(INSTALL_DIR)/lib $(INSTALL_DIR)/schemas $(INSTALL_DIR)/icons cp extension.js prefs.js metadata.json stylesheet.css $(INSTALL_DIR)/ cp lib/*.js $(INSTALL_DIR)/lib/ + cp icons/*.svg $(INSTALL_DIR)/icons/ cp $(SCHEMA_DIR)/*.xml $(SCHEMA_DIR)/gschemas.compiled $(INSTALL_DIR)/schemas/ @echo "Installed to $(INSTALL_DIR)" @echo "Reload GNOME Shell (Alt+F2, r, Enter on X11; log out/in on Wayland), then run:" @@ -25,7 +26,7 @@ uninstall: rm -rf $(INSTALL_DIR) pack: schemas - gnome-extensions pack --force --extra-source=lib -o dist . + gnome-extensions pack --force --extra-source=lib --extra-source=icons -o dist . clean: rm -f $(SCHEMA_DIR)/gschemas.compiled diff --git a/README.md b/README.md index e956ac1..7150192 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,8 @@ gnome-extensions prefs benthicbloom@quinta0.github.io logging, and the folders scanned for wallpapers. - **Rotation** — enable/disable, interval, shuffle vs. sequential order, and crossfade transition settings. -- **Live Wallpaper** — enable/disable, video file, mute, playback speed, - and power-saving pause behavior. +- **Live Wallpaper** — enable/disable, video file, source folders to pick + a video/GIF from, mute, playback speed, and power-saving pause behavior. - **OLED Protection** — master switch plus independent controls for pixel shifting, idle dimming, and forced periodic rotation. @@ -99,6 +99,7 @@ lib/rotationManager.js Timer-driven wallpaper rotation + crossfade overlay lib/liveWallpaper.js GStreamer video playback rendered onto a Clutter actor lib/oledProtection.js Pixel shifting, idle dimming, forced rotation lib/indicator.js Top-bar quick-access menu +icons/ Bundled symbolic icons for the preferences window schemas/ GSettings schema ``` diff --git a/icons/bb-about-symbolic.svg b/icons/bb-about-symbolic.svg new file mode 100644 index 0000000..b08af0c --- /dev/null +++ b/icons/bb-about-symbolic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/icons/bb-general-symbolic.svg b/icons/bb-general-symbolic.svg new file mode 100644 index 0000000..2b157e9 --- /dev/null +++ b/icons/bb-general-symbolic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/icons/bb-live-wallpaper-symbolic.svg b/icons/bb-live-wallpaper-symbolic.svg new file mode 100644 index 0000000..3343d06 --- /dev/null +++ b/icons/bb-live-wallpaper-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/icons/bb-oled-symbolic.svg b/icons/bb-oled-symbolic.svg new file mode 100644 index 0000000..5184bcb --- /dev/null +++ b/icons/bb-oled-symbolic.svg @@ -0,0 +1,5 @@ + + + + diff --git a/icons/bb-rotation-symbolic.svg b/icons/bb-rotation-symbolic.svg new file mode 100644 index 0000000..4897dcd --- /dev/null +++ b/icons/bb-rotation-symbolic.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/lib/settingsKeys.js b/lib/settingsKeys.js index d6eb665..ed7c823 100644 --- a/lib/settingsKeys.js +++ b/lib/settingsKeys.js @@ -13,6 +13,7 @@ export const SettingsKey = Object.freeze({ LIVE_WALLPAPER_ENABLED: 'live-wallpaper-enabled', LIVE_WALLPAPER_PATH: 'live-wallpaper-path', + LIVE_WALLPAPER_FOLDERS: 'live-wallpaper-folders', LIVE_WALLPAPER_MUTED: 'live-wallpaper-muted', LIVE_WALLPAPER_PLAYBACK_RATE: 'live-wallpaper-playback-rate', LIVE_WALLPAPER_PAUSE_ON_BATTERY: 'live-wallpaper-pause-on-battery', @@ -32,3 +33,7 @@ export const SettingsKey = Object.freeze({ export const IMAGE_EXTENSIONS = Object.freeze([ '.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tiff', '.tif', '.gif', ]); + +export const LIVE_WALLPAPER_EXTENSIONS = Object.freeze([ + '.gif', '.mp4', '.webm', '.mkv', '.mov', '.avi', '.m4v', '.ogv', +]); diff --git a/lib/wallpaperSource.js b/lib/wallpaperSource.js index 7695685..8562405 100644 --- a/lib/wallpaperSource.js +++ b/lib/wallpaperSource.js @@ -1,19 +1,19 @@ import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; -import {IMAGE_EXTENSIONS} from './settingsKeys.js'; +import {IMAGE_EXTENSIONS, LIVE_WALLPAPER_EXTENSIONS} from './settingsKeys.js'; Gio._promisify(Gio.File.prototype, 'enumerate_children_async', 'enumerate_children_finish'); Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async', 'next_files_finish'); Gio._promisify(Gio.FileEnumerator.prototype, 'close_async', 'close_finish'); -function hasImageExtension(name) { +function hasExtension(name, extensions) { const lower = name.toLowerCase(); - return IMAGE_EXTENSIONS.some(ext => lower.endsWith(ext)); + return extensions.some(ext => lower.endsWith(ext)); } -/** Non-recursively lists image files directly inside a single folder. */ -export async function listImagesInFolder(folderPath) { +/** Non-recursively lists files with one of `extensions` directly inside a single folder. */ +async function listFilesInFolder(folderPath, extensions) { const results = []; const dir = folderPath.startsWith('file://') ? Gio.File.new_for_uri(folderPath) @@ -38,7 +38,7 @@ export async function listImagesInFolder(folderPath) { for (const info of infos) { if (info.get_file_type() !== Gio.FileType.REGULAR) continue; - if (!hasImageExtension(info.get_name())) + if (!hasExtension(info.get_name(), extensions)) continue; results.push(enumerator.get_child(info).get_path()); } @@ -53,10 +53,10 @@ export async function listImagesInFolder(folderPath) { return results; } -/** Merges and de-duplicates images found across several folders. */ -export async function listImagesInFolders(folderPaths) { +/** Merges and de-duplicates files with one of `extensions` found across several folders. */ +async function listFilesInFolders(folderPaths, extensions) { const lists = await Promise.all( - folderPaths.map(folder => listImagesInFolder(folder).catch(() => []))); + folderPaths.map(folder => listFilesInFolder(folder, extensions).catch(() => []))); const seen = new Set(); const merged = []; @@ -72,3 +72,18 @@ export async function listImagesInFolders(folderPaths) { merged.sort(); return merged; } + +/** Non-recursively lists image files directly inside a single folder. */ +export async function listImagesInFolder(folderPath) { + return listFilesInFolder(folderPath, IMAGE_EXTENSIONS); +} + +/** Merges and de-duplicates images found across several folders. */ +export async function listImagesInFolders(folderPaths) { + return listFilesInFolders(folderPaths, IMAGE_EXTENSIONS); +} + +/** Merges and de-duplicates live-wallpaper-capable videos/GIFs found across several folders. */ +export async function listMediaFilesInFolders(folderPaths) { + return listFilesInFolders(folderPaths, LIVE_WALLPAPER_EXTENSIONS); +} diff --git a/prefs.js b/prefs.js index 1051370..df9576c 100644 --- a/prefs.js +++ b/prefs.js @@ -7,12 +7,18 @@ import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Ex 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)); @@ -42,7 +48,7 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { // --- General page ------------------------------------------------ _buildGeneralPage(settings) { - const page = new Adw.PreferencesPage({title: _('General'), icon_name: 'preferences-system-symbolic'}); + const page = new Adw.PreferencesPage({title: _('General'), icon_name: 'bb-general-symbolic'}); const behaviorGroup = new Adw.PreferencesGroup({title: _('Behavior')}); page.add(behaviorGroup); @@ -62,12 +68,8 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { }); page.add(foldersGroup); - this._folderList = new Gtk.ListBox({ - selection_mode: Gtk.SelectionMode.NONE, - css_classes: ['boxed-list'], - }); - foldersGroup.add(this._folderList); - this._refreshFolderList(settings); + const folderList = this._buildFolderListBox(settings, SettingsKey.WALLPAPER_FOLDERS, _('No folders added yet')); + foldersGroup.add(folderList); const addButton = new Gtk.Button({ label: _('Add Folder…'), @@ -75,23 +77,32 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { margin_top: 6, css_classes: ['flat'], }); - addButton.connect('clicked', () => this._pickFolder(settings)); + addButton.connect('clicked', () => this._pickFolderForList( + settings, SettingsKey.WALLPAPER_FOLDERS, folderList, _('No folders added yet'))); foldersGroup.add(addButton); return page; } - _refreshFolderList(settings) { - let child = this._folderList.get_first_child(); + // --- 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(); - this._folderList.remove(child); + listBox.remove(child); child = next; } - const folders = settings.get_strv(SettingsKey.WALLPAPER_FOLDERS); + const folders = settings.get_strv(key); if (folders.length === 0) { - this._folderList.append(new Adw.ActionRow({title: _('No folders added yet')})); + listBox.append(new Adw.ActionRow({title: emptyText})); return; } @@ -103,27 +114,29 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { css_classes: ['flat'], }); removeButton.connect('clicked', () => { - const current = settings.get_strv(SettingsKey.WALLPAPER_FOLDERS); - settings.set_strv(SettingsKey.WALLPAPER_FOLDERS, current.filter(f => f !== folder)); - this._refreshFolderList(settings); + 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); - this._folderList.append(row); + listBox.append(row); } } - _pickFolder(settings) { - const dialog = new Gtk.FileDialog({title: _('Select Wallpaper Folder')}); - dialog.select_folder(this._folderList.get_root(), null, (source, result) => { + _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(SettingsKey.WALLPAPER_FOLDERS); + const current = settings.get_strv(key); if (!current.includes(path)) { - settings.set_strv(SettingsKey.WALLPAPER_FOLDERS, [...current, path]); - this._refreshFolderList(settings); + settings.set_strv(key, [...current, path]); + this._refreshFolderListBox(listBox, settings, key, emptyText, onChange); + onChange?.(); } } catch (e) { // Dialog was dismissed; nothing to do. @@ -134,7 +147,7 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { // --- Rotation page ------------------------------------------------- _buildRotationPage(settings) { - const page = new Adw.PreferencesPage({title: _('Rotation'), icon_name: 'media-playlist-shuffle-symbolic'}); + const page = new Adw.PreferencesPage({title: _('Rotation'), icon_name: 'bb-rotation-symbolic'}); const group = new Adw.PreferencesGroup({title: _('Automatic Rotation')}); page.add(group); @@ -173,7 +186,7 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { // --- Live wallpaper page -------------------------------------------- _buildLiveWallpaperPage(settings, gstreamerError) { - const page = new Adw.PreferencesPage({title: _('Live Wallpaper'), icon_name: 'video-x-generic-symbolic'}); + const page = new Adw.PreferencesPage({title: _('Live Wallpaper'), icon_name: 'bb-live-wallpaper-symbolic'}); if (gstreamerError) { const warningGroup = new Adw.PreferencesGroup(); @@ -229,6 +242,7 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { 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. } @@ -251,6 +265,36 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { 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( @@ -267,16 +311,73 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { 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: 'weather-clear-night-symbolic'}); + const page = new Adw.PreferencesPage({title: _('OLED Protection'), icon_name: 'bb-oled-symbolic'}); const group = new Adw.PreferencesGroup({ title: _('Burn-in Protection'), @@ -335,7 +436,7 @@ export default class BenthicBloomPreferences extends ExtensionPreferences { // --- About page ----------------------------------------------------- _buildAboutPage() { - const page = new Adw.PreferencesPage({title: _('About'), icon_name: 'help-about-symbolic'}); + const page = new Adw.PreferencesPage({title: _('About'), icon_name: 'bb-about-symbolic'}); const group = new Adw.PreferencesGroup(); page.add(group); diff --git a/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml b/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml index 8291cf2..c2f51a0 100644 --- a/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml +++ b/schemas/org.gnome.shell.extensions.benthicbloom.gschema.xml @@ -79,6 +79,12 @@ Path to the video or animated GIF file used as the live wallpaper. + + [] + Live wallpaper source folders + List of folder paths scanned for videos and animated GIFs to pick a live wallpaper from. + + true Mute live wallpaper audio