From e1dcf8da4c6ec9e59abe7cf6189b15d3f9eb3376 Mon Sep 17 00:00:00 2001 From: Benjamin Schaaf Date: Sun, 31 Jan 2021 21:38:52 +1100 Subject: [PATCH] WIP --- sublime_music/app.py | 28 +- sublime_music/ui/albums.py | 2 +- sublime_music/ui/app_styles.css | 15 +- sublime_music/ui/common/__init__.py | 2 + sublime_music/ui/common/album_with_songs.py | 2 +- sublime_music/ui/common/icon_button.py | 46 +- sublime_music/ui/common/spinner_picture.py | 63 ++ sublime_music/ui/main.py | 112 ++- sublime_music/ui/player_controls.py | 845 ------------------- sublime_music/ui/player_controls/__init__.py | 3 + sublime_music/ui/player_controls/common.py | 81 ++ sublime_music/ui/player_controls/desktop.py | 485 +++++++++++ sublime_music/ui/player_controls/manager.py | 407 +++++++++ sublime_music/ui/player_controls/mobile.py | 328 +++++++ sublime_music/ui/state.py | 5 + 15 files changed, 1511 insertions(+), 913 deletions(-) create mode 100644 sublime_music/ui/common/spinner_picture.py create mode 100644 sublime_music/ui/player_controls/__init__.py create mode 100644 sublime_music/ui/player_controls/common.py create mode 100644 sublime_music/ui/player_controls/desktop.py create mode 100644 sublime_music/ui/player_controls/manager.py create mode 100644 sublime_music/ui/player_controls/mobile.py diff --git a/sublime_music/app.py b/sublime_music/app.py index c0c258a..3c39ac4 100644 --- a/sublime_music/app.py +++ b/sublime_music/app.py @@ -181,9 +181,9 @@ class SublimeMusicApp(Gtk.Application): self.window.connect("notification-closed", self.on_notification_closed) self.window.connect("go-to", self.on_window_go_to) 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_controls.connect("device-update", self.on_device_update) - self.window.player_controls.connect("volume-change", self.on_volume_change) + self.window.player_manager.connect("seek", self.on_seek) + self.window.player_manager.connect("device-update", self.on_device_update) + # self.window.player_manager.volume.connect("value-changed", self.on_volume_change) # Configure the players self.last_play_queue_update = timedelta(0) @@ -204,7 +204,7 @@ class SublimeMusicApp(Gtk.Application): self.app_config.state.song_progress = timedelta(seconds=value) GLib.idle_add( - self.window.player_controls.update_scrubber, + self.window.update_song_progress, self.app_config.state.song_progress, self.app_config.state.current_song.duration, self.app_config.state.song_stream_cache_progress, @@ -262,7 +262,7 @@ class SublimeMusicApp(Gtk.Application): seconds=event.stream_cache_duration ) GLib.idle_add( - self.window.player_controls.update_scrubber, + self.window.update_song_progress, self.app_config.state.song_progress, self.app_config.state.current_song.duration, 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 # it could be a directory. assert self.app_config.state.current_song.duration is not None - self.on_song_scrub( - None, - ( - new_seconds.total_seconds() - / self.app_config.state.current_song.duration.total_seconds() - ) - * 100, - ) + self.window.player_manager.scrubber = new_seconds.total_seconds() def set_pos_fn(track_id: str, position: float = 0): if self.app_config.state.playing: @@ -892,7 +885,7 @@ class SublimeMusicApp(Gtk.Application): self.save_play_queue() @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: return @@ -900,14 +893,9 @@ class SublimeMusicApp(Gtk.Application): # a duration, but the Child object has `duration` optional because # it could be a directory. 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.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 self.player_manager and self.player_manager.song_loaded: diff --git a/sublime_music/ui/albums.py b/sublime_music/ui/albums.py index d35edaa..caf5330 100644 --- a/sublime_music/ui/albums.py +++ b/sublime_music/ui/albums.py @@ -159,7 +159,7 @@ class AlbumsPanel(Gtk.Box): actionbar.pack_end(self.show_count_dropdown) actionbar.pack_end(Gtk.Label(label="Show")) - self.add(actionbar) + # self.add(actionbar) scrolled_window = Gtk.ScrolledWindow() self.grid = AlbumsGrid() diff --git a/sublime_music/ui/app_styles.css b/sublime_music/ui/app_styles.css index ea767f6..0ffdd48 100644 --- a/sublime_music/ui/app_styles.css +++ b/sublime_music/ui/app_styles.css @@ -49,12 +49,12 @@ min-width: 230px; } -#icon-button-box image { +.icon-button-box image { margin: 5px 2px; min-width: 15px; } -#icon-button-box label { +.icon-button-box label { margin-left: 5px; margin-right: 3px; } @@ -161,12 +161,12 @@ entry.invalid { /* ********** Playback Controls ********** */ #player-controls-album-artwork { - min-height: 70px; - min-width: 70px; + /*min-height: 70px; + min-width: 70px;*/ margin-right: 10px; } -#player-controls-bar #play-button { +.play-button-large { min-height: 45px; min-width: 35px; border-width: 1px; @@ -174,7 +174,7 @@ entry.invalid { } /* Make the play icon look centered. */ -#player-controls-bar #play-button image { +.play-button-large image { margin-left: 5px; margin-right: 5px; margin-top: 1px; @@ -182,7 +182,7 @@ entry.invalid { } #player-controls-bar #song-scrubber { - min-width: 400px; + min-width: 200px; } #player-controls-bar #volume-slider { @@ -194,6 +194,7 @@ entry.invalid { } #player-controls-bar #song-title { + min-width: 150px; margin-bottom: 3px; font-weight: bold; } diff --git a/sublime_music/ui/common/__init__.py b/sublime_music/ui/common/__init__.py index 8ba2f74..73f3179 100644 --- a/sublime_music/ui/common/__init__.py +++ b/sublime_music/ui/common/__init__.py @@ -3,6 +3,7 @@ from .icon_button import IconButton, IconMenuButton, IconToggleButton from .load_error import LoadError from .song_list_column import SongListColumn from .spinner_image import SpinnerImage +from .spinner_picture import SpinnerPicture __all__ = ( "AlbumWithSongs", @@ -12,4 +13,5 @@ __all__ = ( "LoadError", "SongListColumn", "SpinnerImage", + "SpinnerPicture", ) diff --git a/sublime_music/ui/common/album_with_songs.py b/sublime_music/ui/common/album_with_songs.py index 188a39c..b16dc8e 100644 --- a/sublime_music/ui/common/album_with_songs.py +++ b/sublime_music/ui/common/album_with_songs.py @@ -42,7 +42,7 @@ class AlbumWithSongs(Gtk.Box): image_size=cover_art_size, ) # 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(Gtk.Box(), True, True, 0) self.pack_start(box, False, False, 0) diff --git a/sublime_music/ui/common/icon_button.py b/sublime_music/ui/common/icon_button.py index 418c15b..fa43b5a 100644 --- a/sublime_music/ui/common/icon_button.py +++ b/sublime_music/ui/common/icon_button.py @@ -1,6 +1,6 @@ from typing import Any, Optional -from gi.repository import Gtk +from gi.repository import Gtk, GObject class IconButton(Gtk.Button): @@ -16,8 +16,10 @@ class IconButton(Gtk.Button): Gtk.Button.__init__(self, **kwargs) 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) box.pack_start(self.image, False, False, 0) @@ -30,7 +32,21 @@ class IconButton(Gtk.Button): self.add(box) self.set_tooltip_text(tooltip_text) + # TODO: Remove 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) @@ -46,8 +62,10 @@ class IconToggleButton(Gtk.ToggleButton): ): Gtk.ToggleButton.__init__(self, **kwargs) 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) box.add(self.image) @@ -60,15 +78,22 @@ class IconToggleButton(Gtk.ToggleButton): self.add(box) self.set_tooltip_text(tooltip_text) + # TODO: Remove 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) - def get_active(self) -> bool: - return super().get_active() - - def set_active(self, active: bool): - super().set_active(active) - class IconMenuButton(Gtk.MenuButton): def __init__( @@ -88,7 +113,8 @@ class IconMenuButton(Gtk.MenuButton): self.set_popover(popover) 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) box.add(self.image) diff --git a/sublime_music/ui/common/spinner_picture.py b/sublime_music/ui/common/spinner_picture.py new file mode 100644 index 0000000..1a698c8 --- /dev/null +++ b/sublime_music/ui/common/spinner_picture.py @@ -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() diff --git a/sublime_music/ui/main.py b/sublime_music/ui/main.py index ab858df..30eb4a8 100644 --- a/sublime_music/ui/main.py +++ b/sublime_music/ui/main.py @@ -1,9 +1,14 @@ +from datetime import timedelta from functools import partial from typing import Any, Callable, Dict, Optional, Set, Tuple 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 ( AdapterManager, @@ -46,7 +51,7 @@ class MainWindow(Gtk.ApplicationWindow): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.set_default_size(1342, 756) + # self.set_default_size(1342, 756) # Create the stack self.albums_panel = albums.AlbumsPanel() @@ -55,16 +60,16 @@ class MainWindow(Gtk.ApplicationWindow): self.playlists_panel = playlists.PlaylistsPanel() self.stack = self._create_stack( Albums=self.albums_panel, - Artists=self.artists_panel, - Browse=self.browse_panel, - Playlists=self.playlists_panel, + # Artists=self.artists_panel, + # Browse=self.browse_panel, + # Playlists=self.playlists_panel, ) self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) 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.add(self.stack) @@ -91,23 +96,66 @@ class MainWindow(Gtk.ApplicationWindow): self.notification_revealer.add(notification_box) notification_container.add_overlay(self.notification_revealer) - flowbox.pack_start(notification_container, True, True, 0) + flap.set_content(notification_container) - # Player Controls - self.player_controls = player_controls.PlayerControls() - self.player_controls.connect( + # Player state + self.player_manager = player_controls.Manager() + self.player_manager.connect( "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) ) - self.player_controls.connect( + self.player_manager.connect( "refresh-window", 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) @@ -362,7 +410,13 @@ class MainWindow(Gtk.ApplicationWindow): if hasattr(active_panel, "update"): 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): if progress.type == DownloadProgress.Type.QUEUED: @@ -519,7 +573,7 @@ class MainWindow(Gtk.ApplicationWindow): """ Configure the header bar for the window. """ - header = Gtk.HeaderBar() + header = Handy.HeaderBar() header.set_show_close_button(True) 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): self._hide_search() - if not self._event_in_widgets( - event, - self.player_controls.device_button, - self.player_controls.device_popover, - ): - self.player_controls.device_popover.popdown() + # if not self._event_in_widgets( + # event, + # self.player_controls.device_button, + # self.player_controls.device_popover, + # ): + # self.player_controls.device_popover.popdown() - if not self._event_in_widgets( - event, - self.player_controls.play_queue_button, - self.player_controls.play_queue_popover, - ): - self.player_controls.play_queue_popover.popdown() + # if not self._event_in_widgets( + # event, + # self.player_controls.play_queue_button, + # self.player_controls.play_queue_popover, + # ): + # self.player_controls.play_queue_popover.popdown() return False diff --git a/sublime_music/ui/player_controls.py b/sublime_music/ui/player_controls.py index 9a880f3..e69de29 100644 --- a/sublime_music/ui/player_controls.py +++ b/sublime_music/ui/player_controls.py @@ -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("Play Queue") - else: - song_label = util.pluralize("song", play_queue_len) - self.popover_label.set_markup( - f"Play Queue: {play_queue_len} {song_label}" - ) - - # TODO (#207) this is super freaking stupid inefficient. - # IDEAS: batch it, don't get the queue until requested - self.editing_play_queue_song_list = True - - 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"{title}\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="Devices", - 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="Play Queue", - use_markup=True, - halign=Gtk.Align.START, - margin=10, - ) - play_queue_popover_header.add(self.popover_label) - - self.load_play_queue_button = IconButton( - "folder-download-symbolic", "Load Queue from Server", margin=5 - ) - self.load_play_queue_button.set_action_name("app.update-play-queue-from-server") - play_queue_popover_header.pack_end(self.load_play_queue_button, False, False, 0) - - play_queue_popover_box.add(play_queue_popover_header) - - play_queue_loading_overlay = Gtk.Overlay() - play_queue_scrollbox = Gtk.ScrolledWindow( - min_content_height=600, - min_content_width=400, - ) - - 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 diff --git a/sublime_music/ui/player_controls/__init__.py b/sublime_music/ui/player_controls/__init__.py new file mode 100644 index 0000000..e1e4460 --- /dev/null +++ b/sublime_music/ui/player_controls/__init__.py @@ -0,0 +1,3 @@ +from .desktop import Desktop +from .mobile import MobileHandle, MobileFlap +from .manager import Manager diff --git a/sublime_music/ui/player_controls/common.py b/sublime_music/ui/player_controls/common.py new file mode 100644 index 0000000..f30d9b4 --- /dev/null +++ b/sublime_music/ui/player_controls/common.py @@ -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 diff --git a/sublime_music/ui/player_controls/desktop.py b/sublime_music/ui/player_controls/desktop.py new file mode 100644 index 0000000..73c76ee --- /dev/null +++ b/sublime_music/ui/player_controls/desktop.py @@ -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("Play Queue") + else: + song_label = util.pluralize("song", play_queue_len) + self.popover_label.set_markup( + f"Play Queue: {play_queue_len} {song_label}" + ) + + # TODO (#207) this is super freaking stupid inefficient. + # IDEAS: batch it, don't get the queue until requested + # self.editing_play_queue_song_list = True + + # self.editing_play_queue_song_list = False + + def set_cover_art(self, cover_art_filename: str, loading: bool): + self.album_art.set_from_file(cover_art_filename) + self.album_art.set_loading(loading) + + def on_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="Play Queue", + use_markup=True, + halign=Gtk.Align.START, + margin=10, + ) + play_queue_popover_header.add(self.popover_label) + + self.load_play_queue_button = IconButton( + "folder-download-symbolic", "Load Queue from Server", margin=5 + ) + self.load_play_queue_button.set_action_name("app.update-play-queue-from-server") + play_queue_popover_header.pack_end(self.load_play_queue_button, False, False, 0) + + play_queue_popover_box.add(play_queue_popover_header) + + play_queue_loading_overlay = Gtk.Overlay() + play_queue_scrollbox = Gtk.ScrolledWindow( + # min_content_height=600, + min_content_width=400, + propagate_natural_height=True, + ) + + self.play_queue_list = 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 diff --git a/sublime_music/ui/player_controls/manager.py b/sublime_music/ui/player_controls/manager.py new file mode 100644 index 0000000..27cdfdb --- /dev/null +++ b/sublime_music/ui/player_controls/manager.py @@ -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="Devices", + 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"{title}\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)) + diff --git a/sublime_music/ui/player_controls/mobile.py b/sublime_music/ui/player_controls/mobile.py new file mode 100644 index 0000000..a579b94 --- /dev/null +++ b/sublime_music/ui/player_controls/mobile.py @@ -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="Devices", + 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) diff --git a/sublime_music/ui/state.py b/sublime_music/ui/state.py index 90c601a..55aed9a 100644 --- a/sublime_music/ui/state.py +++ b/sublime_music/ui/state.py @@ -109,6 +109,11 @@ class UIState: self.current_notification = None 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): from sublime_music.players import PlayerManager