486 lines
18 KiB
Python
486 lines
18 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
|
|
|
|
def on_download_state_change(song_id: str):
|
|
# Refresh the entire window (no force) because the song could
|
|
# be in a list anywhere in the window.
|
|
self.state.emit("refresh-window", {}, 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
|
|
|
|
song_ids = [self.play_queue_store[p][-1] for p in paths]
|
|
|
|
remove_text = (
|
|
"Remove " + util.pluralize("song", len(song_ids)) + " from queue"
|
|
)
|
|
|
|
def on_remove_songs_click(_: Any):
|
|
self.state.emit("songs-removed", [p.get_indices()[0] for p in paths])
|
|
|
|
util.show_song_popover(
|
|
song_ids,
|
|
event.x,
|
|
event.y,
|
|
tree,
|
|
self.offline_mode,
|
|
on_download_state_change=on_download_state_change,
|
|
extra_menu_items=[
|
|
(Gtk.ModelButton(text=remove_text), on_remove_songs_click),
|
|
],
|
|
)
|
|
|
|
# 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])
|
|
|
|
# Album Art column. This function defines what image to use for the play queue
|
|
# song icon.
|
|
def filename_to_pixbuf(
|
|
column: Any,
|
|
cell: Gtk.CellRendererPixbuf,
|
|
model: Gtk.ListStore,
|
|
tree_iter: Gtk.TreeIter,
|
|
flags: Any,
|
|
):
|
|
cell.set_property("sensitive", model.get_value(tree_iter, 0))
|
|
filename = model.get_value(tree_iter, 1)
|
|
if not filename:
|
|
cell.set_property("icon_name", "")
|
|
return
|
|
|
|
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename, 50, 50, True)
|
|
|
|
# If this is the playing song, then overlay the play icon.
|
|
if model.get_value(tree_iter, 3):
|
|
play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file(
|
|
str(resolve_path("ui/images/play-queue-play.png"))
|
|
)
|
|
|
|
play_overlay_pixbuf.composite(
|
|
pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 200
|
|
)
|
|
|
|
cell.set_property("pixbuf", pixbuf)
|
|
|
|
renderer = Gtk.CellRendererPixbuf()
|
|
renderer.set_fixed_size(55, 60)
|
|
column = Gtk.TreeViewColumn("", renderer)
|
|
column.set_cell_data_func(renderer, 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
|