import copy import math from datetime import timedelta from functools import partial from typing import Any, Callable, Dict, Optional, Set, Tuple import bleach from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango from .. import util from ..common import IconButton, IconToggleButton, SpinnerImage from ..state import RepeatType from ...adapters import AdapterManager, Result, SongCacheStatus from ...adapters.api_objects import Song from ...config import AppConfiguration from ...util import resolve_path from . import common class Desktop(Gtk.Box): """ Defines the player controls panel that appears at the bottom of the window on the desktop view. """ editing: bool = False # editing_play_queue_song_list: bool = False reordering_play_queue_song_list: bool = False current_song = None current_device = None current_playing_index: Optional[int] = None current_play_queue: Tuple[str, ...] = () # play_queue_update_order_token = 0 offline_mode = False def __init__(self, state): super().__init__(orientation=Gtk.Orientation.HORIZONTAL) self.set_name("player-controls-bar") self.state = state self.state.add_control(self) song_display = self.create_song_display() playback_controls = self.create_playback_controls() play_queue_volume = self.create_play_queue_volume() self.pack_start(song_display, False, False, 0) self.set_center_widget(playback_controls) self.child_set_property(playback_controls, "expand", True) self.pack_end(play_queue_volume, False, False, 0) connecting_to_device_token = 0 connecting_icon_index = 0 def update(self, app_config: AppConfiguration, force: bool = False): self.current_device = app_config.state.current_device has_current_song = app_config.state.current_song is not None has_next_song = False if app_config.state.repeat_type in ( RepeatType.REPEAT_QUEUE, RepeatType.REPEAT_SONG, ): has_next_song = True elif has_current_song: last_idx_in_queue = len(app_config.state.play_queue) - 1 has_next_song = app_config.state.current_song_index < last_idx_in_queue # Toggle button states. self.song_scrubber.set_sensitive(has_current_song) self.connecting_to_device = app_config.state.connecting_to_device def cycle_connecting(connecting_to_device_token: int): if ( self.connecting_to_device_token != connecting_to_device_token or not self.connecting_to_device ): return icon = f"chromecast-connecting-{self.connecting_icon_index}-symbolic" self.device_button.set_icon(icon) self.connecting_icon_index = (self.connecting_icon_index + 1) % 3 GLib.timeout_add(350, cycle_connecting, connecting_to_device_token) icon = "" if app_config.state.connecting_to_device: icon = "-connecting-0" self.connecting_icon_index = 0 self.connecting_to_device_token += 1 GLib.timeout_add(350, cycle_connecting, self.connecting_to_device_token) elif app_config.state.current_device != "this device": icon = "-connected" self.device_button.set_icon(f"chromecast{icon}-symbolic") self.play_queue_button.set_sensitive(len(app_config.state.play_queue) > 0) # Volume button and slider if app_config.state.is_muted: icon_name = "muted" elif app_config.state.volume < 30: icon_name = "low" elif app_config.state.volume < 70: icon_name = "medium" else: icon_name = "high" self.volume_mute_toggle.set_icon(f"audio-volume-{icon_name}-symbolic") self.volume_slider.set_value( 0 if app_config.state.is_muted else app_config.state.volume ) self.volume_slider.set_sensitive(not app_config.state.is_muted) # Update the current song information. # TODO (#126): add popup of bigger cover art photo here if app_config.state.current_song is not None: self.song_title.set_markup(bleach.clean(app_config.state.current_song.title)) # TODO (#71): use walrus once MYPY gets its act together album = app_config.state.current_song.album artist = app_config.state.current_song.artist if album: self.album_name.set_markup(bleach.clean(album.name)) self.artist_name.show() else: self.album_name.set_markup("") self.album_name.hide() if artist: self.artist_name.set_markup(bleach.clean(artist.name)) self.artist_name.show() else: self.artist_name.set_markup("") self.artist_name.hide() else: # Clear out the cover art and song tite if no song self.song_title.set_markup("") self.album_name.set_markup("") self.artist_name.set_markup("") self.load_play_queue_button.set_sensitive(not self.offline_mode) if app_config.state.loading_play_queue: self.play_queue_spinner.start() self.play_queue_spinner.show() else: self.play_queue_spinner.stop() self.play_queue_spinner.hide() # Short circuit if no changes to the play queue force |= self.offline_mode != app_config.offline_mode self.offline_mode = app_config.offline_mode if not force and ( self.current_play_queue == app_config.state.play_queue and self.current_playing_index == app_config.state.current_song_index ): return self.current_play_queue = app_config.state.play_queue self.current_playing_index = app_config.state.current_song_index # Set the Play Queue button popup. play_queue_len = len(app_config.state.play_queue) if play_queue_len == 0: self.popover_label.set_markup("Play Queue") else: song_label = util.pluralize("song", play_queue_len) self.popover_label.set_markup( f"Play Queue: {play_queue_len} {song_label}" ) # TODO (#207) this is super freaking stupid inefficient. # IDEAS: batch it, don't get the queue until requested # self.editing_play_queue_song_list = True # self.editing_play_queue_song_list = False def set_cover_art(self, cover_art_filename: str, loading: bool): self.album_art.set_from_file(cover_art_filename) self.album_art.set_loading(loading) def on_play_queue_open(self, *_): if not self.get_child_visible(): self.play_queue_popover.popdown() return if not self.state.play_queue_open: self.play_queue_popover.popdown() else: # TODO (#88): scroll the currently playing song into view. self.play_queue_popover.popup() self.play_queue_popover.show_all() # Hide the load play queue button if the adapter can't do that. if not AdapterManager.can_get_play_queue(): self.load_play_queue_button.hide() def on_song_activated(self, t: Any, idx: Gtk.TreePath, c: Any): if not self.play_queue_store[idx[0]][0]: return self.get_action_group('app').activate_action( 'play-song', idx.get_indices()[0], None, {"no_reshuffle": True}) def on_play_queue_button_press(self, tree: Any, event: Gdk.EventButton) -> bool: if event.button == 3: # Right click clicked_path = tree.get_path_at_pos(event.x, event.y) store, paths = tree.get_selection().get_selected_rows() allow_deselect = False # Use the new selection instead of the old one for calculating what # to do the right click on. if clicked_path[0] not in paths: paths = [clicked_path[0]] allow_deselect = True common.show_play_queue_popover(self.state, tree, paths, event.x, event.y) # If the click was on a selected row, don't deselect anything. if not allow_deselect: return True return False def create_song_display(self) -> Gtk.Box: box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.album_art = SpinnerImage( image_name="player-controls-album-artwork", image_size=70, ) box.pack_start(self.album_art, False, False, 0) details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) details_box.pack_start(Gtk.Box(), True, True, 0) def make_label(name: str) -> Gtk.Label: return Gtk.Label( name=name, halign=Gtk.Align.START, xalign=0, use_markup=True, ellipsize=Pango.EllipsizeMode.END, ) self.song_title = make_label("song-title") details_box.add(self.song_title) self.album_name = make_label("album-name") details_box.add(self.album_name) self.artist_name = make_label("artist-name") details_box.add(self.artist_name) details_box.pack_start(Gtk.Box(), True, True, 0) box.pack_start(details_box, False, False, 5) return box def create_playback_controls(self) -> Gtk.Box: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # Scrubber and song progress/length labels scrubber_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) scrubber_box.pack_start(common.create_label(self.state, "progress-label"), False, False, 5) self.song_scrubber = Gtk.Scale( orientation=Gtk.Orientation.HORIZONTAL, adjustment=self.state.scrubber, hexpand=True, draw_value=False, restrict_to_fill_level=False, show_fill_level=True) self.song_scrubber.set_name("song-scrubber") self.state.bind_property("scrubber-cache", self.song_scrubber, "fill-level") scrubber_box.pack_start(self.song_scrubber, True, True, 0) scrubber_box.pack_start(common.create_label(self.state, "duration-label"), False, False, 5) box.add(scrubber_box) buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) buttons.pack_start(Gtk.Box(), True, True, 0) # Repeat button repeat_button = common.create_repeat_button(self.state, valign=Gtk.Align.CENTER) buttons.pack_start(repeat_button, False, False, 5) # Previous button prev_button = common.create_prev_button(self.state, valign=Gtk.Align.CENTER) buttons.pack_start(prev_button, False, False, 5) buttons.pack_start(common.create_play_button(self.state), False, False, 0) # Next button next_button = common.create_next_button(self.state, valign=Gtk.Align.CENTER) buttons.pack_start(next_button, False, False, 5) # Shuffle button shuffle_button = common.create_shuffle_button(self.state, valign=Gtk.Align.CENTER) buttons.pack_start(shuffle_button, False, False, 5) buttons.pack_start(Gtk.Box(), True, True, 0) box.add(buttons) return box def create_play_queue_volume(self) -> Gtk.Box: vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) vbox.pack_start(Gtk.Box(), True, True, 0) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) # Device button (for chromecast) self.device_button = IconButton( "chromecast-symbolic", "Show available audio output devices", icon_size=Gtk.IconSize.LARGE_TOOLBAR, ) self.device_button.connect("clicked", self.state.popup_devices) box.pack_start(self.device_button, False, True, 5) # Play Queue button self.play_queue_button = IconToggleButton( "view-list-symbolic", "Open play queue", icon_size=Gtk.IconSize.LARGE_TOOLBAR, ) self.state.bind_property("play-queue-open", self.play_queue_button, "active", GObject.BindingFlags.BIDIRECTIONAL) self.state.connect("notify::play-queue-open", self.on_play_queue_open) box.pack_start(self.play_queue_button, False, True, 5) self.play_queue_popover = Gtk.PopoverMenu(modal=False, name="up-next-popover", constrain_to=Gtk.PopoverConstraint.WINDOW) self.play_queue_popover.set_relative_to(self.play_queue_button) play_queue_popover_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) play_queue_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.popover_label = Gtk.Label( label="Play Queue", use_markup=True, halign=Gtk.Align.START, margin=10, ) play_queue_popover_header.add(self.popover_label) self.load_play_queue_button = IconButton( "folder-download-symbolic", "Load Queue from Server", margin=5 ) self.load_play_queue_button.set_action_name("app.update-play-queue-from-server") play_queue_popover_header.pack_end(self.load_play_queue_button, False, False, 0) play_queue_popover_box.add(play_queue_popover_header) play_queue_loading_overlay = Gtk.Overlay() play_queue_scrollbox = Gtk.ScrolledWindow( # min_content_height=600, min_content_width=400, propagate_natural_height=True, ) self.play_queue_list = common.create_play_queue_list(self.state) self.play_queue_list.connect("row-activated", self.on_song_activated) self.play_queue_list.connect( "button-press-event", self.on_play_queue_button_press ) play_queue_scrollbox.add(self.play_queue_list) play_queue_loading_overlay.add(play_queue_scrollbox) self.play_queue_spinner = Gtk.Spinner( name="play-queue-spinner", active=False, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, ) play_queue_loading_overlay.add_overlay(self.play_queue_spinner) play_queue_popover_box.pack_end(play_queue_loading_overlay, True, True, 0) self.play_queue_popover.add(play_queue_popover_box) # Volume mute toggle self.volume_mute_toggle = IconButton( "audio-volume-high-symbolic", "Toggle mute" ) self.volume_mute_toggle.set_action_name("app.toggle-mute") box.pack_start(self.volume_mute_toggle, False, True, 0) # Volume slider self.volume_slider = Gtk.Scale.new( orientation=Gtk.Orientation.HORIZONTAL, adjustment=self.state.volume) self.volume_slider.set_name("volume-slider") self.volume_slider.set_draw_value(False) box.pack_start(self.volume_slider, True, True, 0) vbox.pack_start(box, False, True, 0) vbox.pack_start(Gtk.Box(), True, True, 0) return vbox