This commit is contained in:
Benjamin Schaaf
2021-01-31 21:38:52 +11:00
parent b6b5d7a272
commit e1dcf8da4c
15 changed files with 1511 additions and 913 deletions

View File

@@ -181,9 +181,9 @@ class SublimeMusicApp(Gtk.Application):
self.window.connect("notification-closed", self.on_notification_closed) self.window.connect("notification-closed", self.on_notification_closed)
self.window.connect("go-to", self.on_window_go_to) self.window.connect("go-to", self.on_window_go_to)
self.window.connect("key-press-event", self.on_window_key_press) self.window.connect("key-press-event", self.on_window_key_press)
self.window.player_controls.connect("song-scrub", self.on_song_scrub) self.window.player_manager.connect("seek", self.on_seek)
self.window.player_controls.connect("device-update", self.on_device_update) self.window.player_manager.connect("device-update", self.on_device_update)
self.window.player_controls.connect("volume-change", self.on_volume_change) # self.window.player_manager.volume.connect("value-changed", self.on_volume_change)
# Configure the players # Configure the players
self.last_play_queue_update = timedelta(0) self.last_play_queue_update = timedelta(0)
@@ -204,7 +204,7 @@ class SublimeMusicApp(Gtk.Application):
self.app_config.state.song_progress = timedelta(seconds=value) self.app_config.state.song_progress = timedelta(seconds=value)
GLib.idle_add( GLib.idle_add(
self.window.player_controls.update_scrubber, self.window.update_song_progress,
self.app_config.state.song_progress, self.app_config.state.song_progress,
self.app_config.state.current_song.duration, self.app_config.state.current_song.duration,
self.app_config.state.song_stream_cache_progress, self.app_config.state.song_stream_cache_progress,
@@ -262,7 +262,7 @@ class SublimeMusicApp(Gtk.Application):
seconds=event.stream_cache_duration seconds=event.stream_cache_duration
) )
GLib.idle_add( GLib.idle_add(
self.window.player_controls.update_scrubber, self.window.update_song_progress,
self.app_config.state.song_progress, self.app_config.state.song_progress,
self.app_config.state.current_song.duration, self.app_config.state.current_song.duration,
self.app_config.state.song_stream_cache_progress, self.app_config.state.song_stream_cache_progress,
@@ -376,14 +376,7 @@ class SublimeMusicApp(Gtk.Application):
# a duration, but the Child object has `duration` optional because # a duration, but the Child object has `duration` optional because
# it could be a directory. # it could be a directory.
assert self.app_config.state.current_song.duration is not None assert self.app_config.state.current_song.duration is not None
self.on_song_scrub( self.window.player_manager.scrubber = new_seconds.total_seconds()
None,
(
new_seconds.total_seconds()
/ self.app_config.state.current_song.duration.total_seconds()
)
* 100,
)
def set_pos_fn(track_id: str, position: float = 0): def set_pos_fn(track_id: str, position: float = 0):
if self.app_config.state.playing: if self.app_config.state.playing:
@@ -892,7 +885,7 @@ class SublimeMusicApp(Gtk.Application):
self.save_play_queue() self.save_play_queue()
@dbus_propagate() @dbus_propagate()
def on_song_scrub(self, _, scrub_value: float): def on_seek(self, _, value: float):
if not self.app_config.state.current_song or not self.window: if not self.app_config.state.current_song or not self.window:
return return
@@ -900,14 +893,9 @@ class SublimeMusicApp(Gtk.Application):
# a duration, but the Child object has `duration` optional because # a duration, but the Child object has `duration` optional because
# it could be a directory. # it could be a directory.
assert self.app_config.state.current_song.duration is not None assert self.app_config.state.current_song.duration is not None
new_time = self.app_config.state.current_song.duration * (scrub_value / 100)
new_time = timedelta(seconds=value)
self.app_config.state.song_progress = new_time self.app_config.state.song_progress = new_time
self.window.player_controls.update_scrubber(
self.app_config.state.song_progress,
self.app_config.state.current_song.duration,
self.app_config.state.song_stream_cache_progress,
)
# If already playing, then make the player itself seek. # If already playing, then make the player itself seek.
if self.player_manager and self.player_manager.song_loaded: if self.player_manager and self.player_manager.song_loaded:

View File

@@ -159,7 +159,7 @@ class AlbumsPanel(Gtk.Box):
actionbar.pack_end(self.show_count_dropdown) actionbar.pack_end(self.show_count_dropdown)
actionbar.pack_end(Gtk.Label(label="Show")) actionbar.pack_end(Gtk.Label(label="Show"))
self.add(actionbar) # self.add(actionbar)
scrolled_window = Gtk.ScrolledWindow() scrolled_window = Gtk.ScrolledWindow()
self.grid = AlbumsGrid() self.grid = AlbumsGrid()

View File

@@ -49,12 +49,12 @@
min-width: 230px; min-width: 230px;
} }
#icon-button-box image { .icon-button-box image {
margin: 5px 2px; margin: 5px 2px;
min-width: 15px; min-width: 15px;
} }
#icon-button-box label { .icon-button-box label {
margin-left: 5px; margin-left: 5px;
margin-right: 3px; margin-right: 3px;
} }
@@ -161,12 +161,12 @@ entry.invalid {
/* ********** Playback Controls ********** */ /* ********** Playback Controls ********** */
#player-controls-album-artwork { #player-controls-album-artwork {
min-height: 70px; /*min-height: 70px;
min-width: 70px; min-width: 70px;*/
margin-right: 10px; margin-right: 10px;
} }
#player-controls-bar #play-button { .play-button-large {
min-height: 45px; min-height: 45px;
min-width: 35px; min-width: 35px;
border-width: 1px; border-width: 1px;
@@ -174,7 +174,7 @@ entry.invalid {
} }
/* Make the play icon look centered. */ /* Make the play icon look centered. */
#player-controls-bar #play-button image { .play-button-large image {
margin-left: 5px; margin-left: 5px;
margin-right: 5px; margin-right: 5px;
margin-top: 1px; margin-top: 1px;
@@ -182,7 +182,7 @@ entry.invalid {
} }
#player-controls-bar #song-scrubber { #player-controls-bar #song-scrubber {
min-width: 400px; min-width: 200px;
} }
#player-controls-bar #volume-slider { #player-controls-bar #volume-slider {
@@ -194,6 +194,7 @@ entry.invalid {
} }
#player-controls-bar #song-title { #player-controls-bar #song-title {
min-width: 150px;
margin-bottom: 3px; margin-bottom: 3px;
font-weight: bold; font-weight: bold;
} }

View File

