Files
Modus/modules/controlcenter/player.py
T

784 lines
27 KiB
Python

import os
import re
import tempfile
import urllib.parse
import urllib.request
from typing import List
from fabric.utils import (
bulk_connect,
)
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.image import Image
from modules.controlcenter.expanded_player import ExpandedPlayer
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.stack import Stack
from gi.repository import GLib, GObject
from loguru import logger
from services.mpris import MprisPlayer, MprisPlayerManager
import config.data as data
from widgets.mousecapture import DropDownMouseCapture
CACHE_DIR = f"{data.CACHE_DIR}/media"
def cleanup_old_cache_files():
"""Clean up old artwork cache files (older than 1 day)."""
try:
if not os.path.exists(CACHE_DIR):
return
import time
current_time = time.time()
one_day_ago = current_time - (24 * 60 * 60) # 24 hours
for filename in os.listdir(CACHE_DIR):
filepath = os.path.join(CACHE_DIR, filename)
try:
if os.path.isfile(filepath):
file_mtime = os.path.getmtime(filepath)
if file_mtime < one_day_ago:
os.unlink(filepath)
except Exception:
pass # Ignore individual file errors
except Exception:
pass # Ignore all errors in cleanup
class PlayerBoxStack(Box):
"""A widget that displays the current player information."""
def __init__(self, mpris_manager: MprisPlayerManager, **kwargs):
# Clean up old cache files on startup
cleanup_old_cache_files()
# The player stack
self.player_stack = Stack(
# transition_type="slide-left-right",
# transition_duration=500,
name="player-stack",
)
self.current_stack_pos = 0
# List to store player buttons
self.player_buttons: list[Button] = []
# Create a "No media playing" placeholder
self.no_media_box = self._create_no_media_box()
super().__init__(orientation="v", name="media", children=[self.player_stack])
# Show the no media box initially
self.player_stack.children = [self.no_media_box]
self.set_visible(True)
self.mpris_manager = mpris_manager
bulk_connect(
self.mpris_manager,
{
"player-appeared": self.on_new_player,
"player-vanished": self.on_lost_player,
},
)
for player in self.mpris_manager.players: # type: ignore
logger.info(
f"[PLAYER MANAGER] player found: {player.get_property('player-name')}",
)
self.on_new_player(self.mpris_manager, player)
def _create_no_media_box(self):
"""Create a placeholder box for when no media is playing."""
fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
# Album cover with fallback image
album_cover = Box(style_classes="album-image")
album_cover.set_style(f"background-image:url('{fallback_cover_path}')")
image_stack = Box(h_align="start", v_align="center", name="player-image-stack")
image_stack.children = [album_cover]
# Track info showing "No media playing"
track_title = Label(
label="No media playing",
name="player-title",
justification="left",
max_chars_width=15,
ellipsization="end",
h_align="start",
)
track_artist = Label(
label="",
name="player-artist",
justification="left",
max_chars_width=15,
ellipsization="end",
h_align="start",
visible=False, # Hide artist and album when no media
)
track_album = Label(
label="",
name="player-album",
justification="left",
max_chars_width=15,
ellipsization="end",
h_align="start",
visible=False, # Hide artist and album when no media
)
track_info = Box(
name="track-info",
spacing=5,
orientation="v",
v_align="start",
h_align="start",
children=[track_title, track_artist, track_album],
)
# No control buttons for no media state - just an empty box
controls_box = Box(
name="player-controls",
visible=False, # Hide controls when no media
)
player_info_box = Box(
name="player-info-box",
v_align="center",
h_align="start",
orientation="v",
children=[track_info, controls_box],
)
inner_box = Box(
name="inner-player-box",
v_align="center",
h_align="start",
)
outer_box = Box(
name="outer-player-box",
h_align="start",
)
overlay_box = Overlay(
child=outer_box,
overlays=[
inner_box,
player_info_box,
image_stack,
],
)
no_media_box = Box(
h_align="center",
name="player-box",
h_expand=True,
children=[overlay_box],
)
return no_media_box
def _find_playing_player_index(self):
"""Find the index of the currently playing player."""
players: List[PlayerBox] = self.player_stack.get_children()
for i, player_box in enumerate(players):
if (
hasattr(player_box, "player")
and player_box.player.playback_status == "playing"
):
return i
return None
def _switch_to_playing_player(self):
"""Switch to the currently playing player if one exists."""
playing_index = self._find_playing_player_index()
if playing_index is not None and playing_index != self.current_stack_pos:
logger.info(
f"[PlayerBoxStack] Auto-switching to playing player at index {playing_index}"
)
self.on_player_clicked_by_index(playing_index)
def on_player_playback_changed(self, player_box, status):
"""Called when a player's playback status changes."""
if status == "playing":
# Find this player's index and switch to it
players: List[PlayerBox] = self.player_stack.get_children()
for i, pb in enumerate(players):
if pb == player_box:
if i != self.current_stack_pos:
logger.info(
f"[PlayerBoxStack] Switching to playing player: {player_box.player.player_name}"
)
self.on_player_clicked_by_index(i)
break
def on_player_clicked(self, type):
# unset active from prev active button
self.player_buttons[self.current_stack_pos].remove_style_class("active")
if type == "next":
self.current_stack_pos = (
self.current_stack_pos + 1
if self.current_stack_pos != len(self.player_stack.get_children()) - 1
else 0
)
elif type == "prev":
self.current_stack_pos = (
self.current_stack_pos - 1
if self.current_stack_pos != 0
else len(self.player_stack.get_children()) - 1
)
# set new active button
print(f"[PlayerBoxStack] Switching to player at index {self.current_stack_pos}")
self.player_buttons[self.current_stack_pos].add_style_class("active")
self.player_stack.set_visible_child(
self.player_stack.get_children()[self.current_stack_pos],
)
def on_player_clicked_by_index(self, index):
"""Switch to player at given index"""
if 0 <= index < len(self.player_buttons):
# unset active from prev active button
self.player_buttons[self.current_stack_pos].remove_style_class("active")
# set new position
self.current_stack_pos = index
# set new active button
self.player_buttons[self.current_stack_pos].add_style_class("active")
self.player_stack.set_visible_child(
self.player_stack.get_children()[self.current_stack_pos],
)
# Update all player boxes with new button state
self._update_all_player_buttons()
def on_new_player(self, mpris_manager, player):
player_name = player.props.player_name
# if player_name in self.config.get("ignore", []):
# return
# Remove the no media box if it's the only child
if (
len(self.player_stack.get_children()) == 1
and self.player_stack.get_children()[0] == self.no_media_box
):
self.player_stack.children = []
self.current_stack_pos = 0
self.set_visible(True)
new_player_box = PlayerBox(player=MprisPlayer(player), player_stack=self)
self.player_stack.children = [
*self.player_stack.children,
new_player_box,
]
self.make_new_player_button(self.player_stack.get_children()[-1])
logger.info(
f"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}",
)
if self.player_buttons:
self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
# Update all player boxes with current button state
self._update_all_player_buttons()
# Check if this new player is playing and switch to it
self._switch_to_playing_player()
def on_lost_player(self, mpris_manager, player_name):
# the playerBox is automatically removed from mprisbox children on being removed
logger.info(f"[PLAYER_MANAGER] Player Removed {player_name}")
players: List[PlayerBox] = self.player_stack.get_children()
# Check if this was the last player
if len(players) == 1 and player_name == players[0].player.player_name:
# Show the no media box instead of hiding
self.player_stack.children = [self.no_media_box]
self.current_stack_pos = 0
self.player_buttons = [] # Clear player buttons
return
if (
players
and self.current_stack_pos < len(players)
and players[self.current_stack_pos].player.player_name == player_name
):
self.current_stack_pos = max(0, self.current_stack_pos - 1)
if self.player_stack.get_children():
self.player_stack.set_visible_child(
self.player_stack.get_children()[self.current_stack_pos],
)
if self.player_buttons and self.current_stack_pos < len(self.player_buttons):
self.player_buttons[self.current_stack_pos].set_style_classes(["active"])
# Update all player boxes with current button state
self._update_all_player_buttons()
# After a player is removed, check if we should switch to a playing player
self._switch_to_playing_player()
def make_new_player_button(self, player_box):
new_button = Button(name="player-stack-button")
def on_player_button_click(button: Button):
self.player_buttons[self.current_stack_pos].remove_style_class("active")
self.current_stack_pos = self.player_buttons.index(button)
button.add_style_class("active")
self.player_stack.set_visible_child(player_box)
new_button.connect(
"clicked",
on_player_button_click,
)
self.player_buttons.append(new_button)
# This will automatically destroy our used button
player_box.connect(
"destroy",
lambda *_: [
new_button.destroy(), # type: ignore
self.player_buttons.pop(self.player_buttons.index(new_button)),
],
)
def _update_all_player_buttons(self):
"""Update all player boxes with the current button state"""
players: List[PlayerBox] = self.player_stack.get_children()
logger.info(
f"[PlayerBoxStack] Updating buttons for {len(players)} players, {
len(self.player_buttons)
} buttons"
)
for player_box in players:
if hasattr(player_box, "update_buttons"):
player_box.update_buttons(self.player_buttons, len(players) > 1)
else:
logger.warning(
"[PlayerBoxStack] PlayerBox missing update_buttons method"
)
class PlayerBox(Box):
"""A widget that displays the current player information."""
def __init__(self, player: MprisPlayer, player_stack=None, **kwargs):
super().__init__(
h_align="center",
name="player-box",
**kwargs,
h_expand=True,
)
# Setup
self.player: MprisPlayer = player
self.player_stack = player_stack
self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
self.expanded_player = ExpandedPlayer()
self.ex_mousecapture = DropDownMouseCapture(
layer="top", child_window=self.expanded_player
)
self.image_size = 120
self.icon_size = 15
# State
self.exit = False
self.skipped = False
# Memory management
self.temp_artwork_files = [] # Track temp files for cleanup
self.current_download_thread = None # Track current download thread
self.album_cover = Box(style_classes="album-image")
self.album_cover.set_style(
f"background-image:url('{self.fallback_cover_path}')"
)
self.image_stack = Box(
h_align="start", v_align="center", name="player-image-stack"
)
self.image_stack.children = [*self.image_stack.children, self.album_cover]
# Track Info
self.track_title = Label(
label="No Title",
name="player-title",
justification="left",
max_chars_width=15,
ellipsization="end",
h_align="start",
)
self.track_artist = Label(
label="No Artist",
name="player-artist",
justification="left",
max_chars_width=15,
ellipsization="end",
h_align="start",
visible=True,
)
self.track_album = Label(
label="No Album",
name="player-album",
justification="left",
max_chars_width=15,
ellipsization="end",
h_align="start",
visible=True,
)
self.player.bind_property(
"title",
self.track_title,
"label",
GObject.BindingFlags.DEFAULT,
lambda _, x: (
re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title"
), # type: ignore
)
self.player.bind_property(
"artist",
self.track_artist,
"label",
GObject.BindingFlags.DEFAULT,
lambda _, x: (
re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Artist"
), # type: ignore
)
self.player.bind_property(
"album",
self.track_album,
"label",
GObject.BindingFlags.DEFAULT,
lambda _, x: (
re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Album"
), # type: ignore
)
self.track_info = Box(
name="track-info",
spacing=5,
orientation="v",
v_align="start",
h_align="start",
children=[
self.track_title,
self.track_artist,
self.track_album,
],
)
# Buttons
self.button_box = Box(
name="button-box",
h_align="center",
spacing=2,
)
# Stack switcher buttons box
self.stack_buttons_box = Box(
name="stack-buttons-box", spacing=4, h_align="center"
)
self.stack_buttons_box.hide() # Initially hidden
self.controls_box = CenterBox(
name="player-controls",
center_children=self.button_box,
end_children=self.stack_buttons_box,
)
self.skip_next_icon = Image(icon_name="media-skip-forward")
self.skip_prev_icon = Image(icon_name="media-skip-backward")
self.shuffle_icon = Image(icon_name="shuffle")
self.play_pause_icon = Image(icon_name="media-playback-start")
self.play_pause_button = Button(
name="player-button",
child=self.play_pause_icon,
on_clicked=self.player.play_pause,
)
self.player.bind_property("can_pause", self.play_pause_button, "sensitive")
self.next_button = Button(
style_classes=["player-button"],
child=self.skip_next_icon,
on_clicked=self._on_player_next,
)
self.player.bind_property("can_go_next", self.next_button, "sensitive")
self.prev_button = Button(
style_classes=["player-button"],
child=self.skip_prev_icon,
on_clicked=lambda *_: (
self.expanded_player.set_visible(True),
self.ex_mousecapture.toggle_mousecapture(),
),
# on_clicked=self._on_player_prev,
)
self.shuffle_button = Button(
style_classes=["player-button"],
child=self.shuffle_icon,
on_clicked=self.player.toggle_shuffle,
)
self.player.bind_property("can_shuffle", self.shuffle_button, "sensitive")
self.button_box.children = (
self.shuffle_button,
self.prev_button,
self.play_pause_button,
self.next_button,
)
self.custom = Button(
child=self.track_info,
on_clicked=lambda *_: self.expanded_player.set_visible(True),
)
self.player_info_box = Box(
name="player-info-box",
v_align="center",
h_align="start",
orientation="v",
children=[self.custom, self.controls_box, self.stack_buttons_box],
)
self.inner_box = Box(
name="inner-player-box",
v_align="center",
h_align="start",
)
# resize the inner box
self.outer_box = Box(
name="outer-player-box",
h_align="start",
)
self.overlay_box = Overlay(
child=self.outer_box,
overlays=[
self.inner_box,
self.player_info_box,
self.image_stack,
Box(
children=Image(icon_name=self.player.player_name, icon_size=18),
h_align="end",
v_align="start",
style="margin-top: 5px; margin-right: 10px;",
tooltip_text=self.player.player_name, # type: ignore
),
],
)
self.children = [*self.children, self.overlay_box]
bulk_connect(
self.player,
{
"exit": self._on_player_exit,
"notify::playback-status": self._on_playback_change,
"notify::shuffle": self._on_shuffle_update,
"notify::metadata": self._on_metadata,
},
)
def __del__(self):
"""Ensure cleanup happens even if player exits unexpectedly."""
try:
self._cleanup_temp_files()
except Exception:
pass # Ignore errors during cleanup in destructor
def update_buttons(self, player_buttons, show_buttons):
# """Update the stack switcher buttons in this player box"""
pass
# logger.info(
# f"[PlayerBox] update_buttons called: show_buttons={
# show_buttons
# }, num_buttons={len(player_buttons)}"
# )
#
# # Clear existing buttons
# for child in self.stack_buttons_box.get_children():
# child.destroy()
#
# if show_buttons and len(player_buttons) > 1:
# logger.info(f"[PlayerBox] Creating {len(player_buttons)} stack buttons")
# # Create simple dot indicators for each player
# for i, button in enumerate(player_buttons):
# # Create a more visible dot button
# dot_button = Button(
# name="player-stack-button",
# style="min-width: 16px; min-height: 16px; border-radius: 8px; margin: 4px; background-color: #666; border: 1px solid #999;",
# )
#
# # Set active state based on original button
# if button.get_style_context().has_class("active"):
# dot_button.add_style_class("active")
# dot_button.set_style(
# "min-width: 16px; min-height: 16px; border-radius: 8px; margin: 4px; background-color: #fff; border: 1px solid #ccc;"
# )
#
# # Connect click handler to switch to this player
# def make_click_handler(index):
# return lambda *_: self.player_stack.on_player_clicked_by_index(
# index
# )
#
# dot_button.connect("clicked", make_click_handler(i))
# self.stack_buttons_box.children = [
# *self.stack_buttons_box.children,
# dot_button,
# ]
# logger.info(
# f"[PlayerBox] Added dot button {i}, total children: {
# len(self.stack_buttons_box.get_children())
# }"
# )
#
# self.stack_buttons_box.show_all()
# logger.info("[PlayerBox] Stack buttons box shown")
# else:
# self.stack_buttons_box.hide()
# logger.info("[PlayerBox] Stack buttons box hidden")
def _on_metadata(self, *_):
self._set_image()
def _cleanup_temp_files(self):
"""Clean up temporary artwork files."""
for temp_file in self.temp_artwork_files:
try:
if os.path.exists(temp_file):
os.unlink(temp_file)
except Exception as e:
logger.warning(f"Failed to cleanup temp file {temp_file}: {e}")
self.temp_artwork_files.clear()
def _on_player_exit(self, _, value):
self.exit = value
self._cleanup_temp_files() # Clean up temp files before destroying
self.destroy()
def _on_player_next(self, *_):
self.player.next()
def _on_player_prev(self, *_):
self.player.previous()
def _on_shuffle_update(self, *_):
if self.player.shuffle is None:
return
if self.player.shuffle is True:
self.shuffle_icon.style_classes = []
self.shuffle_icon.add_style_class("shuffle-on")
else:
self.shuffle_icon.style_classes = []
self.shuffle_icon.add_style_class("shuffle-off")
def _on_playback_change(self, player, status):
status = player.get_property("playback-status")
if status == "paused":
self.play_pause_icon.set_from_icon_name("media-playbook-start")
if status == "playing":
self.play_pause_icon.set_from_icon_name("media-playback-pause")
# Notify the player stack that this player started playing
if self.player_stack and hasattr(
self.player_stack, "on_player_playback_changed"
):
self.player_stack.on_player_playback_changed(self, status)
def _update_image(self, image_path):
if image_path and os.path.isfile(image_path):
self.album_cover.set_style(f"background-image:url('{image_path}')")
else:
self.album_cover.set_style(
f"background-image:url('{self.fallback_cover_path}')"
)
def _set_image(self, *_):
art_url = self.player.arturl
parsed = urllib.parse.urlparse(art_url)
if parsed.scheme == "file":
local_arturl = urllib.parse.unquote(parsed.path)
self._update_image(local_arturl)
elif parsed.scheme in ("http", "https"):
# Cancel any existing download thread to prevent memory buildup
if self.current_download_thread is not None:
# Note: GLib.Thread doesn't have a direct cancel method,
# but we can track and ignore old downloads
pass
self.current_download_thread = GLib.Thread.new(
"download-artwork", self._download_and_set_artwork, art_url
)
else:
self._update_image(art_url)
def _download_and_set_artwork(self, arturl):
"""
Download the artwork from the given URL asynchronously and update the cover
using GLib.idle_add to ensure UI updates occur on the main thread.
"""
local_arturl = self.fallback_cover_path
temp_file_path = None
try:
# Clean up old temp files first (keep only last 2 to avoid excessive cleanup)
if len(self.temp_artwork_files) > 2:
old_files = self.temp_artwork_files[:-2]
for old_file in old_files:
try:
if os.path.exists(old_file):
os.unlink(old_file)
except Exception:
pass
self.temp_artwork_files = self.temp_artwork_files[-2:]
# Download artwork
parsed = urllib.parse.urlparse(arturl)
suffix = os.path.splitext(parsed.path)[1] or ".png"
with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout
data = response.read()
# Create temp file in cache directory instead of system temp
os.makedirs(CACHE_DIR, exist_ok=True)
with tempfile.NamedTemporaryFile(
delete=False, suffix=suffix, dir=CACHE_DIR
) as temp_file:
temp_file.write(data)
temp_file_path = temp_file.name
local_arturl = temp_file_path
# Track temp file for cleanup
if temp_file_path:
self.temp_artwork_files.append(temp_file_path)
except Exception as e:
logger.warning(f"Failed to download artwork from {arturl}: {e}")
# Clean up failed temp file
if temp_file_path and os.path.exists(temp_file_path):
try:
os.unlink(temp_file_path)
except Exception:
pass
GLib.idle_add(self._update_image, local_arturl)
return None
def close_bluetooth(self, *args):
"""Close Bluetooth control center"""
self.ex_mousecapture.hide_child_window()