diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f4b3e4c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool.black] +exclude = ''' +( + /flatpak/ +) +''' diff --git a/sublime/ui/app_styles.css b/sublime/ui/app_styles.css index ab599f7..7c9918f 100644 --- a/sublime/ui/app_styles.css +++ b/sublime/ui/app_styles.css @@ -181,6 +181,10 @@ min-width: 50px; } +#play-queue-image-disabled { + opacity: 0.5; +} + /* ********** General ********** */ .menu-button { padding: 5px; @@ -258,15 +262,18 @@ margin: 15px; } +@define-color box_shadow_color rgba(0, 0, 0, 0.2); + #artist-info-panel { + box-shadow: 0 5px 5px @box_shadow_color; margin-bottom: 10px; + padding-bottom: 10px; } -@define-color detail_color rgba(0, 0, 0, 0.2); #artist-detail-box { padding-top: 10px; padding-bottom: 10px; - box-shadow: inset 0 5px 5px @detail_color, - inset 0 -5px 5px @detail_color; - background-color: @detail_color; + box-shadow: inset 0 5px 5px @box_shadow_color, + inset 0 -5px 5px @box_shadow_color; + background-color: @box_shadow_color; } diff --git a/sublime/ui/artists.py b/sublime/ui/artists.py index c1d3319..47280ae 100644 --- a/sublime/ui/artists.py +++ b/sublime/ui/artists.py @@ -234,13 +234,13 @@ class ArtistDetailPanel(Gtk.Box): # TODO: make these disabled if there are no songs that can be played. play_button = IconButton( - "media-playback-start-symbolic", label="Play All", relief=True, + "media-playback-start-symbolic", label="Play All", relief=True ) play_button.connect("clicked", self.on_play_all_clicked) self.play_shuffle_buttons.pack_start(play_button, False, False, 0) shuffle_button = IconButton( - "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True, + "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True ) shuffle_button.connect("clicked", self.on_shuffle_all_button) self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5) diff --git a/sublime/ui/player_controls.py b/sublime/ui/player_controls.py index 2c83b37..403cf46 100644 --- a/sublime/ui/player_controls.py +++ b/sublime/ui/player_controls.py @@ -7,7 +7,7 @@ from typing import Any, Callable, List, Optional, Tuple from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango from pychromecast import Chromecast -from sublime.adapters import AdapterManager, Result +from sublime.adapters import AdapterManager, Result, SongCacheStatus from sublime.adapters.api_objects import Song from sublime.config import AppConfiguration from sublime.players import ChromecastPlayer @@ -177,6 +177,7 @@ class PlayerControls(Gtk.ActionBar): self.update_device_list() # Short circuit if no changes to the play queue + force |= self.offline_mode != app_config.offline_mode self.offline_mode = app_config.offline_mode self.load_play_queue_button.set_sensitive(not self.offline_mode) @@ -224,7 +225,7 @@ class PlayerControls(Gtk.ActionBar): if order_token != self.play_queue_update_order_token: return - self.play_queue_store[idx][0] = cover_art_filename + 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 @@ -247,21 +248,26 @@ class PlayerControls(Gtk.ActionBar): if order_token != self.play_queue_update_order_token: return - self.play_queue_store[idx][1] = calculate_label(song_details) + 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][0] = 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 in enumerate(app_config.state.play_queue): + 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 = "" @@ -282,6 +288,11 @@ class PlayerControls(Gtk.ActionBar): 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, @@ -361,6 +372,8 @@ class PlayerControls(Gtk.ActionBar): self.play_queue_popover.show_all() 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", @@ -481,7 +494,7 @@ class PlayerControls(Gtk.ActionBar): # 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[2] + i for i, s in enumerate(self.play_queue_store) if s[3] # playing ][0] self.emit( "refresh-window", @@ -706,6 +719,7 @@ class PlayerControls(Gtk.ActionBar): ) self.play_queue_store = Gtk.ListStore( + bool, # playable str, # image filename str, # title, album, artist bool, # playing @@ -714,30 +728,35 @@ class PlayerControls(Gtk.ActionBar): self.play_queue_list = Gtk.TreeView( model=self.play_queue_store, reorderable=True, headers_visible=False, ) - self.play_queue_list.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) + 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. + # 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, - iter: Gtk.TreeIter, + tree_iter: Gtk.TreeIter, flags: Any, ): - filename = model.get_value(iter, 0) + 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(iter, 2): + if model.get_value(tree_iter, 3): play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file( str(Path(__file__).parent.joinpath("images/play-queue-play.png")) ) play_overlay_pixbuf.composite( - pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 255 + pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 200 ) cell.set_property("pixbuf", pixbuf) @@ -749,8 +768,8 @@ class PlayerControls(Gtk.ActionBar): 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=1) + 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) diff --git a/sublime/ui/playlists.py b/sublime/ui/playlists.py index 4c2f07c..23714d9 100644 --- a/sublime/ui/playlists.py +++ b/sublime/ui/playlists.py @@ -713,6 +713,8 @@ class PlaylistDetailPanel(Gtk.Overlay): ) def on_song_activated(self, _, idx: Gtk.TreePath, col: Any): + if not self.playlist_song_store[idx[0]][0]: + return # The song ID is in the last column of the model. self.emit( "song-clicked", diff --git a/tests/adapter_tests/subsonic_adapter_tests.py b/tests/adapter_tests/subsonic_adapter_tests.py index 236a91c..be33ca5 100644 --- a/tests/adapter_tests/subsonic_adapter_tests.py +++ b/tests/adapter_tests/subsonic_adapter_tests.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any, Generator, List, Tuple import pytest +from dateutil.tz import tzutc from sublime.adapters.subsonic import ( api_objects as SubsonicAPI, @@ -111,8 +112,8 @@ def test_get_playlists(adapter: SubsonicAdapter): name="Test", song_count=132, duration=timedelta(seconds=33072), - created=datetime(2020, 3, 27, 5, 38, 45, 0, tzinfo=timezone.utc), - changed=datetime(2020, 4, 9, 16, 3, 26, 0, tzinfo=timezone.utc), + created=datetime(2020, 3, 27, 5, 38, 45, 0, tzinfo=tzutc()), + changed=datetime(2020, 4, 9, 16, 3, 26, 0, tzinfo=tzutc()), comment="Foo", owner="foo", public=True, @@ -123,8 +124,8 @@ def test_get_playlists(adapter: SubsonicAdapter): name="Bar", song_count=23, duration=timedelta(seconds=847), - created=datetime(2020, 3, 27, 5, 39, 4, 0, tzinfo=timezone.utc), - changed=datetime(2020, 3, 27, 5, 45, 23, 0, tzinfo=timezone.utc), + created=datetime(2020, 3, 27, 5, 39, 4, 0, tzinfo=tzutc()), + changed=datetime(2020, 3, 27, 5, 45, 23, 0, tzinfo=tzutc()), comment="", owner="foo", public=False, @@ -136,7 +137,7 @@ def test_get_playlists(adapter: SubsonicAdapter): logging.info(filename) logging.debug(data) adapter._set_mock_data(data) - assert adapter.get_playlists() == expected + assert adapter.get_playlists() == sorted(expected, key=lambda e: e.name) # When playlists is null, expect an empty list. adapter._set_mock_data(mock_json()) diff --git a/tests/config_test.py b/tests/config_test.py index 4f76e1c..cc06070 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -46,14 +46,15 @@ def test_yaml_load_unload(): def test_config_migrate(): - config = AppConfiguration() + config = AppConfiguration(always_stream=True) server = ServerConfiguration( name="Test", server_address="https://test.host", username="test" ) config.servers.append(server) config.migrate() - assert config.version == 3 + assert config.version == 4 + assert config.allow_song_downloads is False for server in config.servers: server.version == 0