From 390b3243f87d1cf93d2672685de83350d258c827 Mon Sep 17 00:00:00 2001 From: D34thm0u53 <124012142+D34thm0u53@users.noreply.github.com> Date: Wed, 31 Dec 2025 21:14:16 +1000 Subject: [PATCH 1/3] Feat control thumbnail bg (#1) --- MediaAction.py | 3 + .../ThumnaiBg_simplified.svg | 102 ++ doc/ThumbnailBackground/TumbnailBg.svg | 102 ++ locales/de_DE.json | 11 +- locales/en_US.json | 11 +- log_wrapper.py | 89 ++ main.py | 894 +++++++++++++++++- settings.py | 118 +++ 8 files changed, 1278 insertions(+), 52 deletions(-) create mode 100644 doc/ThumbnailBackground/ThumnaiBg_simplified.svg create mode 100644 doc/ThumbnailBackground/TumbnailBg.svg create mode 100644 log_wrapper.py create mode 100644 settings.py diff --git a/MediaAction.py b/MediaAction.py index 999adce..5d2cc8f 100644 --- a/MediaAction.py +++ b/MediaAction.py @@ -11,6 +11,9 @@ from PIL import Image, ImageEnhance import os import math +import sys + +from loguru import logger as log class MediaAction(ActionBase): def __init__(self, *args, **kwargs): diff --git a/doc/ThumbnailBackground/ThumnaiBg_simplified.svg b/doc/ThumbnailBackground/ThumnaiBg_simplified.svg new file mode 100644 index 0000000..30056a7 --- /dev/null +++ b/doc/ThumbnailBackground/ThumnaiBg_simplified.svg @@ -0,0 +1,102 @@ +No ChangesChanges DetectedNoYesAction Triggeredon_ready / on_tickCheck if State ChangedSkip UpdateLoad ThumbnailThumbnailFound?Show Base BackgroundResize & PositionBased on Size ModeStore RenderedThumbnailRequest CompositeBatch UpdatesDebounce TimeoutComposite All Thumbnailsonto Base BackgroundApply to DeckDisplay ResultRender Complete \ No newline at end of file diff --git a/doc/ThumbnailBackground/TumbnailBg.svg b/doc/ThumbnailBackground/TumbnailBg.svg new file mode 100644 index 0000000..f1c0579 --- /dev/null +++ b/doc/ThumbnailBackground/TumbnailBg.svg @@ -0,0 +1,102 @@ +Not PresentPresentNo ChangesChanges DetectedNo ThumbnailThumbnail Foundstretchfill1x1/2x2/3x3/4x4YesNoNoYesNo ThumbnailsHas ThumbnailsNo DirtyDirty ActionsThumbnailBackground Action TriggeredCheck if Actionis PresentReturnNo Updateon_ready OR on_tickShould Update?_should_updateSkip Updateupdate_imageGet SettingsExtract Size Modestretch/fill/1x1/2x2/3x3/4x4_get_thumbnail_pathThumbnailAvailable?restore_original_backgroundClear rendered_thumbnailRequest Composite_request_compositeLoad Thumbnail ImageImage.openCache Statethumbnail_path, background_path, coordsSize Mode?set_stretch_backgroundResize to Full Deckfull_width × full_heightConvert to RGBAStore in rendered_thumbnailRequest Compositeset_fill_screen_backgroundCalculate ScaleFill by Longest SideResize ThumbnailCenter on Canvasfull_width × full_heightStore in rendered_thumbnailRequest Compositeset_grid_sized_backgroundParse Grid SizeGet Action Positioncoords from input_identCalculate Grid Cell DimensionsResize & Positionon Transparent CanvasStore in rendered_thumbnailRequest Composite_request_compositeMark Action is_dirty = TrueSet _pending_composite = TrueExisting Timeout?Cancel Previous TimeoutGLib.source_removeSchedule New TimeoutGLib.timeout_addWait for TimeoutDelay COMPOSITE_TIMEOUT ms_execute_composite_callback_execute_composite_if_neededPending &Not In Progress?ReturnSkip CompositeSet _composite_in_progress = True_get_all_thumbnail_actionsGet All ThumbnailBackgroundActions on PageSort by Priorityfill → stretch → gridCache Actions & Page IDCheck for Dirty ActionsAny Actionswith Thumbnails?Reload PageRestore Original BackgroundClear is_dirty FlagsSet _composite_in_progress = FalseHas DirtyActions?Skip Composite_composite_all_thumbnailsGet Deck DimensionsGet Original Backgroundget_original_backgroundLoad Base Background ImageCache Background Path& ImageIterate All Rendered ThumbnailsAlpha Composite Each ThumbnailLayer onto BaseReturn Composite ImageApply to Deck Backgrounddeck_controller.background.set_imageClose Composite ImagePrevent Memory LeaksClear is_dirty FlagsAll ActionsSet _composite_in_progress = FalseSet _pending_composite = FalseRender CompleteDeck DisplaysComposited Backgroundwith All ThumbnailsReturn FalseRemove GLib Timeout \ No newline at end of file diff --git a/locales/de_DE.json b/locales/de_DE.json index 0a82f86..86cd4c2 100644 --- a/locales/de_DE.json +++ b/locales/de_DE.json @@ -13,5 +13,14 @@ "actions.media-action.show-thumbnail-switch.label": "Bild des spielenden Titels anzeigen", "actions.media-action.show-thumbnail-switch.subtitle": "Den Titelbild des aktuell spielenden Titels anzeigen", "actions.media-action-bind-to-player.all-players": "Alle Spieler", - "actions.info.seperator.text": "Trenntext:" + "actions.info.seperator.text": "Trenntext:", + "actions.thumbnail-background.size-mode.label": "Miniaturbild-Größe", + "actions.thumbnail-background.size-mode.subtitle": "Größe des Miniaturbilds auf dem Deck festlegen", + "actions.thumbnail-background.size-mode.stretch": "Strecken um zu passen", + "actions.thumbnail-background.size-mode.fill": "Bildschirm füllen", + "settings.title": "Plugin-Einstellungen", + "settings.composite-timeout.label": "Composite-Timeout", + "settings.composite-timeout.subtitle": "Verzögerung vor dem Zusammensetzen von Miniaturbildern (ms)", + "settings.log-level.label": "Log-Ebene", + "settings.log-level.subtitle": "Kontrollen Sie die Ausführlichkeit der Plugin-Protokollierung" } \ No newline at end of file diff --git a/locales/en_US.json b/locales/en_US.json index d96522b..4acc734 100644 --- a/locales/en_US.json +++ b/locales/en_US.json @@ -14,5 +14,14 @@ "actions.media-action.show-thumbnail-switch.label": "Show thumbnail of the playing song", "actions.media-action.show-thumbnail-switch.subtitle": "Show the thumbnail of the currently playing media", "actions.media-action-bind-to-player.all-players": "All Players", - "actions.info.seperator.text": "Seperator Text:" + "actions.info.seperator.text": "Seperator Text:", + "actions.thumbnail-background.size-mode.label": "Thumbnail Size", + "actions.thumbnail-background.size-mode.subtitle": "Set the size of the thumbnail on the deck", + "actions.thumbnail-background.size-mode.stretch": "Stretch to Fit", + "actions.thumbnail-background.size-mode.fill": "Fill Screen", + "settings.title": "Plugin Settings", + "settings.composite-timeout.label": "Composite Timeout", + "settings.composite-timeout.subtitle": "Delay before compositing thumbnails (ms)", + "settings.log-level.label": "Log Level", + "settings.log-level.subtitle": "Control the verbosity of plugin logging" } \ No newline at end of file diff --git a/log_wrapper.py b/log_wrapper.py new file mode 100644 index 0000000..9f97c0b --- /dev/null +++ b/log_wrapper.py @@ -0,0 +1,89 @@ +""" +Plugin-level logging wrapper for MediaPlugin. + +Provides a configurable logger that can be controlled independently of the parent application's +log level, allowing fine-grained control over MediaPlugin's log output. +""" + +from loguru import logger as _loguru_logger +import sys + +# Log levels from highest to lowest severity +LOG_LEVELS = { + "CRITICAL": 50, + "ERROR": 40, + "WARNING": 30, + "INFO": 20, + "DEBUG": 10, + "TRACE": 5, +} + +# Current log level for the plugin +_current_log_level = "INFO" + + +class PluginLogger: + """Wrapper around loguru logger with plugin-level configuration.""" + + def __init__(self): + self._logger = _loguru_logger + + def _should_log(self, level: str) -> bool: + """Check if a message at the given level should be logged.""" + level_value = LOG_LEVELS.get(level.upper(), 20) + current_value = LOG_LEVELS.get(_current_log_level.upper(), 20) + return level_value >= current_value + + def trace(self, message: str, *args, **kwargs): + """Log a trace level message.""" + if self._should_log("TRACE"): + self._logger.trace(message, *args, **kwargs) + + def debug(self, message: str, *args, **kwargs): + """Log a debug level message.""" + if self._should_log("DEBUG"): + self._logger.debug(message, *args, **kwargs) + + def info(self, message: str, *args, **kwargs): + """Log an info level message.""" + if self._should_log("INFO"): + self._logger.info(message, *args, **kwargs) + + def warning(self, message: str, *args, **kwargs): + """Log a warning level message.""" + if self._should_log("WARNING"): + self._logger.warning(message, *args, **kwargs) + + def error(self, message: str, *args, **kwargs): + """Log an error level message.""" + if self._should_log("ERROR"): + self._logger.error(message, *args, **kwargs) + + def critical(self, message: str, *args, **kwargs): + """Log a critical level message.""" + if self._should_log("CRITICAL"): + self._logger.critical(message, *args, **kwargs) + + +def set_log_level(level: str) -> None: + """ + Set the plugin's log level. + + Valid levels: CRITICAL, ERROR, WARNING, INFO, DEBUG, TRACE + + :param level: The log level to set + """ + global _current_log_level + level_upper = level.upper() + if level_upper not in LOG_LEVELS: + raise ValueError(f"Invalid log level: {level}. Must be one of {list(LOG_LEVELS.keys())}") + _current_log_level = level_upper + + +def get_log_level() -> str: + """Get the current plugin log level.""" + return _current_log_level + + +# Create the global logger instance +log = PluginLogger() diff --git a/main.py b/main.py index 6abd5fd..39a2be5 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ import shutil -from src.backend.PluginManager.ActionBase import ActionBase +# Import internal modules from src.backend.PluginManager.ActionHolder import ActionHolder from src.backend.PluginManager.PluginBase import PluginBase from src.backend.DeckManagement.InputIdentifier import Input @@ -12,24 +12,27 @@ import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") -from gi.repository import Gtk, Adw +from gi.repository import Gtk, Adw, GLib import sys import os import io -from loguru import logger as log -from PIL import Image, ImageEnhance -import math +from PIL import Image, ImageEnhance, ImageOps import globals as gl -# Add plugin to sys.paths -sys.path.append(os.path.dirname(__file__)) +# Load our submodules +plugin_dir = os.path.dirname(__file__) +sys.path.insert(0, plugin_dir) + +from settings import PluginSettings, KEY_LOG_LEVEL, DEFAULT_LOG_LEVEL, KEY_COMPOSITE_TIMEOUT, DEFAULT_COMPOSITE_TIMEOUT +from log_wrapper import log, set_log_level from MediaController import MediaController from MediaAction import MediaAction + class Play(MediaAction): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -105,7 +108,6 @@ def update_image(self): self.set_media(image=image) - class Pause(MediaAction): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -181,7 +183,6 @@ def update_image(self): self.set_media(image=image) - class PlayPause(MediaAction): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -428,62 +429,843 @@ def on_change_seperator_text(self, entry, *args): # Update image self.set_center_label(self.get_settings().get("seperator_text", "--"), font_size=12) - - class ThumbnailBackground(MediaAction): + """ + Media action that renders one or more thumbnail images onto the deck background. + + This class coordinates multiple `ThumbnailBackground` actions on the same page to + produce a single composited background image. It uses several class-level caches + and flags to avoid redundant work and reduce flicker: + + * Action list cache: `_cached_actions` and `_cached_page_id` cache the set of + thumbnail actions for the current page so that repeated lookups are avoided. + * Background cache: `_original_background_image` and `_cached_background_path` + store the unmodified background so it can be reused while layering thumbnails + on top, instead of reloading or recomputing it for every instance. + * Batched compositing: `_pending_composite`, `_composite_in_progress`, and + `_idle_composite_id` implement a batched composite pattern where changes from + multiple actions are coalesced and applied once via a GLib idle callback. + + Each instance tracks its own thumbnail path, size/placement mode, and last + contribution to the composite (`rendered_thumbnail`, `is_dirty`, etc.) so that + only changed thumbnails trigger a recomposite. The actual update of the deck + background is thus performed once per batch rather than once per action. + """ + + # Class-level cache for action list optimization + _cached_actions = None # Cached list of all thumbnail actions + _cached_page_id = None # ID of page for which actions are cached + + # Class-level coordinator for batched updates + _pending_composite = False # Flag indicating composite is needed + _composite_in_progress = False # Prevent recursive compositing + _idle_composite_id = None # GLib idle callback ID for deferred execution + + # Class-level background cache (shared by all actions) + _original_background_image = None # Cached original background + _cached_background_path = None # Track which background is cached + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - - self.title: str = None - self.artist: str = None + + # Optimization: Track state to detect changes + self.last_thumbnail_path = None + self.last_size_mode = None + self.last_background_path = None + self.last_coords = None # Track position for grid modes + + # Track rendering state for multi-thumbnail coordination + self.rendered_thumbnail = None # Store our rendered thumbnail for compositing + self.is_dirty = False # Flag indicating this action needs recompositing + + def _get_all_thumbnail_actions(self): + """Get all ThumbnailBackground actions on the current page, sorted by type and position.""" + if not hasattr(self, 'page') or self.page is None: + return [self] + + # Use page object id as cache key + current_page_id = id(self.page) + + # Return cached list if valid + if (ThumbnailBackground._cached_actions is not None and + ThumbnailBackground._cached_page_id == current_page_id): + return ThumbnailBackground._cached_actions + + # Cache miss - rebuild the list + actions = [] + try: + # Iterate through all action objects on the page + for input_type in self.page.action_objects.values(): + for identifier in input_type.values(): + for state in identifier.values(): + for action in state.values(): + if isinstance(action, ThumbnailBackground): + actions.append(action) + except Exception as e: + log.error(f"Failed to collect all thumbnail actions while iterating through page.action_objects hierarchy: {e}") + return [self] + + # If no actions found, at least include self (shouldn't happen if self is properly in action_objects) + if not actions: + log.warning("ThumbnailBackground: No thumbnail actions found on page, falling back to [self]") + return [self] + + # Sort by layering order: Fill -> Stretch -> Grid (top-left to bottom-right) + def get_sort_key(action): + settings = action.get_settings() + size_mode = settings.get("size_mode", "stretch") if settings else "stretch" + + # Priority order: fill=0, stretch=1, grid modes=2 + if size_mode == "fill": + priority = 0 + elif size_mode == "stretch": + priority = 1 + else: # Grid modes (1x1, 2x2, 3x3, 4x4) + priority = 2 + + # Within same priority, sort by position (row, col) + if hasattr(action.input_ident, 'coords'): + row, col = action.input_ident.coords + return (priority, row, col) + + # Handle badly configured actions without coordinates + return (priority, float("inf"), float("inf")) + + actions.sort(key=get_sort_key) + + # Cache the result + ThumbnailBackground._cached_actions = actions + ThumbnailBackground._cached_page_id = current_page_id + + return actions + + def _request_composite(self): + """Request a composite operation. Will be batched with other requests.""" + coords = self.input_ident.coords if hasattr(self.input_ident, 'coords') else None # type: ignore[attr-defined] + log.trace(f"ThumbnailBackground: _request_composite called by [{coords}] with is_dirty state: [{self.is_dirty}]") + # Mark this action as dirty + self.is_dirty = True + + # Set the pending flag + ThumbnailBackground._pending_composite = True + + # Cancel any existing timeout and schedule a new one + # Use a small delay to allow all actions in current tick cycle to update + if ThumbnailBackground._idle_composite_id is not None: + log.trace("ThumbnailBackground: _request_composite - cancelling existing timeout") + try: + GLib.source_remove(ThumbnailBackground._idle_composite_id) + except (OSError, ValueError): + pass # Timeout may have already fired or invalid ID + + timeout = self.plugin_base.get_settings().get(KEY_COMPOSITE_TIMEOUT, DEFAULT_COMPOSITE_TIMEOUT) + log.trace(f"ThumbnailBackground: _request_composite - scheduling {timeout}ms timeout") + ThumbnailBackground._idle_composite_id = GLib.timeout_add( + timeout, # milliseconds + self._execute_composite_callback + ) + + def _execute_composite_callback(self): + """Callback for GLib.timeout that executes the composite.""" + log.trace("ThumbnailBackground: _execute_composite_callback - timeout fired") + # Clear the idle callback ID + ThumbnailBackground._idle_composite_id = None + + try: + # Execute the composite + self._execute_composite_if_needed() + except Exception as e: + # Ensure we always reset the in_progress flag even if something goes wrong + ThumbnailBackground._composite_in_progress = False + log.error(f"ThumbnailBackground: Exception in _execute_composite_callback: {e}", exc_info=True) + + # Return False to prevent this callback from being called again + return False + + def _execute_composite_if_needed(self): + """Execute composite if pending and not already in progress.""" + log.trace(f"ThumbnailBackground: _execute_composite_if_needed - pending={ThumbnailBackground._pending_composite}, in_progress={ThumbnailBackground._composite_in_progress}") + # Check if composite is needed and not already running + if not ThumbnailBackground._pending_composite: + log.trace("ThumbnailBackground: _execute_composite_if_needed - not pending, returning") + return + + if ThumbnailBackground._composite_in_progress: + log.trace("ThumbnailBackground: _execute_composite_if_needed - already in progress, returning") + return + + # Mark as in progress to prevent recursion + ThumbnailBackground._composite_in_progress = True + ThumbnailBackground._pending_composite = False + + try: + # Get all thumbnail actions on the page + all_actions = self._get_all_thumbnail_actions() + + # Check if there are any actions with rendered thumbnails + actions_with_thumbnails = [a for a in all_actions if a.rendered_thumbnail is not None] + dirty_actions = [action for action in all_actions if action.is_dirty] + log.trace(f"ThumbnailBackground: _execute_composite_if_needed - {len(dirty_actions)} dirty actions, {len(actions_with_thumbnails)} with thumbnails, {len(all_actions)} total") + + # If no actions have thumbnails to display, reload the page to restore the original background + if not actions_with_thumbnails: + log.trace("ThumbnailBackground: _execute_composite_if_needed - no thumbnails to display, reloading page to restore background") + # Clear dirty flags first + for action in all_actions: + action.is_dirty = False + # Trigger a page reload to restore the original background + if hasattr(self, 'page') and self.page is not None: + self.page.reload_similar_pages(reload_self=True) + return + + if dirty_actions: + log.trace("ThumbnailBackground: _execute_composite_if_needed - calling _composite_all_thumbnails") + composite = None + try: + composite = self._composite_all_thumbnails() + + # Apply the composite to the deck background + log.trace("ThumbnailBackground: _execute_composite_if_needed - applying composite to deck background") + self.deck_controller.background.set_image( + image=BackgroundImage(self.deck_controller, image=composite), # type: ignore[attr-defined] + update=True + ) + log.trace("ThumbnailBackground: _execute_composite_if_needed - composite applied, clearing dirty flags") + finally: + # Always close the composite image to prevent memory leaks + if composite is not None: + try: + composite.close() + except Exception as e: + log.error(f"Failed to close composite image: {e}") + + # Clear all dirty flags + for action in all_actions: + action.is_dirty = False + else: + log.trace("ThumbnailBackground: _execute_composite_if_needed - no dirty actions, skipping") + finally: + ThumbnailBackground._composite_in_progress = False + log.trace("ThumbnailBackground: _execute_composite_if_needed - complete") + + def _composite_all_thumbnails(self): + """Composite all thumbnail actions onto the base background.""" + log.trace("ThumbnailBackground: _composite_all_thumbnails - starting") + full_width, full_height, _, _, _, _ = self.get_deck_dimensions() + + # Start with the base background + log.trace("ThumbnailBackground: _composite_all_thumbnails - getting original background") + composite = self.get_original_background(full_width, full_height) + + try: + # Layer each thumbnail action's rendered image + all_actions = self._get_all_thumbnail_actions() + actions_with_thumbnails = [a for a in all_actions if a.rendered_thumbnail is not None] + log.trace(f"ThumbnailBackground: _composite_all_thumbnails - compositing {len(actions_with_thumbnails)} thumbnails") + + for action in all_actions: + if action.rendered_thumbnail is not None: + try: + # Ensure the thumbnail is in RGBA mode and matches the composite size + thumb = action.rendered_thumbnail + if thumb.mode != "RGBA": + thumb = thumb.convert("RGBA") + if thumb.size != composite.size: + thumb = thumb.resize(composite.size, Image.Resampling.LANCZOS) + + # Use alpha_composite for proper RGBA compositing + composite.alpha_composite(thumb, (0, 0)) + except Exception as e: + log.error(f"Failed to composite thumbnail: {e}") + + log.trace("ThumbnailBackground: _composite_all_thumbnails - complete") + return composite + except Exception as e: + # If something goes wrong, clean up and re-raise + log.error(f"Unexpected error in _composite_all_thumbnails: {e}", exc_info=True) + try: + composite.close() + except Exception: + pass + raise + + def _should_update(self) -> bool: + """Check if update is needed based on state changes.""" + + # Check if media is playing + title = self.plugin_base.mc.title(self.get_player_name()) # type: ignore[attr-defined] + artist = self.plugin_base.mc.artist(self.get_player_name()) # type: ignore[attr-defined] + + # If both title and artist are None, no media is playing + if title is None and artist is None: + # Check if we were previously showing a thumbnail + if self.last_thumbnail_path is not None: + return True + return False + + # Get current settings + settings = self.get_settings() + if settings is None: + log.trace("ThumbnailBackground: No settings available, skipping update check") + return False + + # Compare size mode change + size_mode = settings.get("size_mode", "stretch") + if size_mode != self.last_size_mode: + log.trace(f"ThumbnailBackground: Size mode changed from {self.last_size_mode} to {size_mode}") + return True + + # Compare position + current_coords = self.input_ident.coords if hasattr(self.input_ident, 'coords') else None # type: ignore[attr-defined] + if current_coords != self.last_coords: + log.trace(f"ThumbnailBackground: Position changed from {self.last_coords} to {current_coords}") + return True + + # Compare thumbnail path + thumbnail_path = self._get_thumbnail_path() + if thumbnail_path != self.last_thumbnail_path: + log.trace(f"ThumbnailBackground: Thumbnail path changed from {self.last_thumbnail_path} to {thumbnail_path}") + return True + + # Compare background path + current_bg_path = self.get_background_path() + if current_bg_path != self.last_background_path: + log.trace(f"ThumbnailBackground: Background path changed from {self.last_background_path} to {current_bg_path}") + return True + + # No relevant changes detected + return False + + def _get_thumbnail_path(self) -> str | None: + """ + Extract the thumbnail file path from the media controller's thumbnail data. + Returns None if no thumbnail is available or if the data format is unexpected. + """ + try: + thumbnail_data = self.plugin_base.mc.thumbnail(self.get_player_name()) # type: ignore[attr-defined] + if isinstance(thumbnail_data, list) and thumbnail_data: + first_item = thumbnail_data[0] + # Validate that the first item is a non-empty string and a valid file + if isinstance(first_item, str) and first_item and first_item.lower() != "none": + if os.path.isfile(first_item): + return first_item + else: + log.trace(f"ThumbnailBackground: Thumbnail path '{first_item}' is not a valid file") + except Exception as e: + log.error(f"Failed to extract thumbnail path: {e}") + return None def on_ready(self): - self.title = None - self.artist = None + """ + Initialize optimization caches to track the current state. + Enables avoiding triggering an update each tick. + An initial update is performed to display the starting background + based on the current media state. + """ + # Invalidate action list cache when page loads + ThumbnailBackground._cached_actions = None + ThumbnailBackground._cached_page_id = None + + # Clean up old background cache before resetting + if ThumbnailBackground._original_background_image is not None: + try: + ThumbnailBackground._original_background_image.close() + except Exception as e: + log.error(f"Failed to close cached background image: {e}") + + # Always reset cache references + ThumbnailBackground._original_background_image = None + ThumbnailBackground._cached_background_path = None + + try: + self._initialize_caches() + self.update_image() + except Exception as e: + log.error(f"Failed to initialize ThumbnailBackground: {e}", exc_info=True) + # Set defaults to ensure action is in a safe state + self.last_size_mode = "stretch" + self.last_thumbnail_path = None + self.last_background_path = "" + self.last_coords = None def on_tick(self): + # Optimization: Only update if something changed + if self._should_update(): + self.update_image() + + def get_config_rows(self) -> "list[Adw.PreferencesRow]": + # Call parent to initialize player_selector (we only want this row, not label/thumbnail toggles) + try: + super().get_config_rows() + except Exception as e: + log.error(f"Failed to initialize parent config rows: {e}") + + # Get player selector from parent initialization + if not hasattr(self, "player_selector") or self.player_selector is None: + log.warning("Player selector not initialized in config rows") + rows = [] + else: + rows = [self.player_selector] + + # Add size mode selector + self.size_mode_model = Gtk.StringList() + self.size_mode_selector = Adw.ComboRow( + model=self.size_mode_model, + title=self.plugin_base.lm.get("actions.thumbnail-background.size-mode.label"), # type: ignore[attr-defined] + subtitle=self.plugin_base.lm.get("actions.thumbnail-background.size-mode.subtitle") # type: ignore[attr-defined] + ) + + # Populate size options + size_options = [ + ("1x1", "1x1"), + ("2x2", "2x2"), + ("3x3", "3x3"), + ("4x4", "4x4"), + ("stretch", self.plugin_base.lm.get("actions.thumbnail-background.size-mode.stretch")), # type: ignore[attr-defined] + ("fill", self.plugin_base.lm.get("actions.thumbnail-background.size-mode.fill")) # type: ignore[attr-defined] + ] + + self.size_mode_options = [opt[0] for opt in size_options] + for _, label in size_options: + self.size_mode_model.append(label) + + self.load_size_mode_default() + self.size_mode_selector.connect("notify::selected", self.on_change_size_mode) + + rows.append(self.size_mode_selector) + return rows + + def load_size_mode_default(self): + """ + Load the default size mode setting and apply it to the size mode selector. + Load from actions settings, load and store ``"stretch"`` as the default, + If an invalid option is stored, fall back to the index for ``"stretch"``. + """ + settings = self.get_settings() + if settings is None: + return + + size_mode = settings.setdefault("size_mode", "stretch") + + # Select the appropriate mode + try: + selected_index = self.size_mode_options.index(size_mode) + except ValueError: + # Default to "stretch" if the stored mode is invalid + selected_index = self.size_mode_options.index("stretch") + + self.size_mode_selector.set_selected(selected_index) + + def on_change_size_mode(self, combo, *args): + """ + When the user selects a different size for the thumbnail display in the UI: + trigger a background image refresh to apply the new sizing behavior. + :param combo: The size mode selector widget (e.g. an Adw.ComboRow) that + emitted the change notification. + :param args: Additional signal parameters provided by the toolkit, + which are currently ignored. + """ + settings = self.get_settings() + if settings is None or not hasattr(self, 'size_mode_options') or not self.size_mode_options: + log.warning("ThumbnailBackground: Cannot change size mode - settings or size_mode_options unavailable") + return + + selected_index = combo.get_selected() + if selected_index < 0 or selected_index >= len(self.size_mode_options): + log.warning(f"ThumbnailBackground: Invalid size mode selection index {selected_index}") + return + + # Invalidate cache since size mode affects sort order (fill/stretch/grid) + ThumbnailBackground._cached_actions = None + ThumbnailBackground._cached_page_id = None + + settings["size_mode"] = self.size_mode_options[selected_index] + self.set_settings(settings) self.update_image() def update_image(self): + """ + Update the background image with a thumbnail based on current settings. + Retrieves the thumbnail path, loads the image, and applies the appropriate + sizing/positioning mode (stretch, fill, or grid-based). + Restore the original background if the thumbnail cannot be loaded. + """ + log.trace("ThumbnailBackground: update_image called") if not self.get_is_present(): return - ## Thumbnail - thumbnail = self.plugin_base.mc.thumbnail(self.get_player_name()) - if isinstance(thumbnail, list): - if thumbnail[0] is None: - thumbnail = None - return + settings = self.get_settings() + if settings is None: + return + + size_mode = settings.setdefault("size_mode", "stretch") + self.last_size_mode = size_mode + + # Get thumbnail path using helper method + thumbnail_path = self._get_thumbnail_path() + + if thumbnail_path is None: + self.last_thumbnail_path = None + self.restore_original_background() + return + + # Load thumbnail image + try: + thumbnail = Image.open(thumbnail_path) + except (OSError, ValueError) as e: + log.error(f"Failed to load thumbnail image from {thumbnail_path}: {e}") + self.last_thumbnail_path = None + self.restore_original_background() + return + + # Track thumbnail path, background path, and position + self.last_thumbnail_path = thumbnail_path + self.last_background_path = self.get_background_path() + if hasattr(self.input_ident, 'coords'): + self.last_coords = self.input_ident.coords # type: ignore[attr-defined] + else: + self.last_coords = None + + # Handle different size modes + if size_mode == "stretch": + # Stretch to exact deck dimensions (may distort aspect ratio) + log.trace("ThumbnailBackground: calling set_stretch_background") + self.set_stretch_background(thumbnail) + elif size_mode == "fill": + log.trace("ThumbnailBackground: calling set_fill_screen_background") + self.set_fill_screen_background(thumbnail) + else: + # Grid sizes (1x1, 2x2, 3x3, 4x4) + log.trace(f"ThumbnailBackground: calling set_grid_sized_background with mode {size_mode}") + self.set_grid_sized_background(thumbnail, size_mode) + + # Close the thumbnail image to prevent memory leaks + thumbnail.close() + + def _close_rendered_thumbnail(self) -> None: + """Close and clear the rendered thumbnail to prevent memory leaks.""" + if self.rendered_thumbnail is not None: try: - thumbnail = Image.open(thumbnail[0]) - except: - thumbnail = None - - if thumbnail is None: - self.clear() - else: - self.deck_controller.background.set_image( - image=BackgroundImage( - self.deck_controller, - image=thumbnail, - ), - update=True - ) + self.rendered_thumbnail.close() + except Exception: + pass + self.rendered_thumbnail = None + + def _initialize_caches(self) -> None: + """Initialize tracking caches with current state.""" + settings = self.get_settings() + self.last_size_mode = settings.get("size_mode", "stretch") if settings else "stretch" + self.last_thumbnail_path = self._get_thumbnail_path() + self.last_background_path = self.get_background_path() + self.last_coords = self.input_ident.coords if hasattr(self.input_ident, 'coords') else None # type: ignore[attr-defined] + + def get_deck_dimensions(self): + """Helper to get full deck dimensions.""" + key_rows, key_cols = self.deck_controller.deck.key_layout() + key_width, key_height = self.deck_controller.get_key_image_size() # type: ignore + spacing_x, spacing_y = self.deck_controller.key_spacing + + full_width = key_width * key_cols + spacing_x * (key_cols - 1) + full_height = key_height * key_rows + spacing_y * (key_rows - 1) + + return full_width, full_height, key_width, key_height, spacing_x, spacing_y - def clear(self): - if not self.get_is_present(): + def set_stretch_background(self, thumbnail: Image.Image): + """Scale the given thumbnail to exactly match the full deck dimensions and set it""" + full_width, full_height, _, _, _, _ = self.get_deck_dimensions() + + self._close_rendered_thumbnail() + self.rendered_thumbnail = thumbnail.resize((full_width, full_height), Image.Resampling.LANCZOS) + + # Convert to RGBA to ensure it has alpha channel for compositing + if self.rendered_thumbnail.mode != 'RGBA': + new_img = self.rendered_thumbnail.convert('RGBA') + self.rendered_thumbnail.close() + self.rendered_thumbnail = new_img + + self._request_composite() + + def set_fill_screen_background(self, thumbnail: Image.Image): + """Scale thumbnail to fill the screen by its longest side, centered.""" + full_width, full_height, _, _, _, _ = self.get_deck_dimensions() + + # Calculate scaling to fill by longest side + thumb_width, thumb_height = thumbnail.size + scale = max(full_width / thumb_width, full_height / thumb_height) + + new_width = int(thumb_width * scale) + new_height = int(thumb_height * scale) + + # Resize and center thumbnail + resized_thumbnail = thumbnail.resize((new_width, new_height), Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", (full_width, full_height), (0, 0, 0, 0)) + + x_offset = (full_width - new_width) // 2 + y_offset = (full_height - new_height) // 2 + canvas.paste(resized_thumbnail, (x_offset, y_offset)) + + self._close_rendered_thumbnail() + self.rendered_thumbnail = canvas + resized_thumbnail.close() + + self._request_composite() + + def set_grid_sized_background(self, thumbnail: Image.Image, size_mode: str): + """Place thumbnail at specific grid size overlaid on current background.""" + # Parse grid size + try: + grid_size = int(size_mode[0]) + except Exception: + # Fallback to stretch behavior if parsing fails + self.set_stretch_background(thumbnail) return - self.deck_controller.background.set_image( - image=None, - update=True - ) + + # Get action position + if not hasattr(self.input_ident, 'coords'): + # Fallback to stretch behavior if no coords available + self.set_stretch_background(thumbnail) + return + + col, row = self.input_ident.coords # type: ignore[attr-defined] + full_width, full_height, key_width, key_height, spacing_x, spacing_y = self.get_deck_dimensions() + + # Calculate thumbnail dimensions + thumb_width = key_width * grid_size + spacing_x * (grid_size - 1) + thumb_height = key_height * grid_size + spacing_y * (grid_size - 1) + + # Resize and position thumbnail on a transparent canvas + resized_thumbnail = thumbnail.resize((thumb_width, thumb_height), Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", (full_width, full_height), (0, 0, 0, 0)) + x_pos = col * (key_width + spacing_x) + y_pos = row * (key_height + spacing_y) + canvas.paste(resized_thumbnail, (x_pos, y_pos)) + + self._close_rendered_thumbnail() + self.rendered_thumbnail = canvas + resized_thumbnail.close() + + self._request_composite() + + def get_original_background(self, full_width: int, full_height: int) -> Image.Image: + """ + Get the original deck or page background without any thumbnail overlays. + + Returns a copy of the cached background image to allow safe compositing. + Multiple thumbnails may layer onto the same base background, so each caller + gets an independent copy to avoid cross-contamination between composites. + + Returns a black canvas if no background is configured or if loading fails. + """ + background_path = self.get_background_path() + + def _reset_background_cache(): + """Close and clear the cached background image.""" + if ThumbnailBackground._original_background_image is not None: + try: + ThumbnailBackground._original_background_image.close() + except Exception as e: + log.error(f"Failed to close background image: {e}") + ThumbnailBackground._original_background_image = None + ThumbnailBackground._cached_background_path = None + + # If no background is configured, always return black (don't cache) + if not background_path or not os.path.isfile(background_path): + log.trace(f"ThumbnailBackground: No valid background configured (path={background_path})") + _reset_background_cache() + return Image.new("RGBA", (full_width, full_height), (0, 0, 0, 255)) + + # Check if background path has changed - invalidate cache if so + if background_path != ThumbnailBackground._cached_background_path: + log.trace(f"ThumbnailBackground: Background path changed from {ThumbnailBackground._cached_background_path} to {background_path}") + _reset_background_cache() + + # Check if current background is a video/animated image + # PIL cannot render videos, so return black canvas instead + video_extensions = {'.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.wmv', '.m4v', '.gif', '.gifv'} + is_video = any(background_path.lower().endswith(ext) for ext in video_extensions) + if is_video: + log.trace(f"ThumbnailBackground: Background is video file (not supported by PIL): {background_path}") + _reset_background_cache() + return Image.new("RGBA", (full_width, full_height), (0, 0, 0, 255)) + + # Return a copy of cached background if available + if ThumbnailBackground._original_background_image is not None: + log.trace(f"ThumbnailBackground: Using cached background from {background_path}") + try: + return ThumbnailBackground._original_background_image.copy() + except Exception as e: + log.error(f"Failed to copy cached background image: {e}") + _reset_background_cache() + + # Cache miss - load and fit image to deck size + log.trace(f"ThumbnailBackground: Loading background image from {background_path}") + try: + with Image.open(background_path) as bg_image: + result = ImageOps.fit(bg_image.copy(), (full_width, full_height), Image.Resampling.LANCZOS) + if result.mode != "RGBA": + result = result.convert("RGBA") + + # Cache the result with its path + ThumbnailBackground._original_background_image = result + ThumbnailBackground._cached_background_path = background_path + log.trace(f"ThumbnailBackground: Cached background image from {background_path}") + return ThumbnailBackground._original_background_image.copy() + except Exception as e: + log.warning(f"Failed to load background from {background_path}: {e}") + _reset_background_cache() + return Image.new("RGBA", (full_width, full_height), (0, 0, 0, 255)) + + def get_background_path(self) -> str: + """ + Get the configured background path from deck or page settings. + Retrieve from Page, falling back to Deck if no page override is set. + """ + deck_settings = self.deck_controller.get_deck_settings() + deck_bg = deck_settings.get("background", {}) + page_bg = self.deck_controller.active_page.dict.get("background", {}) + + # Priority order: + # 1. Page override enabled + # - show enabled: use page background + # - show disabled: return none + # 2. Page override disabled + # - show enabled: use deck background + # - show disabled: return none + # 3. No background configured: return none + + # Check if page is overriding + if page_bg.get("overwrite", False): + # Page is overriding - check if show is enabled + if page_bg.get("show", False): + path = page_bg.get("path") + if path: + return path + # Page override with show disabled = use black + return "" + + # Page not overriding - check deck background + if deck_bg.get("enable", False): + path = deck_bg.get("path") + if path: + return path + + return "" + + def restore_original_background(self, force: bool = False): + """ + Restore the page/deck background when no media is available. + + Clears this action's rendered thumbnail and requests a batched composite + to show the base background and any remaining thumbnails from other actions. + + :param force: If True, requests composite even if no thumbnail was displayed. + Used during action removal to ensure background is properly updated. + """ + if not self.get_is_present() and not force: + return + + changed = False + # Clear this action's rendered thumbnail + if self.rendered_thumbnail is not None: + try: + self.rendered_thumbnail.close() + except Exception as e: + log.error(f"Failed to close rendered thumbnail: {e}") + self.rendered_thumbnail = None + changed = True + + # Update tracking variables for no-media state + self.last_thumbnail_path = None + self.last_background_path = self.get_background_path() + + # Request batched composite only if something changed or forced + # This avoids unnecessary page reloads when no thumbnail was displayed + if changed or force: + log.trace(f"ThumbnailBackground: Requesting composite to restore background (changed={changed}, force={force})") + self._request_composite() + + def clear(self): + """ + Cleanup cached images and reset state when action is removed. + - Invalidate class-level caches ( background image) + - Close and clear this action's rendered thumbnail + - Clear the key image on deck + - Request final composite to show remaining actions/background + """ + + log.debug("ThumbnailBackground: clear called, cleaning up cached images") + + # Reset this instance's tracking variables + self.last_thumbnail_path = None + self.last_size_mode = None + self.last_background_path = "" + self.last_coords = None + + # Close and clear this action's rendered thumbnail + if self.rendered_thumbnail is not None: + try: + self.rendered_thumbnail.close() + except Exception as e: + log.error(f"Failed to close rendered thumbnail during clear: {e}") + self.rendered_thumbnail = None + + # Request batched composite to update background with remaining actions + try: + self._request_composite() + except Exception as e: + log.error(f"Failed to request composite during clear: {e}") + + # Clear the key image so deck shows the composited background properly + try: + if self.get_is_present(): + self.set_media(image=None, update=True) + except Exception: + pass + # Expected during removal when settings are already cleared + pass + + # Clean up class-level background cache + # Note: Don't invalidate cache for all instances - only clear if explicitly needed + # The cache will be invalidated when background path changes or on page load + if ThumbnailBackground._original_background_image is not None: + try: + ThumbnailBackground._original_background_image.close() + except Exception as e: + log.error(f"Failed to close cached background image during clear: {e}") + ThumbnailBackground._original_background_image = None + ThumbnailBackground._cached_background_path = None + + # Cleanup on removal from cache or deletion + # These Three Methods are called in different removal scenarios. + # Though I am not entirely sure when each is called, nor when it should be called. def on_removed_from_cache(self): + """ + Seems to be called on remove via: + Right Click -> Remove Action + Select Action -> Delete Key + """ self.clear() def on_remove(self) -> None: + """ + Seems to be called on remove via: + Red Remove Action button in UI + """ + # Invalidate action list cache since we're removing an action + ThumbnailBackground._cached_actions = None + ThumbnailBackground._cached_page_id = None + + self.clear() + # Reload the page to refresh the background with remaining actions + if hasattr(self, 'page') and self.page is not None: + self.page.reload_similar_pages(reload_self=True) + + def __del__(self): + """ + * Sometimes * also called after on remove and in one case, only this was called?? + """ self.clear() - class MediaPlugin(PluginBase): def __init__(self): @@ -491,12 +1273,21 @@ def __init__(self): self.mc = MediaController() self.lm = self.locale_manager self.lm.set_to_os_default() + + # Initialize settings + self._settings_manager = PluginSettings(self) + self.has_plugin_settings = False + + # Initialize log level from settings + settings = self.get_settings() + log_level = settings.get(KEY_LOG_LEVEL, DEFAULT_LOG_LEVEL) + set_log_level(log_level) shutil.rmtree(os.path.join(gl.DATA_PATH, "com_core447_MediaPlugin", "cache"), ignore_errors=True) self.play_holder = ActionHolder( plugin_base=self, - action_base=Play, + action_base=Play, # type: ignore[arg-type] action_id_suffix="Play", action_name=self.lm.get("actions.play.name"), action_support={ @@ -509,7 +1300,7 @@ def __init__(self): self.pause_holder = ActionHolder( plugin_base=self, - action_base=Pause, + action_base=Pause, # type: ignore[arg-type] action_id_suffix="Pause", action_name=self.lm.get("actions.pause.name"), action_support={ @@ -522,7 +1313,7 @@ def __init__(self): self.play_pause_holder = ActionHolder( plugin_base=self, - action_base=PlayPause, + action_base=PlayPause, # type: ignore[arg-type] action_id_suffix="PlayPause", action_name=self.lm.get("actions.play-pause.name"), action_support={ @@ -535,7 +1326,7 @@ def __init__(self): self.next_holder = ActionHolder( plugin_base=self, - action_base=Next, + action_base=Next, # type: ignore[arg-type] action_id_suffix="Next", action_name=self.lm.get("actions.next.name"), action_support={ @@ -548,7 +1339,7 @@ def __init__(self): self.previous_holder = ActionHolder( plugin_base=self, - action_base=Previous, + action_base=Previous, # type: ignore[arg-type] action_id_suffix="Previous", action_name=self.lm.get("actions.previous.name"), action_support={ @@ -561,7 +1352,7 @@ def __init__(self): self.info_holder = ActionHolder( plugin_base=self, - action_base=Info, + action_base=Info, # type: ignore[arg-type] action_id_suffix="Info", action_name=self.lm.get("actions.info.name"), action_support={ @@ -574,7 +1365,7 @@ def __init__(self): self.thumbnail_holder = ActionHolder( plugin_base=self, - action_base=ThumbnailBackground, + action_base=ThumbnailBackground, # type: ignore[arg-type] action_id_suffix="Thumbnail", action_name=self.lm.get("actions.thumbnail.name"), action_support={ @@ -593,3 +1384,6 @@ def __init__(self): ) self.request_dbus_permission("org.mpris.MediaPlayer2.*") + + def get_settings_area(self): + return self._settings_manager.get_settings_area() diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..6220b78 --- /dev/null +++ b/settings.py @@ -0,0 +1,118 @@ +from gi.repository import Gtk, Adw +import gi +import sys +import os + +from loguru import logger as log + +from src.backend.PluginManager import PluginBase + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +KEY_COMPOSITE_TIMEOUT = "composite_timeout" +DEFAULT_COMPOSITE_TIMEOUT = 80 # milliseconds + +KEY_LOG_LEVEL = "log_level" +DEFAULT_LOG_LEVEL = "INFO" +AVAILABLE_LOG_LEVELS = ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + + +class PluginSettings: + def __init__(self, plugin_base: PluginBase.PluginBase): + self._plugin_base = plugin_base + self._settings_cache = None + + def get_settings_area(self) -> Adw.PreferencesGroup: + + # Composite timeout spin button + self._composite_timeout_adjustment = Gtk.Adjustment( + value=DEFAULT_COMPOSITE_TIMEOUT, + lower=10, + upper=500, + step_increment=10, + page_increment=50 + ) + self._composite_timeout_spin = Adw.SpinRow( + adjustment=self._composite_timeout_adjustment, + title=self._plugin_base.lm.get("settings.composite-timeout.label"), # type: ignore + subtitle=self._plugin_base.lm.get("settings.composite-timeout.subtitle") # type: ignore + ) + + # Log level combo box + self._log_level_model = Gtk.StringList() + for level in AVAILABLE_LOG_LEVELS: + self._log_level_model.append(level) + + self._log_level_combo = Adw.ComboRow( + model=self._log_level_model, + title=self._plugin_base.lm.get("settings.log-level.label"), # type: ignore + subtitle=self._plugin_base.lm.get("settings.log-level.subtitle") # type: ignore + ) + + self._load_settings() + self._composite_timeout_spin.connect("notify::value", self._on_change_composite_timeout) + self._log_level_combo.connect("notify::selected", self._on_change_log_level) + + pref_group = Adw.PreferencesGroup() + pref_group.set_title(self._plugin_base.lm.get("settings.title")) # type: ignore + pref_group.add(self._composite_timeout_spin) + pref_group.add(self._log_level_combo) + return pref_group + + def _get_cached_settings(self): + """Get settings from cache or load from storage.""" + if self._settings_cache is None: + self._settings_cache = self._plugin_base.get_settings() + return self._settings_cache + + def _invalidate_cache(self): + """Invalidate settings cache after modifications.""" + self._settings_cache = None + + def _load_settings(self): + settings = self._get_cached_settings() + composite_timeout = settings.get(KEY_COMPOSITE_TIMEOUT, DEFAULT_COMPOSITE_TIMEOUT) + log_level = settings.get(KEY_LOG_LEVEL, DEFAULT_LOG_LEVEL) + + self._composite_timeout_spin.set_value(composite_timeout) + + try: + selected_index = AVAILABLE_LOG_LEVELS.index(log_level) + except ValueError: + selected_index = AVAILABLE_LOG_LEVELS.index(DEFAULT_LOG_LEVEL) + self._log_level_combo.set_selected(selected_index) + + def _update_settings(self, key: str, value: str): + settings = self._get_cached_settings() + settings[key] = value + self._plugin_base.set_settings(settings) + self._invalidate_cache() + + def _on_change_composite_timeout(self, spin, _): + timeout = int(spin.get_value()) + self._update_settings(KEY_COMPOSITE_TIMEOUT, str(timeout)) + + def _on_change_log_level(self, combo, _): + selected_index = combo.get_selected() + if 0 <= selected_index < len(AVAILABLE_LOG_LEVELS): + level = AVAILABLE_LOG_LEVELS[selected_index] + self._update_settings(KEY_LOG_LEVEL, level) + # Apply the log level immediately + self._apply_log_level(level) + + def _apply_log_level(self, level: str): + """Apply the log level to the plugin logger.""" + try: + import sys + import os + sys.path.insert(0, os.path.dirname(__file__)) + from log_wrapper import set_log_level + set_log_level(level) + except Exception as e: + log.error(f"Failed to set log level: {e}") + + def get_composite_timeout(self) -> int: + """Get the configured composite timeout in milliseconds.""" + settings = self._get_cached_settings() + return settings.get(KEY_COMPOSITE_TIMEOUT, DEFAULT_COMPOSITE_TIMEOUT) \ No newline at end of file From af4c14058f2ec9f94414649a917c841ba08de742 Mon Sep 17 00:00:00 2001 From: D34thm0u53 <124012142+D34thm0u53@users.noreply.github.com> Date: Sat, 10 Jan 2026 10:41:44 +1000 Subject: [PATCH 2/3] bug: update default insert value (#11) --- main.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 39a2be5..d348cce 100644 --- a/main.py +++ b/main.py @@ -838,21 +838,21 @@ def get_config_rows(self) -> "list[Adw.PreferencesRow]": def load_size_mode_default(self): """ Load the default size mode setting and apply it to the size mode selector. - Load from actions settings, load and store ``"stretch"`` as the default, - If an invalid option is stored, fall back to the index for ``"stretch"``. + Load from actions settings, load and store ``"fill"`` as the default, + If an invalid option is stored, fall back to the index for ``"fill"``. """ settings = self.get_settings() if settings is None: return - size_mode = settings.setdefault("size_mode", "stretch") + size_mode = settings.setdefault("size_mode", "fill") # Select the appropriate mode try: selected_index = self.size_mode_options.index(size_mode) except ValueError: - # Default to "stretch" if the stored mode is invalid - selected_index = self.size_mode_options.index("stretch") + # Default to "fill" if the stored mode is invalid + selected_index = self.size_mode_options.index("fill") self.size_mode_selector.set_selected(selected_index) @@ -898,7 +898,7 @@ def update_image(self): if settings is None: return - size_mode = settings.setdefault("size_mode", "stretch") + size_mode = settings.setdefault("size_mode", "fill") self.last_size_mode = size_mode # Get thumbnail path using helper method @@ -954,7 +954,7 @@ def _close_rendered_thumbnail(self) -> None: def _initialize_caches(self) -> None: """Initialize tracking caches with current state.""" settings = self.get_settings() - self.last_size_mode = settings.get("size_mode", "stretch") if settings else "stretch" + self.last_size_mode = settings.get("size_mode", "fill") if settings else "fill" self.last_thumbnail_path = self._get_thumbnail_path() self.last_background_path = self.get_background_path() self.last_coords = self.input_ident.coords if hasattr(self.input_ident, 'coords') else None # type: ignore[attr-defined] From 1d10cf667b11ee0fafb0765271891f2f68053053 Mon Sep 17 00:00:00 2001 From: D34thm0u53 <124012142+D34thm0u53@users.noreply.github.com> Date: Sat, 10 Jan 2026 12:54:44 +1000 Subject: [PATCH 3/3] Cleanup (#12) * Cleanup: Remove unused * Cleanup: Revert removal --- MediaAction.py | 3 --- main.py | 5 ++--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/MediaAction.py b/MediaAction.py index 5d2cc8f..999adce 100644 --- a/MediaAction.py +++ b/MediaAction.py @@ -11,9 +11,6 @@ from PIL import Image, ImageEnhance import os import math -import sys - -from loguru import logger as log class MediaAction(ActionBase): def __init__(self, *args, **kwargs): diff --git a/main.py b/main.py index d348cce..41640f2 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ import shutil -# Import internal modules +from src.backend.PluginManager.ActionBase import ActionBase from src.backend.PluginManager.ActionHolder import ActionHolder from src.backend.PluginManager.PluginBase import PluginBase from src.backend.DeckManagement.InputIdentifier import Input @@ -32,7 +32,6 @@ from MediaAction import MediaAction - class Play(MediaAction): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -832,7 +831,7 @@ def get_config_rows(self) -> "list[Adw.PreferencesRow]": self.load_size_mode_default() self.size_mode_selector.connect("notify::selected", self.on_change_size_mode) - rows.append(self.size_mode_selector) + rows.append(self.size_mode_selector) # type: ignore[arg-type] return rows def load_size_mode_default(self):
No Changes
Changes Detected
No
Yes
Action Triggeredon_ready / on_tick
Check if State Changed
Skip Update
Load Thumbnail
ThumbnailFound?
Show Base Background
Resize & PositionBased on Size Mode
Store RenderedThumbnail
Request Composite
Batch UpdatesDebounce Timeout
Composite All Thumbnailsonto Base Background
Apply to DeckDisplay Result
Render Complete
Not Present
Present
No Thumbnail
Thumbnail Found
stretch
fill
1x1/2x2/3x3/4x4
No Thumbnails
Has Thumbnails
No Dirty
Dirty Actions
ThumbnailBackground Action Triggered
Check if Actionis Present
ReturnNo Update
on_ready OR on_tick
Should Update?_should_update
update_image
Get Settings
Extract Size Modestretch/fill/1x1/2x2/3x3/4x4
_get_thumbnail_path
ThumbnailAvailable?
restore_original_background
Clear rendered_thumbnail
Request Composite_request_composite
Load Thumbnail ImageImage.open
Cache Statethumbnail_path, background_path, coords
Size Mode?
set_stretch_background
Resize to Full Deckfull_width × full_height
Convert to RGBA
Store in rendered_thumbnail
set_fill_screen_background
Calculate ScaleFill by Longest Side
Resize Thumbnail
Center on Canvasfull_width × full_height
set_grid_sized_background
Parse Grid Size
Get Action Positioncoords from input_ident
Calculate Grid Cell Dimensions
Resize & Positionon Transparent Canvas
_request_composite
Mark Action is_dirty = True
Set _pending_composite = True
Existing Timeout?
Cancel Previous TimeoutGLib.source_remove
Schedule New TimeoutGLib.timeout_add
Wait for TimeoutDelay COMPOSITE_TIMEOUT ms
_execute_composite_callback
_execute_composite_if_needed
Pending &Not In Progress?
ReturnSkip Composite
Set _composite_in_progress = True
_get_all_thumbnail_actions
Get All ThumbnailBackgroundActions on Page
Sort by Priorityfill → stretch → grid
Cache Actions & Page ID
Check for Dirty Actions
Any Actionswith Thumbnails?
Reload PageRestore Original Background
Clear is_dirty Flags
Set _composite_in_progress = False
Has DirtyActions?
Skip Composite
_composite_all_thumbnails
Get Deck Dimensions
Get Original Backgroundget_original_background
Load Base Background Image
Cache Background Path& Image
Iterate All Rendered Thumbnails
Alpha Composite Each ThumbnailLayer onto Base
Return Composite Image
Apply to Deck Backgrounddeck_controller.background.set_image
Close Composite ImagePrevent Memory Leaks
Clear is_dirty FlagsAll Actions
Set _pending_composite = False
Deck DisplaysComposited Backgroundwith All Thumbnails
Return FalseRemove GLib Timeout