diff --git a/LEDMatrix.wiki b/LEDMatrix.wiki index fbd8d89a..a01c72e1 160000 --- a/LEDMatrix.wiki +++ b/LEDMatrix.wiki @@ -1 +1 @@ -Subproject commit fbd8d89a186e5757d1785737b0ee4c03ad442dbf +Subproject commit a01c72e156b46c08a5ef1c67db79acd73300a6f7 diff --git a/assets/static_images/default.png b/assets/static_images/default.png new file mode 100644 index 00000000..6db47288 Binary files /dev/null and b/assets/static_images/default.png differ diff --git a/config/config.template.json b/config/config.template.json index 7bba122e..a7e30b74 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -72,7 +72,8 @@ "ncaam_basketball_upcoming": 30, "music": 30, "of_the_day": 40, - "news_manager": 60 + "news_manager": 60, + "static_image": 10 }, "use_short_date_format": true }, @@ -130,7 +131,7 @@ "duration_buffer": 0.1 }, "odds_ticker": { - "enabled": false, + "enabled": true, "show_favorite_teams_only": true, "games_per_favorite_team": 1, "max_games_per_league": 5, @@ -233,8 +234,6 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, - "show_all_live": false, - "show_shots_on_goal": false, "favorite_teams": [ "TB" ], @@ -301,7 +300,6 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, - "show_all_live": false, "favorite_teams": [ "TB", "DAL" @@ -334,11 +332,9 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, - "show_all_live": false, "favorite_teams": [ "UGA", - "AUB", - "AP_TOP_25" + "AUB" ], "logo_dir": "assets/sports/ncaa_logos", "show_records": true, @@ -369,8 +365,6 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, - "show_all_live": false, - "show_series_summary": false, "favorite_teams": [ "UGA", "AUB" @@ -419,8 +413,6 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, - "show_all_live": false, - "show_shots_on_goal": false, "favorite_teams": [ "RIT" ], @@ -452,8 +444,6 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, - "show_all_live": false, - "show_series_summary": false, "favorite_teams": [ "TB", "TEX" @@ -611,5 +601,16 @@ 0, 0 ] + }, + "static_image": { + "enabled": false, + "image_path": "assets/static_images/default.png", + "fit_to_display": true, + "preserve_aspect_ratio": true, + "background_color": [ + 0, + 0, + 0 + ] } } diff --git a/src/display_controller.py b/src/display_controller.py index 9a55df8e..151499b6 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -34,6 +34,7 @@ from src.ncaam_hockey_managers import NCAAMHockeyLiveManager, NCAAMHockeyRecentM from src.youtube_display import YouTubeDisplay from src.calendar_manager import CalendarManager from src.text_display import TextDisplay +from src.static_image_manager import StaticImageManager from src.music_manager import MusicManager from src.of_the_day_manager import OfTheDayManager from src.news_manager import NewsManager @@ -66,10 +67,12 @@ class DisplayController: self.calendar = CalendarManager(self.display_manager, self.config) if self.config.get('calendar', {}).get('enabled', False) else None self.youtube = YouTubeDisplay(self.display_manager, self.config) if self.config.get('youtube', {}).get('enabled', False) else None self.text_display = TextDisplay(self.display_manager, self.config) if self.config.get('text_display', {}).get('enabled', False) else None + self.static_image = StaticImageManager(self.display_manager, self.config) if self.config.get('static_image', {}).get('enabled', False) else None self.of_the_day = OfTheDayManager(self.display_manager, self.config) if self.config.get('of_the_day', {}).get('enabled', False) else None self.news_manager = NewsManager(self.config, self.display_manager, self.config_manager) if self.config.get('news_manager', {}).get('enabled', False) else None logger.info(f"Calendar Manager initialized: {'Object' if self.calendar else 'None'}") logger.info(f"Text Display initialized: {'Object' if self.text_display else 'None'}") + logger.info(f"Static Image Manager initialized: {'Object' if self.static_image else 'None'}") logger.info(f"OfTheDay Manager initialized: {'Object' if self.of_the_day else 'None'}") logger.info(f"News Manager initialized: {'Object' if self.news_manager else 'None'}") logger.info("Display modes initialized in %.3f seconds", time.time() - init_time) @@ -282,6 +285,7 @@ class DisplayController: if self.calendar: self.available_modes.append('calendar') if self.youtube: self.available_modes.append('youtube') if self.text_display: self.available_modes.append('text_display') + if self.static_image: self.available_modes.append('static_image') if self.of_the_day: self.available_modes.append('of_the_day') if self.news_manager: self.available_modes.append('news_manager') if self.music_manager: @@ -600,6 +604,7 @@ class DisplayController: if self.calendar: self.calendar.update(time.time()) if self.youtube: self.youtube.update() if self.text_display: self.text_display.update() + if self.static_image: self.static_image.update() if self.of_the_day: self.of_the_day.update(time.time()) else: # Not scrolling, perform all updates normally @@ -610,6 +615,7 @@ class DisplayController: if self.calendar: self.calendar.update(time.time()) if self.youtube: self.youtube.update() if self.text_display: self.text_display.update() + if self.static_image: self.static_image.update() if self.of_the_day: self.of_the_day.update(time.time()) # Update sports managers for leaderboard data @@ -1208,6 +1214,8 @@ class DisplayController: manager_to_display = self.youtube elif self.current_display_mode == 'text_display' and self.text_display: manager_to_display = self.text_display + elif self.current_display_mode == 'static_image' and self.static_image: + manager_to_display = self.static_image elif self.current_display_mode == 'of_the_day' and self.of_the_day: manager_to_display = self.of_the_day elif self.current_display_mode == 'news_manager' and self.news_manager: @@ -1321,6 +1329,8 @@ class DisplayController: manager_to_display.display(force_clear=self.force_clear) elif self.current_display_mode == 'text_display': manager_to_display.display() # Assumes internal clearing + elif self.current_display_mode == 'static_image': + manager_to_display.display(force_clear=self.force_clear) elif self.current_display_mode == 'of_the_day': manager_to_display.display(force_clear=self.force_clear) elif self.current_display_mode == 'news_manager': diff --git a/src/static_image_manager.py b/src/static_image_manager.py new file mode 100644 index 00000000..f18d9999 --- /dev/null +++ b/src/static_image_manager.py @@ -0,0 +1,211 @@ +import logging +import os +import time +from typing import Optional, Tuple +from PIL import Image, ImageOps +import json + +from .display_manager import DisplayManager + +logger = logging.getLogger(__name__) + +class StaticImageManager: + """ + Manager for displaying static images on the LED matrix. + Supports image scaling, transparency, and configurable display duration. + """ + + def __init__(self, display_manager: DisplayManager, config: dict): + self.display_manager = display_manager + self.config = config.get('static_image', {}) + + # Configuration + self.enabled = self.config.get('enabled', False) + self.image_path = self.config.get('image_path', '') + # Get display duration from main display_durations block + self.display_duration = config.get('display', {}).get('display_durations', {}).get('static_image', 10) + self.fit_to_display = self.config.get('fit_to_display', True) # Auto-fit to display dimensions + self.preserve_aspect_ratio = self.config.get('preserve_aspect_ratio', True) + self.background_color = tuple(self.config.get('background_color', [0, 0, 0])) + + # State + self.current_image = None + self.image_loaded = False + self.last_update_time = 0 + + # Load initial image if enabled + if self.enabled and self.image_path: + self._load_image() + + def _load_image(self) -> bool: + """ + Load and process the image for display. + Returns True if successful, False otherwise. + """ + if not self.image_path or not os.path.exists(self.image_path): + logger.warning(f"[Static Image] Image file not found: {self.image_path}") + return False + + try: + # Load the image + img = Image.open(self.image_path) + + # Convert to RGBA to handle transparency + if img.mode != 'RGBA': + img = img.convert('RGBA') + + # Get display dimensions + display_width = self.display_manager.matrix.width + display_height = self.display_manager.matrix.height + + # Calculate target size - always fit to display while preserving aspect ratio + target_size = self._calculate_fit_size(img.size, (display_width, display_height)) + + # Resize image + if self.preserve_aspect_ratio: + img = img.resize(target_size, Image.Resampling.LANCZOS) + else: + img = img.resize((display_width, display_height), Image.Resampling.LANCZOS) + + # Create display-sized canvas with background color + canvas = Image.new('RGB', (display_width, display_height), self.background_color) + + # Calculate position to center the image + paste_x = (display_width - img.width) // 2 + paste_y = (display_height - img.height) // 2 + + # Handle transparency by compositing + if img.mode == 'RGBA': + # Create a temporary image with the background color + temp_canvas = Image.new('RGB', (display_width, display_height), self.background_color) + temp_canvas.paste(img, (paste_x, paste_y), img) + canvas = temp_canvas + else: + canvas.paste(img, (paste_x, paste_y)) + + self.current_image = canvas + self.image_loaded = True + self.last_update_time = time.time() + + logger.info(f"[Static Image] Successfully loaded and processed image: {self.image_path}") + logger.info(f"[Static Image] Original size: {Image.open(self.image_path).size}, " + f"Display size: {target_size}, Position: ({paste_x}, {paste_y})") + + return True + + except Exception as e: + logger.error(f"[Static Image] Error loading image {self.image_path}: {e}") + self.image_loaded = False + return False + + def _calculate_fit_size(self, image_size: Tuple[int, int], display_size: Tuple[int, int]) -> Tuple[int, int]: + """ + Calculate the size to fit an image within display bounds while preserving aspect ratio. + """ + img_width, img_height = image_size + display_width, display_height = display_size + + # Calculate scaling factor to fit within display + scale_x = display_width / img_width + scale_y = display_height / img_height + scale = min(scale_x, scale_y) + + return (int(img_width * scale), int(img_height * scale)) + + def update(self): + """ + Update method - no continuous updates needed for static images. + """ + pass + + def display(self, force_clear: bool = False): + """ + Display the static image on the LED matrix. + """ + if not self.enabled or not self.image_loaded or not self.current_image: + if self.enabled: + logger.warning("[Static Image] Manager enabled but no image loaded") + return + + # Clear display if requested + if force_clear: + self.display_manager.clear() + + # Set the image on the display manager + self.display_manager.image = self.current_image.copy() + + # Update the display + self.display_manager.update_display() + + logger.debug(f"[Static Image] Displayed image: {self.image_path}") + + def set_image_path(self, image_path: str) -> bool: + """ + Set a new image path and load it. + Returns True if successful, False otherwise. + """ + self.image_path = image_path + return self._load_image() + + def set_fit_to_display(self, fit_to_display: bool): + """ + Set the fit to display option and reload the image. + """ + self.fit_to_display = fit_to_display + if self.image_path: + self._load_image() + logger.info(f"[Static Image] Fit to display set to: {self.fit_to_display}") + + def set_display_duration(self, duration: int): + """ + Set the display duration in seconds. + """ + self.display_duration = max(1, duration) # Minimum 1 second + logger.info(f"[Static Image] Display duration set to: {self.display_duration} seconds") + + def set_background_color(self, color: Tuple[int, int, int]): + """ + Set the background color and reload the image. + """ + self.background_color = color + if self.image_path: + self._load_image() + logger.info(f"[Static Image] Background color set to: {self.background_color}") + + def get_image_info(self) -> dict: + """ + Get information about the currently loaded image. + """ + if not self.image_loaded or not self.current_image: + return {"loaded": False} + + return { + "loaded": True, + "path": self.image_path, + "display_size": self.current_image.size, + "fit_to_display": self.fit_to_display, + "display_duration": self.display_duration, + "background_color": self.background_color + } + + def reload_image(self) -> bool: + """ + Reload the current image. + """ + if not self.image_path: + logger.warning("[Static Image] No image path set for reload") + return False + + return self._load_image() + + def is_enabled(self) -> bool: + """ + Check if the manager is enabled. + """ + return self.enabled + + def get_display_duration(self) -> int: + """ + Get the display duration in seconds. + """ + return self.display_duration diff --git a/templates/index_v2.html b/templates/index_v2.html index d3dc3c5a..c67a6be1 100644 --- a/templates/index_v2.html +++ b/templates/index_v2.html @@ -875,6 +875,9 @@ + @@ -1637,6 +1640,37 @@ + +