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

578 lines
20 KiB
Python

import os
import tempfile
import urllib.parse
import requests
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.eventbox import EventBox
from fabric.widgets.label import Label
from fabric.widgets.stack import Stack
from gi.repository import Gdk, GdkPixbuf, GLib
import config.data as data
import utils.icons as icons
from services.mpris import MediaManager
from utils.audio_visualizer import SpectrumRender
from utils.circle_image import CircleImage
class MusicPlayer(EventBox):
def __init__(self, **kwargs):
super().__init__(name="music-player", events=["button-press"], **kwargs)
orientation = "v" if data.VERTICAL else "h"
self.main_container = Box(
name="music-player-container",
spacing=4,
orientation=orientation,
)
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 = []
self._marquee_timeout_id = None
self._marquee_position = 0
self._original_title = ""
self._title_needs_marquee = False
self._spin_timeout_id = None
self._spin_angle = 0
self._show_visualizer = False
self._create_music_player_view()
self.add(self.main_container)
self.connect("button-press-event", self._on_music_player_clicked)
# Start hidden - visibility will be managed by _update_display() based on media availability
self.set_visible(False)
# Ensure visualizer is disabled in vertical mode
if data.VERTICAL:
self._show_visualizer = False
GLib.timeout_add(100, self._delayed_initial_update)
def _create_music_player_view(self):
orientation = "v" if data.VERTICAL else "h"
self.album_thumbnail = CircleImage(
name="music-album-thumbnail",
size=30,
v_align="center",
visible=True,
)
self.album_thumbnail.add_events(Gdk.EventMask.BUTTON_PRESS_MASK)
self.album_thumbnail.connect("button-press-event", self._on_thumbnail_clicked)
# Create track label (hidden in vertical mode)
self.track_label = Label(
name="music-track",
label="No Media",
ellipsize=0,
h_align="center",
)
if data.VERTICAL:
self.track_label.set_visible(False) # Hide label in vertical mode
self.track_label.set_size_request(-1, -1)
else:
self.track_label.set_visible(True)
self.track_label.set_size_request(100, -1)
self.track_label.set_hexpand(False)
self.track_label.set_vexpand(False)
self.cavalcade = None
self.cavalcade_box = None
self.center_stack = Stack(
name="music-player-stack",
transition_type="crossfade",
transition_duration=100,
v_align="center",
v_expand=False,
children=[self.track_label],
)
self.center_stack.set_visible_child(self.track_label)
self._create_controls()
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 vertical class for CSS styling
if data.VERTICAL:
self.music_player_box.add_style_class("vertical")
self.main_container.add_style_class("vertical")
self.main_container.add(self.music_player_box)
def _load_album_art(self, art_url):
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://"):
file_path = urllib.parse.unquote(art_url[7:])
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://")):
response = requests.get(art_url)
if response.status_code == 200:
with tempfile.NamedTemporaryFile(
delete=False, suffix=".jpg"
) as temp_file:
temp_file.write(response.content)
temp_path = temp_file.name
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
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):
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):
self.album_thumbnail.set_visible(True)
self._start_spin_animation()
def _hide_album_art(self):
self.album_thumbnail.set_visible(False)
def _start_spin_animation(self):
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):
if self._spin_timeout_id:
GLib.source_remove(self._spin_timeout_id)
self._spin_timeout_id = None
self._spin_angle = 0
self.album_thumbnail.angle = 0
def _animate_spin(self):
if not self._current_player or not self.album_thumbnail.get_visible():
return False
if self._current_player.status == "playing":
self._spin_angle = (self._spin_angle + 5) % 360
self.album_thumbnail.angle = self._spin_angle
return True
def _update_player_indicators(self):
available_players = self._media_manager.players
if len(available_players) <= 1:
for child in self.player_indicators_box.get_children():
self.player_indicators_box.remove(child)
return
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"
)
for player in available_players:
player_name = player.player_name or "Unknown"
player_icon_markup = self._get_player_icon(player_name)
indicator_button = Button(
name="music-player-indicator-button",
child=Label(
name="music-player-indicator",
markup=player_icon_markup,
),
can_focus=False,
)
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)
if is_current_player:
indicator_button.get_child().set_name("music-player-indicator-active")
else:
indicator_button.get_child().set_name("music-player-indicator")
indicator_button.connect(
"clicked", self._on_player_indicator_clicked, player
)
self.player_indicators_box.add(indicator_button)
self.player_indicators_box.show_all()
def _create_controls(self):
self.controls_box = Box(
name="music-controls",
orientation="h" if not data.VERTICAL else "v",
spacing=2,
)
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")
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")
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")
self.player_indicators_box = Box(
name="music-player-indicators",
orientation="h" if not data.VERTICAL else "v",
spacing=2,
)
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):
self._update_display()
return False
def _connect_player_signals(self, player):
if player:
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):
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):
GLib.idle_add(self._update_display)
def _on_playback_status_changed(self, *_args):
GLib.idle_add(self._update_display)
def _on_player_changed(self, *_args):
GLib.idle_add(self._update_display)
def _update_display(self):
previous_player = self._current_player
if self._manually_selected_player:
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:
self._manually_selected_player = None
self._current_player = self._media_manager.current_player
else:
self._current_player = self._media_manager.current_player
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:
# Hide the component when there's no media player available
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
self._stop_spin_animation()
return False
# Only show if enabled in configuration
if data.DOCK_COMPONENTS_VISIBILITY.get("music_player", True):
self.set_visible(True)
else:
self.set_visible(False)
return False
album_art_url = self._current_player.album_image_url
if album_art_url:
self._load_album_art(album_art_url)
else:
self._load_fallback_image()
self._update_stack_display()
self._update_player_indicators()
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)
# Skip text processing in vertical mode since label is hidden
if not data.VERTICAL:
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
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()
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)
if self._update_timeout_id:
GLib.source_remove(self._update_timeout_id)
self._update_timeout_id = None
return True
def _start_marquee_animation(self):
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):
if not self._current_player:
return False
self._marquee_position += 1
title_length = len(self._original_title)
loop_length = title_length + 3
if self._marquee_position >= loop_length:
self._marquee_position = 0
self._update_marquee_text()
return True
def _update_marquee_text(self):
if not self._current_player:
return
# Skip updating label text in vertical mode since it's hidden
if data.VERTICAL:
return
# Use the same character limit as in _update_display
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):
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):
if not self._current_player:
return
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"
tooltip_text = f"{full_track_title}\nby {track_artist}\n\nPlayer: {player_name}"
if data.VERTICAL:
# In vertical mode, show tooltip on the main container and album thumbnail
self.set_tooltip_text(tooltip_text)
self.album_thumbnail.set_tooltip_text(tooltip_text)
else:
# In horizontal mode, show tooltip on the track label
self.track_label.set_tooltip_text(tooltip_text)
def _on_play_pause_clicked(self, _button):
if self._current_player:
self._current_player.play_pause()
def _on_previous_clicked(self, _button):
if self._current_player:
self._current_player.previous()
def _on_next_clicked(self, _button):
if self._current_player:
self._current_player.next()
def _on_player_indicator_clicked(self, _button, player):
if player:
self._manually_selected_player = player
self._update_display()
def _on_thumbnail_clicked(self, _widget, _event):
if self._current_player and not data.VERTICAL:
# Only toggle visualizer in horizontal mode
self._show_visualizer = not self._show_visualizer
self._update_stack_display()
return True
def _on_music_player_clicked(self, _widget, _event):
if self._current_player and not data.VERTICAL:
# Only toggle visualizer in horizontal mode
self._show_visualizer = not self._show_visualizer
self._update_stack_display()
return True
def _create_visualizer_if_needed(self):
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):
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):
# Never show visualizer in vertical mode
if self._show_visualizer and self._current_player and not data.VERTICAL:
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:
self.center_stack.set_visible_child(self.track_label)
self._destroy_visualizer()
def __del__(self):
self._disconnect_player_signals()
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)
self._destroy_visualizer()