Files
sublime-music/sublime_music/ui/player_controls/desktop.py
Benjamin Schaaf c612f31f42 WIP
2021-12-20 22:07:06 +11:00

433 lines
16 KiB
Python

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")
# 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.editing = True
self.volume_slider.set_value(
0 if app_config.state.is_muted else app_config.state.volume
)
self.editing = False
# 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("<b>Play Queue</b>")
else:
song_label = util.pluralize("song", play_queue_len)
self.popover_label.set_markup(
f"<b>Play Queue:</b> {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_volume_change(self, scale: Gtk.Scale):
if not self.editing:
self.emit("volume-change", scale.get_value())
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
# The song ID is in the last column of the model.
self.state.emit(
"song-clicked",
idx.get_indices()[0],
[m[-1] for m in self.play_queue_store],
{"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,
)
self.song_scrubber.set_name("song-scrubber")
self.song_scrubber.set_hexpand(True)
self.song_scrubber.set_draw_value(False)
self.song_scrubber.set_restrict_to_fill_level(False)
self.state.connect("notify::scrubber-cache", lambda *_: self.song_scrubber.set_fill_level(self.state.scrubber_cache))
# self.song_scrubber.set_name()
# self.song_scrubber.set_draw_value(False)
# self.song_scrubber.set_restrict_to_fill_level(False)
# self.song_scrubber.connect(
# "change-value", lambda s, t, v: self.emit("song-scrub", v)
# )
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="<b>Play Queue</b>",
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 = Gtk.TreeView(
model=self.state.play_queue_store,
reorderable=True,
headers_visible=False,
)
selection = self.play_queue_list.get_selection()
selection.set_mode(Gtk.SelectionMode.MULTIPLE)
selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(55, 60)
column = Gtk.TreeViewColumn("", renderer)
column.set_cell_data_func(renderer, common.filename_to_pixbuf)
column.set_resizable(True)
self.play_queue_list.append_column(column)
renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END)
column = Gtk.TreeViewColumn("", renderer, markup=2, sensitive=0)
self.play_queue_list.append_column(column)
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.mute-toggle")
box.pack_start(self.volume_mute_toggle, False, True, 0)
# Volume slider
self.volume_slider = Gtk.Scale.new_with_range(
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5
)
self.volume_slider.set_name("volume-slider")
self.volume_slider.set_draw_value(False)
self.volume_slider.connect("value-changed", self.on_volume_change)
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