Files
Modus/modules/dock/components/music_player.py
T

635 lines
24 KiB
Python

import config.data as data
import utils.icons as icons
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.eventbox import EventBox
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.stack import Stack
from gi.repository import GLib, Gdk, GdkPixbuf
from services.mpris import MediaManager
from utils.circle_image import CircleImage
from utils.audio_visualizer import SpectrumRender
import urllib.parse
import os
import requests
import tempfile
class MusicPlayer(EventBox):
def __init__(self, **kwargs):
super().__init__(
name="music-player",
events=["button-press"],
**kwargs
)
orientation = "v" if data.VERTICAL else "h"
# Create main container box
self.main_container = Box(
name="music-player-container",
spacing=4,
orientation=orientation,
)
# Initialize media manager
self._media_manager = MediaManager()
self._media_manager.connect("player-appeared", self._on_player_changed)
self._media_manager.connect("player-vanished", self._on_player_changed)
self._current_player = None
self._manually_selected_player = None
self._update_timeout_id = None
self._current_player_signals = [] # Track signal connections for current player
# Marquee animation variables
self._marquee_timeout_id = None
self._marquee_position = 0
self._original_title = ""
self._title_needs_marquee = False
# Thumbnail spinning animation variables
self._spin_timeout_id = None
self._spin_angle = 0
# Visualizer state
self._show_visualizer = False
# Create music player view with stack for visualizer/text switching
self._create_music_player_view()
# Set the main container as child
self.add(self.main_container)
# Connect click handler for the entire music player
self.connect("button-press-event", self._on_music_player_clicked)
# Hide initially - will show when media players become available
self.set_visible(False)
# Delay initial update to allow MediaManager to initialize properly
GLib.timeout_add(100, self._delayed_initial_update)
def _create_music_player_view(self):
"""Create the music player view with stack for visualizer/text switching like PlayerSmall"""
orientation = "v" if data.VERTICAL else "h"
# Create CircleImage for album art thumbnails (clickable to toggle visualizer)
self.album_thumbnail = CircleImage(
name="music-album-thumbnail",
size=34, # Appropriate size for dock
v_align="center",
visible=True, # Always visible, will show default or album art
)
# Make album thumbnail clickable to toggle visualizer
self.album_thumbnail.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
self.album_thumbnail.connect("button-press-event", self._on_thumbnail_clicked)
# Create track info display
self.track_label = Label(
name="music-track",
label="No Media",
ellipsize=0, # No ellipsize for marquee
h_align="center",
)
# Fixed width to prevent box size changes
self.track_label.set_size_request(100, -1)
self.track_label.set_hexpand(False)
self.track_label.set_vexpand(False)
# Create spectrum visualizer lazily to save memory
self.cavalcade = None
self.cavalcade_box = None
# Create stack for crossfade between visualizer and text
self.center_stack = Stack(
name="music-player-stack",
transition_type="crossfade",
transition_duration=100,
v_align="center",
v_expand=False,
children=[self.track_label] # Start with just track label
)
# Start with track label visible
self.center_stack.set_visible_child(self.track_label)
# Create control buttons
self._create_controls()
# Create main music player layout using CenterBox like PlayerSmall
self.music_player_box = CenterBox(
name="music-player-centerbox",
orientation=orientation,
h_expand=True,
h_align="fill",
v_align="center",
v_expand=False,
start_children=self.album_thumbnail,
center_children=self.center_stack,
end_children=self.controls_box,
)
# Add to main container
self.main_container.add(self.music_player_box)
def _load_album_art(self, art_url):
"""Load album art from URL and set it to the CircleImage"""
# Cache the last loaded URL to avoid reloading the same image
if hasattr(self, '_last_art_url') and self._last_art_url == art_url:
return
if not art_url:
self._load_fallback_image()
self._last_art_url = None
return
try:
if art_url.startswith('file://'):
# Handle local file URLs
file_path = urllib.parse.unquote(art_url[7:]) # Remove 'file://' prefix
if os.path.exists(file_path):
self.album_thumbnail.set_image_from_file(file_path)
self._show_album_art()
self._last_art_url = art_url
else:
self._load_fallback_image()
self._last_art_url = None
elif art_url.startswith(('http://', 'https://')):
# Handle remote URLs by downloading them first
response = requests.get(art_url)
if response.status_code == 200:
# Create a temporary file
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
temp_file.write(response.content)
temp_path = temp_file.name
# Load the image from the temporary file
pixbuf = GdkPixbuf.Pixbuf.new_from_file(temp_path)
self.album_thumbnail.set_image_from_pixbuf(pixbuf)
self._show_album_art()
self._last_art_url = art_url
# Clean up the temporary file
os.unlink(temp_path)
else:
self._load_fallback_image()
self._last_art_url = None
else:
self._load_fallback_image()
self._last_art_url = None
except Exception:
self._load_fallback_image()
self._last_art_url = None
def _load_fallback_image(self):
"""Load fallback image from current wallpaper when no album art is available"""
fallback = os.path.expanduser("~/.current.wall")
try:
if os.path.exists(fallback):
self.album_thumbnail.set_image_from_file(fallback)
self._show_album_art()
else:
self._hide_album_art()
except Exception:
self._hide_album_art()
def _show_album_art(self):
"""Show album art thumbnail"""
self.album_thumbnail.set_visible(True)
# Start spinning animation when showing album art
self._start_spin_animation()
def _hide_album_art(self):
"""Hide album art thumbnail"""
self.album_thumbnail.set_visible(False)
# Stop spinning animation when hiding album art
self._stop_spin_animation()
def _start_spin_animation(self):
"""Start the thumbnail spinning animation"""
if not self._spin_timeout_id:
self._spin_timeout_id = GLib.timeout_add(100, self._animate_spin) # 100ms to save CPU
def _stop_spin_animation(self):
"""Stop the thumbnail spinning animation"""
if self._spin_timeout_id:
GLib.source_remove(self._spin_timeout_id)
self._spin_timeout_id = None
# Reset angle to 0
self._spin_angle = 0
self.album_thumbnail.angle = 0
def _animate_spin(self):
"""Animate the thumbnail spinning"""
if not self._current_player or not self.album_thumbnail.get_visible():
return False
# Only spin when music is playing
if self._current_player.status == "playing":
self._spin_angle = (self._spin_angle + 5) % 360 # Rotate 2 degrees per frame
self.album_thumbnail.angle = self._spin_angle
return True # Continue animation
def _update_player_indicators(self):
"""Update the player indicators to show all available players"""
# Get all available players
available_players = self._media_manager.players
# Only show indicators if there are multiple players to save memory
if len(available_players) <= 1:
# Clear indicators if only one or no players
for child in self.player_indicators_box.get_children():
self.player_indicators_box.remove(child)
return
# Clear existing indicators
for child in self.player_indicators_box.get_children():
self.player_indicators_box.remove(child)
current_player_name = self._current_player.player_name if self._current_player else "None"
# Create indicators for each player (only when multiple players exist)
for player in available_players:
player_name = player.player_name or "Unknown"
player_icon_markup = self._get_player_icon(player_name)
# Create clickable button with indicator label
indicator_button = Button(
name="music-player-indicator-button",
child=Label(
name="music-player-indicator",
markup=player_icon_markup,
),
can_focus=False,
)
# Set tooltip with player info
is_current_player = (player.player_name == current_player_name)
tooltip_text = f"Player: {player_name}"
if is_current_player:
tooltip_text += " (Active)"
else:
tooltip_text += " - Click to switch"
indicator_button.set_tooltip_text(tooltip_text)
# Add visual distinction for active player (you can style this with CSS)
if is_current_player:
indicator_button.get_child().set_name("music-player-indicator-active")
else:
indicator_button.get_child().set_name("music-player-indicator")
# Connect click handler with player reference
indicator_button.connect("clicked", self._on_player_indicator_clicked, player)
self.player_indicators_box.add(indicator_button)
# Force refresh the indicators container
self.player_indicators_box.show_all()
def _create_controls(self):
"""Create the media control buttons"""
self.controls_box = Box(
name="music-controls",
orientation="h" if not data.VERTICAL else "v",
spacing=2,
)
# Previous button
self.prev_button = Button(
name="music-prev",
child=Label(markup=icons.prev),
can_focus=False,
)
self.prev_button.connect("clicked", self._on_previous_clicked)
self.prev_button.set_tooltip_text("Previous Track")
# Play/Pause button
self.play_pause_button = Button(
name="music-play-pause",
child=Label(name="play-pause-icon", markup=icons.play),
can_focus=False,
)
self.play_pause_button.connect("clicked", self._on_play_pause_clicked)
self.play_pause_button.set_tooltip_text("Play/Pause")
# Next button
self.next_button = Button(
name="music-next",
child=Label(markup=icons.next),
can_focus=False,
)
self.next_button.connect("clicked", self._on_next_clicked)
self.next_button.set_tooltip_text("Next Track")
# Player indicators container
self.player_indicators_box = Box(
name="music-player-indicators",
orientation="h" if not data.VERTICAL else "v",
spacing=2,
)
# Add buttons and indicators to controls
self.controls_box.add(self.prev_button)
self.controls_box.add(self.play_pause_button)
self.controls_box.add(self.next_button)
self.controls_box.add(self.player_indicators_box)
def _delayed_initial_update(self):
"""Delayed initial update to allow MediaManager to initialize"""
self._update_display()
return False # Don't repeat this timeout
def _connect_player_signals(self, player):
"""Connect to player signals for immediate updates"""
if player:
# Connect to metadata changes for immediate track/thumbnail updates
metadata_signal = player.connect("metadata_changed", self._on_metadata_changed)
status_signal = player.connect("playback_status_changed", self._on_playback_status_changed)
self._current_player_signals = [metadata_signal, status_signal]
def _disconnect_player_signals(self):
"""Disconnect from current player signals"""
if self._current_player and self._current_player_signals:
for signal_id in self._current_player_signals:
try:
self._current_player.disconnect(signal_id)
except:
pass # Signal might already be disconnected
self._current_player_signals = []
def _on_metadata_changed(self, *_args):
"""Handle immediate metadata changes (track, artist, album art)"""
GLib.idle_add(self._update_display)
def _on_playback_status_changed(self, *_args):
"""Handle immediate playback status changes"""
GLib.idle_add(self._update_display)
def _on_player_changed(self, *_args):
"""Handle player appearance/disappearance"""
GLib.idle_add(self._update_display)
def _update_display(self):
"""Update the display based on current player state"""
# Store previous player to detect changes
previous_player = self._current_player
# Determine current player (manual selection takes priority)
if self._manually_selected_player:
# Check if manually selected player is still available
manually_selected_available = next(
(p for p in self._media_manager.players
if p.player_name == self._manually_selected_player.player_name),
None
)
if manually_selected_available:
self._current_player = manually_selected_available
self._manually_selected_player = manually_selected_available
else:
# Manual player no longer available, clear selection
self._manually_selected_player = None
self._current_player = self._media_manager.current_player
else:
self._current_player = self._media_manager.current_player
# Connect to new player signals if player changed
if self._current_player != previous_player:
self._disconnect_player_signals()
if self._current_player:
self._connect_player_signals(self._current_player)
if not self._current_player:
# No players available, hide the component
self.set_visible(False)
if self._update_timeout_id:
GLib.source_remove(self._update_timeout_id)
self._update_timeout_id = None
if self._marquee_timeout_id:
GLib.source_remove(self._marquee_timeout_id)
self._marquee_timeout_id = None
# Stop spinning animation when no player
self._stop_spin_animation()
return False
self.set_visible(True)
# Try to load album art, use fallback if not available
album_art_url = self._current_player.album_image_url
if album_art_url:
self._load_album_art(album_art_url)
else:
# No album art available, use fallback image
self._load_fallback_image()
# Update stack display (text vs visualizer)
self._update_stack_display()
# Update player indicators to show all available players
self._update_player_indicators()
# Update play/pause button
play_pause_icon = self.play_pause_button.get_child()
if self._current_player.status == "playing":
play_pause_icon.set_markup(icons.pause)
else:
play_pause_icon.set_markup(icons.play)
# Update track info with marquee animation
track_title = self._current_player.track_title or "Unknown Track"
max_display_chars = 12
self._original_title = track_title
self._title_needs_marquee = len(track_title) > max_display_chars
# Reset marquee if title changed
if self._original_title != getattr(self, '_last_original_title', ''):
self._last_original_title = self._original_title
self._marquee_position = 0
self._start_marquee_animation()
self._update_marquee_text()
self._update_tooltips()
# Update button sensitivity
self.prev_button.set_sensitive(self._current_player.can_go_previous)
self.next_button.set_sensitive(self._current_player.can_go_next)
self.play_pause_button.set_sensitive(self._current_player.can_pause)
# Remove all polling - rely entirely on signals for updates
if self._update_timeout_id:
GLib.source_remove(self._update_timeout_id)
self._update_timeout_id = None
return True
def _start_marquee_animation(self):
"""Start or restart the marquee animation"""
# Stop existing marquee animation
if self._marquee_timeout_id:
GLib.source_remove(self._marquee_timeout_id)
self._marquee_timeout_id = None
if self._title_needs_marquee:
self._marquee_timeout_id = GLib.timeout_add(250, self._animate_marquee)
def _animate_marquee(self):
"""Animate the marquee scrolling"""
if not self._current_player:
return False
self._marquee_position += 1
title_length = len(self._original_title)
loop_length = title_length + 3 # Add spacing between loops
if self._marquee_position >= loop_length:
self._marquee_position = 0
self._update_marquee_text()
return True
def _update_marquee_text(self):
"""Update the displayed text based on marquee position"""
if not self._current_player:
return
max_display_chars = 12
if self._title_needs_marquee:
extended_text = self._original_title + " "
start_pos = self._marquee_position
end_pos = start_pos + max_display_chars
if end_pos <= len(extended_text):
display_title = extended_text[start_pos:end_pos]
else:
part1 = extended_text[start_pos:]
part2 = self._original_title[:end_pos - len(extended_text)]
display_title = (part1 + part2)[:max_display_chars]
else:
display_title = self._original_title[:max_display_chars]
self.track_label.set_label(display_title)
def _get_player_icon(self, player_name):
"""Get the appropriate icon for a player based on its name"""
if not player_name:
return icons.disc
player_name_lower = player_name.lower()
if "firefox" in player_name_lower:
return icons.firefox
elif "spotify" in player_name_lower:
return icons.spotify
elif any(browser in player_name_lower for browser in [
"chromium", "chrome", "edge", "brave", "opera", "vivaldi"
]):
return icons.chromium
else:
return icons.disc
def _update_tooltips(self):
"""Update tooltips with full track information"""
if not self._current_player:
return
# Get full track information for tooltip
full_track_title = self._current_player.track_title or "Unknown Track"
track_artist = self._current_player.track_artist or "Unknown Artist"
player_name = self._current_player.player_name or "Media Player"
# Create tooltip text with full information
tooltip_text = f"{full_track_title}\nby {track_artist}\n\nPlayer: {player_name}"
self.track_label.set_tooltip_text(tooltip_text)
def _on_play_pause_clicked(self, _button):
"""Handle play/pause button click"""
if self._current_player:
self._current_player.play_pause()
# No need for manual update - signal will trigger it
def _on_previous_clicked(self, _button):
"""Handle previous button click"""
if self._current_player:
self._current_player.previous()
# No need for manual update - signal will trigger it
def _on_next_clicked(self, _button):
"""Handle next button click"""
if self._current_player:
self._current_player.next()
# No need for manual update - signal will trigger it
def _on_player_indicator_clicked(self, _button, player):
"""Handle player indicator click to switch players"""
if player:
self._manually_selected_player = player
self._update_display()
def _on_thumbnail_clicked(self, _widget, _event):
"""Handle thumbnail click to toggle visualizer"""
if self._current_player:
self._show_visualizer = not self._show_visualizer
self._update_stack_display()
return True # Stop event propagation
def _on_music_player_clicked(self, _widget, _event):
"""Handle music player click to toggle between text and visualizer view"""
if self._current_player:
self._show_visualizer = not self._show_visualizer
self._update_stack_display()
return True
def _create_visualizer_if_needed(self):
"""Create visualizer lazily to save memory"""
if not self.cavalcade:
self.cavalcade = SpectrumRender()
self.cavalcade_box = self.cavalcade.get_spectrum_box()
self.center_stack.add(self.cavalcade_box)
def _destroy_visualizer(self):
"""Destroy visualizer to free memory when not needed"""
if self.cavalcade_box:
self.center_stack.remove(self.cavalcade_box)
self.cavalcade_box = None
if self.cavalcade and hasattr(self.cavalcade, 'cava'):
self.cavalcade.cava.close()
self.cavalcade = None
def _update_stack_display(self):
"""Update stack to show either text or visualizer"""
if self._show_visualizer and self._current_player:
# Create visualizer only when needed
self._create_visualizer_if_needed()
if self.cavalcade_box:
self.cavalcade_box.set_visible(True)
self.cavalcade_box.show_all()
self.center_stack.set_visible_child(self.cavalcade_box)
else:
# Show text label in stack
self.center_stack.set_visible_child(self.track_label)
# Destroy visualizer to save memory when not in use
self._destroy_visualizer()
def __del__(self):
"""Cleanup when component is destroyed"""
# Disconnect player signals
self._disconnect_player_signals()
# Clean up all timeouts
if self._update_timeout_id:
GLib.source_remove(self._update_timeout_id)
if self._marquee_timeout_id:
GLib.source_remove(self._marquee_timeout_id)
if self._spin_timeout_id:
GLib.source_remove(self._spin_timeout_id)
# Clean up visualizer
self._destroy_visualizer()