diff --git a/.qwen/settings.json b/.qwen/settings.json deleted file mode 100644 index fe38678..0000000 --- a/.qwen/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "useWriteTodos": false, - "$version": 2 -} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index da3a41c..d09a996 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,8 @@ librosa sounddevice scipy numpy +pyobjc-core; sys_platform == 'darwin' +pyobjc-framework-MediaPlayer; sys_platform == 'darwin' bump2version appdirs black diff --git a/src/audio/audio_engine.py b/src/audio/audio_engine.py index 5df944e..bd0e72e 100644 --- a/src/audio/audio_engine.py +++ b/src/audio/audio_engine.py @@ -471,11 +471,87 @@ def _extract_album_art(self, audio_file): return None def get_album_art(self): - """Returns embedded album art data of the loaded track.""" - if self.metadata and "album_art" in self.metadata: + """Returns album art data of the loaded track - either embedded or from external file.""" + import os + + # First, try to get embedded album art from metadata + if ( + self.metadata + and "album_art" in self.metadata + and self.metadata["album_art"] + ): return self.metadata["album_art"] + + # If no embedded art, search for local folder images + if self.file_path: + folder_path = os.path.dirname(self.file_path) + album_art_file = self._search_local_album_art(folder_path) + + if album_art_file: + try: + # Read the image file and return its binary data + with open(album_art_file, "rb") as f: + return f.read() + except Exception as e: + print(f"Error reading album art file {album_art_file}: {e}") + return None + def _search_local_album_art(self, folder_path): + """Search for local album art files in the specified folder.""" + import os + + if not os.path.isdir(folder_path): + return None + + # Define the search order according to the specification + search_files = [ + "folder.jpg", + "folder.png", + "cover.jpg", + "cover.png", + "album.jpg", + "album.png", + ] + + # First, check for standard filenames in order of preference + for filename in search_files: + file_path = os.path.join(folder_path, filename) + if os.path.exists(file_path): + return file_path + + # If standard names aren't found, search for album title matches + # Get the album name from metadata if possible + metadata = self.get_metadata() + album_title = metadata.get("album", "Unknown") + + # Normalize album title for matching + normalized_title = self._normalize_filename(album_title) + + # Look for files starting with the album title + for filename in os.listdir(folder_path): + if os.path.isfile(os.path.join(folder_path, filename)): + name, ext = os.path.splitext(filename) + if ext.lower() in [".jpg", ".jpeg", ".png", ".bmp", ".gif"]: + if self._normalize_filename(name).startswith(normalized_title): + return os.path.join(folder_path, filename) + + return None + + def _normalize_filename(self, filename): + """Normalize a filename for comparison by converting to lowercase and handling special characters.""" + import re + + # Convert to lowercase + normalized = filename.lower() + # Replace common separators with spaces + normalized = re.sub(r"[-_\s]+", " ", normalized) + # Remove common characters that don't affect matching + normalized = re.sub(r"[^\w\s]", "", normalized) + # Split and rejoin to normalize multiple spaces to single spaces + normalized = " ".join(normalized.split()) + return normalized + def get_metadata(self): """Returns metadata of the loaded track.""" return self.metadata diff --git a/src/ui/main_window.py b/src/ui/main_window.py index a3d5132..181f5ba 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -220,6 +220,24 @@ def __init__(self): # Initialize UI state self.ui_state = UIState() + # Initialize macOS media integration if on macOS + self.mac_media_integration = None + try: + from ..utils.mac_media_integration import create_mac_media_integration + + print("Attempting to create macOS media integration...") + self.mac_media_integration = create_mac_media_integration(self) + if self.mac_media_integration: + print("macOS media integration successfully loaded") + else: + print( + "macOS media integration not loaded (likely not on macOS or PyObjC unavailable)" + ) + except ImportError as e: + print( + f"Could not import mac_media_integration: {e}" + ) # pyobjc not available + # Set up keyboard shortcuts for media controls self.setup_media_shortcuts() @@ -1004,7 +1022,10 @@ def update_playback_position(self, position, duration): # This callback receives position updates from the audio engine # We store this info so the UI timer can use it consistently # The actual UI update is still handled by the timer to avoid race conditions - pass # The audio engine's current_position is already updated, just let the timer handle the UI update + + # Update macOS media integration if available + if hasattr(self, "mac_media_integration") and self.mac_media_integration: + self.mac_media_integration.update_playback_state() def update_ui_from_engine(self): """Update UI based on audio engine state.""" @@ -1104,6 +1125,14 @@ def play_track_at_index(self, index): if self.album_art_window.isVisible(): self.album_art_window.refresh_album_art(self.audio_engine) + # Update macOS media integration with new track info + if ( + hasattr(self, "mac_media_integration") + and self.mac_media_integration + ): + self.mac_media_integration.update_now_playing_info() + self.mac_media_integration.update_playback_state() + self.update() return True return False @@ -1120,6 +1149,11 @@ def play_next_track(self): if self.album_art_window.isVisible(): self.album_art_window.refresh_album_art(self.audio_engine) + # Update macOS media integration + if hasattr(self, "mac_media_integration") and self.mac_media_integration: + self.mac_media_integration.update_now_playing_info() + self.mac_media_integration.update_playback_state() + def play_previous_track(self): """Play the previous track in the playlist.""" if self.playlist and self.current_track_index > 0: @@ -1129,6 +1163,11 @@ def play_previous_track(self): if self.album_art_window.isVisible(): self.album_art_window.refresh_album_art(self.audio_engine) + # Update macOS media integration + if hasattr(self, "mac_media_integration") and self.mac_media_integration: + self.mac_media_integration.update_now_playing_info() + self.mac_media_integration.update_playback_state() + def play_selected_track(self, index): """Play the track at the given index when clicked in the playlist.""" self.play_track_at_index(index) @@ -1137,6 +1176,11 @@ def play_selected_track(self, index): if self.album_art_window.isVisible(): self.album_art_window.refresh_album_art(self.audio_engine) + # Update macOS media integration + if hasattr(self, "mac_media_integration") and self.mac_media_integration: + self.mac_media_integration.update_now_playing_info() + self.mac_media_integration.update_playback_state() + def _handle_stop_action(self): """Handle the stop action from media key or stop button.""" self.audio_engine.stop() @@ -1145,6 +1189,10 @@ def _handle_stop_action(self): if hasattr(self, "playlist_window") and self.playlist_window: self.playlist_window.set_current_track_index(self.current_track_index) + # Update macOS media integration with new playback state + if hasattr(self, "mac_media_integration") and self.mac_media_integration: + self.mac_media_integration.update_playback_state() + def check_track_completion(self): """Check if the current track has finished playing and advance if needed.""" # Get current playback state from audio engine @@ -1499,6 +1547,14 @@ def mousePressEvent(self, event): and self.album_art_window.isVisible() ): self.album_art_window.refresh_album_art(self.audio_engine) + + # Update macOS media integration with new track info + if ( + hasattr(self, "mac_media_integration") + and self.mac_media_integration + ): + self.mac_media_integration.update_now_playing_info() + self.mac_media_integration.update_playback_state() else: self.ui_state.current_track_title = "Error loading track" @@ -1978,6 +2034,10 @@ def toggle_play_pause(self): selected_track_index = 0 self.play_track_at_index(selected_track_index) + # Update macOS media integration with new playback state + if hasattr(self, "mac_media_integration") and self.mac_media_integration: + self.mac_media_integration.update_playback_state() + def load_and_play_file(self, file_path): """ Load and play a file passed from command line or file opening event. @@ -2035,6 +2095,14 @@ def load_and_play_file(self, file_path): ): self.album_art_window.refresh_album_art(self.audio_engine) + # Update macOS media integration with new track info + if ( + hasattr(self, "mac_media_integration") + and self.mac_media_integration + ): + self.mac_media_integration.update_now_playing_info() + self.mac_media_integration.update_playback_state() + return True else: self.ui_state.current_track_title = "Error loading track" @@ -2329,6 +2397,10 @@ def _on_close(self, event): if hasattr(self, "visualization_timer"): self.visualization_timer.stop() + # Clean up macOS media integration + if hasattr(self, "mac_media_integration") and self.mac_media_integration: + self.mac_media_integration.cleanup() + event.accept() def _initiate_shutdown(self): @@ -2367,10 +2439,14 @@ def _perform_coordinated_shutdown(self): if hasattr(self, "audio_engine"): self.audio_engine.stop() - # 4. Save current window visibility states to preferences + # 4. Clean up macOS media integration + if hasattr(self, "mac_media_integration") and self.mac_media_integration: + self.mac_media_integration.cleanup() + + # 5. Save current window visibility states to preferences self._save_window_visibility_states() - # 5. Save main window position + # 6. Save main window position self.preferences.set_main_window_position(self.x(), self.y()) def _save_window_visibility_states(self): diff --git a/src/ui/playlist_window.py b/src/ui/playlist_window.py index 866dd8d..f436814 100644 --- a/src/ui/playlist_window.py +++ b/src/ui/playlist_window.py @@ -3581,6 +3581,14 @@ def _handle_transport_control_action(self, control_name): # Update playlist window to show currently playing track self.set_current_track_index(0) + + # Update macOS media integration with new track info + if ( + hasattr(self.main_window, "mac_media_integration") + and self.main_window.mac_media_integration + ): + self.main_window.mac_media_integration.update_now_playing_info() + self.main_window.mac_media_integration.update_playback_state() else: self.main_window.current_track_title = "Error loading track" # Update the visual state after action diff --git a/src/utils/mac_media_integration.py b/src/utils/mac_media_integration.py new file mode 100644 index 0000000..20c068a --- /dev/null +++ b/src/utils/mac_media_integration.py @@ -0,0 +1,402 @@ +""" +mac_media_integration.py + +macOS media integration module for WimPyAmp application. +Uses PyObjC to interface with macOS Media Player framework for +system-level media controls in Control Center and menu bar. +""" + +import platform +from typing import TYPE_CHECKING, Optional + + +def is_macos(): + current_system = platform.system() + print(f"Current platform: {current_system}") + return current_system == "Darwin" + + +def check_pyobjc_availability(): + """Check if PyObjC is available by attempting to import required modules.""" + if platform.system() != "Darwin": + return False + + try: + # Try to import the required modules - these are used in actual functionality + import MediaPlayer # type: ignore[import-untyped] + + # Check that the specific attributes exist + attrs_needed = [ + "MPNowPlayingInfoCenter", + "MPNowPlayingInfoPropertyElapsedPlaybackTime", + "MPRemoteCommandCenter", + "MPNowPlayingPlaybackStatePlaying", + "MPNowPlayingPlaybackStatePaused", + "MPNowPlayingPlaybackStateStopped", + "MPMediaItemPropertyTitle", + "MPMediaItemPropertyArtist", + "MPMediaItemPropertyAlbumTitle", + "MPMediaItemPropertyPlaybackDuration", + ] + + for attr in attrs_needed: + if not hasattr(MediaPlayer, attr): + print(f"Missing MediaPlayer attribute: {attr}") + return False + + return True + except ImportError as e: + print(f"ImportError during PyObjC availability check: {e}") + return False + except Exception as e: + print(f"Other error during PyObjC availability check: {e}") + return False + + +if TYPE_CHECKING: + from ..ui.main_window import MainWindow + + +class MacMediaIntegration: + """ + macOS media integration class that interfaces with the Media Player framework + to provide system-level media controls in Control Center and menu bar. + """ + + def __init__(self, main_window: "MainWindow"): + """ + Initialize the macOS media integration with a reference to main window. + + Args: + main_window: Reference to the main window instance + """ + if platform.system() != "Darwin": + raise RuntimeError("MacMediaIntegration can only be initialized on macOS") + + # Check availability at runtime + if not check_pyobjc_availability(): + raise ImportError( + "PyObjC is required for macOS media integration but is not available" + ) + + # Import required modules inside the method + from MediaPlayer import ( # type: ignore[import-untyped] + MPNowPlayingInfoCenter, + MPRemoteCommandCenter, + ) + + self.main_window = main_window + self.now_playing_info_center = MPNowPlayingInfoCenter.defaultCenter() + self.remote_command_center = MPRemoteCommandCenter.sharedCommandCenter() + + # Set up the remote command handlers + self.setup_remote_commands() + + print("MacMediaIntegration initialized successfully") + + def update_now_playing_info(self): + """ + Update track metadata in system Now Playing info center. + """ + if not check_pyobjc_availability(): + return + + # Import required modules inside the method + from MediaPlayer import ( # type: ignore[import-untyped] + MPMediaItemPropertyTitle, + MPMediaItemPropertyArtist, + MPMediaItemPropertyAlbumTitle, + MPMediaItemPropertyPlaybackDuration, + MPMediaItemPropertyArtwork, + MPNowPlayingInfoPropertyElapsedPlaybackTime, + ) + from Foundation import NSNumber # type: ignore[import-untyped] + + # Get current track information from audio engine + metadata = self.main_window.audio_engine.get_metadata() + duration = self.main_window.audio_engine.get_duration() + album_art = self.main_window.audio_engine.get_album_art() + + # Create the now playing info dictionary with ALL required information + now_playing_info = {} + + # Add required metadata - ensure these are properly set + if "title" in metadata and metadata["title"] and metadata["title"] != "Unknown": + now_playing_info[MPMediaItemPropertyTitle] = str(metadata["title"]) + else: + # Fallback to filename if no title available + if self.main_window.audio_engine.file_path: + import os + + now_playing_info[MPMediaItemPropertyTitle] = os.path.splitext( + os.path.basename(self.main_window.audio_engine.file_path) + )[0] + else: + now_playing_info[MPMediaItemPropertyTitle] = "Unknown" + + if ( + "artist" in metadata + and metadata["artist"] + and metadata["artist"] != "Unknown" + ): + now_playing_info[MPMediaItemPropertyArtist] = str(metadata["artist"]) + else: + now_playing_info[MPMediaItemPropertyArtist] = "Unknown Artist" + + if "album" in metadata and metadata["album"] and metadata["album"] != "Unknown": + now_playing_info[MPMediaItemPropertyAlbumTitle] = str(metadata["album"]) + else: + now_playing_info[MPMediaItemPropertyAlbumTitle] = "Unknown Album" + + # Duration handling + if duration and duration > 0: + now_playing_info[MPMediaItemPropertyPlaybackDuration] = ( + NSNumber.numberWithFloat_(duration) + ) + else: + now_playing_info[MPMediaItemPropertyPlaybackDuration] = ( + NSNumber.numberWithFloat_(0.0) + ) + + # Add elapsed playback time + current_position = self.main_window.audio_engine.get_current_position() + now_playing_info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = ( + NSNumber.numberWithFloat_(current_position) + ) + + # Handle album art and add it to the now playing info - this is the key fix + if album_art and len(album_art) > 0: + try: + from Foundation import NSData, NSImage # type: ignore[import-untyped] + from MediaPlayer import MPMediaItemArtwork # type: ignore[import-untyped] + + # Convert album art bytes to NSData + ns_data = NSData.dataWithBytes_length_(album_art, len(album_art)) + + # Create NSImage from the data + artwork_image = NSImage.alloc().initWithData_(ns_data) + + if artwork_image: + print(f"Album art processed, size: {len(album_art)} bytes") + + # Create MPMediaItemArtwork with the NSImage + # The handler function must accept a CGSize parameter as required by the Objective-C API + def image_getter(representation_size): + return artwork_image + + # Create the media item artwork object + media_artwork = ( + MPMediaItemArtwork.alloc().initWithBoundsSize_requestHandler_( + artwork_image.size(), image_getter + ) + ) + + # Add the artwork to the now playing info + now_playing_info[MPMediaItemPropertyArtwork] = media_artwork + print("Album art successfully added to now playing info") + else: + print("Failed to create NSImage from album art data") + except Exception as e: + print(f"Error processing album art: {e}") + import traceback + + traceback.print_exc() + else: + print("No album art available for this track") + + # Update the system now playing info with metadata (this now includes artwork!) + print(f"Now Playing Info Keys: {list(now_playing_info.keys())}") + self.now_playing_info_center.setNowPlayingInfo_(now_playing_info) + + print("Now Playing info updated with all track info including album art") + + def update_playback_state(self): + """ + Update current position, duration, and playback state. + """ + if not check_pyobjc_availability(): + return + + # Import required modules inside the method + from MediaPlayer import ( # type: ignore[import-untyped] + MPNowPlayingInfoPropertyElapsedPlaybackTime, + MPNowPlayingPlaybackStatePlaying, + MPNowPlayingPlaybackStatePaused, + MPNowPlayingPlaybackStateStopped, + ) + from Foundation import NSNumber # type: ignore[import-untyped] + + # Get the current playback position from audio engine + current_position = self.main_window.audio_engine.get_current_position() + + # Get existing now playing info and update the elapsed playback time + now_playing_info = self.now_playing_info_center.nowPlayingInfo() + if now_playing_info is None: + now_playing_info = {} + # If there's no existing info, we need to populate it completely + self.update_now_playing_info() + now_playing_info = self.now_playing_info_center.nowPlayingInfo() or {} + + now_playing_info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = ( + NSNumber.numberWithFloat_(current_position) + ) + + # Update the system now playing info + self.now_playing_info_center.setNowPlayingInfo_(now_playing_info) + + # Update playback state (playing/paused) + playback_state = self.main_window.audio_engine.get_playback_state() + if playback_state.get("is_playing", False): + self.now_playing_info_center.setPlaybackState_( + MPNowPlayingPlaybackStatePlaying + ) + elif playback_state.get("is_paused", False): + self.now_playing_info_center.setPlaybackState_( + MPNowPlayingPlaybackStatePaused + ) + else: + self.now_playing_info_center.setPlaybackState_( + MPNowPlayingPlaybackStateStopped + ) + + def setup_remote_commands(self): + """ + Register handlers for system remote commands. + """ + if not check_pyobjc_availability(): + return + + # Import required modules inside the function + from MediaPlayer import MPRemoteCommandHandlerStatusSuccess, MPRemoteCommandHandlerStatusCommandFailed # type: ignore[import-untyped] + + # Set up play command handler + def handle_play_command(_): + try: + self.main_window.toggle_play_pause() + return MPRemoteCommandHandlerStatusSuccess + except Exception as e: + print(f"Error handling play command: {e}") + return MPRemoteCommandHandlerStatusCommandFailed + + # Set up pause command handler + def handle_pause_command(_): + try: + # If currently playing, pause it + state = self.main_window.audio_engine.get_playback_state() + if state.get("is_playing", False): + self.main_window.audio_engine.pause() + self.main_window.ui_state.is_play_pressed = False + self.main_window.ui_state.is_pause_pressed = True + self.main_window.update() + return MPRemoteCommandHandlerStatusSuccess + except Exception as e: + print(f"Error handling pause command: {e}") + return MPRemoteCommandHandlerStatusCommandFailed + + # Set up play/pause toggle handler + def handle_play_pause_command(_): + try: + self.main_window.toggle_play_pause() + return MPRemoteCommandHandlerStatusSuccess + except Exception as e: + print(f"Error handling play/pause command: {e}") + return MPRemoteCommandHandlerStatusCommandFailed + + # Set up next track command handler + def handle_next_command(_): + try: + self.main_window.play_next_track() + return MPRemoteCommandHandlerStatusSuccess + except Exception as e: + print(f"Error handling next command: {e}") + return MPRemoteCommandHandlerStatusCommandFailed + + # Set up previous track command handler + def handle_previous_command(_): + try: + self.main_window.play_previous_track() + return MPRemoteCommandHandlerStatusSuccess + except Exception as e: + print(f"Error handling previous command: {e}") + return MPRemoteCommandHandlerStatusCommandFailed + + # Set up change playback position command handler + def handle_seek_command(commandEvent): + try: + new_position = commandEvent.playbackPosition() + # Convert position to fraction of duration for seeking + duration = self.main_window.audio_engine.get_duration() + if duration > 0: + position_fraction = new_position / duration + self.main_window.audio_engine.seek(position_fraction) + return MPRemoteCommandHandlerStatusSuccess + except Exception as e: + print(f"Error handling seek command: {e}") + return MPRemoteCommandHandlerStatusCommandFailed + + # Register the command handlers + # Import the remote command center inside the function + from MediaPlayer import MPRemoteCommandCenter # type: ignore[import-untyped] + + remote_command_center = MPRemoteCommandCenter.sharedCommandCenter() + + remote_command_center.playCommand().addTargetWithHandler_(handle_play_command) + remote_command_center.pauseCommand().addTargetWithHandler_(handle_pause_command) + remote_command_center.togglePlayPauseCommand().addTargetWithHandler_( + handle_play_pause_command + ) + remote_command_center.nextTrackCommand().addTargetWithHandler_( + handle_next_command + ) + remote_command_center.previousTrackCommand().addTargetWithHandler_( + handle_previous_command + ) + # Note: Commenting out changePlaybackPositionCommand to reduce complexity + # If needed, uncomment line below, but note it requires special handling in PyObjC + # remote_command_center.changePlaybackPositionCommand().addTargetWithHandler_(handle_seek_command) + + def cleanup(self): + """ + Clean up resources when application closes. + """ + if not check_pyobjc_availability(): + return + + # Import required modules inside the method + from MediaPlayer import MPNowPlayingPlaybackStateStopped # type: ignore[import-untyped] + + # Clear the now playing info + self.now_playing_info_center.setNowPlayingInfo_(None) + self.now_playing_info_center.setPlaybackState_(MPNowPlayingPlaybackStateStopped) + + print("MacMediaIntegration cleaned up successfully") + + +def create_mac_media_integration(main_window) -> Optional["MacMediaIntegration"]: + """ + Safely create a MacMediaIntegration instance with proper error handling. + + Args: + main_window: Reference to the main window instance + + Returns: + MacMediaIntegration instance if on macOS and PyObjC is available, None otherwise + """ + if not is_macos(): + print("Not running on macOS, skipping media integration") + return None + + if not check_pyobjc_availability(): + print("PyObjC is not available. macOS media integration will not be loaded.") + return None + + try: + integration = MacMediaIntegration(main_window) + print("MacMediaIntegration successfully initialized and loaded") + return integration + except Exception as e: + print(f"Failed to initialize MacMediaIntegration: {type(e).__name__}: {e}") + import traceback + + traceback.print_exc() + return None