@@ -3,6 +3,7 @@ from .icon_button import IconButton, IconMenuButton, IconToggleButton
from .load_error import LoadError from .load_error import LoadError
from .song_list_column import SongListColumn from .song_list_column import SongListColumn
from .spinner_image import SpinnerImage from .spinner_image import SpinnerImage
from .spinner_picture import SpinnerPicture
__all__ = ( __all__ = (
"AlbumWithSongs", "AlbumWithSongs",
@@ -12,4 +13,5 @@ __all__ = (
"LoadError", "LoadError",
"SongListColumn", "SongListColumn",
"SpinnerImage", "SpinnerImage",
"SpinnerPicture",
) )

View File

@@ -42,7 +42,7 @@ class AlbumWithSongs(Gtk.Box):
image_size=cover_art_size, image_size=cover_art_size,
) )
# Account for 10px margin on all sides with "+ 20". # Account for 10px margin on all sides with "+ 20".
artist_artwork.set_size_request(cover_art_size + 20, cover_art_size + 20) # artist_artwork.set_size_request(cover_art_size + 20, cover_art_size + 20)
box.pack_start(artist_artwork, False, False, 0) box.pack_start(artist_artwork, False, False, 0)
box.pack_start(Gtk.Box(), True, True, 0) box.pack_start(Gtk.Box(), True, True, 0)
self.pack_start(box, False, False, 0) self.pack_start(box, False, False, 0)

View File

