refactor music display out of display controller

This commit is contained in:
ChuckBuilds
2025-05-23 13:35:43 -05:00
parent 1be8b9fd5a
commit a886ac3b2b
5 changed files with 528 additions and 339 deletions

View File

@@ -2,9 +2,6 @@ import time
import logging
import sys
from typing import Dict, Any, List
import requests
from io import BytesIO
from PIL import Image
# Configure logging
logging.basicConfig(
@@ -63,26 +60,20 @@ class DisplayController:
# Initialize Music Manager
music_init_time = time.time()
self.music_manager = None
self.current_music_info = None
self.album_art_image = None
self.last_album_art_url = None
self.scroll_position_title = 0
self.scroll_position_artist = 0
self.title_scroll_tick = 0 # For slowing down title scroll
self.artist_scroll_tick = 0 # For slowing down artist scroll
# Ensure config is loaded before accessing it for music_manager
if hasattr(self, 'config'):
music_config_main = self.config.get('music', {})
if music_config_main.get('enabled', False):
try:
# Pass the update_callback here
self.music_manager = MusicManager(update_callback=self._handle_music_update)
if self.music_manager.enabled: # Check MusicManager's internal enabled status
# Pass display_manager and config. The callback is now optional for MusicManager.
# DisplayController might not need a specific music update callback anymore if MusicManager handles all display.
self.music_manager = MusicManager(display_manager=self.display_manager, config=self.config, update_callback=self._handle_music_update)
if self.music_manager.enabled:
logger.info("MusicManager initialized successfully.")
self.music_manager.start_polling()
else:
logger.info("MusicManager initialized but is internally disabled or failed to load its own config.")
self.music_manager = None # Ensure it's None if not truly usable
self.music_manager = None
except Exception as e:
logger.error(f"Failed to initialize MusicManager: {e}", exc_info=True)
self.music_manager = None
@@ -320,59 +311,21 @@ class DisplayController:
logger.info(f"NCAA FB Favorite teams: {self.ncaa_fb_favorite_teams}") # Log NCAA FB teams
# Removed redundant NHL/MLB init time logs
def _fetch_and_resize_image(self, url: str, target_size: tuple[int, int]) -> Image.Image | None:
"""Fetches an image from a URL, resizes it, and returns a PIL Image object."""
if not url:
return None
try:
response = requests.get(url, timeout=5) # 5-second timeout for image download
response.raise_for_status() # Raise an exception for bad status codes
img_data = BytesIO(response.content)
img = Image.open(img_data)
# Ensure image is RGB for compatibility with the matrix
img = img.convert("RGB")
# Resize while maintaining aspect ratio (letterbox/pillarbox if necessary)
# This creates a new image of target_size with the original image pasted into it.
# Original image is scaled to fit within target_size.
img.thumbnail(target_size, Image.Resampling.LANCZOS)
# Create a new image with a black background (or any color)
# and paste the thumbnail onto it to ensure it's exactly target_size
# This is good if the matrix hardware expects exact dimensions.
final_img = Image.new("RGB", target_size, (0,0,0)) # Black background
paste_x = (target_size[0] - img.width) // 2
paste_y = (target_size[1] - img.height) // 2
final_img.paste(img, (paste_x, paste_y))
return final_img
except requests.exceptions.RequestException as e:
logger.error(f"Error fetching image from {url}: {e}")
return None
except IOError as e:
logger.error(f"Error processing image from {url}: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error fetching/processing image {url}: {e}")
return None
def _handle_music_update(self, track_info: Dict[str, Any]):
"""Callback for when music track info changes."""
self.current_music_info = track_info # Store the latest info
# Reset album art if URL changes or no URL
new_album_art_url = track_info.get('album_art_url') if track_info else None
if new_album_art_url != self.last_album_art_url:
self.album_art_image = None # Clear old image, will be refetched
self.last_album_art_url = new_album_art_url
"""Callback for when music track info changes. (Simplified)"""
# MusicManager now handles its own display state (album art, etc.)
# This callback might still be useful if DisplayController needs to react to music changes
# for reasons other than directly re-drawing the music screen (e.g., logging, global state).
# For now, we'll keep it simple. If the music screen is active, it will redraw on its own.
if track_info:
logger.info(f"DisplayController received music update (via callback): Title - {track_info.get('title')}, Playing - {track_info.get('is_playing')}")
else:
logger.info("DisplayController received music update (via callback): Track is None or not playing.")
if self.current_display_mode == 'music' and track_info:
logger.info("Music screen is active and track changed, queueing redraw.")
# The run loop will call display_music_screen, which will use self.current_music_info
# No need to force clear here unless specifically desired for music updates
# self.force_clear = True
elif self.current_display_mode == 'music' and not track_info:
logger.info("Music screen is active and track stopped/is None, queueing redraw for 'Nothing Playing'.")
# If the current display mode is music, the MusicManager's display method will be called
# in the main loop and will use its own updated internal state. No explicit action needed here
# to force a redraw of the music screen itself, unless DisplayController wants to switch TO music mode.
# Example: if self.current_display_mode == 'music': self.force_clear = True (but MusicManager.display handles this)
def get_current_duration(self) -> int:
"""Get the duration for the current display mode."""
@@ -615,149 +568,6 @@ class DisplayController:
self.ncaa_fb_current_team_index = (self.ncaa_fb_current_team_index + 1) % len(self.ncaa_fb_favorite_teams)
self.ncaa_fb_showing_recent = True # Reset to recent for the new team
def display_music_screen(self, force_clear: bool = False):
if force_clear or self.force_clear:
self.display_manager.clear()
self.force_clear = False
display_info = self.current_music_info
if not display_info or not display_info.get('is_playing', False) or display_info.get('title') == 'Nothing Playing':
if not hasattr(self, '_last_nothing_playing_log_time') or time.time() - self._last_nothing_playing_log_time > 30:
logger.info("Music Screen: Nothing playing or info unavailable.")
self._last_nothing_playing_log_time = time.time()
self.display_manager.clear()
text_width = self.display_manager.get_text_width("Nothing Playing", self.display_manager.regular_font)
x_pos = (self.display_manager.matrix.width - text_width) // 2
y_pos = (self.display_manager.matrix.height // 2) - 4
self.display_manager.draw_text("Nothing Playing", x=x_pos, y=y_pos, font=self.display_manager.regular_font)
self.display_manager.update_display()
self.scroll_position_title = 0
self.scroll_position_artist = 0
self.title_scroll_tick = 0 # Reset ticks
self.artist_scroll_tick = 0
self.album_art_image = None
return
self.display_manager.draw.rectangle([0, 0, self.display_manager.matrix.width, self.display_manager.matrix.height], fill=(0, 0, 0))
# Album Art Configuration
matrix_height = self.display_manager.matrix.height
album_art_size = matrix_height - 2 # Slightly smaller than matrix height, with 1px padding top/bottom
album_art_target_size = (album_art_size, album_art_size)
album_art_x = 1
album_art_y = 1
text_area_x_start = album_art_x + album_art_size + 2 # Start text 2px to the right of art
text_area_width = self.display_manager.matrix.width - text_area_x_start - 1 # 1px padding on right
# Fetch and display album art
if self.last_album_art_url and not self.album_art_image:
logger.info(f"Fetching album art from: {self.last_album_art_url}")
self.album_art_image = self._fetch_and_resize_image(self.last_album_art_url, album_art_target_size)
if self.album_art_image:
logger.info(f"Album art fetched and processed successfully.")
else:
logger.warning(f"Failed to fetch or process album art.")
if self.album_art_image:
self.display_manager.image.paste(self.album_art_image, (album_art_x, album_art_y))
else:
# No album art, text area uses full width
text_area_x_start = 1
text_area_width = self.display_manager.matrix.width - 2
# Optionally draw a placeholder for album art
self.display_manager.draw.rectangle([album_art_x, album_art_y,
album_art_x + album_art_size -1, album_art_y + album_art_size -1],
outline=(50,50,50), fill=(10,10,10))
self.display_manager.draw_text("?", x=album_art_x + album_art_size//2 - 3, y=album_art_y + album_art_size//2 - 4, color=(100,100,100))
title = display_info.get('title', ' ')
artist = display_info.get('artist', ' ')
album = display_info.get('album', ' ') # Added album
font_title = self.display_manager.small_font
font_artist_album = self.display_manager.extra_small_font
line_height_title = 8 # Approximate height for PressStart2P 8px
line_height_artist_album = 7 # Approximate height for 4x6 font 6px
padding_between_lines = 1
TEXT_SCROLL_DIVISOR = 5 # Adjust this value to change scroll speed (higher is slower)
# --- Title ---
y_pos_title = 2 # Small top margin for title
title_width = self.display_manager.get_text_width(title, font_title)
# Always draw the current state of the text based on scroll_position_title
current_title_display_text = title
if title_width > text_area_width:
current_title_display_text = title[self.scroll_position_title:] + " " + title[:self.scroll_position_title]
self.display_manager.draw_text(current_title_display_text,
x=text_area_x_start, y=y_pos_title, color=(255, 255, 255), font=font_title)
# Only update scroll position based on the divisor
if title_width > text_area_width:
self.title_scroll_tick += 1
if self.title_scroll_tick % TEXT_SCROLL_DIVISOR == 0:
self.scroll_position_title = (self.scroll_position_title + 1) % len(title)
self.title_scroll_tick = 0 # Reset tick to avoid large numbers
else:
self.scroll_position_title = 0
self.title_scroll_tick = 0
# --- Artist ---
y_pos_artist = y_pos_title + line_height_title + padding_between_lines
artist_width = self.display_manager.get_text_width(artist, font_artist_album)
current_artist_display_text = artist
if artist_width > text_area_width:
current_artist_display_text = artist[self.scroll_position_artist:] + " " + artist[:self.scroll_position_artist]
self.display_manager.draw_text(current_artist_display_text,
x=text_area_x_start, y=y_pos_artist, color=(180, 180, 180), font=font_artist_album)
if artist_width > text_area_width:
self.artist_scroll_tick += 1
if self.artist_scroll_tick % TEXT_SCROLL_DIVISOR == 0:
self.scroll_position_artist = (self.scroll_position_artist + 1) % len(artist)
self.artist_scroll_tick = 0
else:
self.scroll_position_artist = 0
self.artist_scroll_tick = 0
# --- Album (optional, if space permits, or scroll, or alternate with artist) ---
# For now, let's place it below artist if it fits, otherwise omit or consider more complex layouts later.
y_pos_album = y_pos_artist + line_height_artist_album + padding_between_lines
# Check if album can fit before progress bar.
# Progress bar height: ~3px + padding. Let's say 5px total.
# Available space for album: matrix_height - y_pos_album - 5
if (matrix_height - y_pos_album - 5) >= line_height_artist_album :
album_width = self.display_manager.get_text_width(album, font_artist_album)
if album_width <= text_area_width: # Only display if it fits without scrolling for now
self.display_manager.draw_text(album, x=text_area_x_start, y=y_pos_album, color=(150, 150, 150), font=font_artist_album)
# --- Progress Bar ---
progress_bar_height = 3
progress_bar_y = matrix_height - progress_bar_height - 1 # 1px from bottom
duration_ms = display_info.get('duration_ms', 0)
progress_ms = display_info.get('progress_ms', 0)
if duration_ms > 0:
bar_total_width = text_area_width
filled_ratio = progress_ms / duration_ms
filled_width = int(filled_ratio * bar_total_width)
# Draw background/empty part of progress bar
self.display_manager.draw.rectangle([
text_area_x_start, progress_bar_y,
text_area_x_start + bar_total_width -1, progress_bar_y + progress_bar_height -1
], outline=(60, 60, 60), fill=(30,30,30)) # Dim outline and fill for empty part
# Draw filled part of progress bar
if filled_width > 0:
self.display_manager.draw.rectangle([
text_area_x_start, progress_bar_y,
text_area_x_start + filled_width -1, progress_bar_y + progress_bar_height -1
], fill=(200, 200, 200)) # Brighter fill for progress
self.display_manager.update_display()
def run(self):
"""Run the display controller, switching between displays."""
if not self.available_modes:
@@ -965,7 +775,8 @@ class DisplayController:
# --- Perform Display Update ---
try:
if self.current_display_mode == 'music' and self.music_manager:
self.display_music_screen(force_clear=self.force_clear)
# Call MusicManager's display method
self.music_manager.display(force_clear=self.force_clear)
# Reset force_clear if it was true for this mode
if self.force_clear:
self.force_clear = False