Closes #192 Play queue resume is non-modal

Also added infrastructure for showing other non-modal notifications
This commit is contained in:
Sumner Evans
2020-05-16 23:05:54 -06:00
parent 9c4a9f09d2
commit 00516581dc
8 changed files with 111 additions and 38 deletions

View File

@@ -5,6 +5,9 @@ v0.9.3
* The Albums tab is now paginated. * The Albums tab is now paginated.
* You can sort the Albums tab ascending or descending. * You can sort the Albums tab ascending or descending.
* The amount of the song that is cached is now shown while streaming a song.
* The dialog for resuming a play queue from another device has been greatly
improved and is now less intrusive.
* This release has a ton of under-the-hood changes to make things more robust * This release has a ton of under-the-hood changes to make things more robust
and performant. and performant.

View File

@@ -2,6 +2,7 @@
These are the API objects that are returned by Subsonic. These are the API objects that are returned by Subsonic.
""" """
import hashlib
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
@@ -81,7 +82,8 @@ class Album(SublimeAPI.Album):
@dataclass_json(letter_case=LetterCase.CAMEL) @dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass @dataclass
class ArtistAndArtistInfo(SublimeAPI.Artist): class ArtistAndArtistInfo(SublimeAPI.Artist):
id: str id: str = field(init=False)
_id: Optional[str] = field(metadata=config(field_name="id"))
name: str name: str
albums: List[Album] = field( albums: List[Album] = field(
default_factory=list, metadata=config(field_name="album") default_factory=list, metadata=config(field_name="album")
@@ -99,7 +101,16 @@ class ArtistAndArtistInfo(SublimeAPI.Artist):
music_brainz_id: Optional[str] = None music_brainz_id: Optional[str] = None
last_fm_url: Optional[str] = None last_fm_url: Optional[str] = None
@staticmethod
def _strhash(string: str) -> str:
return hashlib.sha1(bytes(string, "utf8")).hexdigest()
def __post_init__(self): def __post_init__(self):
self.id = (
self._id
if self._id is not None
else ArtistAndArtistInfo._strhash(self.name)
)
self.album_count = self.album_count or len(self.albums) self.album_count = self.album_count or len(self.albums)
if not self.artist_image_url: if not self.artist_image_url:
self.artist_image_url = self.cover_art self.artist_image_url = self.cover_art

View File

@@ -40,7 +40,7 @@ from .players import ChromecastPlayer, MPVPlayer, Player, PlayerEvent
from .ui.configure_servers import ConfigureServersDialog from .ui.configure_servers import ConfigureServersDialog
from .ui.main import MainWindow from .ui.main import MainWindow
from .ui.settings import SettingsDialog from .ui.settings import SettingsDialog
from .ui.state import RepeatType from .ui.state import RepeatType, UIState
class SublimeMusicApp(Gtk.Application): class SublimeMusicApp(Gtk.Application):
@@ -128,6 +128,7 @@ class SublimeMusicApp(Gtk.Application):
self.window.connect("song-clicked", self.on_song_clicked) self.window.connect("song-clicked", self.on_song_clicked)
self.window.connect("songs-removed", self.on_songs_removed) self.window.connect("songs-removed", self.on_songs_removed)
self.window.connect("refresh-window", self.on_refresh_window) self.window.connect("refresh-window", self.on_refresh_window)
self.window.connect("notification-closed", self.on_notification_closed)
self.window.connect("go-to", self.on_window_go_to) self.window.connect("go-to", self.on_window_go_to)
self.window.connect("key-press-event", self.on_window_key_press) self.window.connect("key-press-event", self.on_window_key_press)
self.window.player_controls.connect("song-scrub", self.on_song_scrub) self.window.player_controls.connect("song-scrub", self.on_song_scrub)
@@ -496,6 +497,10 @@ class SublimeMusicApp(Gtk.Application):
setattr(self.app_config.state, k, v) setattr(self.app_config.state, k, v)
self.update_window(force=force) self.update_window(force=force)
def on_notification_closed(self, _):
self.app_config.state.current_notification = None
self.update_window()
def on_configure_servers(self, *args): def on_configure_servers(self, *args):
self.show_configure_servers_dialog() self.show_configure_servers_dialog()
@@ -915,13 +920,6 @@ class SublimeMusicApp(Gtk.Application):
return return
# TODO (#167): info bar here (maybe pop up above the player controls?) # TODO (#167): info bar here (maybe pop up above the player controls?)
dialog = Gtk.MessageDialog(
transient_for=self.window,
message_type=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.YES_NO,
text="Resume Playback?",
)
resume_text = "Do you want to resume the play queue" resume_text = "Do you want to resume the play queue"
if play_queue.changed_by or play_queue.changed: if play_queue.changed_by or play_queue.changed:
resume_text += " saved" resume_text += " saved"
@@ -934,21 +932,25 @@ class SublimeMusicApp(Gtk.Application):
resume_text += f" at {changed_str}" resume_text += f" at {changed_str}"
resume_text += "?" resume_text += "?"
dialog.format_secondary_markup(resume_text) def on_resume_click():
result = dialog.run()
dialog.destroy()
if result == Gtk.ResponseType.YES:
self.app_config.state.play_queue = new_play_queue self.app_config.state.play_queue = new_play_queue
self.app_config.state.song_progress = play_queue.position self.app_config.state.song_progress = play_queue.position
self.app_config.state.current_song_index = ( self.app_config.state.current_song_index = (
play_queue.current_index or 0 play_queue.current_index or 0
) )
self.player.reset() self.player.reset()
self.app_config.state.current_notification = None
self.update_window() self.update_window()
if was_playing: if was_playing:
self.on_play_pause() self.on_play_pause()
self.app_config.state.current_notification = UIState.UINotification(
markup=f"<b>{resume_text}</b>",
actions=(("Resume", on_resume_click),),
)
self.update_window()
play_queue_future = AdapterManager.get_play_queue() play_queue_future = AdapterManager.get_play_queue()
play_queue_future.add_done_callback(lambda f: GLib.idle_add(do_update, f)) play_queue_future.add_done_callback(lambda f: GLib.idle_add(do_update, f))

View File

@@ -158,7 +158,6 @@ class AppConfiguration:
# Do the import in the function to avoid circular imports. # Do the import in the function to avoid circular imports.
from sublime.adapters import AdapterManager from sublime.adapters import AdapterManager
AdapterManager.reset(self) AdapterManager.reset(self)
@property @property

View File

@@ -557,6 +557,7 @@ class AlbumsGrid(Gtk.Overlay):
self.detail_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self.detail_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.detail_box.pack_start(Gtk.Box(), True, True, 0) self.detail_box.pack_start(Gtk.Box(), True, True, 0)
# TODO wrap in revealer?
self.detail_box_inner = Gtk.Box() self.detail_box_inner = Gtk.Box()
self.detail_box.pack_start(self.detail_box_inner, False, False, 0) self.detail_box.pack_start(self.detail_box_inner, False, False, 0)

View File

@@ -6,7 +6,7 @@ from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
from sublime.adapters import AdapterManager, api_objects as API, Result from sublime.adapters import AdapterManager, api_objects as API, Result
from sublime.config import AppConfiguration from sublime.config import AppConfiguration
from sublime.ui import albums, artists, browse, player_controls, playlists, util from sublime.ui import albums, artists, browse, player_controls, playlists, util
from sublime.ui.common import SpinnerImage from sublime.ui.common import IconButton, SpinnerImage
class MainWindow(Gtk.ApplicationWindow): class MainWindow(Gtk.ApplicationWindow):
@@ -24,6 +24,7 @@ class MainWindow(Gtk.ApplicationWindow):
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
), ),
"notification-closed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (),),
"go-to": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str, str),), "go-to": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str, str),),
} }
@@ -43,6 +44,33 @@ class MainWindow(Gtk.ApplicationWindow):
self.titlebar = self._create_headerbar(self.stack) self.titlebar = self._create_headerbar(self.stack)
self.set_titlebar(self.titlebar) self.set_titlebar(self.titlebar)
flowbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
notification_container = Gtk.Overlay()
notification_container.add(self.stack)
self.notification_revealer = Gtk.Revealer(
valign=Gtk.Align.END, halign=Gtk.Align.CENTER
)
notification_box = Gtk.Box(can_focus=False, valign="start", spacing=10)
notification_box.get_style_context().add_class("app-notification")
self.notification_text = Gtk.Label(use_markup=True)
notification_box.pack_start(self.notification_text, True, False, 0)
self.notification_actions = Gtk.Box()
notification_box.pack_start(self.notification_actions, True, False, 0)
notification_box.add(close_button := IconButton("window-close-symbolic"))
close_button.connect("clicked", lambda _: self.emit("notification-closed"))
self.notification_revealer.add(notification_box)
notification_container.add_overlay(self.notification_revealer)
flowbox.pack_start(notification_container, True, True, 0)
# Player Controls
self.player_controls = player_controls.PlayerControls() self.player_controls = player_controls.PlayerControls()
self.player_controls.connect( self.player_controls.connect(
"song-clicked", lambda _, *a: self.emit("song-clicked", *a) "song-clicked", lambda _, *a: self.emit("song-clicked", *a)
@@ -53,15 +81,32 @@ class MainWindow(Gtk.ApplicationWindow):
self.player_controls.connect( self.player_controls.connect(
"refresh-window", lambda _, *args: self.emit("refresh-window", *args), "refresh-window", lambda _, *args: self.emit("refresh-window", *args),
) )
flowbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
flowbox.pack_start(self.stack, True, True, 0)
flowbox.pack_start(self.player_controls, False, True, 0) flowbox.pack_start(self.player_controls, False, True, 0)
self.add(flowbox) self.add(flowbox)
self.connect("button-release-event", self._on_button_release) self.connect("button-release-event", self._on_button_release)
current_notification_hash = None
def update(self, app_config: AppConfiguration, force: bool = False): def update(self, app_config: AppConfiguration, force: bool = False):
notification = app_config.state.current_notification
if notification and hash(notification) != self.current_notification_hash:
self.notification_text.set_markup(notification.markup)
for c in self.notification_actions.get_children():
self.notification_actions.remove(c)
for label, fn in notification.actions:
self.notification_actions.add(action_button := Gtk.Button(label=label))
action_button.connect("clicked", lambda _: fn())
self.notification_revealer.show_all()
self.notification_revealer.set_reveal_child(True)
if notification is None:
self.notification_revealer.set_reveal_child(False)
# Update the Connected to label on the popup menu. # Update the Connected to label on the popup menu.
if app_config.server: if app_config.server:
self.connected_to_label.set_markup( self.connected_to_label.set_markup(

View File

@@ -1,7 +1,7 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import timedelta from datetime import timedelta
from enum import Enum from enum import Enum
from typing import Any, Dict, Optional, Tuple from typing import Any, Callable, Dict, Optional, Tuple
from sublime.adapters import AlbumSearchQuery from sublime.adapters import AlbumSearchQuery
from sublime.adapters.api_objects import Genre, Song from sublime.adapters.api_objects import Genre, Song
@@ -35,6 +35,13 @@ class RepeatType(Enum):
class UIState: class UIState:
"""Represents the UI state of the application.""" """Represents the UI state of the application."""
@dataclass(unsafe_hash=True)
class UINotification:
markup: str
actions: Tuple[Tuple[str, Callable[[], None]], ...] = field(
default_factory=tuple
)
version: int = 1 version: int = 1
playing: bool = False playing: bool = False
current_song_index: int = -1 current_song_index: int = -1
@@ -55,15 +62,18 @@ class UIState:
album_sort_direction: str = "ascending" album_sort_direction: str = "ascending"
album_page_size: int = 30 album_page_size: int = 30
album_page: int = 0 album_page: int = 0
current_notification: Optional[UINotification] = None
def __getstate__(self): def __getstate__(self):
state = self.__dict__.copy() state = self.__dict__.copy()
del state["song_stream_cache_progress"] del state["song_stream_cache_progress"]
del state["current_notification"]
return state return state
def __setstate__(self, state: Dict[str, Any]): def __setstate__(self, state: Dict[str, Any]):
self.__dict__.update(state) self.__dict__.update(state)
self.song_stream_cache_progress = None self.song_stream_cache_progress = None
self.current_notification = None
class _DefaultGenre(Genre): class _DefaultGenre(Genre):
def __init__(self): def __init__(self):
@@ -83,11 +93,11 @@ class UIState:
@property @property
def current_song(self) -> Optional[Song]: def current_song(self) -> Optional[Song]:
from sublime.adapters import AdapterManager
if not self.play_queue or self.current_song_index < 0: if not self.play_queue or self.current_song_index < 0:
return None return None
from sublime.adapters import AdapterManager
current_song_id = self.play_queue[self.current_song_index] current_song_id = self.play_queue[self.current_song_index]
if not self._current_song or self._current_song.id != current_song_id: if not self._current_song or self._current_song.id != current_song_id:

View File

@@ -267,11 +267,13 @@ def test_caching_get_playlist_then_details(cache_adapter: FilesystemAdapter):
def test_cache_cover_art(cache_adapter: FilesystemAdapter): def test_cache_cover_art(cache_adapter: FilesystemAdapter):
with pytest.raises(CacheMissError): with pytest.raises(CacheMissError):
cache_adapter.get_cover_art_uri("pl_test1", "file") cache_adapter.get_cover_art_uri("pl_test1", "file", size=300)
# After ingesting the data, reading from the cache should give the exact same file. # After ingesting the data, reading from the cache should give the exact same file.
cache_adapter.ingest_new_data(KEYS.COVER_ART_FILE, "pl_test1", MOCK_ALBUM_ART) cache_adapter.ingest_new_data(KEYS.COVER_ART_FILE, "pl_test1", MOCK_ALBUM_ART)
with open(cache_adapter.get_cover_art_uri("pl_test1", "file"), "wb+") as cached: with open(
cache_adapter.get_cover_art_uri("pl_test1", "file", size=300), "wb+"
) as cached:
with open(MOCK_ALBUM_ART, "wb+") as expected: with open(MOCK_ALBUM_ART, "wb+") as expected:
assert cached.read() == expected.read() assert cached.read() == expected.read()
@@ -294,8 +296,8 @@ def test_invalidate_playlist(cache_adapter: FilesystemAdapter):
KEYS.COVER_ART_FILE, "pl_2", MOCK_ALBUM_ART2, KEYS.COVER_ART_FILE, "pl_2", MOCK_ALBUM_ART2,
) )
stale_uri_1 = cache_adapter.get_cover_art_uri("pl_test1", "file") stale_uri_1 = cache_adapter.get_cover_art_uri("pl_test1", "file", size=300)
stale_uri_2 = cache_adapter.get_cover_art_uri("pl_2", "file") stale_uri_2 = cache_adapter.get_cover_art_uri("pl_2", "file", size=300)
cache_adapter.invalidate_data(KEYS.PLAYLISTS, None) cache_adapter.invalidate_data(KEYS.PLAYLISTS, None)
cache_adapter.invalidate_data(KEYS.PLAYLIST_DETAILS, "2") cache_adapter.invalidate_data(KEYS.PLAYLIST_DETAILS, "2")
@@ -311,7 +313,7 @@ def test_invalidate_playlist(cache_adapter: FilesystemAdapter):
assert len(e.partial_data) == 2 assert len(e.partial_data) == 2
try: try:
cache_adapter.get_cover_art_uri("pl_test1", "file") cache_adapter.get_cover_art_uri("pl_test1", "file", size=300)
assert 0, "DID NOT raise CacheMissError" assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e: except CacheMissError as e:
assert e.partial_data assert e.partial_data
@@ -326,7 +328,7 @@ def test_invalidate_playlist(cache_adapter: FilesystemAdapter):
# Even though the pl_2 cover art file wasn't explicitly invalidated, it should have # Even though the pl_2 cover art file wasn't explicitly invalidated, it should have
# been invalidated with the playlist details invalidation. # been invalidated with the playlist details invalidation.
try: try:
cache_adapter.get_cover_art_uri("pl_2", "file") cache_adapter.get_cover_art_uri("pl_2", "file", size=300)
assert 0, "DID NOT raise CacheMissError" assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e: except CacheMissError as e:
assert e.partial_data assert e.partial_data
@@ -349,7 +351,7 @@ def test_invalidate_song_file(cache_adapter: FilesystemAdapter):
cache_adapter.get_song_uri("1", "file") cache_adapter.get_song_uri("1", "file")
with pytest.raises(CacheMissError): with pytest.raises(CacheMissError):
cache_adapter.get_cover_art_uri("s1", "file") cache_adapter.get_cover_art_uri("s1", "file", size=300)
# Make sure it didn't delete the other song. # Make sure it didn't delete the other song.
assert cache_adapter.get_song_uri("2", "file").endswith("song2.mp3") assert cache_adapter.get_song_uri("2", "file").endswith("song2.mp3")
@@ -430,7 +432,7 @@ def test_delete_playlists(cache_adapter: FilesystemAdapter):
# Deleting a playlist with associated cover art should get rid the cover art too. # Deleting a playlist with associated cover art should get rid the cover art too.
cache_adapter.delete_data(KEYS.PLAYLIST_DETAILS, "1") cache_adapter.delete_data(KEYS.PLAYLIST_DETAILS, "1")
try: try:
cache_adapter.get_cover_art_uri("pl_1", "file") cache_adapter.get_cover_art_uri("pl_1", "file", size=300)
assert 0, "DID NOT raise CacheMissError" assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e: except CacheMissError as e:
assert e.partial_data is None assert e.partial_data is None
@@ -440,7 +442,7 @@ def test_delete_playlists(cache_adapter: FilesystemAdapter):
MOCK_ALBUM_ART, str(cache_adapter.cover_art_dir.joinpath(MOCK_ALBUM_ART_HASH)), MOCK_ALBUM_ART, str(cache_adapter.cover_art_dir.joinpath(MOCK_ALBUM_ART_HASH)),
) )
try: try:
cache_adapter.get_cover_art_uri("pl_1", "file") cache_adapter.get_cover_art_uri("pl_1", "file", size=300)
assert 0, "DID NOT raise CacheMissError" assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e: except CacheMissError as e:
assert e.partial_data is None assert e.partial_data is None
@@ -454,7 +456,7 @@ def test_delete_song_data(cache_adapter: FilesystemAdapter):
) )
music_file_path = cache_adapter.get_song_uri("1", "file") music_file_path = cache_adapter.get_song_uri("1", "file")
cover_art_path = cache_adapter.get_cover_art_uri("s1", "file") cover_art_path = cache_adapter.get_cover_art_uri("s1", "file", size=300)
cache_adapter.delete_data(KEYS.SONG_FILE, "1") cache_adapter.delete_data(KEYS.SONG_FILE, "1")
cache_adapter.delete_data(KEYS.COVER_ART_FILE, "s1") cache_adapter.delete_data(KEYS.COVER_ART_FILE, "s1")
@@ -469,7 +471,7 @@ def test_delete_song_data(cache_adapter: FilesystemAdapter):
assert e.partial_data is None assert e.partial_data is None
try: try:
cache_adapter.get_cover_art_uri("s1", "file") cache_adapter.get_cover_art_uri("s1", "file", size=300)
assert 0, "DID NOT raise CacheMissError" assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e: except CacheMissError as e:
assert e.partial_data is None assert e.partial_data is None
@@ -798,9 +800,9 @@ def test_caching_invalidate_artist(cache_adapter: FilesystemAdapter):
stale_artist = cache_adapter.get_artist("artist1") stale_artist = cache_adapter.get_artist("artist1")
stale_album_1 = cache_adapter.get_album("1") stale_album_1 = cache_adapter.get_album("1")
stale_album_2 = cache_adapter.get_album("2") stale_album_2 = cache_adapter.get_album("2")
stale_artist_artwork = cache_adapter.get_cover_art_uri("image", "file") stale_artist_artwork = cache_adapter.get_cover_art_uri("image", "file", size=300)
stale_cover_art_1 = cache_adapter.get_cover_art_uri("1", "file") stale_cover_art_1 = cache_adapter.get_cover_art_uri("1", "file", size=300)
stale_cover_art_2 = cache_adapter.get_cover_art_uri("2", "file") stale_cover_art_2 = cache_adapter.get_cover_art_uri("2", "file", size=300)
cache_adapter.invalidate_data(KEYS.ARTIST, "artist1") cache_adapter.invalidate_data(KEYS.ARTIST, "artist1")
@@ -827,21 +829,21 @@ def test_caching_invalidate_artist(cache_adapter: FilesystemAdapter):
assert e.partial_data == stale_album_2 assert e.partial_data == stale_album_2
try: try:
cache_adapter.get_cover_art_uri("image", "file") cache_adapter.get_cover_art_uri("image", "file", size=300)
assert 0, "DID NOT raise CacheMissError" assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e: except CacheMissError as e:
assert e.partial_data assert e.partial_data
assert e.partial_data == stale_artist_artwork assert e.partial_data == stale_artist_artwork
try: try:
cache_adapter.get_cover_art_uri("1", "file") cache_adapter.get_cover_art_uri("1", "file", size=300)
assert 0, "DID NOT raise CacheMissError" assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e: except CacheMissError as e:
assert e.partial_data assert e.partial_data
assert e.partial_data == stale_cover_art_1 assert e.partial_data == stale_cover_art_1
try: try:
cache_adapter.get_cover_art_uri("2", "file") cache_adapter.get_cover_art_uri("2", "file", size=300)
assert 0, "DID NOT raise CacheMissError" assert 0, "DID NOT raise CacheMissError"
except CacheMissError as e: except CacheMissError as e:
assert e.partial_data assert e.partial_data