@@ -1,6 +1,6 @@
from typing import Any, Optional from typing import Any, Optional
from gi.repository import Gtk from gi.repository import Gtk, GObject
class IconButton(Gtk.Button): class IconButton(Gtk.Button):
@@ -16,8 +16,10 @@ class IconButton(Gtk.Button):
Gtk.Button.__init__(self, **kwargs) Gtk.Button.__init__(self, **kwargs)
self.icon_size = icon_size self.icon_size = icon_size
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box") box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
box.get_style_context().add_class("icon-button-box")
self._icon_name = icon_name
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size) self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
box.pack_start(self.image, False, False, 0) box.pack_start(self.image, False, False, 0)
@@ -30,7 +32,21 @@ class IconButton(Gtk.Button):
self.add(box) self.add(box)
self.set_tooltip_text(tooltip_text) self.set_tooltip_text(tooltip_text)
# TODO: Remove
def set_icon(self, icon_name: Optional[str]): def set_icon(self, icon_name: Optional[str]):
self.icon_name = icon_name
# self.image.set_from_icon_name(icon_name, self.icon_size)
@GObject.Property(type=str)
def icon_name(self):
return self._icon_name
@icon_name.setter
def icon_name(self, icon_name):
if icon_name == self._icon_name:
return
self._icon_name = icon_name
self.image.set_from_icon_name(icon_name, self.icon_size) self.image.set_from_icon_name(icon_name, self.icon_size)
@@ -46,8 +62,10 @@ class IconToggleButton(Gtk.ToggleButton):
): ):
Gtk.ToggleButton.__init__(self, **kwargs) Gtk.ToggleButton.__init__(self, **kwargs)
self.icon_size = icon_size self.icon_size = icon_size
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box") box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
box.get_style_context().add_class("icon-button-box")
self._icon_name = icon_name
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size) self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
box.add(self.image) box.add(self.image)
@@ -60,15 +78,22 @@ class IconToggleButton(Gtk.ToggleButton):
self.add(box) self.add(box)
self.set_tooltip_text(tooltip_text) self.set_tooltip_text(tooltip_text)
# TODO: Remove
def set_icon(self, icon_name: Optional[str]): def set_icon(self, icon_name: Optional[str]):
self.icon_name = icon_name
@GObject.Property(type=str)
def icon_name(self):
return self._icon_name
@icon_name.setter
def icon_name(self, icon_name):
if icon_name == self._icon_name:
return
self._icon_name = icon_name
self.image.set_from_icon_name(icon_name, self.icon_size) self.image.set_from_icon_name(icon_name, self.icon_size)
def get_active(self) -> bool:
return super().get_active()
def set_active(self, active: bool):
super().set_active(active)
class IconMenuButton(Gtk.MenuButton): class IconMenuButton(Gtk.MenuButton):
def __init__( def __init__(
@@ -88,7 +113,8 @@ class IconMenuButton(Gtk.MenuButton):
self.set_popover(popover) self.set_popover(popover)
self.icon_size = icon_size self.icon_size = icon_size
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box") box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
box.get_style_context().add_class("icon-button-box")
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size) self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
box.add(self.image) box.add(self.image)

View File

@@ -0,0 +1,63 @@
from typing import Optional
from gi.repository import Gdk, GdkPixbuf, Gtk
class SpinnerPicture(Gtk.Overlay):
def __init__(
self,
loading: bool = True,
spinner_name: str = None,
**kwargs,
):
"""An picture with a loading overlay."""
super().__init__()
self.filename: Optional[str] = None
self.pixbuf = None
self.drawing_area = Gtk.DrawingArea()
self.drawing_area.connect("draw", self.expose)
self.add_overlay(self.drawing_area)
self.spinner = Gtk.Spinner(
name=spinner_name,
active=loading,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
self.add_overlay(self.spinner)
def set_from_file(self, filename: Optional[str]):
"""Set the picture to the given filename."""
filename = filename or None
if self.filename == filename:
return
self.filename = filename
if self.filename:
self.pixbuf = GdkPixbuf.Pixbuf.new_from_file(self.filename)
else:
self.pixbuf = None
def set_loading(self, loading_status: bool):
if loading_status:
self.spinner.start()
self.spinner.show()
else:
self.spinner.stop()
self.spinner.hide()
def expose(self, area, cr):
if self.pixbuf:
pix_width = self.pixbuf.get_width()
pix_height = self.pixbuf.get_height()
alloc_width = self.get_allocated_width()
alloc_height = self.get_allocated_height()
cr.translate(alloc_width / 2, alloc_height / 2);
scale = max(alloc_width / pix_width, alloc_height / pix_height)
cr.scale(scale, scale)
cr.translate(-pix_width / 2, -pix_height / 2);
Gdk.cairo_set_source_pixbuf(cr, self.pixbuf, 0, 0)
cr.paint()

View File

@@ -1,9 +1,14 @@
from datetime import timedelta
from functools import partial from functools import partial
from typing import Any, Callable, Dict, Optional, Set, Tuple from typing import Any, Callable, Dict, Optional, Set, Tuple
import bleach import bleach
from gi.repository import Gdk, GLib, GObject, Gtk, Pango import gi
from gi.repository import GIRepository
GIRepository.Repository.prepend_search_path('/usr/local/lib/x86_64-linux-gnu/girepository-1.0')
gi.require_version('Handy', '1')
from gi.repository import Gdk, GLib, GObject, Gtk, Pango, Handy
from ..adapters import ( from ..adapters import (
AdapterManager, AdapterManager,
@@ -46,7 +51,7 @@ class MainWindow(Gtk.ApplicationWindow):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.set_default_size(1342, 756) # self.set_default_size(1342, 756)
# Create the stack # Create the stack
self.albums_panel = albums.AlbumsPanel() self.albums_panel = albums.AlbumsPanel()
@@ -55,16 +60,16 @@ class MainWindow(Gtk.ApplicationWindow):
self.playlists_panel = playlists.PlaylistsPanel() self.playlists_panel = playlists.PlaylistsPanel()
self.stack = self._create_stack( self.stack = self._create_stack(
Albums=self.albums_panel, Albums=self.albums_panel,
Artists=self.artists_panel, # Artists=self.artists_panel,
Browse=self.browse_panel, # Browse=self.browse_panel,
Playlists=self.playlists_panel, # Playlists=self.playlists_panel,
) )
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
self.titlebar = self._create_headerbar(self.stack) self.titlebar = self._create_headerbar(self.stack)
self.set_titlebar(self.titlebar) # self.set_titlebar(self.titlebar)
flowbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) flap = Handy.Flap(orientation=Gtk.Orientation.VERTICAL, fold_policy=Handy.FlapFoldPolicy.ALWAYS, flap_position=Gtk.PackType.END)
notification_container = Gtk.Overlay() notification_container = Gtk.Overlay()
notification_container.add(self.stack) notification_container.add(self.stack)
@@ -91,23 +96,66 @@ class MainWindow(Gtk.ApplicationWindow):
self.notification_revealer.add(notification_box) self.notification_revealer.add(notification_box)
notification_container.add_overlay(self.notification_revealer) notification_container.add_overlay(self.notification_revealer)
flowbox.pack_start(notification_container, True, True, 0) flap.set_content(notification_container)
# Player Controls # Player state
self.player_controls = player_controls.PlayerControls() self.player_manager = player_controls.Manager()
self.player_controls.connect( self.player_manager.connect(
"song-clicked", lambda _, *a: self.emit("song-clicked", *a) "song-clicked", lambda _, *a: self.emit("song-clicked", *a)
) )
self.player_controls.connect( self.player_manager.connect(
"songs-removed", lambda _, *a: self.emit("songs-removed", *a) "songs-removed", lambda _, *a: self.emit("songs-removed", *a)
) )
self.player_controls.connect( self.player_manager.connect(
"refresh-window", "refresh-window",
lambda _, *args: self.emit("refresh-window", *args), lambda _, *args: self.emit("refresh-window", *args),
) )
flowbox.pack_start(self.player_controls, False, True, 0)
self.add(flowbox) # Player Controls
flap.set_separator(Gtk.Separator())
squeezer = Handy.Squeezer(vexpand=True, homogeneous=False)
desktop_controls = player_controls.Desktop(self.player_manager)
squeezer.add(desktop_controls)
mobile_handle = player_controls.MobileHandle(self.player_manager)
# Toggle flap when handle is pressed
def toggle_flap(handle, event):
if event.get_click_count() != (True, 1) or not flap.get_swipe_to_open():
return
flap.set_reveal_flap(not flap.get_reveal_flap())
mobile_handle.connect("button-press-event", toggle_flap)
squeezer.add(mobile_handle)
# Only show the handle on desktop
def squeezer_changed(squeezer, _):
is_desktop = squeezer.get_visible_child() == desktop_controls
flap.set_swipe_to_open(not is_desktop)
# When transitioning, don't play the reveal animation
dur = flap.get_reveal_duration()
flap.set_reveal_duration(0)
flap.set_reveal_flap(False)
flap.set_reveal_duration(dur)
squeezer.connect("notify::visible-child", squeezer_changed)
flap.set_handle(squeezer)
mobile_flap = player_controls.MobileFlap(self.player_manager)
flap.set_flap(mobile_flap)
def flap_reveal_progress(flap, _):
self.player_manager.flap_open = flap.get_reveal_progress() > 0.5
flap.connect("notify::reveal-progress", flap_reveal_progress)
self.add(flap)
self.connect("button-release-event", self._on_button_release) self.connect("button-release-event", self._on_button_release)
@@ -362,7 +410,13 @@ class MainWindow(Gtk.ApplicationWindow):
if hasattr(active_panel, "update"): if hasattr(active_panel, "update"):
active_panel.update(app_config, force=force) active_panel.update(app_config, force=force)
self.player_controls.update(app_config, force=force) self.player_manager.update(app_config, force=force)
def update_song_progress(self,
progress: Optional[timedelta],
duration: Optional[timedelta],
cache_progess: Optional[timedelta]):
self.player_manager.update_song_progress(progress, duration, cache_progess)
def update_song_download_progress(self, song_id: str, progress: DownloadProgress): def update_song_download_progress(self, song_id: str, progress: DownloadProgress):
if progress.type == DownloadProgress.Type.QUEUED: if progress.type == DownloadProgress.Type.QUEUED:
@@ -519,7 +573,7 @@ class MainWindow(Gtk.ApplicationWindow):
""" """
Configure the header bar for the window. Configure the header bar for the window.
""" """
header = Gtk.HeaderBar() header = Handy.HeaderBar()
header.set_show_close_button(True) header.set_show_close_button(True)
header.props.title = "Sublime Music" header.props.title = "Sublime Music"
@@ -921,19 +975,19 @@ class MainWindow(Gtk.ApplicationWindow):
if not self._event_in_widgets(event, self.search_entry, self.search_popup): if not self._event_in_widgets(event, self.search_entry, self.search_popup):
self._hide_search() self._hide_search()
if not self._event_in_widgets( # if not self._event_in_widgets(
event, # event,
self.player_controls.device_button, # self.player_controls.device_button,
self.player_controls.device_popover, # self.player_controls.device_popover,
): # ):
self.player_controls.device_popover.popdown() # self.player_controls.device_popover.popdown()
if not self._event_in_widgets( # if not self._event_in_widgets(
event, # event,
self.player_controls.play_queue_button, # self.player_controls.play_queue_button,
self.player_controls.play_queue_popover, # self.player_controls.play_queue_popover,
): # ):
self.player_controls.play_queue_popover.popdown() # self.player_controls.play_queue_popover.popdown()
return False return False

View File

@@ -1,845 +0,0 @@
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
class PlayerControls(Gtk.ActionBar):
"""
Defines the player controls panel that appears at the bottom of the window.
"""
__gsignals__ = {
"song-scrub": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,)),
"volume-change": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,)),
"device-update": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str,)),
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
"songs-removed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,)),
"refresh-window": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, bool),
),
}
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, ...] = ()
cover_art_update_order_token = 0
play_queue_update_order_token = 0
offline_mode = False
def __init__(self):
Gtk.ActionBar.__init__(self)
self.set_name("player-controls-bar")
song_display = self.create_song_display()
playback_controls = self.create_playback_controls()
play_queue_volume = self.create_play_queue_volume()
self.last_device_list_update = None
self.pack_start(song_display)
self.set_center_widget(playback_controls)
self.pack_end(play_queue_volume)
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
self.update_device_list(app_config)
duration = (
app_config.state.current_song.duration
if app_config.state.current_song
else None
)
song_stream_cache_progress = (
app_config.state.song_stream_cache_progress
if app_config.state.current_song
else None
)
self.update_scrubber(
app_config.state.song_progress, duration, song_stream_cache_progress
)
icon = "pause" if app_config.state.playing else "start"
self.play_button.set_icon(f"media-playback-{icon}-symbolic")
self.play_button.set_tooltip_text(
"Pause" if app_config.state.playing else "Play"
)
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.repeat_button.set_action_name(None)
self.shuffle_button.set_action_name(None)
repeat_on = app_config.state.repeat_type in (
RepeatType.REPEAT_QUEUE,
RepeatType.REPEAT_SONG,
)
self.repeat_button.set_active(repeat_on)
self.repeat_button.set_icon(app_config.state.repeat_type.icon)
self.shuffle_button.set_active(app_config.state.shuffle_on)
self.repeat_button.set_action_name("app.repeat-press")
self.shuffle_button.set_action_name("app.shuffle-press")
self.song_scrubber.set_sensitive(has_current_song)
self.prev_button.set_sensitive(has_current_song)
self.play_button.set_sensitive(has_current_song)
self.next_button.set_sensitive(has_current_song and has_next_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.cover_art_update_order_token += 1
self.update_cover_art(
app_config.state.current_song.cover_art,
order_token=self.cover_art_update_order_token,
)
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.album_art.set_from_file(None)
self.album_art.set_loading(False)
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
new_store = []
def calculate_label(song_details: Song) -> str:
title = song_details.title
# TODO (#71): use walrus once MYPY gets its act together
# album = a.name if (a := song_details.album) else None
# artist = a.name if (a := song_details.artist) else None
album = song_details.album.name if song_details.album else None
artist = song_details.artist.name if song_details.artist else None
return bleach.clean(f"<b>{title}</b>\n{util.dot_join(album, artist)}")
def make_idle_index_capturing_function(
idx: int,
order_tok: int,
fn: Callable[[int, int, Any], None],
) -> Callable[[Result], None]:
return lambda f: GLib.idle_add(fn, idx, order_tok, f.result())
def on_cover_art_future_done(
idx: int,
order_token: int,
cover_art_filename: str,
):
if order_token != self.play_queue_update_order_token:
return
self.play_queue_store[idx][1] = cover_art_filename
def get_cover_art_filename_or_create_future(
cover_art_id: Optional[str], idx: int, order_token: int
) -> Optional[str]:
cover_art_result = AdapterManager.get_cover_art_uri(cover_art_id, "file")
if not cover_art_result.data_is_available:
cover_art_result.add_done_callback(
make_idle_index_capturing_function(
idx, order_token, on_cover_art_future_done
)
)
return None
# The cover art is already cached.
return cover_art_result.result()
def on_song_details_future_done(idx: int, order_token: int, song_details: Song):
if order_token != self.play_queue_update_order_token:
return
self.play_queue_store[idx][2] = calculate_label(song_details)
# Cover Art
filename = get_cover_art_filename_or_create_future(
song_details.cover_art, idx, order_token
)
if filename:
self.play_queue_store[idx][1] = filename
current_play_queue = [x[-1] for x in self.play_queue_store]
if app_config.state.play_queue != current_play_queue:
self.play_queue_update_order_token += 1
song_details_results = []
for i, (song_id, cached_status) in enumerate(
zip(
app_config.state.play_queue,
AdapterManager.get_cached_statuses(app_config.state.play_queue),
)
):
song_details_result = AdapterManager.get_song_details(song_id)
cover_art_filename = ""
label = "\n"
if song_details_result.data_is_available:
# We have the details of the song already cached.
song_details = song_details_result.result()
label = calculate_label(song_details)
filename = get_cover_art_filename_or_create_future(
song_details.cover_art, i, self.play_queue_update_order_token
)
if filename:
cover_art_filename = filename
else:
song_details_results.append((i, song_details_result))
new_store.append(
[
(
not self.offline_mode
or cached_status
in (SongCacheStatus.CACHED, SongCacheStatus.PERMANENTLY_CACHED)
),
cover_art_filename,
label,
i == app_config.state.current_song_index,
song_id,
]
)
util.diff_song_store(self.play_queue_store, new_store)
# Do this after the diff to avoid race conditions.
for idx, song_details_result in song_details_results:
song_details_result.add_done_callback(
make_idle_index_capturing_function(
idx,
self.play_queue_update_order_token,
on_song_details_future_done,
)
)
self.editing_play_queue_song_list = False
@util.async_callback(
partial(AdapterManager.get_cover_art_uri, scheme="file"),
before_download=lambda self: self.album_art.set_loading(True),
on_failure=lambda self, e: self.album_art.set_loading(False),
)
def update_cover_art(
self,
cover_art_filename: str,
app_config: AppConfiguration,
force: bool = False,
order_token: int = None,
is_partial: bool = False,
):
if order_token != self.cover_art_update_order_token:
return
self.album_art.set_from_file(cover_art_filename)
self.album_art.set_loading(False)
def update_scrubber(
self,
current: Optional[timedelta],
duration: Optional[timedelta],
song_stream_cache_progress: Optional[timedelta],
):
if current is None or duration is None:
self.song_duration_label.set_text("-:--")
self.song_progress_label.set_text("-:--")
self.song_scrubber.set_value(0)
return
percent_complete = current / duration * 100
if not self.editing:
self.song_scrubber.set_value(percent_complete)
self.song_scrubber.set_show_fill_level(song_stream_cache_progress is not None)
if song_stream_cache_progress is not None:
percent_cached = song_stream_cache_progress / duration * 100
self.song_scrubber.set_fill_level(percent_cached)
self.song_duration_label.set_text(util.format_song_duration(duration))
self.song_progress_label.set_text(
util.format_song_duration(math.floor(current.total_seconds()))
)
def on_volume_change(self, scale: Gtk.Scale):
if not self.editing:
self.emit("volume-change", scale.get_value())
def on_play_queue_click(self, _: Any):
if self.play_queue_popover.is_visible():
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.emit(
"song-clicked",
idx.get_indices()[0],
[m[-1] for m in self.play_queue_store],
{"no_reshuffle": True},
)
_current_player_id = None
_current_available_players: Dict[type, Set[Tuple[str, str]]] = {}
def update_device_list(self, app_config: AppConfiguration):
if (
self._current_available_players == app_config.state.available_players
and self._current_player_id == app_config.state.current_device
):
return
self._current_player_id = app_config.state.current_device
self._current_available_players = copy.deepcopy(
app_config.state.available_players
)
for c in self.device_list.get_children():
self.device_list.remove(c)
for i, (player_type, players) in enumerate(
app_config.state.available_players.items()
):
if len(players) == 0:
continue
if i > 0:
self.device_list.add(
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
)
self.device_list.add(
Gtk.Label(
label=f"{player_type.name} Devices",
halign=Gtk.Align.START,
name="device-type-section-title",
)
)
for player_id, player_name in sorted(players, key=lambda p: p[1]):
icon = (
"audio-volume-high-symbolic"
if player_id == self.current_device
else None
)
button = IconButton(icon, label=player_name)
button.get_style_context().add_class("menu-button")
button.connect(
"clicked",
lambda _, player_id: self.emit("device-update", player_id),
player_id,
)
self.device_list.add(button)
self.device_list.show_all()
def on_device_click(self, _: Any):
if self.device_popover.is_visible():
self.device_popover.popdown()
else:
self.device_popover.popup()
self.device_popover.show_all()
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.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.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 on_play_queue_model_row_move(self, *args):
# If we are programatically editing the song list, don't do anything.
if self.editing_play_queue_song_list:
return
# We get both a delete and insert event, I think it's deterministic
# which one comes first, but just in case, we have this
# reordering_play_queue_song_list flag.
if self.reordering_play_queue_song_list:
currently_playing_index = [
i for i, s in enumerate(self.play_queue_store) if s[3] # playing
][0]
self.emit(
"refresh-window",
{
"current_song_index": currently_playing_index,
"play_queue": tuple(s[-1] for s in self.play_queue_store),
},
False,
)
self.reordering_play_queue_song_list = False
else:
self.reordering_play_queue_song_list = True
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)
self.song_progress_label = Gtk.Label(label="-:--")
scrubber_box.pack_start(self.song_progress_label, False, False, 5)
self.song_scrubber = Gtk.Scale.new_with_range(
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5
)
self.song_scrubber.set_name("song-scrubber")
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)
self.song_duration_label = Gtk.Label(label="-:--")
scrubber_box.pack_start(self.song_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_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.repeat_button = IconToggleButton(
"media-playlist-repeat", "Switch between repeat modes"
)
self.repeat_button.set_action_name("app.repeat-press")
repeat_button_box.pack_start(Gtk.Box(), True, True, 0)
repeat_button_box.pack_start(self.repeat_button, False, False, 0)
repeat_button_box.pack_start(Gtk.Box(), True, True, 0)
buttons.pack_start(repeat_button_box, False, False, 5)
# Previous button
self.prev_button = IconButton(
"media-skip-backward-symbolic",
"Go to previous song",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.prev_button.set_action_name("app.prev-track")
buttons.pack_start(self.prev_button, False, False, 5)
# Play button
self.play_button = IconButton(
"media-playback-start-symbolic",
"Play",
relief=True,
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.play_button.set_name("play-button")
self.play_button.set_action_name("app.play-pause")
buttons.pack_start(self.play_button, False, False, 0)
# Next button
self.next_button = IconButton(
"media-skip-forward-symbolic",
"Go to next song",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.next_button.set_action_name("app.next-track")
buttons.pack_start(self.next_button, False, False, 5)
# Shuffle button
shuffle_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.shuffle_button = IconToggleButton(
"media-playlist-shuffle-symbolic", "Toggle playlist shuffling"
)
self.shuffle_button.set_action_name("app.shuffle-press")
shuffle_button_box.pack_start(Gtk.Box(), True, True, 0)
shuffle_button_box.pack_start(self.shuffle_button, False, False, 0)
shuffle_button_box.pack_start(Gtk.Box(), True, True, 0)
buttons.pack_start(shuffle_button_box, 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.on_device_click)
box.pack_start(self.device_button, False, True, 5)
self.device_popover = Gtk.PopoverMenu(modal=False, name="device-popover")
self.device_popover.set_relative_to(self.device_button)
device_popover_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
name="device-popover-box",
)
device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.popover_label = Gtk.Label(
label="<b>Devices</b>",
use_markup=True,
halign=Gtk.Align.START,
margin=5,
)
device_popover_header.add(self.popover_label)
refresh_devices = IconButton("view-refresh-symbolic", "Refresh device list")
refresh_devices.set_action_name("app.refresh-devices")
device_popover_header.pack_end(refresh_devices, False, False, 0)
device_popover_box.add(device_popover_header)
device_list_and_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
device_list_and_loading.add(self.device_list)
device_popover_box.pack_end(device_list_and_loading, True, True, 0)
self.device_popover.add(device_popover_box)
# Play Queue button
self.play_queue_button = IconButton(
"view-list-symbolic",
"Open play queue",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.play_queue_button.connect("clicked", self.on_play_queue_click)
box.pack_start(self.play_queue_button, False, True, 5)
self.play_queue_popover = Gtk.PopoverMenu(modal=False, name="up-next-popover")
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,
)
self.play_queue_store = Gtk.ListStore(
bool, # playable
str, # image filename
str, # title, album, artist
bool, # playing
str, # song ID
)
self.play_queue_list = Gtk.TreeView(
model=self.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
)
# Set up drag-and-drop on the song list for editing the order of the
# playlist.
self.play_queue_store.connect("row-inserted", self.on_play_queue_model_row_move)
self.play_queue_store.connect("row-deleted", self.on_play_queue_model_row_move)
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

View File

@@ -0,0 +1,3 @@
from .desktop import Desktop
from .mobile import MobileHandle, MobileFlap
from .manager import Manager

View File

@@ -0,0 +1,81 @@
from ..common import IconButton, IconToggleButton, SpinnerImage, SpinnerPicture
from .manager import Manager
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango, Handy
def create_play_button(manager: Manager, large=True, **kwargs):
button = IconButton(
"media-playback-start-symbolic",
"Play",
relief=large,
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
**kwargs,
)
if large:
button.get_style_context().add_class("play-button-large")
button.set_action_name("app.play-pause")
manager.bind_property("has-song", button, "sensitive")
manager.bind_property("play-button-icon", button, "icon-name")
manager.bind_property("play-button-tooltip", button, "tooltip-text")
return button
def create_repeat_button(manager: Manager, **kwargs):
button = IconToggleButton("media-playlist-repeat", "Switch between repeat modes", **kwargs)
button.set_action_name("app.repeat-press")
manager.bind_property("repeat-button-icon", button, "icon-name")
def on_active_change(*_):
if manager.repeat_button_active != button.get_active():
# Don't run the action, just update visual state
button.set_action_name(None)
button.set_active(manager.repeat_button_active)
button.set_action_name("app.repeat-press")
manager.connect("notify::repeat-button-active", on_active_change)
return button
def create_shuffle_button(manager: Manager, **kwargs):
button = IconToggleButton("media-playlist-shuffle-symbolic", "Toggle playlist shuffling", **kwargs)
button.set_action_name("app.shuffle-press")
def on_active_change(*_):
if manager.shuffle_button_active != button.get_active():
# Don't run the action, just update visual state
button.set_action_name(None)
button.set_active(manager.shuffle_button_active)
button.set_action_name("app.shuffle-press")
manager.connect("notify::shuffle-button-active", on_active_change)
return button
def create_next_button(manager: Manager, **kwargs):
button = IconButton(
"media-skip-forward-symbolic",
"Go to next song",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
**kwargs,
)
button.set_action_name("app.next-track")
manager.bind_property("has-song", button, "sensitive")
return button
def create_prev_button(manager: Manager, **kwargs):
button = IconButton(
"media-skip-backward-symbolic",
"Go to previous song",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
**kwargs,
)
button.set_action_name("app.prev-track")
manager.bind_property("has-song", button, "sensitive")
return button
def create_label(manager: Manager, property: str, **kwargs):
label = Gtk.Label(**kwargs)
manager.bind_property(property, label, "label")
return label

View File

@@ -0,0 +1,485 @@
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

View File

@@ -0,0 +1,407 @@
import copy
from datetime import timedelta
from typing import Any, Optional, Callable, Dict, Set, Tuple
from functools import partial
from gi.repository import GObject, Gtk
from .. import util
from ...adapters import AdapterManager, Result
from ...adapters.api_objects import Song
from ...config import AppConfiguration
from ..state import RepeatType
from ..common import IconButton, IconToggleButton, SpinnerImage, SpinnerPicture
class Manager(GObject.GObject):
__gsignals__ = {
"device-update": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str,)),
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
"songs-removed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,)),
"refresh-window": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, bool),
),
"seek": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,)),
}
volume = GObject.Property(type=Gtk.Adjustment)
scrubber = GObject.Property(type=Gtk.Adjustment)
scrubber_cache = GObject.Property(type=int, default=0)
play_queue_open = GObject.Property(type=bool, default=False)
play_queue_store = GObject.Property(type=Gtk.ListStore)
flap_open = GObject.Property(type=bool, default=False)
offline_mode = GObject.Property(type=bool, default=False)
has_song = GObject.Property(type=bool, default=False)
play_button_icon = GObject.Property(type=str)
play_button_tooltip = GObject.Property(type=str)
repeat_button_icon = GObject.Property(type=str)
repeat_button_active = GObject.Property(type=bool, default=False)
shuffle_button_active = GObject.Property(type=bool, default=False)
duration_label = GObject.Property(type=str, default="-:--")
progress_label = GObject.Property(type=str, default="-:--")
updating_scrubber = False
cover_art_update_order_token = 0
play_queue_update_order_token = 0
def __init__(self):
super().__init__()
self.volume = Gtk.Adjustment(1, 0, 1, 0.01, 0, 0)
self.scrubber = Gtk.Adjustment()
self.scrubber.set_step_increment(1)
self.scrubber.connect("value-changed", self.on_scrubber_changed)
self.scrubber.connect("value-changed", self.update_progress_label)
self.scrubber.connect("changed", self.update_duration_label)
self.play_queue_store = Gtk.ListStore(
bool, # playable
str, # image filename
str, # title, album, artist
bool, # playing
str, # song ID
)
# Set up drag-and-drop on the song list for editing the order of the
# playlist.
self.play_queue_store.connect("row-inserted", self.on_play_queue_model_row_move)
self.play_queue_store.connect("row-deleted", self.on_play_queue_model_row_move)
self._controls = []
# Device popover
self.device_popover = Gtk.PopoverMenu(modal=True, name="device-popover")
device_popover_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
name="device-popover-box",
)
device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
popover_label = Gtk.Label(
label="<b>Devices</b>",
use_markup=True,
halign=Gtk.Align.START,
margin=5,
)
device_popover_header.add(popover_label)
refresh_devices = IconButton("view-refresh-symbolic", "Refresh device list")
refresh_devices.set_action_name("app.refresh-devices")
device_popover_header.pack_end(refresh_devices, False, False, 0)
device_popover_box.add(device_popover_header)
device_list_and_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
device_list_and_loading.add(self.device_list)
device_popover_box.pack_end(device_list_and_loading, True, True, 0)
self.device_popover.add(device_popover_box)
def add_control(self, control):
self._controls.append(control)
def update(self, app_config: AppConfiguration, force: bool = False):
self.has_song = app_config.state.current_song is not None
self.update_song_progress(
app_config.state.song_progress,
app_config.state.current_song and app_config.state.current_song.duration,
app_config.state.song_stream_cache_progress)
if self.has_song:
self.cover_art_update_order_token += 1
self.update_cover_art(
app_config.state.current_song.cover_art,
order_token=self.cover_art_update_order_token)
for control in self._controls:
control.update(app_config, force=force)
if not self.has_song:
self.set_cover_art(None, False)
self.update_player_queue(app_config)
self.update_device_list(app_config)
icon = "pause" if app_config.state.playing else "start"
self.play_button_icon = f"media-playback-{icon}-symbolic"
self.play_button_tooltip = "Pause" if app_config.state.playing else "Play"
repeat_on = app_config.state.repeat_type in (
RepeatType.REPEAT_QUEUE,
RepeatType.REPEAT_SONG,
)
self.repeat_button_icon = app_config.state.repeat_type.icon
self.repeat_button_active = repeat_on
self.shuffle_button_active = app_config.state.shuffle_on
def update_song_progress(self,
progress: Optional[timedelta],
duration: Optional[timedelta],
cache_progess: Optional[timedelta]):
self.updating_scrubber = True
if progress is None or duration is None:
self.scrubber.set_value(0)
self.scrubber.set_upper(0)
self.scrubber_cache = 0
self.updating_scrubber = False
return
self.scrubber.set_value(progress.total_seconds())
self.scrubber.set_upper(duration.total_seconds())
if cache_progess is not None:
self.scrubber_cache = cache_progess.total_seconds()
else:
self.scrubber_cache = duration.total_seconds()
self.updating_scrubber = False
@util.async_callback(
partial(AdapterManager.get_cover_art_uri, scheme="file"),
before_download=lambda self: self.set_cover_art(None, True),
on_failure=lambda self, e: self.set_cover_art(None, False),
)
def update_cover_art(
self,
cover_art_filename: str,
app_config: AppConfiguration,
force: bool = False,
order_token: int = None,
is_partial: bool = False,
):
if order_token != self.cover_art_update_order_token:
return
self.set_cover_art(cover_art_filename, False)
def set_cover_art(self, cover_art_filename: Optional[str], loading: bool):
for control in self._controls:
control.set_cover_art(cover_art_filename, loading)
def on_play_queue_model_row_move(self, *args):
# TODO
return
# If we are programatically editing the song list, don't do anything.
if self.editing_play_queue_song_list:
return
# We get both a delete and insert event, I think it's deterministic
# which one comes first, but just in case, we have this
# reordering_play_queue_song_list flag.
if self.reordering_play_queue_song_list:
currently_playing_index = [
i for i, s in enumerate(self.play_queue_store) if s[3] # playing
][0]
self.state.emit(
"refresh-window",
{
"current_song_index": currently_playing_index,
"play_queue": tuple(s[-1] for s in self.play_queue_store),
},
False,
)
self.reordering_play_queue_song_list = False
else:
self.reordering_play_queue_song_list = True
def update_player_queue(self, app_config: AppConfiguration):
new_store = []
def calculate_label(song_details: Song) -> str:
title = util.esc(song_details.title)
# TODO (#71): use walrus once MYPY works with this
# album = util.esc(album.name if (album := song_details.album) else None)
# artist = util.esc(artist.name if (artist := song_details.artist) else None) # noqa
album = util.esc(song_details.album.name if song_details.album else None)
artist = util.esc(song_details.artist.name if song_details.artist else None)
return f"<b>{title}</b>\n{util.dot_join(album, artist)}"
def make_idle_index_capturing_function(
idx: int,
order_tok: int,
fn: Callable[[int, int, Any], None],
) -> Callable[[Result], None]:
return lambda f: GLib.idle_add(fn, idx, order_tok, f.result())
def on_cover_art_future_done(
idx: int,
order_token: int,
cover_art_filename: str,
):
if order_token != self.play_queue_update_order_token:
return
self.play_queue_store[idx][1] = cover_art_filename
def get_cover_art_filename_or_create_future(
cover_art_id: Optional[str], idx: int, order_token: int
) -> Optional[str]:
cover_art_result = AdapterManager.get_cover_art_uri(cover_art_id, "file")
if not cover_art_result.data_is_available:
cover_art_result.add_done_callback(
make_idle_index_capturing_function(
idx, order_token, on_cover_art_future_done
)
)
return None
# The cover art is already cached.
return cover_art_result.result()
def on_song_details_future_done(idx: int, order_token: int, song_details: Song):
if order_token != self.play_queue_update_order_token:
return
self.play_queue_store[idx][2] = calculate_label(song_details)
# Cover Art
filename = get_cover_art_filename_or_create_future(
song_details.cover_art, idx, order_token
)
if filename:
self.play_queue_store[idx][1] = filename
current_play_queue = [x[-1] for x in self.play_queue_store]
if app_config.state.play_queue != current_play_queue:
self.play_queue_update_order_token += 1
song_details_results = []
for i, (song_id, cached_status) in enumerate(
zip(
app_config.state.play_queue,
AdapterManager.get_cached_statuses(app_config.state.play_queue),
)
):
song_details_result = AdapterManager.get_song_details(song_id)
cover_art_filename = ""
label = "\n"
if song_details_result.data_is_available:
# We have the details of the song already cached.
song_details = song_details_result.result()
label = calculate_label(song_details)
filename = get_cover_art_filename_or_create_future(
song_details.cover_art, i, self.play_queue_update_order_token
)
if filename:
cover_art_filename = filename
else:
song_details_results.append((i, song_details_result))
new_store.append(
[
(
not self.offline_mode
or cached_status
in (SongCacheStatus.CACHED, SongCacheStatus.PERMANENTLY_CACHED)
),
cover_art_filename,
label,
i == app_config.state.current_song_index,
song_id,
]
)
util.diff_song_store(self.play_queue_store, new_store)
# Do this after the diff to avoid race conditions.
for idx, song_details_result in song_details_results:
song_details_result.add_done_callback(
make_idle_index_capturing_function(
idx,
self.play_queue_update_order_token,
on_song_details_future_done,
)
)
def popup_devices(self, relative_to):
self.device_popover.set_relative_to(relative_to)
self.device_popover.popup()
self.device_popover.show_all()
_current_player_id = None
_current_available_players: Dict[type, Set[Tuple[str, str]]] = {}
def update_device_list(self, app_config: AppConfiguration):
if (
self._current_available_players == app_config.state.available_players
and self._current_player_id == app_config.state.current_device
):
return
self._current_player_id = app_config.state.current_device
self._current_available_players = copy.deepcopy(
app_config.state.available_players
)
for c in self.device_list.get_children():
self.device_list.remove(c)
for i, (player_type, players) in enumerate(
app_config.state.available_players.items()
):
if len(players) == 0:
continue
if i > 0:
self.device_list.add(
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
)
self.device_list.add(
Gtk.Label(
label=f"{player_type.name} Devices",
halign=Gtk.Align.START,
name="device-type-section-title",
)
)
for player_id, player_name in sorted(players, key=lambda p: p[1]):
icon = (
"audio-volume-high-symbolic"
if player_id == app_config.state.current_device
else None
)
button = IconButton(icon, label=player_name)
button.get_style_context().add_class("menu-button")
button.connect(
"clicked",
lambda _, player_id: self.state.emit("device-update", player_id),
player_id,
)
self.device_list.add(button)
self.device_list.show_all()
def on_scrubber_changed(self, _: Any):
if self.updating_scrubber:
return
self.emit("seek", self.scrubber.get_value())
def update_progress_label(self, _: Any):
if self.scrubber.get_upper() == 0:
self.progress_label = "-:--"
else:
self.progress_label = util.format_song_duration(
int(self.scrubber.get_value()))
def update_duration_label(self, _: Any):
upper = self.scrubber.get_upper()
if upper == 0:
self.duration_label = "-:--"
else:
self.duration_label = util.format_song_duration(int(upper))

