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