WIP
This commit is contained in:
@@ -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:
|
||||
|
@@ -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()
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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",
|
||||
)
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
63
sublime_music/ui/common/spinner_picture.py
Normal file
63
sublime_music/ui/common/spinner_picture.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from typing import Optional
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, Gtk
|
||||
|
||||
|
||||
class SpinnerPicture(Gtk.Overlay):
|
||||
def __init__(
|
||||
self,
|
||||
loading: bool = True,
|
||||
spinner_name: str = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""An picture with a loading overlay."""
|
||||
super().__init__()
|
||||
self.filename: Optional[str] = None
|
||||
self.pixbuf = None
|
||||
|
||||
self.drawing_area = Gtk.DrawingArea()
|
||||
self.drawing_area.connect("draw", self.expose)
|
||||
self.add_overlay(self.drawing_area)
|
||||
|
||||
self.spinner = Gtk.Spinner(
|
||||
name=spinner_name,
|
||||
active=loading,
|
||||
halign=Gtk.Align.CENTER,
|
||||
valign=Gtk.Align.CENTER,
|
||||
)
|
||||
self.add_overlay(self.spinner)
|
||||
|
||||
def set_from_file(self, filename: Optional[str]):
|
||||
"""Set the picture to the given filename."""
|
||||
filename = filename or None
|
||||
if self.filename == filename:
|
||||
return
|
||||
|
||||
self.filename = filename
|
||||
|
||||
if self.filename:
|
||||
self.pixbuf = GdkPixbuf.Pixbuf.new_from_file(self.filename)
|
||||
else:
|
||||
self.pixbuf = None
|
||||
|
||||
def set_loading(self, loading_status: bool):
|
||||
if loading_status:
|
||||
self.spinner.start()
|
||||
self.spinner.show()
|
||||
else:
|
||||
self.spinner.stop()
|
||||
self.spinner.hide()
|
||||
|
||||
def expose(self, area, cr):
|
||||
if self.pixbuf:
|
||||
pix_width = self.pixbuf.get_width()
|
||||
pix_height = self.pixbuf.get_height()
|
||||
alloc_width = self.get_allocated_width()
|
||||
alloc_height = self.get_allocated_height()
|
||||
|
||||
cr.translate(alloc_width / 2, alloc_height / 2);
|
||||
scale = max(alloc_width / pix_width, alloc_height / pix_height)
|
||||
cr.scale(scale, scale)
|
||||
cr.translate(-pix_width / 2, -pix_height / 2);
|
||||
Gdk.cairo_set_source_pixbuf(cr, self.pixbuf, 0, 0)
|
||||
cr.paint()
|
@@ -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
|
||||
|
||||
|
@@ -1,845 +0,0 @@
|
||||
import copy
|
||||
import math
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Dict, Optional, Set, Tuple
|
||||
|
||||
import bleach
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango
|
||||
|
||||
from . import util
|
||||
from .common import IconButton, IconToggleButton, SpinnerImage
|
||||
from .state import RepeatType
|
||||
from ..adapters import AdapterManager, Result, SongCacheStatus
|
||||
from ..adapters.api_objects import Song
|
||||
from ..config import AppConfiguration
|
||||
from ..util import resolve_path
|
||||
|
||||
|
||||
class PlayerControls(Gtk.ActionBar):
|
||||
"""
|
||||
Defines the player controls panel that appears at the bottom of the window.
|
||||
"""
|
||||
|
||||
__gsignals__ = {
|
||||
"song-scrub": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,)),
|
||||
"volume-change": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,)),
|
||||
"device-update": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str,)),
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
),
|
||||
"songs-removed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,)),
|
||||
"refresh-window": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, bool),
|
||||
),
|
||||
}
|
||||
editing: bool = False
|
||||
editing_play_queue_song_list: bool = False
|
||||
reordering_play_queue_song_list: bool = False
|
||||
current_song = None
|
||||
current_device = None
|
||||
current_playing_index: Optional[int] = None
|
||||
current_play_queue: Tuple[str, ...] = ()
|
||||
cover_art_update_order_token = 0
|
||||
play_queue_update_order_token = 0
|
||||
offline_mode = False
|
||||
|
||||
def __init__(self):
|
||||
Gtk.ActionBar.__init__(self)
|
||||
self.set_name("player-controls-bar")
|
||||
|
||||
song_display = self.create_song_display()
|
||||
playback_controls = self.create_playback_controls()
|
||||
play_queue_volume = self.create_play_queue_volume()
|
||||
|
||||
self.last_device_list_update = None
|
||||
|
||||
self.pack_start(song_display)
|
||||
self.set_center_widget(playback_controls)
|
||||
self.pack_end(play_queue_volume)
|
||||
|
||||
connecting_to_device_token = 0
|
||||
connecting_icon_index = 0
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
self.current_device = app_config.state.current_device
|
||||
self.update_device_list(app_config)
|
||||
|
||||
duration = (
|
||||
app_config.state.current_song.duration
|
||||
if app_config.state.current_song
|
||||
else None
|
||||
)
|
||||
song_stream_cache_progress = (
|
||||
app_config.state.song_stream_cache_progress
|
||||
if app_config.state.current_song
|
||||
else None
|
||||
)
|
||||
self.update_scrubber(
|
||||
app_config.state.song_progress, duration, song_stream_cache_progress
|
||||
)
|
||||
|
||||
icon = "pause" if app_config.state.playing else "start"
|
||||
self.play_button.set_icon(f"media-playback-{icon}-symbolic")
|
||||
self.play_button.set_tooltip_text(
|
||||
"Pause" if app_config.state.playing else "Play"
|
||||
)
|
||||
|
||||
has_current_song = app_config.state.current_song is not None
|
||||
has_next_song = False
|
||||
if app_config.state.repeat_type in (
|
||||
RepeatType.REPEAT_QUEUE,
|
||||
RepeatType.REPEAT_SONG,
|
||||
):
|
||||
has_next_song = True
|
||||
elif has_current_song:
|
||||
last_idx_in_queue = len(app_config.state.play_queue) - 1
|
||||
has_next_song = app_config.state.current_song_index < last_idx_in_queue
|
||||
|
||||
# Toggle button states.
|
||||
self.repeat_button.set_action_name(None)
|
||||
self.shuffle_button.set_action_name(None)
|
||||
repeat_on = app_config.state.repeat_type in (
|
||||
RepeatType.REPEAT_QUEUE,
|
||||
RepeatType.REPEAT_SONG,
|
||||
)
|
||||
self.repeat_button.set_active(repeat_on)
|
||||
self.repeat_button.set_icon(app_config.state.repeat_type.icon)
|
||||
self.shuffle_button.set_active(app_config.state.shuffle_on)
|
||||
self.repeat_button.set_action_name("app.repeat-press")
|
||||
self.shuffle_button.set_action_name("app.shuffle-press")
|
||||
|
||||
self.song_scrubber.set_sensitive(has_current_song)
|
||||
self.prev_button.set_sensitive(has_current_song)
|
||||
self.play_button.set_sensitive(has_current_song)
|
||||
self.next_button.set_sensitive(has_current_song and has_next_song)
|
||||
|
||||
self.connecting_to_device = app_config.state.connecting_to_device
|
||||
|
||||
def cycle_connecting(connecting_to_device_token: int):
|
||||
if (
|
||||
self.connecting_to_device_token != connecting_to_device_token
|
||||
or not self.connecting_to_device
|
||||
):
|
||||
return
|
||||
icon = f"chromecast-connecting-{self.connecting_icon_index}-symbolic"
|
||||
self.device_button.set_icon(icon)
|
||||
self.connecting_icon_index = (self.connecting_icon_index + 1) % 3
|
||||
GLib.timeout_add(350, cycle_connecting, connecting_to_device_token)
|
||||
|
||||
icon = ""
|
||||
if app_config.state.connecting_to_device:
|
||||
icon = "-connecting-0"
|
||||
self.connecting_icon_index = 0
|
||||
self.connecting_to_device_token += 1
|
||||
GLib.timeout_add(350, cycle_connecting, self.connecting_to_device_token)
|
||||
elif app_config.state.current_device != "this device":
|
||||
icon = "-connected"
|
||||
|
||||
self.device_button.set_icon(f"chromecast{icon}-symbolic")
|
||||
|
||||
# Volume button and slider
|
||||
if app_config.state.is_muted:
|
||||
icon_name = "muted"
|
||||
elif app_config.state.volume < 30:
|
||||
icon_name = "low"
|
||||
elif app_config.state.volume < 70:
|
||||
icon_name = "medium"
|
||||
else:
|
||||
icon_name = "high"
|
||||
|
||||
self.volume_mute_toggle.set_icon(f"audio-volume-{icon_name}-symbolic")
|
||||
|
||||
self.editing = True
|
||||
self.volume_slider.set_value(
|
||||
0 if app_config.state.is_muted else app_config.state.volume
|
||||
)
|
||||
self.editing = False
|
||||
|
||||
# Update the current song information.
|
||||
# TODO (#126): add popup of bigger cover art photo here
|
||||
if app_config.state.current_song is not None:
|
||||
self.cover_art_update_order_token += 1
|
||||
self.update_cover_art(
|
||||
app_config.state.current_song.cover_art,
|
||||
order_token=self.cover_art_update_order_token,
|
||||
)
|
||||
|
||||
self.song_title.set_markup(
|
||||
bleach.clean(app_config.state.current_song.title)
|
||||
)
|
||||
# TODO (#71): use walrus once MYPY gets its act together
|
||||
album = app_config.state.current_song.album
|
||||
artist = app_config.state.current_song.artist
|
||||
if album:
|
||||
self.album_name.set_markup(bleach.clean(album.name))
|
||||
self.artist_name.show()
|
||||
else:
|
||||
self.album_name.set_markup("")
|
||||
self.album_name.hide()
|
||||
if artist:
|
||||
self.artist_name.set_markup(bleach.clean(artist.name))
|
||||
self.artist_name.show()
|
||||
else:
|
||||
self.artist_name.set_markup("")
|
||||
self.artist_name.hide()
|
||||
else:
|
||||
# Clear out the cover art and song tite if no song
|
||||
self.album_art.set_from_file(None)
|
||||
self.album_art.set_loading(False)
|
||||
self.song_title.set_markup("")
|
||||
self.album_name.set_markup("")
|
||||
self.artist_name.set_markup("")
|
||||
|
||||
self.load_play_queue_button.set_sensitive(not self.offline_mode)
|
||||
if app_config.state.loading_play_queue:
|
||||
self.play_queue_spinner.start()
|
||||
self.play_queue_spinner.show()
|
||||
else:
|
||||
self.play_queue_spinner.stop()
|
||||
self.play_queue_spinner.hide()
|
||||
|
||||
# Short circuit if no changes to the play queue
|
||||
force |= self.offline_mode != app_config.offline_mode
|
||||
self.offline_mode = app_config.offline_mode
|
||||
if not force and (
|
||||
self.current_play_queue == app_config.state.play_queue
|
||||
and self.current_playing_index == app_config.state.current_song_index
|
||||
):
|
||||
return
|
||||
self.current_play_queue = app_config.state.play_queue
|
||||
self.current_playing_index = app_config.state.current_song_index
|
||||
|
||||
# Set the Play Queue button popup.
|
||||
play_queue_len = len(app_config.state.play_queue)
|
||||
if play_queue_len == 0:
|
||||
self.popover_label.set_markup("<b>Play Queue</b>")
|
||||
else:
|
||||
song_label = util.pluralize("song", play_queue_len)
|
||||
self.popover_label.set_markup(
|
||||
f"<b>Play Queue:</b> {play_queue_len} {song_label}"
|
||||
)
|
||||
|
||||
# TODO (#207) this is super freaking stupid inefficient.
|
||||
# IDEAS: batch it, don't get the queue until requested
|
||||
self.editing_play_queue_song_list = True
|
||||
|
||||
new_store = []
|
||||
|
||||
def calculate_label(song_details: Song) -> str:
|
||||
title = song_details.title
|
||||
# TODO (#71): use walrus once MYPY gets its act together
|
||||
# album = a.name if (a := song_details.album) else None
|
||||
# artist = a.name if (a := song_details.artist) else None
|
||||
album = song_details.album.name if song_details.album else None
|
||||
artist = song_details.artist.name if song_details.artist else None
|
||||
return bleach.clean(f"<b>{title}</b>\n{util.dot_join(album, artist)}")
|
||||
|
||||
def make_idle_index_capturing_function(
|
||||
idx: int,
|
||||
order_tok: int,
|
||||
fn: Callable[[int, int, Any], None],
|
||||
) -> Callable[[Result], None]:
|
||||
return lambda f: GLib.idle_add(fn, idx, order_tok, f.result())
|
||||
|
||||
def on_cover_art_future_done(
|
||||
idx: int,
|
||||
order_token: int,
|
||||
cover_art_filename: str,
|
||||
):
|
||||
if order_token != self.play_queue_update_order_token:
|
||||
return
|
||||
|
||||
self.play_queue_store[idx][1] = cover_art_filename
|
||||
|
||||
def get_cover_art_filename_or_create_future(
|
||||
cover_art_id: Optional[str], idx: int, order_token: int
|
||||
) -> Optional[str]:
|
||||
cover_art_result = AdapterManager.get_cover_art_uri(cover_art_id, "file")
|
||||
if not cover_art_result.data_is_available:
|
||||
cover_art_result.add_done_callback(
|
||||
make_idle_index_capturing_function(
|
||||
idx, order_token, on_cover_art_future_done
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
# The cover art is already cached.
|
||||
return cover_art_result.result()
|
||||
|
||||
def on_song_details_future_done(idx: int, order_token: int, song_details: Song):
|
||||
if order_token != self.play_queue_update_order_token:
|
||||
return
|
||||
|
||||
self.play_queue_store[idx][2] = calculate_label(song_details)
|
||||
|
||||
# Cover Art
|
||||
filename = get_cover_art_filename_or_create_future(
|
||||
song_details.cover_art, idx, order_token
|
||||
)
|
||||
if filename:
|
||||
self.play_queue_store[idx][1] = filename
|
||||
|
||||
current_play_queue = [x[-1] for x in self.play_queue_store]
|
||||
if app_config.state.play_queue != current_play_queue:
|
||||
self.play_queue_update_order_token += 1
|
||||
|
||||
song_details_results = []
|
||||
for i, (song_id, cached_status) in enumerate(
|
||||
zip(
|
||||
app_config.state.play_queue,
|
||||
AdapterManager.get_cached_statuses(app_config.state.play_queue),
|
||||
)
|
||||
):
|
||||
song_details_result = AdapterManager.get_song_details(song_id)
|
||||
|
||||
cover_art_filename = ""
|
||||
label = "\n"
|
||||
|
||||
if song_details_result.data_is_available:
|
||||
# We have the details of the song already cached.
|
||||
song_details = song_details_result.result()
|
||||
label = calculate_label(song_details)
|
||||
|
||||
filename = get_cover_art_filename_or_create_future(
|
||||
song_details.cover_art, i, self.play_queue_update_order_token
|
||||
)
|
||||
if filename:
|
||||
cover_art_filename = filename
|
||||
else:
|
||||
song_details_results.append((i, song_details_result))
|
||||
|
||||
new_store.append(
|
||||
[
|
||||
(
|
||||
not self.offline_mode
|
||||
or cached_status
|
||||
in (SongCacheStatus.CACHED, SongCacheStatus.PERMANENTLY_CACHED)
|
||||
),
|
||||
cover_art_filename,
|
||||
label,
|
||||
i == app_config.state.current_song_index,
|
||||
song_id,
|
||||
]
|
||||
)
|
||||
|
||||
util.diff_song_store(self.play_queue_store, new_store)
|
||||
|
||||
# Do this after the diff to avoid race conditions.
|
||||
for idx, song_details_result in song_details_results:
|
||||
song_details_result.add_done_callback(
|
||||
make_idle_index_capturing_function(
|
||||
idx,
|
||||
self.play_queue_update_order_token,
|
||||
on_song_details_future_done,
|
||||
)
|
||||
)
|
||||
|
||||
self.editing_play_queue_song_list = False
|
||||
|
||||
@util.async_callback(
|
||||
partial(AdapterManager.get_cover_art_uri, scheme="file"),
|
||||
before_download=lambda self: self.album_art.set_loading(True),
|
||||
on_failure=lambda self, e: self.album_art.set_loading(False),
|
||||
)
|
||||
def update_cover_art(
|
||||
self,
|
||||
cover_art_filename: str,
|
||||
app_config: AppConfiguration,
|
||||
force: bool = False,
|
||||
order_token: int = None,
|
||||
is_partial: bool = False,
|
||||
):
|
||||
if order_token != self.cover_art_update_order_token:
|
||||
return
|
||||
|
||||
self.album_art.set_from_file(cover_art_filename)
|
||||
self.album_art.set_loading(False)
|
||||
|
||||
def update_scrubber(
|
||||
self,
|
||||
current: Optional[timedelta],
|
||||
duration: Optional[timedelta],
|
||||
song_stream_cache_progress: Optional[timedelta],
|
||||
):
|
||||
if current is None or duration is None:
|
||||
self.song_duration_label.set_text("-:--")
|
||||
self.song_progress_label.set_text("-:--")
|
||||
self.song_scrubber.set_value(0)
|
||||
return
|
||||
|
||||
percent_complete = current / duration * 100
|
||||
|
||||
if not self.editing:
|
||||
self.song_scrubber.set_value(percent_complete)
|
||||
|
||||
self.song_scrubber.set_show_fill_level(song_stream_cache_progress is not None)
|
||||
if song_stream_cache_progress is not None:
|
||||
percent_cached = song_stream_cache_progress / duration * 100
|
||||
self.song_scrubber.set_fill_level(percent_cached)
|
||||
|
||||
self.song_duration_label.set_text(util.format_song_duration(duration))
|
||||
self.song_progress_label.set_text(
|
||||
util.format_song_duration(math.floor(current.total_seconds()))
|
||||
)
|
||||
|
||||
def on_volume_change(self, scale: Gtk.Scale):
|
||||
if not self.editing:
|
||||
self.emit("volume-change", scale.get_value())
|
||||
|
||||
def on_play_queue_click(self, _: Any):
|
||||
if self.play_queue_popover.is_visible():
|
||||
self.play_queue_popover.popdown()
|
||||
else:
|
||||
# TODO (#88): scroll the currently playing song into view.
|
||||
self.play_queue_popover.popup()
|
||||
self.play_queue_popover.show_all()
|
||||
|
||||
# Hide the load play queue button if the adapter can't do that.
|
||||
if not AdapterManager.can_get_play_queue():
|
||||
self.load_play_queue_button.hide()
|
||||
|
||||
def on_song_activated(self, t: Any, idx: Gtk.TreePath, c: Any):
|
||||
if not self.play_queue_store[idx[0]][0]:
|
||||
return
|
||||
# The song ID is in the last column of the model.
|
||||
self.emit(
|
||||
"song-clicked",
|
||||
idx.get_indices()[0],
|
||||
[m[-1] for m in self.play_queue_store],
|
||||
{"no_reshuffle": True},
|
||||
)
|
||||
|
||||
_current_player_id = None
|
||||
_current_available_players: Dict[type, Set[Tuple[str, str]]] = {}
|
||||
|
||||
def update_device_list(self, app_config: AppConfiguration):
|
||||
if (
|
||||
self._current_available_players == app_config.state.available_players
|
||||
and self._current_player_id == app_config.state.current_device
|
||||
):
|
||||
return
|
||||
|
||||
self._current_player_id = app_config.state.current_device
|
||||
self._current_available_players = copy.deepcopy(
|
||||
app_config.state.available_players
|
||||
)
|
||||
for c in self.device_list.get_children():
|
||||
self.device_list.remove(c)
|
||||
|
||||
for i, (player_type, players) in enumerate(
|
||||
app_config.state.available_players.items()
|
||||
):
|
||||
if len(players) == 0:
|
||||
continue
|
||||
if i > 0:
|
||||
self.device_list.add(
|
||||
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
)
|
||||
self.device_list.add(
|
||||
Gtk.Label(
|
||||
label=f"{player_type.name} Devices",
|
||||
halign=Gtk.Align.START,
|
||||
name="device-type-section-title",
|
||||
)
|
||||
)
|
||||
|
||||
for player_id, player_name in sorted(players, key=lambda p: p[1]):
|
||||
icon = (
|
||||
"audio-volume-high-symbolic"
|
||||
if player_id == self.current_device
|
||||
else None
|
||||
)
|
||||
button = IconButton(icon, label=player_name)
|
||||
button.get_style_context().add_class("menu-button")
|
||||
button.connect(
|
||||
"clicked",
|
||||
lambda _, player_id: self.emit("device-update", player_id),
|
||||
player_id,
|
||||
)
|
||||
self.device_list.add(button)
|
||||
|
||||
self.device_list.show_all()
|
||||
|
||||
def on_device_click(self, _: Any):
|
||||
if self.device_popover.is_visible():
|
||||
self.device_popover.popdown()
|
||||
else:
|
||||
self.device_popover.popup()
|
||||
self.device_popover.show_all()
|
||||
|
||||
def on_play_queue_button_press(self, tree: Any, event: Gdk.EventButton) -> bool:
|
||||
if event.button == 3: # Right click
|
||||
clicked_path = tree.get_path_at_pos(event.x, event.y)
|
||||
|
||||
store, paths = tree.get_selection().get_selected_rows()
|
||||
allow_deselect = False
|
||||
|
||||
def on_download_state_change(song_id: str):
|
||||
# Refresh the entire window (no force) because the song could
|
||||
# be in a list anywhere in the window.
|
||||
self.emit("refresh-window", {}, False)
|
||||
|
||||
# Use the new selection instead of the old one for calculating what
|
||||
# to do the right click on.
|
||||
if clicked_path[0] not in paths:
|
||||
paths = [clicked_path[0]]
|
||||
allow_deselect = True
|
||||
|
||||
song_ids = [self.play_queue_store[p][-1] for p in paths]
|
||||
|
||||
remove_text = (
|
||||
"Remove " + util.pluralize("song", len(song_ids)) + " from queue"
|
||||
)
|
||||
|
||||
def on_remove_songs_click(_: Any):
|
||||
self.emit("songs-removed", [p.get_indices()[0] for p in paths])
|
||||
|
||||
util.show_song_popover(
|
||||
song_ids,
|
||||
event.x,
|
||||
event.y,
|
||||
tree,
|
||||
self.offline_mode,
|
||||
on_download_state_change=on_download_state_change,
|
||||
extra_menu_items=[
|
||||
(Gtk.ModelButton(text=remove_text), on_remove_songs_click),
|
||||
],
|
||||
)
|
||||
|
||||
# If the click was on a selected row, don't deselect anything.
|
||||
if not allow_deselect:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def on_play_queue_model_row_move(self, *args):
|
||||
# If we are programatically editing the song list, don't do anything.
|
||||
if self.editing_play_queue_song_list:
|
||||
return
|
||||
|
||||
# We get both a delete and insert event, I think it's deterministic
|
||||
# which one comes first, but just in case, we have this
|
||||
# reordering_play_queue_song_list flag.
|
||||
if self.reordering_play_queue_song_list:
|
||||
currently_playing_index = [
|
||||
i for i, s in enumerate(self.play_queue_store) if s[3] # playing
|
||||
][0]
|
||||
self.emit(
|
||||
"refresh-window",
|
||||
{
|
||||
"current_song_index": currently_playing_index,
|
||||
"play_queue": tuple(s[-1] for s in self.play_queue_store),
|
||||
},
|
||||
False,
|
||||
)
|
||||
self.reordering_play_queue_song_list = False
|
||||
else:
|
||||
self.reordering_play_queue_song_list = True
|
||||
|
||||
def create_song_display(self) -> Gtk.Box:
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.album_art = SpinnerImage(
|
||||
image_name="player-controls-album-artwork",
|
||||
image_size=70,
|
||||
)
|
||||
box.pack_start(self.album_art, False, False, 0)
|
||||
|
||||
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
details_box.pack_start(Gtk.Box(), True, True, 0)
|
||||
|
||||
def make_label(name: str) -> Gtk.Label:
|
||||
return Gtk.Label(
|
||||
name=name,
|
||||
halign=Gtk.Align.START,
|
||||
xalign=0,
|
||||
use_markup=True,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
)
|
||||
|
||||
self.song_title = make_label("song-title")
|
||||
details_box.add(self.song_title)
|
||||
|
||||
self.album_name = make_label("album-name")
|
||||
details_box.add(self.album_name)
|
||||
|
||||
self.artist_name = make_label("artist-name")
|
||||
details_box.add(self.artist_name)
|
||||
|
||||
details_box.pack_start(Gtk.Box(), True, True, 0)
|
||||
box.pack_start(details_box, False, False, 5)
|
||||
|
||||
return box
|
||||
|
||||
def create_playback_controls(self) -> Gtk.Box:
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
# Scrubber and song progress/length labels
|
||||
scrubber_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.song_progress_label = Gtk.Label(label="-:--")
|
||||
scrubber_box.pack_start(self.song_progress_label, False, False, 5)
|
||||
|
||||
self.song_scrubber = Gtk.Scale.new_with_range(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5
|
||||
)
|
||||
self.song_scrubber.set_name("song-scrubber")
|
||||
self.song_scrubber.set_draw_value(False)
|
||||
self.song_scrubber.set_restrict_to_fill_level(False)
|
||||
self.song_scrubber.connect(
|
||||
"change-value", lambda s, t, v: self.emit("song-scrub", v)
|
||||
)
|
||||
scrubber_box.pack_start(self.song_scrubber, True, True, 0)
|
||||
|
||||
self.song_duration_label = Gtk.Label(label="-:--")
|
||||
scrubber_box.pack_start(self.song_duration_label, False, False, 5)
|
||||
|
||||
box.add(scrubber_box)
|
||||
|
||||
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
buttons.pack_start(Gtk.Box(), True, True, 0)
|
||||
|
||||
# Repeat button
|
||||
repeat_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.repeat_button = IconToggleButton(
|
||||
"media-playlist-repeat", "Switch between repeat modes"
|
||||
)
|
||||
self.repeat_button.set_action_name("app.repeat-press")
|
||||
repeat_button_box.pack_start(Gtk.Box(), True, True, 0)
|
||||
repeat_button_box.pack_start(self.repeat_button, False, False, 0)
|
||||
repeat_button_box.pack_start(Gtk.Box(), True, True, 0)
|
||||
buttons.pack_start(repeat_button_box, False, False, 5)
|
||||
|
||||
# Previous button
|
||||
self.prev_button = IconButton(
|
||||
"media-skip-backward-symbolic",
|
||||
"Go to previous song",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
self.prev_button.set_action_name("app.prev-track")
|
||||
buttons.pack_start(self.prev_button, False, False, 5)
|
||||
|
||||
# Play button
|
||||
self.play_button = IconButton(
|
||||
"media-playback-start-symbolic",
|
||||
"Play",
|
||||
relief=True,
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
self.play_button.set_name("play-button")
|
||||
self.play_button.set_action_name("app.play-pause")
|
||||
buttons.pack_start(self.play_button, False, False, 0)
|
||||
|
||||
# Next button
|
||||
self.next_button = IconButton(
|
||||
"media-skip-forward-symbolic",
|
||||
"Go to next song",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
self.next_button.set_action_name("app.next-track")
|
||||
buttons.pack_start(self.next_button, False, False, 5)
|
||||
|
||||
# Shuffle button
|
||||
shuffle_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.shuffle_button = IconToggleButton(
|
||||
"media-playlist-shuffle-symbolic", "Toggle playlist shuffling"
|
||||
)
|
||||
self.shuffle_button.set_action_name("app.shuffle-press")
|
||||
shuffle_button_box.pack_start(Gtk.Box(), True, True, 0)
|
||||
shuffle_button_box.pack_start(self.shuffle_button, False, False, 0)
|
||||
shuffle_button_box.pack_start(Gtk.Box(), True, True, 0)
|
||||
buttons.pack_start(shuffle_button_box, False, False, 5)
|
||||
|
||||
buttons.pack_start(Gtk.Box(), True, True, 0)
|
||||
box.add(buttons)
|
||||
|
||||
return box
|
||||
|
||||
def create_play_queue_volume(self) -> Gtk.Box:
|
||||
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
vbox.pack_start(Gtk.Box(), True, True, 0)
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
# Device button (for chromecast)
|
||||
self.device_button = IconButton(
|
||||
"chromecast-symbolic",
|
||||
"Show available audio output devices",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
self.device_button.connect("clicked", self.on_device_click)
|
||||
box.pack_start(self.device_button, False, True, 5)
|
||||
|
||||
self.device_popover = Gtk.PopoverMenu(modal=False, name="device-popover")
|
||||
self.device_popover.set_relative_to(self.device_button)
|
||||
|
||||
device_popover_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
name="device-popover-box",
|
||||
)
|
||||
device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.popover_label = Gtk.Label(
|
||||
label="<b>Devices</b>",
|
||||
use_markup=True,
|
||||
halign=Gtk.Align.START,
|
||||
margin=5,
|
||||
)
|
||||
device_popover_header.add(self.popover_label)
|
||||
|
||||
refresh_devices = IconButton("view-refresh-symbolic", "Refresh device list")
|
||||
refresh_devices.set_action_name("app.refresh-devices")
|
||||
device_popover_header.pack_end(refresh_devices, False, False, 0)
|
||||
|
||||
device_popover_box.add(device_popover_header)
|
||||
|
||||
device_list_and_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
device_list_and_loading.add(self.device_list)
|
||||
|
||||
device_popover_box.pack_end(device_list_and_loading, True, True, 0)
|
||||
|
||||
self.device_popover.add(device_popover_box)
|
||||
|
||||
# Play Queue button
|
||||
self.play_queue_button = IconButton(
|
||||
"view-list-symbolic",
|
||||
"Open play queue",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
self.play_queue_button.connect("clicked", self.on_play_queue_click)
|
||||
box.pack_start(self.play_queue_button, False, True, 5)
|
||||
|
||||
self.play_queue_popover = Gtk.PopoverMenu(modal=False, name="up-next-popover")
|
||||
self.play_queue_popover.set_relative_to(self.play_queue_button)
|
||||
|
||||
play_queue_popover_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
play_queue_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.popover_label = Gtk.Label(
|
||||
label="<b>Play Queue</b>",
|
||||
use_markup=True,
|
||||
halign=Gtk.Align.START,
|
||||
margin=10,
|
||||
)
|
||||
play_queue_popover_header.add(self.popover_label)
|
||||
|
||||
self.load_play_queue_button = IconButton(
|
||||
"folder-download-symbolic", "Load Queue from Server", margin=5
|
||||
)
|
||||
self.load_play_queue_button.set_action_name("app.update-play-queue-from-server")
|
||||
play_queue_popover_header.pack_end(self.load_play_queue_button, False, False, 0)
|
||||
|
||||
play_queue_popover_box.add(play_queue_popover_header)
|
||||
|
||||
play_queue_loading_overlay = Gtk.Overlay()
|
||||
play_queue_scrollbox = Gtk.ScrolledWindow(
|
||||
min_content_height=600,
|
||||
min_content_width=400,
|
||||
)
|
||||
|
||||
self.play_queue_store = Gtk.ListStore(
|
||||
bool, # playable
|
||||
str, # image filename
|
||||
str, # title, album, artist
|
||||
bool, # playing
|
||||
str, # song ID
|
||||
)
|
||||
self.play_queue_list = Gtk.TreeView(
|
||||
model=self.play_queue_store,
|
||||
reorderable=True,
|
||||
headers_visible=False,
|
||||
)
|
||||
selection = self.play_queue_list.get_selection()
|
||||
selection.set_mode(Gtk.SelectionMode.MULTIPLE)
|
||||
selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
|
||||
|
||||
# Album Art column. This function defines what image to use for the play queue
|
||||
# song icon.
|
||||
def filename_to_pixbuf(
|
||||
column: Any,
|
||||
cell: Gtk.CellRendererPixbuf,
|
||||
model: Gtk.ListStore,
|
||||
tree_iter: Gtk.TreeIter,
|
||||
flags: Any,
|
||||
):
|
||||
cell.set_property("sensitive", model.get_value(tree_iter, 0))
|
||||
filename = model.get_value(tree_iter, 1)
|
||||
if not filename:
|
||||
cell.set_property("icon_name", "")
|
||||
return
|
||||
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename, 50, 50, True)
|
||||
|
||||
# If this is the playing song, then overlay the play icon.
|
||||
if model.get_value(tree_iter, 3):
|
||||
play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file(
|
||||
str(resolve_path("ui/images/play-queue-play.png"))
|
||||
)
|
||||
|
||||
play_overlay_pixbuf.composite(
|
||||
pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 200
|
||||
)
|
||||
|
||||
cell.set_property("pixbuf", pixbuf)
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf()
|
||||
renderer.set_fixed_size(55, 60)
|
||||
column = Gtk.TreeViewColumn("", renderer)
|
||||
column.set_cell_data_func(renderer, filename_to_pixbuf)
|
||||
column.set_resizable(True)
|
||||
self.play_queue_list.append_column(column)
|
||||
|
||||
renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END)
|
||||
column = Gtk.TreeViewColumn("", renderer, markup=2, sensitive=0)
|
||||
self.play_queue_list.append_column(column)
|
||||
|
||||
self.play_queue_list.connect("row-activated", self.on_song_activated)
|
||||
self.play_queue_list.connect(
|
||||
"button-press-event", self.on_play_queue_button_press
|
||||
)
|
||||
|
||||
# Set up drag-and-drop on the song list for editing the order of the
|
||||
# playlist.
|
||||
self.play_queue_store.connect("row-inserted", self.on_play_queue_model_row_move)
|
||||
self.play_queue_store.connect("row-deleted", self.on_play_queue_model_row_move)
|
||||
|
||||
play_queue_scrollbox.add(self.play_queue_list)
|
||||
play_queue_loading_overlay.add(play_queue_scrollbox)
|
||||
|
||||
self.play_queue_spinner = Gtk.Spinner(
|
||||
name="play-queue-spinner",
|
||||
active=False,
|
||||
halign=Gtk.Align.CENTER,
|
||||
valign=Gtk.Align.CENTER,
|
||||
)
|
||||
play_queue_loading_overlay.add_overlay(self.play_queue_spinner)
|
||||
play_queue_popover_box.pack_end(play_queue_loading_overlay, True, True, 0)
|
||||
|
||||
self.play_queue_popover.add(play_queue_popover_box)
|
||||
|
||||
# Volume mute toggle
|
||||
self.volume_mute_toggle = IconButton(
|
||||
"audio-volume-high-symbolic", "Toggle mute"
|
||||
)
|
||||
self.volume_mute_toggle.set_action_name("app.mute-toggle")
|
||||
box.pack_start(self.volume_mute_toggle, False, True, 0)
|
||||
|
||||
# Volume slider
|
||||
self.volume_slider = Gtk.Scale.new_with_range(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5
|
||||
)
|
||||
self.volume_slider.set_name("volume-slider")
|
||||
self.volume_slider.set_draw_value(False)
|
||||
self.volume_slider.connect("value-changed", self.on_volume_change)
|
||||
box.pack_start(self.volume_slider, True, True, 0)
|
||||
|
||||
vbox.pack_start(box, False, True, 0)
|
||||
vbox.pack_start(Gtk.Box(), True, True, 0)
|
||||
return vbox
|
||||
|
3
sublime_music/ui/player_controls/__init__.py
Normal file
3
sublime_music/ui/player_controls/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .desktop import Desktop
|
||||
from .mobile import MobileHandle, MobileFlap
|
||||
from .manager import Manager
|
81
sublime_music/ui/player_controls/common.py
Normal file
81
sublime_music/ui/player_controls/common.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from ..common import IconButton, IconToggleButton, SpinnerImage, SpinnerPicture
|
||||
from .manager import Manager
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango, Handy
|
||||
|
||||
def create_play_button(manager: Manager, large=True, **kwargs):
|
||||
button = IconButton(
|
||||
"media-playback-start-symbolic",
|
||||
"Play",
|
||||
relief=large,
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if large:
|
||||
button.get_style_context().add_class("play-button-large")
|
||||
|
||||
button.set_action_name("app.play-pause")
|
||||
manager.bind_property("has-song", button, "sensitive")
|
||||
manager.bind_property("play-button-icon", button, "icon-name")
|
||||
manager.bind_property("play-button-tooltip", button, "tooltip-text")
|
||||
|
||||
return button
|
||||
|
||||
def create_repeat_button(manager: Manager, **kwargs):
|
||||
button = IconToggleButton("media-playlist-repeat", "Switch between repeat modes", **kwargs)
|
||||
button.set_action_name("app.repeat-press")
|
||||
manager.bind_property("repeat-button-icon", button, "icon-name")
|
||||
|
||||
def on_active_change(*_):
|
||||
if manager.repeat_button_active != button.get_active():
|
||||
# Don't run the action, just update visual state
|
||||
button.set_action_name(None)
|
||||
button.set_active(manager.repeat_button_active)
|
||||
button.set_action_name("app.repeat-press")
|
||||
manager.connect("notify::repeat-button-active", on_active_change)
|
||||
|
||||
return button
|
||||
|
||||
def create_shuffle_button(manager: Manager, **kwargs):
|
||||
button = IconToggleButton("media-playlist-shuffle-symbolic", "Toggle playlist shuffling", **kwargs)
|
||||
button.set_action_name("app.shuffle-press")
|
||||
|
||||
def on_active_change(*_):
|
||||
if manager.shuffle_button_active != button.get_active():
|
||||
# Don't run the action, just update visual state
|
||||
button.set_action_name(None)
|
||||
button.set_active(manager.shuffle_button_active)
|
||||
button.set_action_name("app.shuffle-press")
|
||||
manager.connect("notify::shuffle-button-active", on_active_change)
|
||||
|
||||
return button
|
||||
|
||||
def create_next_button(manager: Manager, **kwargs):
|
||||
button = IconButton(
|
||||
"media-skip-forward-symbolic",
|
||||
"Go to next song",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
**kwargs,
|
||||
)
|
||||
button.set_action_name("app.next-track")
|
||||
manager.bind_property("has-song", button, "sensitive")
|
||||
|
||||
return button
|
||||
|
||||
def create_prev_button(manager: Manager, **kwargs):
|
||||
button = IconButton(
|
||||
"media-skip-backward-symbolic",
|
||||
"Go to previous song",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
**kwargs,
|
||||
)
|
||||
button.set_action_name("app.prev-track")
|
||||
manager.bind_property("has-song", button, "sensitive")
|
||||
|
||||
return button
|
||||
|
||||
def create_label(manager: Manager, property: str, **kwargs):
|
||||
label = Gtk.Label(**kwargs)
|
||||
manager.bind_property(property, label, "label")
|
||||
return label
|
485
sublime_music/ui/player_controls/desktop.py
Normal file
485
sublime_music/ui/player_controls/desktop.py
Normal file
@@ -0,0 +1,485 @@
|
||||
import copy
|
||||
import math
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Dict, Optional, Set, Tuple
|
||||
|
||||
import bleach
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango
|
||||
|
||||
from .. import util
|
||||
from ..common import IconButton, IconToggleButton, SpinnerImage
|
||||
from ..state import RepeatType
|
||||
from ...adapters import AdapterManager, Result, SongCacheStatus
|
||||
from ...adapters.api_objects import Song
|
||||
from ...config import AppConfiguration
|
||||
from ...util import resolve_path
|
||||
from . import common
|
||||
|
||||
|
||||
class Desktop(Gtk.Box):
|
||||
"""
|
||||
Defines the player controls panel that appears at the bottom of the window
|
||||
on the desktop view.
|
||||
"""
|
||||
|
||||
editing: bool = False
|
||||
# editing_play_queue_song_list: bool = False
|
||||
reordering_play_queue_song_list: bool = False
|
||||
current_song = None
|
||||
current_device = None
|
||||
current_playing_index: Optional[int] = None
|
||||
current_play_queue: Tuple[str, ...] = ()
|
||||
# play_queue_update_order_token = 0
|
||||
offline_mode = False
|
||||
|
||||
def __init__(self, state):
|
||||
super().__init__(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
self.set_name("player-controls-bar")
|
||||
|
||||
self.state = state
|
||||
self.state.add_control(self)
|
||||
|
||||
song_display = self.create_song_display()
|
||||
playback_controls = self.create_playback_controls()
|
||||
play_queue_volume = self.create_play_queue_volume()
|
||||
|
||||
self.pack_start(song_display, False, False, 0)
|
||||
self.set_center_widget(playback_controls)
|
||||
self.child_set_property(playback_controls, "expand", True)
|
||||
self.pack_end(play_queue_volume, False, False, 0)
|
||||
|
||||
connecting_to_device_token = 0
|
||||
connecting_icon_index = 0
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
self.current_device = app_config.state.current_device
|
||||
|
||||
has_current_song = app_config.state.current_song is not None
|
||||
has_next_song = False
|
||||
if app_config.state.repeat_type in (
|
||||
RepeatType.REPEAT_QUEUE,
|
||||
RepeatType.REPEAT_SONG,
|
||||
):
|
||||
has_next_song = True
|
||||
elif has_current_song:
|
||||
last_idx_in_queue = len(app_config.state.play_queue) - 1
|
||||
has_next_song = app_config.state.current_song_index < last_idx_in_queue
|
||||
|
||||
# Toggle button states.
|
||||
self.song_scrubber.set_sensitive(has_current_song)
|
||||
|
||||
self.connecting_to_device = app_config.state.connecting_to_device
|
||||
|
||||
def cycle_connecting(connecting_to_device_token: int):
|
||||
if (
|
||||
self.connecting_to_device_token != connecting_to_device_token
|
||||
or not self.connecting_to_device
|
||||
):
|
||||
return
|
||||
icon = f"chromecast-connecting-{self.connecting_icon_index}-symbolic"
|
||||
self.device_button.set_icon(icon)
|
||||
self.connecting_icon_index = (self.connecting_icon_index + 1) % 3
|
||||
GLib.timeout_add(350, cycle_connecting, connecting_to_device_token)
|
||||
|
||||
icon = ""
|
||||
if app_config.state.connecting_to_device:
|
||||
icon = "-connecting-0"
|
||||
self.connecting_icon_index = 0
|
||||
self.connecting_to_device_token += 1
|
||||
GLib.timeout_add(350, cycle_connecting, self.connecting_to_device_token)
|
||||
elif app_config.state.current_device != "this device":
|
||||
icon = "-connected"
|
||||
|
||||
self.device_button.set_icon(f"chromecast{icon}-symbolic")
|
||||
|
||||
# Volume button and slider
|
||||
if app_config.state.is_muted:
|
||||
icon_name = "muted"
|
||||
elif app_config.state.volume < 30:
|
||||
icon_name = "low"
|
||||
elif app_config.state.volume < 70:
|
||||
icon_name = "medium"
|
||||
else:
|
||||
icon_name = "high"
|
||||
|
||||
self.volume_mute_toggle.set_icon(f"audio-volume-{icon_name}-symbolic")
|
||||
|
||||
self.editing = True
|
||||
self.volume_slider.set_value(
|
||||
0 if app_config.state.is_muted else app_config.state.volume
|
||||
)
|
||||
self.editing = False
|
||||
|
||||
# Update the current song information.
|
||||
# TODO (#126): add popup of bigger cover art photo here
|
||||
if app_config.state.current_song is not None:
|
||||
self.song_title.set_markup(bleach.clean(app_config.state.current_song.title))
|
||||
# TODO (#71): use walrus once MYPY gets its act together
|
||||
album = app_config.state.current_song.album
|
||||
artist = app_config.state.current_song.artist
|
||||
if album:
|
||||
self.album_name.set_markup(bleach.clean(album.name))
|
||||
self.artist_name.show()
|
||||
else:
|
||||
self.album_name.set_markup("")
|
||||
self.album_name.hide()
|
||||
if artist:
|
||||
self.artist_name.set_markup(bleach.clean(artist.name))
|
||||
self.artist_name.show()
|
||||
else:
|
||||
self.artist_name.set_markup("")
|
||||
self.artist_name.hide()
|
||||
else:
|
||||
# Clear out the cover art and song tite if no song
|
||||
self.song_title.set_markup("")
|
||||
self.album_name.set_markup("")
|
||||
self.artist_name.set_markup("")
|
||||
|
||||
self.load_play_queue_button.set_sensitive(not self.offline_mode)
|
||||
if app_config.state.loading_play_queue:
|
||||
self.play_queue_spinner.start()
|
||||
self.play_queue_spinner.show()
|
||||
else:
|
||||
self.play_queue_spinner.stop()
|
||||
self.play_queue_spinner.hide()
|
||||
|
||||
# Short circuit if no changes to the play queue
|
||||
force |= self.offline_mode != app_config.offline_mode
|
||||
self.offline_mode = app_config.offline_mode
|
||||
if not force and (
|
||||
self.current_play_queue == app_config.state.play_queue
|
||||
and self.current_playing_index == app_config.state.current_song_index
|
||||
):
|
||||
return
|
||||
self.current_play_queue = app_config.state.play_queue
|
||||
self.current_playing_index = app_config.state.current_song_index
|
||||
|
||||
# Set the Play Queue button popup.
|
||||
play_queue_len = len(app_config.state.play_queue)
|
||||
if play_queue_len == 0:
|
||||
self.popover_label.set_markup("<b>Play Queue</b>")
|
||||
else:
|
||||
song_label = util.pluralize("song", play_queue_len)
|
||||
self.popover_label.set_markup(
|
||||
f"<b>Play Queue:</b> {play_queue_len} {song_label}"
|
||||
)
|
||||
|
||||
# TODO (#207) this is super freaking stupid inefficient.
|
||||
# IDEAS: batch it, don't get the queue until requested
|
||||
# self.editing_play_queue_song_list = True
|
||||
|
||||
# self.editing_play_queue_song_list = False
|
||||
|
||||
def set_cover_art(self, cover_art_filename: str, loading: bool):
|
||||
self.album_art.set_from_file(cover_art_filename)
|
||||
self.album_art.set_loading(loading)
|
||||
|
||||
def on_volume_change(self, scale: Gtk.Scale):
|
||||
if not self.editing:
|
||||
self.emit("volume-change", scale.get_value())
|
||||
|
||||
def on_play_queue_open(self, *_):
|
||||
if not self.get_child_visible():
|
||||
self.play_queue_popover.popdown()
|
||||
return
|
||||
|
||||
if not self.state.play_queue_open:
|
||||
self.play_queue_popover.popdown()
|
||||
else:
|
||||
# TODO (#88): scroll the currently playing song into view.
|
||||
self.play_queue_popover.popup()
|
||||
self.play_queue_popover.show_all()
|
||||
|
||||
# Hide the load play queue button if the adapter can't do that.
|
||||
if not AdapterManager.can_get_play_queue():
|
||||
self.load_play_queue_button.hide()
|
||||
|
||||
def on_song_activated(self, t: Any, idx: Gtk.TreePath, c: Any):
|
||||
if not self.play_queue_store[idx[0]][0]:
|
||||
return
|
||||
# The song ID is in the last column of the model.
|
||||
self.state.emit(
|
||||
"song-clicked",
|
||||
idx.get_indices()[0],
|
||||
[m[-1] for m in self.play_queue_store],
|
||||
{"no_reshuffle": True},
|
||||
)
|
||||
|
||||
def on_play_queue_button_press(self, tree: Any, event: Gdk.EventButton) -> bool:
|
||||
if event.button == 3: # Right click
|
||||
clicked_path = tree.get_path_at_pos(event.x, event.y)
|
||||
|
||||
store, paths = tree.get_selection().get_selected_rows()
|
||||
allow_deselect = False
|
||||
|
||||
def on_download_state_change(song_id: str):
|
||||
# Refresh the entire window (no force) because the song could
|
||||
# be in a list anywhere in the window.
|
||||
self.state.emit("refresh-window", {}, False)
|
||||
|
||||
# Use the new selection instead of the old one for calculating what
|
||||
# to do the right click on.
|
||||
if clicked_path[0] not in paths:
|
||||
paths = [clicked_path[0]]
|
||||
allow_deselect = True
|
||||
|
||||
song_ids = [self.play_queue_store[p][-1] for p in paths]
|
||||
|
||||
remove_text = (
|
||||
"Remove " + util.pluralize("song", len(song_ids)) + " from queue"
|
||||
)
|
||||
|
||||
def on_remove_songs_click(_: Any):
|
||||
self.state.emit("songs-removed", [p.get_indices()[0] for p in paths])
|
||||
|
||||
util.show_song_popover(
|
||||
song_ids,
|
||||
event.x,
|
||||
event.y,
|
||||
tree,
|
||||
self.offline_mode,
|
||||
on_download_state_change=on_download_state_change,
|
||||
extra_menu_items=[
|
||||
(Gtk.ModelButton(text=remove_text), on_remove_songs_click),
|
||||
],
|
||||
)
|
||||
|
||||
# If the click was on a selected row, don't deselect anything.
|
||||
if not allow_deselect:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def create_song_display(self) -> Gtk.Box:
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.album_art = SpinnerImage(
|
||||
image_name="player-controls-album-artwork",
|
||||
image_size=70,
|
||||
)
|
||||
box.pack_start(self.album_art, False, False, 0)
|
||||
|
||||
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
details_box.pack_start(Gtk.Box(), True, True, 0)
|
||||
|
||||
def make_label(name: str) -> Gtk.Label:
|
||||
return Gtk.Label(
|
||||
name=name,
|
||||
halign=Gtk.Align.START,
|
||||
xalign=0,
|
||||
use_markup=True,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
)
|
||||
|
||||
self.song_title = make_label("song-title")
|
||||
details_box.add(self.song_title)
|
||||
|
||||
self.album_name = make_label("album-name")
|
||||
details_box.add(self.album_name)
|
||||
|
||||
self.artist_name = make_label("artist-name")
|
||||
details_box.add(self.artist_name)
|
||||
|
||||
details_box.pack_start(Gtk.Box(), True, True, 0)
|
||||
box.pack_start(details_box, False, False, 5)
|
||||
|
||||
return box
|
||||
|
||||
def create_playback_controls(self) -> Gtk.Box:
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
# Scrubber and song progress/length labels
|
||||
scrubber_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
scrubber_box.pack_start(common.create_label(self.state, "progress-label"), False, False, 5)
|
||||
|
||||
self.song_scrubber = Gtk.Scale(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
adjustment=self.state.scrubber,
|
||||
hexpand=True,
|
||||
)
|
||||
self.song_scrubber.set_name("song-scrubber")
|
||||
self.song_scrubber.set_hexpand(True)
|
||||
self.song_scrubber.set_draw_value(False)
|
||||
self.song_scrubber.set_restrict_to_fill_level(False)
|
||||
self.state.connect("notify::scrubber-cache", lambda *_: self.song_scrubber.set_fill_level(self.state.scrubber_cache))
|
||||
# self.song_scrubber.set_name()
|
||||
# self.song_scrubber.set_draw_value(False)
|
||||
# self.song_scrubber.set_restrict_to_fill_level(False)
|
||||
# self.song_scrubber.connect(
|
||||
# "change-value", lambda s, t, v: self.emit("song-scrub", v)
|
||||
# )
|
||||
scrubber_box.pack_start(self.song_scrubber, True, True, 0)
|
||||
|
||||
scrubber_box.pack_start(common.create_label(self.state, "duration-label"), False, False, 5)
|
||||
|
||||
box.add(scrubber_box)
|
||||
|
||||
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
buttons.pack_start(Gtk.Box(), True, True, 0)
|
||||
|
||||
# Repeat button
|
||||
repeat_button = common.create_repeat_button(self.state, valign=Gtk.Align.CENTER)
|
||||
buttons.pack_start(repeat_button, False, False, 5)
|
||||
|
||||
# Previous button
|
||||
prev_button = common.create_prev_button(self.state, valign=Gtk.Align.CENTER)
|
||||
buttons.pack_start(prev_button, False, False, 5)
|
||||
|
||||
buttons.pack_start(common.create_play_button(self.state), False, False, 0)
|
||||
|
||||
# Next button
|
||||
next_button = common.create_next_button(self.state, valign=Gtk.Align.CENTER)
|
||||
buttons.pack_start(next_button, False, False, 5)
|
||||
|
||||
# Shuffle button
|
||||
shuffle_button = common.create_shuffle_button(self.state, valign=Gtk.Align.CENTER)
|
||||
buttons.pack_start(shuffle_button, False, False, 5)
|
||||
|
||||
buttons.pack_start(Gtk.Box(), True, True, 0)
|
||||
box.add(buttons)
|
||||
|
||||
return box
|
||||
|
||||
def create_play_queue_volume(self) -> Gtk.Box:
|
||||
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
vbox.pack_start(Gtk.Box(), True, True, 0)
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
# Device button (for chromecast)
|
||||
self.device_button = IconButton(
|
||||
"chromecast-symbolic",
|
||||
"Show available audio output devices",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
self.device_button.connect("clicked", self.state.popup_devices)
|
||||
box.pack_start(self.device_button, False, True, 5)
|
||||
|
||||
# Play Queue button
|
||||
self.play_queue_button = IconToggleButton(
|
||||
"view-list-symbolic",
|
||||
"Open play queue",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
self.state.bind_property("play-queue-open", self.play_queue_button, "active", GObject.BindingFlags.BIDIRECTIONAL)
|
||||
self.state.connect("notify::play-queue-open", self.on_play_queue_open)
|
||||
box.pack_start(self.play_queue_button, False, True, 5)
|
||||
|
||||
self.play_queue_popover = Gtk.PopoverMenu(modal=False, name="up-next-popover", constrain_to=Gtk.PopoverConstraint.WINDOW)
|
||||
self.play_queue_popover.set_relative_to(self.play_queue_button)
|
||||
|
||||
play_queue_popover_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
play_queue_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.popover_label = Gtk.Label(
|
||||
label="<b>Play Queue</b>",
|
||||
use_markup=True,
|
||||
halign=Gtk.Align.START,
|
||||
margin=10,
|
||||
)
|
||||
play_queue_popover_header.add(self.popover_label)
|
||||
|
||||
self.load_play_queue_button = IconButton(
|
||||
"folder-download-symbolic", "Load Queue from Server", margin=5
|
||||
)
|
||||
self.load_play_queue_button.set_action_name("app.update-play-queue-from-server")
|
||||
play_queue_popover_header.pack_end(self.load_play_queue_button, False, False, 0)
|
||||
|
||||
play_queue_popover_box.add(play_queue_popover_header)
|
||||
|
||||
play_queue_loading_overlay = Gtk.Overlay()
|
||||
play_queue_scrollbox = Gtk.ScrolledWindow(
|
||||
# min_content_height=600,
|
||||
min_content_width=400,
|
||||
propagate_natural_height=True,
|
||||
)
|
||||
|
||||
self.play_queue_list = Gtk.TreeView(
|
||||
model=self.state.play_queue_store,
|
||||
reorderable=True,
|
||||
headers_visible=False,
|
||||
)
|
||||
selection = self.play_queue_list.get_selection()
|
||||
selection.set_mode(Gtk.SelectionMode.MULTIPLE)
|
||||
selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
|
||||
|
||||
# Album Art column. This function defines what image to use for the play queue
|
||||
# song icon.
|
||||
def filename_to_pixbuf(
|
||||
column: Any,
|
||||
cell: Gtk.CellRendererPixbuf,
|
||||
model: Gtk.ListStore,
|
||||
tree_iter: Gtk.TreeIter,
|
||||
flags: Any,
|
||||
):
|
||||
cell.set_property("sensitive", model.get_value(tree_iter, 0))
|
||||
filename = model.get_value(tree_iter, 1)
|
||||
if not filename:
|
||||
cell.set_property("icon_name", "")
|
||||
return
|
||||
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename, 50, 50, True)
|
||||
|
||||
# If this is the playing song, then overlay the play icon.
|
||||
if model.get_value(tree_iter, 3):
|
||||
play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file(
|
||||
str(resolve_path("ui/images/play-queue-play.png"))
|
||||
)
|
||||
|
||||
play_overlay_pixbuf.composite(
|
||||
pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 200
|
||||
)
|
||||
|
||||
cell.set_property("pixbuf", pixbuf)
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf()
|
||||
renderer.set_fixed_size(55, 60)
|
||||
column = Gtk.TreeViewColumn("", renderer)
|
||||
column.set_cell_data_func(renderer, filename_to_pixbuf)
|
||||
column.set_resizable(True)
|
||||
self.play_queue_list.append_column(column)
|
||||
|
||||
renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END)
|
||||
column = Gtk.TreeViewColumn("", renderer, markup=2, sensitive=0)
|
||||
self.play_queue_list.append_column(column)
|
||||
|
||||
self.play_queue_list.connect("row-activated", self.on_song_activated)
|
||||
self.play_queue_list.connect(
|
||||
"button-press-event", self.on_play_queue_button_press
|
||||
)
|
||||
|
||||
play_queue_scrollbox.add(self.play_queue_list)
|
||||
play_queue_loading_overlay.add(play_queue_scrollbox)
|
||||
|
||||
self.play_queue_spinner = Gtk.Spinner(
|
||||
name="play-queue-spinner",
|
||||
active=False,
|
||||
halign=Gtk.Align.CENTER,
|
||||
valign=Gtk.Align.CENTER,
|
||||
)
|
||||
play_queue_loading_overlay.add_overlay(self.play_queue_spinner)
|
||||
play_queue_popover_box.pack_end(play_queue_loading_overlay, True, True, 0)
|
||||
|
||||
self.play_queue_popover.add(play_queue_popover_box)
|
||||
|
||||
# Volume mute toggle
|
||||
self.volume_mute_toggle = IconButton(
|
||||
"audio-volume-high-symbolic", "Toggle mute"
|
||||
)
|
||||
self.volume_mute_toggle.set_action_name("app.mute-toggle")
|
||||
box.pack_start(self.volume_mute_toggle, False, True, 0)
|
||||
|
||||
# Volume slider
|
||||
self.volume_slider = Gtk.Scale.new_with_range(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5
|
||||
)
|
||||
self.volume_slider.set_name("volume-slider")
|
||||
self.volume_slider.set_draw_value(False)
|
||||
self.volume_slider.connect("value-changed", self.on_volume_change)
|
||||
box.pack_start(self.volume_slider, True, True, 0)
|
||||
|
||||
vbox.pack_start(box, False, True, 0)
|
||||
vbox.pack_start(Gtk.Box(), True, True, 0)
|
||||
return vbox
|
407
sublime_music/ui/player_controls/manager.py
Normal file
407
sublime_music/ui/player_controls/manager.py
Normal file
@@ -0,0 +1,407 @@
|
||||
import copy
|
||||
from datetime import timedelta
|
||||
from typing import Any, Optional, Callable, Dict, Set, Tuple
|
||||
from functools import partial
|
||||
|
||||
from gi.repository import GObject, Gtk
|
||||
|
||||
from .. import util
|
||||
from ...adapters import AdapterManager, Result
|
||||
from ...adapters.api_objects import Song
|
||||
from ...config import AppConfiguration
|
||||
from ..state import RepeatType
|
||||
from ..common import IconButton, IconToggleButton, SpinnerImage, SpinnerPicture
|
||||
|
||||
class Manager(GObject.GObject):
|
||||
__gsignals__ = {
|
||||
"device-update": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str,)),
|
||||
"song-clicked": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(int, object, object),
|
||||
),
|
||||
"songs-removed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,)),
|
||||
"refresh-window": (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, bool),
|
||||
),
|
||||
"seek": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,)),
|
||||
}
|
||||
|
||||
volume = GObject.Property(type=Gtk.Adjustment)
|
||||
scrubber = GObject.Property(type=Gtk.Adjustment)
|
||||
scrubber_cache = GObject.Property(type=int, default=0)
|
||||
play_queue_open = GObject.Property(type=bool, default=False)
|
||||
play_queue_store = GObject.Property(type=Gtk.ListStore)
|
||||
flap_open = GObject.Property(type=bool, default=False)
|
||||
offline_mode = GObject.Property(type=bool, default=False)
|
||||
|
||||
has_song = GObject.Property(type=bool, default=False)
|
||||
play_button_icon = GObject.Property(type=str)
|
||||
play_button_tooltip = GObject.Property(type=str)
|
||||
repeat_button_icon = GObject.Property(type=str)
|
||||
repeat_button_active = GObject.Property(type=bool, default=False)
|
||||
shuffle_button_active = GObject.Property(type=bool, default=False)
|
||||
duration_label = GObject.Property(type=str, default="-:--")
|
||||
progress_label = GObject.Property(type=str, default="-:--")
|
||||
|
||||
updating_scrubber = False
|
||||
cover_art_update_order_token = 0
|
||||
play_queue_update_order_token = 0
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.volume = Gtk.Adjustment(1, 0, 1, 0.01, 0, 0)
|
||||
self.scrubber = Gtk.Adjustment()
|
||||
self.scrubber.set_step_increment(1)
|
||||
self.scrubber.connect("value-changed", self.on_scrubber_changed)
|
||||
self.scrubber.connect("value-changed", self.update_progress_label)
|
||||
self.scrubber.connect("changed", self.update_duration_label)
|
||||
|
||||
self.play_queue_store = Gtk.ListStore(
|
||||
bool, # playable
|
||||
str, # image filename
|
||||
str, # title, album, artist
|
||||
bool, # playing
|
||||
str, # song ID
|
||||
)
|
||||
|
||||
# Set up drag-and-drop on the song list for editing the order of the
|
||||
# playlist.
|
||||
self.play_queue_store.connect("row-inserted", self.on_play_queue_model_row_move)
|
||||
self.play_queue_store.connect("row-deleted", self.on_play_queue_model_row_move)
|
||||
|
||||
self._controls = []
|
||||
|
||||
# Device popover
|
||||
self.device_popover = Gtk.PopoverMenu(modal=True, name="device-popover")
|
||||
|
||||
device_popover_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
name="device-popover-box",
|
||||
)
|
||||
device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
popover_label = Gtk.Label(
|
||||
label="<b>Devices</b>",
|
||||
use_markup=True,
|
||||
halign=Gtk.Align.START,
|
||||
margin=5,
|
||||
)
|
||||
device_popover_header.add(popover_label)
|
||||
|
||||
refresh_devices = IconButton("view-refresh-symbolic", "Refresh device list")
|
||||
refresh_devices.set_action_name("app.refresh-devices")
|
||||
device_popover_header.pack_end(refresh_devices, False, False, 0)
|
||||
|
||||
device_popover_box.add(device_popover_header)
|
||||
|
||||
device_list_and_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
device_list_and_loading.add(self.device_list)
|
||||
|
||||
device_popover_box.pack_end(device_list_and_loading, True, True, 0)
|
||||
|
||||
self.device_popover.add(device_popover_box)
|
||||
|
||||
def add_control(self, control):
|
||||
self._controls.append(control)
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
self.has_song = app_config.state.current_song is not None
|
||||
|
||||
self.update_song_progress(
|
||||
app_config.state.song_progress,
|
||||
app_config.state.current_song and app_config.state.current_song.duration,
|
||||
app_config.state.song_stream_cache_progress)
|
||||
|
||||
if self.has_song:
|
||||
self.cover_art_update_order_token += 1
|
||||
self.update_cover_art(
|
||||
app_config.state.current_song.cover_art,
|
||||
order_token=self.cover_art_update_order_token)
|
||||
|
||||
for control in self._controls:
|
||||
control.update(app_config, force=force)
|
||||
|
||||
if not self.has_song:
|
||||
self.set_cover_art(None, False)
|
||||
|
||||
self.update_player_queue(app_config)
|
||||
self.update_device_list(app_config)
|
||||
|
||||
icon = "pause" if app_config.state.playing else "start"
|
||||
self.play_button_icon = f"media-playback-{icon}-symbolic"
|
||||
self.play_button_tooltip = "Pause" if app_config.state.playing else "Play"
|
||||
|
||||
repeat_on = app_config.state.repeat_type in (
|
||||
RepeatType.REPEAT_QUEUE,
|
||||
RepeatType.REPEAT_SONG,
|
||||
)
|
||||
self.repeat_button_icon = app_config.state.repeat_type.icon
|
||||
self.repeat_button_active = repeat_on
|
||||
|
||||
self.shuffle_button_active = app_config.state.shuffle_on
|
||||
|
||||
def update_song_progress(self,
|
||||
progress: Optional[timedelta],
|
||||
duration: Optional[timedelta],
|
||||
cache_progess: Optional[timedelta]):
|
||||
self.updating_scrubber = True
|
||||
|
||||
if progress is None or duration is None:
|
||||
self.scrubber.set_value(0)
|
||||
self.scrubber.set_upper(0)
|
||||
self.scrubber_cache = 0
|
||||
self.updating_scrubber = False
|
||||
return
|
||||
|
||||
self.scrubber.set_value(progress.total_seconds())
|
||||
self.scrubber.set_upper(duration.total_seconds())
|
||||
if cache_progess is not None:
|
||||
self.scrubber_cache = cache_progess.total_seconds()
|
||||
else:
|
||||
self.scrubber_cache = duration.total_seconds()
|
||||
|
||||
self.updating_scrubber = False
|
||||
|
||||
@util.async_callback(
|
||||
partial(AdapterManager.get_cover_art_uri, scheme="file"),
|
||||
before_download=lambda self: self.set_cover_art(None, True),
|
||||
on_failure=lambda self, e: self.set_cover_art(None, False),
|
||||
)
|
||||
def update_cover_art(
|
||||
self,
|
||||
cover_art_filename: str,
|
||||
app_config: AppConfiguration,
|
||||
force: bool = False,
|
||||
order_token: int = None,
|
||||
is_partial: bool = False,
|
||||
):
|
||||
if order_token != self.cover_art_update_order_token:
|
||||
return
|
||||
|
||||
self.set_cover_art(cover_art_filename, False)
|
||||
|
||||
def set_cover_art(self, cover_art_filename: Optional[str], loading: bool):
|
||||
for control in self._controls:
|
||||
control.set_cover_art(cover_art_filename, loading)
|
||||
|
||||
def on_play_queue_model_row_move(self, *args):
|
||||
# TODO
|
||||
return
|
||||
|
||||
# If we are programatically editing the song list, don't do anything.
|
||||
if self.editing_play_queue_song_list:
|
||||
return
|
||||
|
||||
# We get both a delete and insert event, I think it's deterministic
|
||||
# which one comes first, but just in case, we have this
|
||||
# reordering_play_queue_song_list flag.
|
||||
if self.reordering_play_queue_song_list:
|
||||
currently_playing_index = [
|
||||
i for i, s in enumerate(self.play_queue_store) if s[3] # playing
|
||||
][0]
|
||||
self.state.emit(
|
||||
"refresh-window",
|
||||
{
|
||||
"current_song_index": currently_playing_index,
|
||||
"play_queue": tuple(s[-1] for s in self.play_queue_store),
|
||||
},
|
||||
False,
|
||||
)
|
||||
self.reordering_play_queue_song_list = False
|
||||
else:
|
||||
self.reordering_play_queue_song_list = True
|
||||
|
||||
def update_player_queue(self, app_config: AppConfiguration):
|
||||
new_store = []
|
||||
|
||||
def calculate_label(song_details: Song) -> str:
|
||||
title = util.esc(song_details.title)
|
||||
# TODO (#71): use walrus once MYPY works with this
|
||||
# album = util.esc(album.name if (album := song_details.album) else None)
|
||||
# artist = util.esc(artist.name if (artist := song_details.artist) else None) # noqa
|
||||
album = util.esc(song_details.album.name if song_details.album else None)
|
||||
artist = util.esc(song_details.artist.name if song_details.artist else None)
|
||||
return f"<b>{title}</b>\n{util.dot_join(album, artist)}"
|
||||
|
||||
def make_idle_index_capturing_function(
|
||||
idx: int,
|
||||
order_tok: int,
|
||||
fn: Callable[[int, int, Any], None],
|
||||
) -> Callable[[Result], None]:
|
||||
return lambda f: GLib.idle_add(fn, idx, order_tok, f.result())
|
||||
|
||||
def on_cover_art_future_done(
|
||||
idx: int,
|
||||
order_token: int,
|
||||
cover_art_filename: str,
|
||||
):
|
||||
if order_token != self.play_queue_update_order_token:
|
||||
return
|
||||
|
||||
self.play_queue_store[idx][1] = cover_art_filename
|
||||
|
||||
def get_cover_art_filename_or_create_future(
|
||||
cover_art_id: Optional[str], idx: int, order_token: int
|
||||
) -> Optional[str]:
|
||||
cover_art_result = AdapterManager.get_cover_art_uri(cover_art_id, "file")
|
||||
if not cover_art_result.data_is_available:
|
||||
cover_art_result.add_done_callback(
|
||||
make_idle_index_capturing_function(
|
||||
idx, order_token, on_cover_art_future_done
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
# The cover art is already cached.
|
||||
return cover_art_result.result()
|
||||
|
||||
def on_song_details_future_done(idx: int, order_token: int, song_details: Song):
|
||||
if order_token != self.play_queue_update_order_token:
|
||||
return
|
||||
|
||||
self.play_queue_store[idx][2] = calculate_label(song_details)
|
||||
|
||||
# Cover Art
|
||||
filename = get_cover_art_filename_or_create_future(
|
||||
song_details.cover_art, idx, order_token
|
||||
)
|
||||
if filename:
|
||||
self.play_queue_store[idx][1] = filename
|
||||
|
||||
current_play_queue = [x[-1] for x in self.play_queue_store]
|
||||
if app_config.state.play_queue != current_play_queue:
|
||||
self.play_queue_update_order_token += 1
|
||||
|
||||
song_details_results = []
|
||||
for i, (song_id, cached_status) in enumerate(
|
||||
zip(
|
||||
app_config.state.play_queue,
|
||||
AdapterManager.get_cached_statuses(app_config.state.play_queue),
|
||||
)
|
||||
):
|
||||
song_details_result = AdapterManager.get_song_details(song_id)
|
||||
|
||||
cover_art_filename = ""
|
||||
label = "\n"
|
||||
|
||||
if song_details_result.data_is_available:
|
||||
# We have the details of the song already cached.
|
||||
song_details = song_details_result.result()
|
||||
label = calculate_label(song_details)
|
||||
|
||||
filename = get_cover_art_filename_or_create_future(
|
||||
song_details.cover_art, i, self.play_queue_update_order_token
|
||||
)
|
||||
if filename:
|
||||
cover_art_filename = filename
|
||||
else:
|
||||
song_details_results.append((i, song_details_result))
|
||||
|
||||
new_store.append(
|
||||
[
|
||||
(
|
||||
not self.offline_mode
|
||||
or cached_status
|
||||
in (SongCacheStatus.CACHED, SongCacheStatus.PERMANENTLY_CACHED)
|
||||
),
|
||||
cover_art_filename,
|
||||
label,
|
||||
i == app_config.state.current_song_index,
|
||||
song_id,
|
||||
]
|
||||
)
|
||||
|
||||
util.diff_song_store(self.play_queue_store, new_store)
|
||||
|
||||
# Do this after the diff to avoid race conditions.
|
||||
for idx, song_details_result in song_details_results:
|
||||
song_details_result.add_done_callback(
|
||||
make_idle_index_capturing_function(
|
||||
idx,
|
||||
self.play_queue_update_order_token,
|
||||
on_song_details_future_done,
|
||||
)
|
||||
)
|
||||
|
||||
def popup_devices(self, relative_to):
|
||||
self.device_popover.set_relative_to(relative_to)
|
||||
self.device_popover.popup()
|
||||
self.device_popover.show_all()
|
||||
|
||||
_current_player_id = None
|
||||
_current_available_players: Dict[type, Set[Tuple[str, str]]] = {}
|
||||
|
||||
def update_device_list(self, app_config: AppConfiguration):
|
||||
if (
|
||||
self._current_available_players == app_config.state.available_players
|
||||
and self._current_player_id == app_config.state.current_device
|
||||
):
|
||||
return
|
||||
|
||||
self._current_player_id = app_config.state.current_device
|
||||
self._current_available_players = copy.deepcopy(
|
||||
app_config.state.available_players
|
||||
)
|
||||
for c in self.device_list.get_children():
|
||||
self.device_list.remove(c)
|
||||
|
||||
for i, (player_type, players) in enumerate(
|
||||
app_config.state.available_players.items()
|
||||
):
|
||||
if len(players) == 0:
|
||||
continue
|
||||
if i > 0:
|
||||
self.device_list.add(
|
||||
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
)
|
||||
self.device_list.add(
|
||||
Gtk.Label(
|
||||
label=f"{player_type.name} Devices",
|
||||
halign=Gtk.Align.START,
|
||||
name="device-type-section-title",
|
||||
)
|
||||
)
|
||||
|
||||
for player_id, player_name in sorted(players, key=lambda p: p[1]):
|
||||
icon = (
|
||||
"audio-volume-high-symbolic"
|
||||
if player_id == app_config.state.current_device
|
||||
else None
|
||||
)
|
||||
button = IconButton(icon, label=player_name)
|
||||
button.get_style_context().add_class("menu-button")
|
||||
button.connect(
|
||||
"clicked",
|
||||
lambda _, player_id: self.state.emit("device-update", player_id),
|
||||
player_id,
|
||||
)
|
||||
self.device_list.add(button)
|
||||
|
||||
self.device_list.show_all()
|
||||
|
||||
def on_scrubber_changed(self, _: Any):
|
||||
if self.updating_scrubber:
|
||||
return
|
||||
|
||||
self.emit("seek", self.scrubber.get_value())
|
||||
|
||||
def update_progress_label(self, _: Any):
|
||||
if self.scrubber.get_upper() == 0:
|
||||
self.progress_label = "-:--"
|
||||
else:
|
||||
self.progress_label = util.format_song_duration(
|
||||
int(self.scrubber.get_value()))
|
||||
|
||||
def update_duration_label(self, _: Any):
|
||||
upper = self.scrubber.get_upper()
|
||||
if upper == 0:
|
||||
self.duration_label = "-:--"
|
||||
else:
|
||||
self.duration_label = util.format_song_duration(int(upper))
|
||||
|
328
sublime_music/ui/player_controls/mobile.py
Normal file
328
sublime_music/ui/player_controls/mobile.py
Normal file
@@ -0,0 +1,328 @@
|
||||
from typing import Any, Callable, Dict, Optional, Set, Tuple
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango, Handy
|
||||
|
||||
from .. import util
|
||||
from ..common import IconButton, IconToggleButton, SpinnerImage, SpinnerPicture
|
||||
from ...config import AppConfiguration
|
||||
from ...util import resolve_path
|
||||
from . import common
|
||||
|
||||
class MobileHandle(Gtk.EventBox):
|
||||
def __init__(self, state):
|
||||
super().__init__(above_child=False)
|
||||
self.get_style_context().add_class("background")
|
||||
|
||||
self.state = state
|
||||
self.state.add_control(self)
|
||||
|
||||
action_bar = Gtk.ActionBar()
|
||||
|
||||
action_bar.pack_start(self.create_song_display())
|
||||
|
||||
buttons = Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE)
|
||||
|
||||
close_buttons = Handy.Squeezer(halign=Gtk.Align.END, homogeneous=False)
|
||||
|
||||
expanded_close_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
expanded_close_buttons.pack_start(common.create_prev_button(self.state), False, False, 5)
|
||||
expanded_close_buttons.pack_start(common.create_play_button(self.state, large=False), False, False, 5)
|
||||
expanded_close_buttons.pack_start(common.create_next_button(self.state), False, False, 5)
|
||||
|
||||
close_buttons.add(expanded_close_buttons)
|
||||
close_buttons.add(common.create_play_button(self.state, large=False, halign=Gtk.Align.END))
|
||||
|
||||
buttons.add(close_buttons)
|
||||
|
||||
open_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.END)
|
||||
|
||||
self.play_queue_button = IconToggleButton(
|
||||
"view-list-symbolic",
|
||||
"Open play queue",
|
||||
relief=True,
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
|
||||
def play_queue_button_toggled(*_):
|
||||
if self.play_queue_button.get_active() != self.state.play_queue_open:
|
||||
self.state.play_queue_open = self.play_queue_button.get_active()
|
||||
self.play_queue_button.connect("toggled", play_queue_button_toggled)
|
||||
|
||||
def on_play_queue_open(*_):
|
||||
self.play_queue_button.set_active(self.state.play_queue_open)
|
||||
self.state.connect("notify::play-queue-open", on_play_queue_open)
|
||||
|
||||
open_buttons.pack_start(self.play_queue_button, False, False, 5)
|
||||
|
||||
self.menu_button = IconButton(
|
||||
"view-more-symbolic",
|
||||
"Menu",
|
||||
relief=True,
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
open_buttons.pack_start(self.menu_button, False, False, 5)
|
||||
|
||||
buttons.add(open_buttons)
|
||||
|
||||
def flap_reveal(*_):
|
||||
if self.state.flap_open:
|
||||
buttons.set_visible_child(open_buttons)
|
||||
else:
|
||||
buttons.set_visible_child(close_buttons)
|
||||
|
||||
self.state.play_queue_open = False
|
||||
self.state.connect("notify::flap-open", flap_reveal)
|
||||
|
||||
action_bar.pack_end(buttons)
|
||||
|
||||
self.add(action_bar)
|
||||
|
||||
def create_song_display(self):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, hexpand=True)
|
||||
box.set_property('width-request', 200)
|
||||
|
||||
self.cover_art = SpinnerImage(
|
||||
image_name="player-controls-album-artwork",
|
||||
image_size=50,
|
||||
)
|
||||
box.pack_start(self.cover_art, False, False, 0)
|
||||
|
||||
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
def make_label(name: str) -> Gtk.Label:
|
||||
return Gtk.Label(
|
||||
name=name,
|
||||
halign=Gtk.Align.START,
|
||||
xalign=0,
|
||||
use_markup=True,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
)
|
||||
|
||||
self.song_title = make_label("song-title")
|
||||
details_box.add(self.song_title)
|
||||
|
||||
self.artist_name = make_label("artist-name")
|
||||
details_box.add(self.artist_name)
|
||||
|
||||
box.pack_start(details_box, False, False, 5)
|
||||
|
||||
return box
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
if app_config.state.current_song is not None:
|
||||
self.song_title.set_markup(bleach.clean(app_config.state.current_song.title))
|
||||
# TODO (#71): use walrus once MYPY gets its act together
|
||||
album = app_config.state.current_song.album
|
||||
artist = app_config.state.current_song.artist
|
||||
if artist:
|
||||
self.artist_name.set_markup(bleach.clean(artist.name))
|
||||
self.artist_name.show()
|
||||
elif album:
|
||||
self.artist_name.set_markup(bleach.clean(album.name))
|
||||
self.artist_name.show()
|
||||
else:
|
||||
self.artist_name.set_markup("")
|
||||
self.artist_name.hide()
|
||||
else:
|
||||
# Clear out the cover art and song tite if no song
|
||||
self.song_title.set_markup("")
|
||||
self.artist_name.set_markup("")
|
||||
|
||||
def set_cover_art(self, cover_art_filename: str, loading: bool):
|
||||
self.cover_art.set_from_file(cover_art_filename)
|
||||
self.cover_art.set_loading(loading)
|
||||
|
||||
|
||||
class MobileFlap(Handy.Flap):
|
||||
def __init__(self, state):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, fold_policy=Handy.FlapFoldPolicy.ALWAYS, vexpand=True, swipe_to_open=False)
|
||||
|
||||
self.state = state
|
||||
self.state.add_control(self)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, vexpand=True)
|
||||
box.get_style_context().add_class("background")
|
||||
|
||||
box.pack_start(Gtk.Separator(), False, False, 0)
|
||||
|
||||
overlay = Gtk.Overlay(hexpand=True, vexpand=True, height_request=100)
|
||||
|
||||
self.cover_art = SpinnerPicture(image_name="player-controls-album-artwork")
|
||||
overlay.add(self.cover_art)
|
||||
|
||||
overlay_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
overlay.add_overlay(overlay_box)
|
||||
|
||||
overlay_row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
overlay_box.pack_end(overlay_row_box, False, False, 0)
|
||||
|
||||
overlay_row_box.pack_start(common.create_label(self.state, "progress-label"), False, False, 5)
|
||||
overlay_row_box.pack_end(common.create_label(self.state, "duration-label"), False, False, 5)
|
||||
|
||||
# Device
|
||||
self.device_button = IconButton(
|
||||
"chromecast-symbolic",
|
||||
"Show available audio output devices",
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
)
|
||||
def on_device_click(_: Any):
|
||||
if self.device_popover.is_visible():
|
||||
self.device_popover.popdown()
|
||||
else:
|
||||
self.device_popover.popup()
|
||||
self.device_popover.show_all()
|
||||
self.device_button.connect("clicked", self.state.popup_devices)
|
||||
overlay_row_box.set_center_widget(self.device_button)
|
||||
|
||||
self.device_popover = Gtk.PopoverMenu(modal=False, name="device-popover")
|
||||
self.device_popover.set_relative_to(self.device_button)
|
||||
|
||||
device_popover_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
name="device-popover-box",
|
||||
)
|
||||
device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.popover_label = Gtk.Label(
|
||||
label="<b>Devices</b>",
|
||||
use_markup=True,
|
||||
halign=Gtk.Align.START,
|
||||
margin=5,
|
||||
)
|
||||
device_popover_header.add(self.popover_label)
|
||||
|
||||
refresh_devices = IconButton("view-refresh-symbolic", "Refresh device list")
|
||||
refresh_devices.set_action_name("app.refresh-devices")
|
||||
device_popover_header.pack_end(refresh_devices, False, False, 0)
|
||||
|
||||
device_popover_box.add(device_popover_header)
|
||||
|
||||
device_list_and_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
device_list_and_loading.add(self.device_list)
|
||||
|
||||
device_popover_box.pack_end(device_list_and_loading, True, True, 0)
|
||||
|
||||
self.device_popover.add(device_popover_box)
|
||||
|
||||
box.pack_start(overlay, True, True, 0)
|
||||
|
||||
# Song Scrubber
|
||||
scrubber = Gtk.Scale(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
adjustment=self.state.scrubber,
|
||||
name="song-scrubber",
|
||||
draw_value=False,
|
||||
restrict_to_fill_level=False)
|
||||
self.state.bind_property("scrubber-cache", scrubber, "fill-level")
|
||||
box.pack_start(scrubber, False, False, 0)
|
||||
|
||||
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
# Repeat button
|
||||
repeat_button = common.create_repeat_button(self.state, valign=Gtk.Align.CENTER, margin_left=10)
|
||||
buttons.pack_start(repeat_button, False, False, 0)
|
||||
|
||||
center_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
# Previous button
|
||||
center_buttons.add(common.create_prev_button(self.state, valign=Gtk.Align.CENTER))
|
||||
|
||||
# Play button
|
||||
center_buttons.add(common.create_play_button(self.state))
|
||||
|
||||
# Next button
|
||||
center_buttons.add(common.create_next_button(self.state, valign=Gtk.Align.CENTER))
|
||||
|
||||
buttons.set_center_widget(center_buttons)
|
||||
|
||||
# Shuffle button
|
||||
shuffle_button = common.create_shuffle_button(self.state, valign=Gtk.Align.CENTER, margin_right=10)
|
||||
buttons.pack_end(shuffle_button, False, False, 0)
|
||||
|
||||
box.pack_start(buttons, False, False, 0)
|
||||
|
||||
box.pack_start(Gtk.Box(), False, False, 5)
|
||||
|
||||
self.set_content(box)
|
||||
|
||||
play_queue_scrollbox = Gtk.ScrolledWindow(vexpand=True)
|
||||
|
||||
self.play_queue_list = Gtk.TreeView(
|
||||
model=self.state.play_queue_store,
|
||||
reorderable=True,
|
||||
headers_visible=False,
|
||||
)
|
||||
self.play_queue_list.get_style_context().add_class("background")
|
||||
|
||||
# Album Art column. This function defines what image to use for the play queue
|
||||
# song icon.
|
||||
def filename_to_pixbuf(
|
||||
column: Any,
|
||||
cell: Gtk.CellRendererPixbuf,
|
||||
model: Gtk.ListStore,
|
||||
tree_iter: Gtk.TreeIter,
|
||||
flags: Any,
|
||||
):
|
||||
cell.set_property("sensitive", model.get_value(tree_iter, 0))
|
||||
filename = model.get_value(tree_iter, 1)
|
||||
if not filename:
|
||||
cell.set_property("icon_name", "")
|
||||
return
|
||||
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename, 50, 50, True)
|
||||
|
||||
# If this is the playing song, then overlay the play icon.
|
||||
if model.get_value(tree_iter, 3):
|
||||
play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file(
|
||||
str(resolve_path("ui/images/play-queue-play.png"))
|
||||
)
|
||||
|
||||
play_overlay_pixbuf.composite(
|
||||
pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 200
|
||||
)
|
||||
|
||||
cell.set_property("pixbuf", pixbuf)
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf()
|
||||
renderer.set_fixed_size(55, 60)
|
||||
column = Gtk.TreeViewColumn("", renderer)
|
||||
column.set_cell_data_func(renderer, filename_to_pixbuf)
|
||||
column.set_resizable(True)
|
||||
self.play_queue_list.append_column(column)
|
||||
|
||||
renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END)
|
||||
column = Gtk.TreeViewColumn("", renderer, markup=2, sensitive=0)
|
||||
column.set_expand(True)
|
||||
self.play_queue_list.append_column(column)
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf(icon_name="view-more-symbolic")
|
||||
renderer.set_fixed_size(30, 24)
|
||||
column = Gtk.TreeViewColumn("", renderer)
|
||||
column.set_resizable(True)
|
||||
self.play_queue_list.append_column(column)
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf(icon_name="window-close-symbolic")
|
||||
renderer.set_fixed_size(30, 24)
|
||||
column = Gtk.TreeViewColumn("", renderer)
|
||||
column.set_resizable(True)
|
||||
self.play_queue_list.append_column(column)
|
||||
|
||||
play_queue_scrollbox.add(self.play_queue_list)
|
||||
self.set_flap(play_queue_scrollbox)
|
||||
|
||||
def on_play_queue_open(*_):
|
||||
if not self.get_child_visible():
|
||||
self.set_reveal_flap(False)
|
||||
return
|
||||
|
||||
self.set_reveal_flap(self.state.play_queue_open)
|
||||
self.state.connect("notify::play-queue-open", on_play_queue_open)
|
||||
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
pass
|
||||
|
||||
def set_cover_art(self, cover_art_filename: str, loading: bool):
|
||||
self.cover_art.set_from_file(cover_art_filename)
|
||||
self.cover_art.set_loading(loading)
|
@@ -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
|
||||
|
||||
|
Reference in New Issue
Block a user