View File

@@ -0,0 +1,328 @@
from typing import Any, Callable, Dict, Optional, Set, Tuple
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango, Handy
from .. import util
from ..common import IconButton, IconToggleButton, SpinnerImage, SpinnerPicture
from ...config import AppConfiguration
from ...util import resolve_path
from . import common
class MobileHandle(Gtk.EventBox):
def __init__(self, state):
super().__init__(above_child=False)
self.get_style_context().add_class("background")
self.state = state
self.state.add_control(self)
action_bar = Gtk.ActionBar()
action_bar.pack_start(self.create_song_display())
buttons = Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE)
close_buttons = Handy.Squeezer(halign=Gtk.Align.END, homogeneous=False)
expanded_close_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
expanded_close_buttons.pack_start(common.create_prev_button(self.state), False, False, 5)
expanded_close_buttons.pack_start(common.create_play_button(self.state, large=False), False, False, 5)
expanded_close_buttons.pack_start(common.create_next_button(self.state), False, False, 5)
close_buttons.add(expanded_close_buttons)
close_buttons.add(common.create_play_button(self.state, large=False, halign=Gtk.Align.END))
buttons.add(close_buttons)
open_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.END)
self.play_queue_button = IconToggleButton(
"view-list-symbolic",
"Open play queue",
relief=True,
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
def play_queue_button_toggled(*_):
if self.play_queue_button.get_active() != self.state.play_queue_open:
self.state.play_queue_open = self.play_queue_button.get_active()
self.play_queue_button.connect("toggled", play_queue_button_toggled)
def on_play_queue_open(*_):
self.play_queue_button.set_active(self.state.play_queue_open)
self.state.connect("notify::play-queue-open", on_play_queue_open)
open_buttons.pack_start(self.play_queue_button, False, False, 5)
self.menu_button = IconButton(
"view-more-symbolic",
"Menu",
relief=True,
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
open_buttons.pack_start(self.menu_button, False, False, 5)
buttons.add(open_buttons)
def flap_reveal(*_):
if self.state.flap_open:
buttons.set_visible_child(open_buttons)
else:
buttons.set_visible_child(close_buttons)
self.state.play_queue_open = False
self.state.connect("notify::flap-open", flap_reveal)
action_bar.pack_end(buttons)
self.add(action_bar)
def create_song_display(self):
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, hexpand=True)
box.set_property('width-request', 200)
self.cover_art = SpinnerImage(
image_name="player-controls-album-artwork",
image_size=50,
)
box.pack_start(self.cover_art, False, False, 0)
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
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.artist_name = make_label("artist-name")
details_box.add(self.artist_name)
box.pack_start(details_box, False, False, 5)
return box
def update(self, app_config: AppConfiguration, force: bool = False):
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 artist:
self.artist_name.set_markup(bleach.clean(artist.name))
self.artist_name.show()
elif album:
self.artist_name.set_markup(bleach.clean(album.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.artist_name.set_markup("")
def set_cover_art(self, cover_art_filename: str, loading: bool):
self.cover_art.set_from_file(cover_art_filename)
self.cover_art.set_loading(loading)
class MobileFlap(Handy.Flap):
def __init__(self, state):
super().__init__(orientation=Gtk.Orientation.VERTICAL, fold_policy=Handy.FlapFoldPolicy.ALWAYS, vexpand=True, swipe_to_open=False)
self.state = state
self.state.add_control(self)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, vexpand=True)
box.get_style_context().add_class("background")
box.pack_start(Gtk.Separator(), False, False, 0)
overlay = Gtk.Overlay(hexpand=True, vexpand=True, height_request=100)
self.cover_art = SpinnerPicture(image_name="player-controls-album-artwork")
overlay.add(self.cover_art)
overlay_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
overlay.add_overlay(overlay_box)
overlay_row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
overlay_box.pack_end(overlay_row_box, False, False, 0)
overlay_row_box.pack_start(common.create_label(self.state, "progress-label"), False, False, 5)
overlay_row_box.pack_end(common.create_label(self.state, "duration-label"), False, False, 5)
# Device
self.device_button = IconButton(
"chromecast-symbolic",
"Show available audio output devices",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
def on_device_click(_: Any):
if self.device_popover.is_visible():
self.device_popover.popdown()
else:
self.device_popover.popup()
self.device_popover.show_all()
self.device_button.connect("clicked", self.state.popup_devices)
overlay_row_box.set_center_widget(self.device_button)
self.device_popover = Gtk.PopoverMenu(modal=False, name="device-popover")
self.device_popover.set_relative_to(self.device_button)
device_popover_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
name="device-popover-box",
)
device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.popover_label = Gtk.Label(
label="<b>Devices</b>",
use_markup=True,
halign=Gtk.Align.START,
margin=5,
)
device_popover_header.add(self.popover_label)
refresh_devices = IconButton("view-refresh-symbolic", "Refresh device list")
refresh_devices.set_action_name("app.refresh-devices")
device_popover_header.pack_end(refresh_devices, False, False, 0)
device_popover_box.add(device_popover_header)
device_list_and_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
device_list_and_loading.add(self.device_list)
device_popover_box.pack_end(device_list_and_loading, True, True, 0)
self.device_popover.add(device_popover_box)
box.pack_start(overlay, True, True, 0)
# Song Scrubber
scrubber = Gtk.Scale(
orientation=Gtk.Orientation.HORIZONTAL,
adjustment=self.state.scrubber,
name="song-scrubber",
draw_value=False,
restrict_to_fill_level=False)
self.state.bind_property("scrubber-cache", scrubber, "fill-level")
box.pack_start(scrubber, False, False, 0)
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
# Repeat button
repeat_button = common.create_repeat_button(self.state, valign=Gtk.Align.CENTER, margin_left=10)
buttons.pack_start(repeat_button, False, False, 0)
center_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
# Previous button
center_buttons.add(common.create_prev_button(self.state, valign=Gtk.Align.CENTER))
# Play button
center_buttons.add(common.create_play_button(self.state))
# Next button
center_buttons.add(common.create_next_button(self.state, valign=Gtk.Align.CENTER))
buttons.set_center_widget(center_buttons)
# Shuffle button
shuffle_button = common.create_shuffle_button(self.state, valign=Gtk.Align.CENTER, margin_right=10)
buttons.pack_end(shuffle_button, False, False, 0)
box.pack_start(buttons, False, False, 0)
box.pack_start(Gtk.Box(), False, False, 5)
self.set_content(box)
play_queue_scrollbox = Gtk.ScrolledWindow(vexpand=True)
self.play_queue_list = Gtk.TreeView(
model=self.state.play_queue_store,
reorderable=True,
headers_visible=False,
)
self.play_queue_list.get_style_context().add_class("background")
# 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)
column.set_expand(True)
self.play_queue_list.append_column(column)
renderer = Gtk.CellRendererPixbuf(icon_name="view-more-symbolic")
renderer.set_fixed_size(30, 24)
column = Gtk.TreeViewColumn("", renderer)
column.set_resizable(True)
self.play_queue_list.append_column(column)
renderer = Gtk.CellRendererPixbuf(icon_name="window-close-symbolic")
renderer.set_fixed_size(30, 24)
column = Gtk.TreeViewColumn("", renderer)
column.set_resizable(True)
self.play_queue_list.append_column(column)
play_queue_scrollbox.add(self.play_queue_list)
self.set_flap(play_queue_scrollbox)
def on_play_queue_open(*_):
if not self.get_child_visible():
self.set_reveal_flap(False)
return
self.set_reveal_flap(self.state.play_queue_open)
self.state.connect("notify::play-queue-open", on_play_queue_open)
def update(self, app_config: AppConfiguration, force: bool = False):
pass
def set_cover_art(self, cover_art_filename: str, loading: bool):
self.cover_art.set_from_file(cover_art_filename)
self.cover_art.set_loading(loading)

View File

@@ -109,6 +109,11 @@ class UIState:
self.current_notification = None self.current_notification = None
self.playing = False self.playing = False
# Ensure a song is selected if the play queue isn't empty
if self.play_queue and self.current_song_index < 0:
self.current_song_index = 0
self.song_progress = timedelta(0)
def __init_available_players__(self): def __init_available_players__(self):
from sublime_music.players import PlayerManager from sublime_music.players import PlayerManager