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

@@ -4,6 +4,9 @@ from enum import Enum, auto
import logging
import json
import os
from io import BytesIO
import requests
from PIL import Image
# Use relative imports for clients within the same package (src)
from .spotify_client import SpotifyClient
@@ -12,6 +15,7 @@ from .ytm_client import YTMClient
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Define paths relative to this file's location
CONFIG_DIR = os.path.join(os.path.dirname(__file__), '..', 'config')
@@ -24,7 +28,9 @@ class MusicSource(Enum):
YTM = auto()
class MusicManager:
def __init__(self, update_callback=None):
def __init__(self, display_manager, config, update_callback=None):
self.display_manager = display_manager
self.config = config
self.spotify = None
self.ytm = None
self.current_track_info = None
@@ -34,6 +40,15 @@ class MusicManager:
self.enabled = False # Default
self.preferred_source = "auto" # Default
self.stop_event = threading.Event()
# Display related attributes moved from DisplayController
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
self.artist_scroll_tick = 0
self._load_config() # Load config first
self._initialize_clients() # Initialize based on loaded config
self.poll_thread = None
@@ -117,6 +132,37 @@ class MusicManager:
logging.info("YTM client initialization skipped due to preferred_source setting.")
self.ytm = None
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")
img.thumbnail(target_size, Image.Resampling.LANCZOS)
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 _poll_music_data(self):
"""Continuously polls music sources for updates, respecting preferences."""
if not self.enabled:
@@ -175,24 +221,33 @@ class MusicManager:
# --- Consolidate and Check for Changes ---
simplified_info = self.get_simplified_track_info(polled_track_info, polled_source)
current_simplified_info = self.get_simplified_track_info(self.current_track_info, self.current_source)
has_changed = False
if simplified_info != current_simplified_info:
if simplified_info != self.current_track_info:
has_changed = True
self.current_track_info = polled_track_info
# Update internal state
old_album_art_url = self.current_track_info.get('album_art_url') if self.current_track_info else None
new_album_art_url = simplified_info.get('album_art_url') if simplified_info else None
self.current_track_info = simplified_info
self.current_source = polled_source
display_title = simplified_info.get('title', 'None') if simplified_info else 'None'
logging.debug(f"Track change detected. Source: {self.current_source.name}. Track: {display_title}")
if new_album_art_url != old_album_art_url:
self.album_art_image = None
self.last_album_art_url = new_album_art_url
display_title = self.current_track_info.get('title', 'None') if self.current_track_info else 'None'
logger.debug(f"Track change detected. Source: {self.current_source.name}. Track: {display_title}")
else:
logging.debug("No change in simplified track info.")
logger.debug("No change in simplified track info.")
if has_changed and self.update_callback:
try:
self.update_callback(simplified_info)
self.update_callback(self.current_track_info)
except Exception as e:
logging.error(f"Error executing update callback: {e}")
logger.error(f"Error executing update callback: {e}")
time.sleep(self.polling_interval)
# Modified to accept data and source, making it more testable/reusable
@@ -263,11 +318,9 @@ class MusicManager:
}
def get_current_display_info(self):
"""Returns the latest simplified info for display purposes."""
# Return default "Nothing Playing" state if manager is disabled
if not self.enabled:
return self.get_simplified_track_info(None, MusicSource.NONE)
return self.get_simplified_track_info(self.current_track_info, self.current_source)
"""Returns the currently stored track information for display."""
# This method might be used by DisplayController if it still needs a snapshot
return self.current_track_info
def start_polling(self):
# Only start polling if enabled
@@ -287,36 +340,232 @@ class MusicManager:
logging.info("Music polling started.")
def stop_polling(self):
"""Stops the music polling thread."""
logger.info("Music manager: Stopping polling thread...")
self.stop_event.set()
if self.poll_thread and self.poll_thread.is_alive():
self.poll_thread.join() # Wait for thread to finish
logging.info("Music polling stopped.")
# Example Usage (for testing)
if __name__ == '__main__':
def print_update(track_info):
print("-" * 20)
if track_info and track_info['source'] != 'None':
print(f"Source: {track_info.get('source')}")
print(f"Title: {track_info.get('title')}")
print(f"Artist: {track_info.get('artist')}")
print(f"Album: {track_info.get('album')}")
print(f"Playing: {track_info.get('is_playing')}")
print(f"Duration: {track_info.get('duration_ms')} ms")
print(f"Progress: {track_info.get('progress_ms')} ms")
print(f"Art URL: {track_info.get('album_art_url')}")
self.poll_thread.join(timeout=self.polling_interval + 1) # Wait for thread to finish
if self.poll_thread and self.poll_thread.is_alive():
logger.warning("Music manager: Polling thread did not terminate cleanly.")
else:
print("Nothing playing or update is None.")
print("-" * 20)
logger.info("Music manager: Polling thread stopped.")
self.poll_thread = None # Clear the thread object
manager = MusicManager(update_callback=print_update)
manager.start_polling()
# Method moved from DisplayController and renamed
def display(self, force_clear: bool = False):
if force_clear: # Removed self.force_clear as it's passed directly
self.display_manager.clear()
# self.force_clear = False # Not needed here
try:
# Keep the main thread alive to allow polling thread to run
while True:
time.sleep(1)
except KeyboardInterrupt:
print("Stopping polling...")
manager.stop_polling()
print("Exiting.")
# Use self.current_track_info which is updated by _poll_music_data
display_info = self.current_track_info
if not display_info or not display_info.get('is_playing', False) or display_info.get('title') == 'Nothing Playing':
# Debounce "Nothing playing" log for this manager
if not hasattr(self, '_last_nothing_playing_log_time') or \
time.time() - getattr(self, '_last_nothing_playing_log_time', 0) > 30:
logger.info("Music Screen (MusicManager): Nothing playing or info unavailable.")
self._last_nothing_playing_log_time = time.time()
self.display_manager.clear() # Clear before drawing "Nothing Playing"
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
self.artist_scroll_tick = 0
self.album_art_image = None # Clear album art if nothing is playing
self.last_album_art_url = None # Also clear the URL
return
# Ensure screen is cleared if not force_clear but needed (e.g. transition from "Nothing Playing")
# This might be handled by DisplayController's force_clear logic, but can be an internal check too.
# For now, assuming DisplayController manages the initial clear for a new mode.
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
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
text_area_width = self.display_manager.matrix.width - text_area_x_start - 1
# Fetch and display album art using self.last_album_art_url and self.album_art_image
if self.last_album_art_url and not self.album_art_image:
logger.info(f"MusicManager: 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"MusicManager: Album art fetched and processed successfully.")
else:
logger.warning(f"MusicManager: 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:
text_area_x_start = 1
text_area_width = self.display_manager.matrix.width - 2
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', ' ')
font_title = self.display_manager.small_font
font_artist_album = self.display_manager.extra_small_font
line_height_title = 8
line_height_artist_album = 7
padding_between_lines = 1
TEXT_SCROLL_DIVISOR = 5
# --- Title ---
y_pos_title = 2
title_width = self.display_manager.get_text_width(title, font_title)
current_title_display_text = title
if title_width > text_area_width:
# Ensure scroll_position_title is valid for the current title length
if self.scroll_position_title >= len(title):
self.scroll_position_title = 0
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)
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
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:
# Ensure scroll_position_artist is valid for the current artist length
if self.scroll_position_artist >= len(artist):
self.scroll_position_artist = 0
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 ---
y_pos_album = y_pos_artist + line_height_artist_album + padding_between_lines
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:
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
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)
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))
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))
self.display_manager.update_display()
# Example usage (for testing this module standalone, if needed)
# def print_update(track_info):
# logging.info(f"Callback: Track update received by dummy callback: {track_info}")
if __name__ == '__main__':
# This is a placeholder for testing.
# To test properly, you'd need a mock DisplayManager and ConfigManager.
logging.basicConfig(level=logging.DEBUG)
logger.info("Running MusicManager standalone test (limited)...")
# Mock DisplayManager and Config objects
class MockDisplayManager:
def __init__(self):
self.matrix = type('Matrix', (), {'width': 64, 'height': 32})() # Mock matrix
self.image = Image.new("RGB", (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image) # Requires ImageDraw
self.regular_font = None # Needs font loading
self.small_font = None
self.extra_small_font = None
# Add other methods/attributes DisplayManager uses if they are called by MusicManager's display
# For simplicity, we won't fully mock font loading here.
# self.regular_font = ImageFont.truetype("path/to/font.ttf", 8)
def clear(self): logger.debug("MockDisplayManager: clear() called")
def get_text_width(self, text, font): return len(text) * 5 # Rough mock
def draw_text(self, text, x, y, color=(255,255,255), font=None): logger.debug(f"MockDisplayManager: draw_text '{text}' at ({x},{y})")
def update_display(self): logger.debug("MockDisplayManager: update_display() called")
class MockConfig:
def get(self, key, default=None):
if key == "music":
return {"enabled": True, "POLLING_INTERVAL_SECONDS": 2, "preferred_source": "auto"}
return default
# Need to import ImageDraw for the mock to work if draw_text is complex
try: from PIL import ImageDraw, ImageFont
except ImportError: ImageDraw = None; ImageFont = None; logger.warning("Pillow ImageDraw/ImageFont not fully available for mock")
mock_display = MockDisplayManager()
mock_config_main = {"music": {"enabled": True, "POLLING_INTERVAL_SECONDS": 2, "preferred_source": "auto"}}
# The MusicManager expects the overall config, not just the music part directly for its _load_config
# So we simulate a config object that has a .get('music', {}) method.
# However, MusicManager's _load_config reads from CONFIG_PATH.
# For a true standalone test, we might need to mock file IO or provide a test config file.
# Simplified test:
# manager = MusicManager(display_manager=mock_display, config=mock_config_main) # This won't work due to file reading
# To truly test, you'd point CONFIG_PATH to a test config.json or mock open()
# For now, this __main__ block is mostly a placeholder.
logger.info("MusicManager standalone test setup is complex due to file dependencies for config.")
logger.info("To test: run the main application and observe logs from MusicManager.")
# if manager.enabled:
# manager.start_polling()
# try:
# while True:
# time.sleep(1)
# # In a real test, you might manually call manager.display() after setting some track info
# except KeyboardInterrupt:
# logger.info("Stopping standalone test...")
# finally:
# if manager.enabled:
# manager.stop_polling()
# logger.info("Test finished.")