From 5c4a29e6ae8a116f9edec79645ed016389689987 Mon Sep 17 00:00:00 2001 From: Benjamin Schaaf Date: Sun, 2 Jan 2022 20:40:07 +1100 Subject: [PATCH] WIP --- sublime_music/adapters/adapter_base.py | 8 +- sublime_music/app.py | 296 ++++++++++---------- sublime_music/ui/actions.py | 256 +++++++++++++++++ sublime_music/ui/albums.py | 233 +++------------ sublime_music/ui/app_styles.css | 5 - sublime_music/ui/artists.py | 4 - sublime_music/ui/common/album_with_songs.py | 53 ++-- sublime_music/ui/main.py | 47 +--- sublime_music/ui/player_controls.py | 0 sublime_music/ui/player_controls/common.py | 65 ++++- sublime_music/ui/player_controls/desktop.py | 38 +-- sublime_music/ui/player_controls/manager.py | 49 ++-- sublime_music/ui/player_controls/mobile.py | 62 +--- sublime_music/ui/state.py | 6 - sublime_music/ui/util.py | 8 +- tests/ui_actions_test.py | 19 ++ 16 files changed, 604 insertions(+), 545 deletions(-) create mode 100644 sublime_music/ui/actions.py delete mode 100644 sublime_music/ui/player_controls.py create mode 100644 tests/ui_actions_test.py diff --git a/sublime_music/adapters/adapter_base.py b/sublime_music/adapters/adapter_base.py index 19fd198..b4c005d 100644 --- a/sublime_music/adapters/adapter_base.py +++ b/sublime_music/adapters/adapter_base.py @@ -81,9 +81,9 @@ class AlbumSearchQuery: :class:`AlbumSearchQuery.Type.GENRE`) return albums of the given genre """ - class _Genre(Genre): - def __init__(self, name: str): - self.name = name + @dataclass + class Genre: + name: str class Type(Enum): """ @@ -116,7 +116,7 @@ class AlbumSearchQuery: type: Type year_range: Tuple[int, int] = this_decade() - genre: Genre = _Genre("Rock") + genre: Genre = Genre("Rock") _strhash: Optional[str] = None diff --git a/sublime_music/app.py b/sublime_music/app.py index b6689da..85bf68c 100644 --- a/sublime_music/app.py +++ b/sublime_music/app.py @@ -19,7 +19,12 @@ except Exception: tap_imported = False import gi -from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk +from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk, GIRepository + +# Temporary for development +repo = GIRepository.Repository.get_default() +repo.prepend_library_path('../libhandy/_build/src') +repo.prepend_search_path('../libhandy/_build/src') gi.require_version('Handy', '1') from gi.repository import Handy @@ -52,6 +57,7 @@ from .players import PlayerDeviceEvent, PlayerEvent, PlayerManager from .ui.configure_provider import ConfigureProviderDialog from .ui.main import MainWindow from .ui.state import RepeatType, UIState +from .ui.actions import register_action from .util import resolve_path @@ -83,38 +89,43 @@ class SublimeMusicApp(Gtk.Application): action.connect("activate", fn) self.add_action(action) - # Add action for menu items. - add_action("add-new-music-provider", self.on_add_new_music_provider) - add_action("edit-current-music-provider", self.on_edit_current_music_provider) - add_action( - "switch-music-provider", self.on_switch_music_provider, parameter_type="s" - ) - add_action( - "remove-music-provider", self.on_remove_music_provider, parameter_type="s" - ) + register_action(self, self.change_tab) + + # Music provider actions + register_action(self, self.add_new_music_provider) + register_action(self, self.edit_current_music_provider) + register_action(self, self.switch_music_provider) + register_action(self, self.remove_music_provider) + + # Connect after we know there's a server configured. + # self.window.connect("notification-closed", self.on_notification_closed) + # self.window.connect("key-press-event", self.on_window_key_press) # Add actions for player controls - add_action("play-pause", self.on_play_pause) - add_action("next-track", self.on_next_track) - add_action("prev-track", self.on_prev_track) - add_action("repeat-press", self.on_repeat_press) - add_action("shuffle-press", self.on_shuffle_press) + register_action(self, self.seek) + register_action(self, self.play_pause) + register_action(self, self.next_track) + register_action(self, self.prev_track) + register_action(self, self.repeat) + register_action(self, self.shuffle) + register_action(self, self.play_song_action, name='play-song') + # self.window.connect("songs-removed", self.on_songs_removed) + + register_action(self, self.select_device) + register_action(self, self.toggle_mute) + register_action(self, self.set_volume) # Navigation actions. - add_action("play-next", self.on_play_next, parameter_type="as") - add_action("add-to-queue", self.on_add_to_queue, parameter_type="as") - add_action("go-to-album", self.on_go_to_album, parameter_type="s") - add_action("go-to-artist", self.on_go_to_artist, parameter_type="s") - add_action("browse-to", self.browse_to, parameter_type="s") - add_action("go-to-playlist", self.on_go_to_playlist, parameter_type="s") + register_action(self, self.queue_next_songs) + register_action(self, self.queue_songs) + register_action(self, self.go_to_album) + register_action(self, self.go_to_artist) + register_action(self, self.browse_to) + register_action(self, self.go_to_playlist) add_action("go-online", self.on_go_online) add_action("refresh-devices", self.on_refresh_devices) - add_action( - "refresh-window", - lambda *a: self.on_refresh_window(None, {}, True), - ) - add_action("mute-toggle", self.on_mute_toggle) + register_action(self, self.force_refresh) add_action( "update-play-queue-from-server", lambda a, p: self.update_play_state_from_server(), @@ -122,11 +133,16 @@ class SublimeMusicApp(Gtk.Application): if tap_imported: self.tap = osxmmkeys.Tap() - self.tap.on("play_pause", self.on_play_pause) - self.tap.on("next_track", self.on_next_track) - self.tap.on("prev_track", self.on_prev_track) + self.tap.on("play_pause", self.play_pause) + self.tap.on("next_track", self.next_track) + self.tap.on("prev_track", self.prev_track) self.tap.start() + # self.add_accelerator("F", 'app.play-pause') + self.add_accelerator("space", 'app.play-pause') + self.add_accelerator("Home", 'app.prev-track') + self.add_accelerator("End", 'app.next-track') + def do_activate(self): # We only allow a single window and raise any existing ones if self.window: @@ -147,6 +163,12 @@ class SublimeMusicApp(Gtk.Application): # closed the application shuts down. self.window = MainWindow(application=self, title="Sublime Music") + albums = Gio.SimpleActionGroup() + register_action(albums, self.albums_set_search_query, 'set-search-query') + register_action(albums, self.albums_set_page, 'set-page') + register_action(albums, self.albums_select_album, 'select-album') + self.window.insert_action_group('albums', albums) + # Configure the CSS provider so that we can style elements on the # window. css_provider = Gtk.CssProvider() @@ -177,18 +199,6 @@ class SublimeMusicApp(Gtk.Application): AdapterManager.reset(self.app_config, self.on_song_download_progress) - # Connect after we know there's a server configured. - self.window.stack.connect("notify::visible-child", self.on_stack_change) - self.window.connect("song-clicked", self.on_song_clicked) - self.window.connect("songs-removed", self.on_songs_removed) - 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("key-press-event", self.on_window_key_press) - 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) self.loading_state = False @@ -237,7 +247,7 @@ class SublimeMusicApp(Gtk.Application): self.update_window() return - GLib.idle_add(self.on_next_track) + GLib.idle_add(self.next_track) def on_player_event(event: PlayerEvent): if event.type == PlayerEvent.EventType.PLAY_STATE_CHANGE: @@ -384,7 +394,7 @@ class SublimeMusicApp(Gtk.Application): def set_pos_fn(track_id: str, position: float = 0): if self.app_config.state.playing: - self.on_play_pause() + self.play_pause() pos_seconds = timedelta(microseconds=position) self.app_config.state.song_progress = pos_seconds track_id, occurrence = track_id.split("/")[-2:] @@ -448,7 +458,7 @@ class SublimeMusicApp(Gtk.Application): if self.app_config.state.shuffle_on: song_idx = random.randint(0, len(playlist.songs) - 1) - self.on_song_clicked( + self.play_song( None, song_idx, tuple(s.id for s in playlist.songs), @@ -499,11 +509,11 @@ class SublimeMusicApp(Gtk.Application): def play(): if not self.app_config.state.playing: - self.on_play_pause() + self.play_pause() def pause(): if self.app_config.state.playing: - self.on_play_pause() + self.play_pause() method_call_map: Dict[str, Dict[str, Any]] = { "org.mpris.MediaPlayer2": { @@ -511,10 +521,10 @@ class SublimeMusicApp(Gtk.Application): "Quit": self.window and self.window.destroy, }, "org.mpris.MediaPlayer2.Player": { - "Next": self.on_next_track, - "Previous": self.on_prev_track, + "Next": self.next_track, + "Previous": self.prev_track, "Pause": pause, - "PlayPause": self.on_play_pause, + "PlayPause": self.play_pause, "Stop": pause, "Play": play, "Seek": seek_fn, @@ -552,10 +562,10 @@ class SublimeMusicApp(Gtk.Application): def set_shuffle(new_val: GLib.Variant): if new_val.get_boolean() != self.app_config.state.shuffle_on: - self.on_shuffle_press(None, None) + self.shuffle() def set_volume(new_val: GLib.Variant): - self.on_volume_change(None, new_val.get_double() * 100) + self.set_volume(new_val.get_double() * 100) setter_map: Dict[str, Dict[str, Any]] = { "org.mpris.MediaPlayer2.Player": { @@ -574,49 +584,52 @@ class SublimeMusicApp(Gtk.Application): setter(value) # ########## ACTION HANDLERS ########## # + # @dbus_propagate() + # def on_refresh_window(self, _, state_updates: Dict[str, Any], force: bool = False): + # if settings := state_updates.get("__settings__"): + # for k, v in settings.items(): + # setattr(self.app_config, k, v) + # if (offline_mode := settings.get("offline_mode")) is not None: + # AdapterManager.on_offline_mode_change(offline_mode) + + # del state_updates["__settings__"] + # self.app_config.save() + + # if player_setting := state_updates.get("__player_setting__"): + # player_name, option_name, value = player_setting + # self.app_config.player_config[player_name][option_name] = value + # del state_updates["__player_setting__"] + # if pm := self.player_manager: + # pm.change_settings(self.app_config.player_config) + # self.app_config.save() + + # for k, v in state_updates.items(): + # setattr(self.app_config.state, k, v) + # self.update_window(force=force) @dbus_propagate() - def on_refresh_window(self, _, state_updates: Dict[str, Any], force: bool = False): - if settings := state_updates.get("__settings__"): - for k, v in settings.items(): - setattr(self.app_config, k, v) - if (offline_mode := settings.get("offline_mode")) is not None: - AdapterManager.on_offline_mode_change(offline_mode) - - del state_updates["__settings__"] - self.app_config.save() - - if player_setting := state_updates.get("__player_setting__"): - player_name, option_name, value = player_setting - self.app_config.player_config[player_name][option_name] = value - del state_updates["__player_setting__"] - if pm := self.player_manager: - pm.change_settings(self.app_config.player_config) - self.app_config.save() - - for k, v in state_updates.items(): - setattr(self.app_config.state, k, v) - self.update_window(force=force) + def force_refresh(self): + self.update_window(force=True) def on_notification_closed(self, _): self.app_config.state.current_notification = None self.update_window() - def on_add_new_music_provider(self, *args): + def add_new_music_provider(self): self.show_configure_servers_dialog() - def on_edit_current_music_provider(self, *args): + def edit_current_music_provider(self): self.show_configure_servers_dialog(self.app_config.provider.clone()) - def on_switch_music_provider(self, _, provider_id: GLib.Variant): + def switch_music_provider(self, provider_id: str): if self.app_config.state.playing: - self.on_play_pause() + self.play_pause() self.app_config.save() - self.app_config.current_provider_id = provider_id.get_string() + self.app_config.current_provider_id = provider_id self.reset_state() self.app_config.save() - def on_remove_music_provider(self, _, provider_id: GLib.Variant): - provider = self.app_config.providers[provider_id.get_string()] + def remove_music_provider(self, provider_id: str): + provider = self.app_config.providers[provider_id] confirm_dialog = Gtk.MessageDialog( transient_for=self.window, message_type=Gtk.MessageType.WARNING, @@ -640,17 +653,10 @@ class SublimeMusicApp(Gtk.Application): confirm_dialog.destroy() - def on_window_go_to(self, win: Any, action: str, value: str): - { - "album": self.on_go_to_album, - "artist": self.on_go_to_artist, - "playlist": self.on_go_to_playlist, - }[action](None, GLib.Variant("s", value)) - _inhibit_cookie = None @dbus_propagate() - def on_play_pause(self, *args): + def play_pause(self): if self.app_config.state.current_song_index < 0: return @@ -675,7 +681,7 @@ class SublimeMusicApp(Gtk.Application): self.update_window() - def on_next_track(self, *args): + def next_track(self): if self.app_config.state.current_song is None: # This may happen due to DBUS, ignore. return @@ -701,7 +707,7 @@ class SublimeMusicApp(Gtk.Application): else: self.update_window() - def on_prev_track(self, *args): + def prev_track(self): if self.app_config.state.current_song is None: # This may happen due to DBUS, ignore. return @@ -734,14 +740,14 @@ class SublimeMusicApp(Gtk.Application): self.update_window() @dbus_propagate() - def on_repeat_press(self, *args): + def repeat(self): # Cycle through the repeat types. new_repeat_type = RepeatType((self.app_config.state.repeat_type.value + 1) % 3) self.app_config.state.repeat_type = new_repeat_type self.update_window() @dbus_propagate() - def on_shuffle_press(self, *args): + def shuffle(self): if self.app_config.state.shuffle_on: # Revert to the old play queue. old_play_queue_copy = self.app_config.state.old_play_queue @@ -765,8 +771,7 @@ class SublimeMusicApp(Gtk.Application): self.update_window() @dbus_propagate() - def on_play_next(self, action: Any, song_ids: GLib.Variant): - song_ids = tuple(song_ids) + def queue_next_songs(self, song_ids: List[str]): if self.app_config.state.current_song is None: insert_at = 0 else: @@ -781,13 +786,13 @@ class SublimeMusicApp(Gtk.Application): self.update_window() @dbus_propagate() - def on_add_to_queue(self, action: Any, song_ids: GLib.Variant): + def queue_songs(self, song_ids: List[str]): song_ids = tuple(song_ids) self.app_config.state.play_queue += tuple(song_ids) self.app_config.state.old_play_queue += tuple(song_ids) self.update_window() - def on_go_to_album(self, action: Any, album_id: GLib.Variant): + def go_to_album(self, album_id: str): # Switch to the Alphabetical by Name view to guarantee that the album is there. self.app_config.state.current_album_search_query = AlbumSearchQuery( AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME, @@ -796,22 +801,22 @@ class SublimeMusicApp(Gtk.Application): ) self.app_config.state.current_tab = "albums" - self.app_config.state.selected_album_id = album_id.get_string() + self.app_config.state.selected_album_id = album_id self.update_window() - def on_go_to_artist(self, action: Any, artist_id: GLib.Variant): + def go_to_artist(self, artist_id: str): self.app_config.state.current_tab = "artists" - self.app_config.state.selected_artist_id = artist_id.get_string() + self.app_config.state.selected_artist_id = artist_id self.update_window() - def browse_to(self, action: Any, item_id: GLib.Variant): + def browse_to(self, item_id: str): self.app_config.state.current_tab = "browse" - self.app_config.state.selected_browse_element_id = item_id.get_string() + self.app_config.state.selected_browse_element_id = item_id self.update_window() - def on_go_to_playlist(self, action: Any, playlist_id: GLib.Variant): + def go_to_playlist(self, playlist_id: str): self.app_config.state.current_tab = "playlists" - self.app_config.state.selected_playlist_id = playlist_id.get_string() + self.app_config.state.selected_playlist_id = playlist_id self.update_window() def on_go_online(self, *args): @@ -822,7 +827,7 @@ class SublimeMusicApp(Gtk.Application): def reset_state(self): if self.app_config.state.playing: - self.on_play_pause() + self.play_pause() self.loading_state = True self.player_manager.reset() AdapterManager.reset(self.app_config, self.on_song_download_progress) @@ -831,17 +836,14 @@ class SublimeMusicApp(Gtk.Application): # Update the window according to the new server configuration. self.update_window() - def on_stack_change(self, stack: Gtk.Stack, _): - self.app_config.state.current_tab = stack.get_visible_child_name() + def change_tab(self, tab_id: str): + self.app_config.state.current_tab = tab_id self.update_window() - def on_song_clicked( - self, - win: Any, - song_index: int, - song_queue: Tuple[str, ...], - metadata: Dict[str, Any], - ): + def play_song_action(self, song_index: int, song_queue: List[str], metadata: Dict[str, bool]): + if not song_queue: + song_queue = self.app_config.state.play_queue + song_queue = tuple(song_queue) # Reset the play queue so that we don't ever revert back to the # previous one. @@ -886,7 +888,7 @@ class SublimeMusicApp(Gtk.Application): if self.app_config.state.current_song_index in song_indexes_to_remove: if len(self.app_config.state.play_queue) == 0: - self.on_play_pause() + self.play_pause() self.app_config.state.current_song_index = -1 self.update_window() return @@ -899,7 +901,7 @@ class SublimeMusicApp(Gtk.Application): self.save_play_queue() @dbus_propagate() - def on_seek(self, _, value: float): + def seek(self, value: float): if not self.app_config.state.current_song or not self.window: return @@ -917,14 +919,14 @@ class SublimeMusicApp(Gtk.Application): self.save_play_queue() - def on_device_update(self, _, device_id: str): + def select_device(self, device_id: str): assert self.player_manager if device_id == self.app_config.state.current_device: return self.app_config.state.current_device = device_id if was_playing := self.app_config.state.playing: - self.on_play_pause() + self.play_pause() self.player_manager.set_current_device_id(self.app_config.state.current_device) @@ -934,51 +936,22 @@ class SublimeMusicApp(Gtk.Application): self.update_window() if was_playing: - self.on_play_pause() + self.play_pause() if self.dbus_manager: self.dbus_manager.property_diff() @dbus_propagate() - def on_mute_toggle(self, *args): + def toggle_mute(self): self.app_config.state.is_muted = not self.app_config.state.is_muted self.player_manager.set_muted(self.app_config.state.is_muted) self.update_window() @dbus_propagate() - def on_volume_change(self, _, value: float): - assert self.player_manager + def set_volume(self, value: float): self.app_config.state.volume = value self.player_manager.set_volume(self.app_config.state.volume) self.update_window() - def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey) -> bool: - # Need to use bitwise & here to see if CTRL is pressed. - if event.keyval == 102 and event.state & Gdk.ModifierType.CONTROL_MASK: - # Ctrl + F - window.search_entry.grab_focus() - return False - - # Allow spaces to work in the text entry boxes. - if ( - window.search_entry.has_focus() - or window.playlists_panel.playlist_list.new_playlist_entry.has_focus() - ): - return False - - # Spacebar, home/prev - keymap = { - 32: self.on_play_pause, - 65360: self.on_prev_track, - 65367: self.on_next_track, - } - - action = keymap.get(event.keyval) - if action: - action() - return True - - return False - def on_song_download_progress(self, song_id: str, progress: DownloadProgress): assert self.window GLib.idle_add(self.window.update_song_download_progress, song_id, progress) @@ -1005,6 +978,23 @@ class SublimeMusicApp(Gtk.Application): self.dbus_manager.shutdown() AdapterManager.shutdown() + def albums_set_search_query(self, query: AlbumSearchQuery, sort_direction: str): + self.app_config.state.current_album_search_query = query + self.app_config.state.album_sort_direction = sort_direction + self.app_config.state.album_page = 0 + self.app_config.state.selected_album_id = None + self.update_window() + + def albums_set_page(self, page: int): + self.app_config.state.album_page = page + self.app_config.state.selected_album_id = None + self.update_window() + + def albums_select_album(self, album_id: str): + self.app_config.state.selected_album_id = album_id + self.update_window() + + # ########## HELPER METHODS ########## # def show_configure_servers_dialog( self, @@ -1026,7 +1016,7 @@ class SublimeMusicApp(Gtk.Application): else: # Switch to the new provider. if self.app_config.state.playing: - self.on_play_pause() + self.play_pause() self.app_config.current_provider_id = provider_id self.app_config.save() self.update_window(force=True) @@ -1066,7 +1056,7 @@ class SublimeMusicApp(Gtk.Application): def do_resume(clear_notification: bool): assert self.player_manager if was_playing := self.app_config.state.playing: - self.on_play_pause() + self.play_pause() self.app_config.state.play_queue = new_play_queue self.app_config.state.song_progress = play_queue.position @@ -1078,7 +1068,7 @@ class SublimeMusicApp(Gtk.Application): self.update_window() if was_playing: - self.on_play_pause() + self.play_pause() if prompt_confirm: # If there's not a significant enough difference in the song state, @@ -1385,13 +1375,13 @@ class SublimeMusicApp(Gtk.Application): # There are no songs that can be played. Show a notification that you # have to go online to play anything and then don't go further. if was_playing := self.app_config.state.playing: - self.on_play_pause() + self.play_pause() def go_online_clicked(): self.app_config.state.current_notification = None self.on_go_online() if was_playing: - self.on_play_pause() + self.play_pause() if all(s == SongCacheStatus.NOT_CACHED for s in statuses): markup = ( diff --git a/sublime_music/ui/actions.py b/sublime_music/ui/actions.py new file mode 100644 index 0000000..8cd5eb3 --- /dev/null +++ b/sublime_music/ui/actions.py @@ -0,0 +1,256 @@ +import inspect +import enum +import dataclasses +from typing import Callable, Optional, Tuple, Any, Union + +from gi.repository import Gio, GLib + + +NoneType = type(None) + + +def run_action(widget, name, *args): + group, action = name.split('.') + action_group = widget.get_action_group(group) + + if args: + type_str = action_group.get_action_parameter_type(action) + assert type_str + + if len(args) > 1: + param = _create_variant(type_str.dup_string(), tuple(args)) + else: + param = _create_variant(type_str.dup_string(), args[0]) + else: + param = None + + action_group.activate_action(action, param) + + +def register_action(group, fn: Callable, name: Optional[str] = None): + if name is None: + name = fn.__name__.replace('_', '-') + + # Determine the type from the signature + signature = inspect.signature(fn) + + if signature.parameters: + arg_types = tuple(p.annotation for p in signature.parameters.values()) + + if inspect.Parameter.empty in arg_types: + raise ValueError('Missing parameter annotation for action ' + name) + + has_multiple = len(arg_types) > 1 + if has_multiple: + param_type = Tuple.__getitem__(arg_types) + else: + param_type = arg_types[0] + + type_str = variant_type_from_python(param_type) + var_type = GLib.VariantType(type_str) + + build = generate_build_function(param_type) + if not build: + build = lambda a: a + else: + var_type = None + + action = Gio.SimpleAction.new(name, var_type) + def activate(action, param): + if param: + if has_multiple: + fn(*build(param.unpack())) + else: + fn(build(param.unpack())) + else: + fn() + action.connect('activate', activate) + + if hasattr(group, 'add_action'): + group.add_action(action) + else: + group.insert(action) + + +def variant_type_from_python(py_type: type) -> str: + if py_type is bool: + return 'b' + elif py_type is int: + return 'x' + elif py_type is float: + return 'd' + elif py_type is str: + return 's' + elif py_type is Any: + return 'v' + elif isinstance(py_type, type) and issubclass(py_type, enum.Enum): + return variant_type_from_python(type(list(py_type)[0].value)) + elif dataclasses.is_dataclass(py_type): + types = (f.type for f in dataclasses.fields(py_type)) + + return '(' + ''.join(map(variant_type_from_python, types)) + ')' + else: + origin = py_type.__origin__ + + if origin is list: + assert len(py_type.__args__) == 1 + + return 'a' + variant_type_from_python(py_type.__args__[0]) + elif origin is tuple: + return '(' + ''.join(map(variant_type_from_python, py_type.__args__)) + ')' + elif origin is dict: + assert len(py_type.__args__) == 2 + + key = variant_type_from_python(py_type.__args__[0]) + value = variant_type_from_python(py_type.__args__[1]) + return 'a{' + key + value + '}' + elif origin is Union: + non_maybe = [t for t in py_type.__args__ if t is not NoneType] + has_maybe = len(non_maybe) != len(py_type.__args__) + + if has_maybe and len(non_maybe) == 1: + return 'm' + variant_type_from_python(non_maybe[0]) + + return ('m[' if has_maybe else '[') + ''.join(''.join(map(variant_type_from_python, non_maybe))) + ']' + else: + raise ValueError('{} does not have an equivalent'.format(py_type)) + + +def unbuilt_type(py_type: type) -> type: + if isinstance(py_type, type) and issubclass(py_type, enum.Enum): + return type(list(py_type)[0].value) + elif dataclasses.is_dataclass(py_type): + return tuple + return py_type + + +def generate_build_function(py_type: type) -> Optional[Callable]: + """ + Return a function for reconstructing dataclasses and enumerations after + unpacking a GVariant. When no reconstruction is needed None is returned. + """ + + if isinstance(py_type, type) and issubclass(py_type, enum.Enum): + return py_type + elif dataclasses.is_dataclass(py_type): + types = tuple(f.type for f in dataclasses.fields(py_type)) + tuple_build = generate_build_function(Tuple.__getitem__(types)) + + if not tuple_build: + return lambda values: py_type(*values) + + return lambda values: py_type(*tuple_build(values)) + elif hasattr(py_type, '__origin__'): + origin = py_type.__origin__ + + if origin is list: + assert len(py_type.__args__) == 1 + + build = generate_build_function(py_type.__args__[0]) + + if build: + return lambda values: [build(v) for v in values] + + elif origin is tuple: + builds = list(map(generate_build_function, py_type.__args__)) + + if not any(builds): + return None + + return lambda values: tuple((build(value) if build else value) for build, value in zip(builds, values)) + elif origin is dict: + assert len(py_type.__args__) == 2 + + build_key = generate_build_function(py_type.__args__[0]) + build_value = generate_build_function(py_type.__args__[1]) + + if not build_key and not build_value: + return None + + return lambda values: { + (build_key(key) if build_key else key): (build_value(value) if build_value else value) + for key, value in values.items()} + elif origin is Union: + builds = list(map(generate_build_function, py_type.__args__)) + + if not any(builds): + return None + + unbuilt_types = list(map(unbuilt_type, py_type.__args__)) + + def build(value): + for bld, type_ in zip(builds, unbuilt_types): + if isinstance(value, type_): + if bld: + return bld(value) + else: + return value + return value + return build + return None + + +_VARIANT_CONSTRUCTORS = { + 'b': GLib.Variant.new_boolean, + 'y': GLib.Variant.new_byte, + 'n': GLib.Variant.new_int16, + 'q': GLib.Variant.new_uint16, + 'i': GLib.Variant.new_int32, + 'u': GLib.Variant.new_uint32, + 'x': GLib.Variant.new_int64, + 't': GLib.Variant.new_uint64, + 'h': GLib.Variant.new_handle, + 'd': GLib.Variant.new_double, + 's': GLib.Variant.new_string, + 'o': GLib.Variant.new_object_path, + 'g': GLib.Variant.new_signature, + 'v': GLib.Variant.new_variant, +} + +from gi._gi import variant_type_from_string + +def _create_variant(type_str, value): + if isinstance(value, enum.Enum): + value = value.value + elif dataclasses.is_dataclass(type(value)): + fields = dataclasses.fields(type(value)) + value = tuple(getattr(value, field.name) for field in fields) + + vtype = GLib.VariantType(type_str) + + if vtype.is_basic(): + return _VARIANT_CONSTRUCTORS[type_str](value) + + builder = GLib.VariantBuilder.new(vtype) + if value is None: + return builder.end() + + if vtype.is_maybe(): + builder.add_value(_create_variant(vtype.element().dup_string(), value)) + return builder.end() + + try: + iter(value) + except TypeError: + raise TypeError("Could not create array, tuple or dictionary entry from non iterable value %s %s" % + (type_str, value)) + + if vtype.is_tuple() and vtype.n_items() != len(value): + raise TypeError("Tuple mismatches value's number of elements %s %s" % (type_str, value)) + if vtype.is_dict_entry() and len(value) != 2: + raise TypeError("Dictionary entries must have two elements %s %s" % (type_str, value)) + + if vtype.is_array(): + element_type = vtype.element().dup_string() + if isinstance(value, dict): + value = value.items() + for i in value: + builder.add_value(_create_variant(element_type, i)) + else: + remainer_format = type_str[1:] + for i in value: + dup = variant_type_from_string(remainer_format).dup_string() + builder.add_value(_create_variant(dup, i)) + remainer_format = remainer_format[len(dup):] + + return builder.end() diff --git a/sublime_music/ui/albums.py b/sublime_music/ui/albums.py index a979a9c..9588e2f 100644 --- a/sublime_music/ui/albums.py +++ b/sublime_music/ui/albums.py @@ -14,8 +14,9 @@ from ..adapters import ( Result, ) from ..config import AppConfiguration -from ..ui import util -from ..ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage, Sizer +from . import util +from .common import AlbumWithSongs, IconButton, LoadError, SpinnerImage, Sizer +from .actions import run_action COVER_ART_WIDTH = 150 @@ -50,28 +51,12 @@ def _from_str(type_str: str) -> AlbumSearchQuery.Type: class AlbumsPanel(Handy.Leaflet): - __gsignals__ = { - "song-clicked": ( - GObject.SignalFlags.RUN_FIRST, - GObject.TYPE_NONE, - (int, object, object), - ), - "refresh-window": ( - GObject.SignalFlags.RUN_FIRST, - GObject.TYPE_NONE, - (object, bool), - ), - } - current_query: AlbumSearchQuery = AlbumSearchQuery(AlbumSearchQuery.Type.RANDOM) offline_mode = False provider_id: Optional[str] = None populating_genre_combo = False album_sort_direction: str = "ascending" - # album_page_size: int = 30 - album_page: int = 0 - grid_pages_count: int = 0 current_albums_result: Result = None @@ -146,14 +131,6 @@ class AlbumsPanel(Handy.Leaflet): self.refresh_button.connect("clicked", self.on_refresh_clicked) actionbar.pack_end(self.refresh_button) - # actionbar.pack_end(Gtk.Label(label="albums per page")) - # self.show_count_dropdown, _ = self.make_combobox( - # ((x, x, True) for x in ("20", "30", "40", "50")), - # self.on_show_count_dropdown_change, - # ) - # actionbar.pack_end(self.show_count_dropdown) - # actionbar.pack_end(Gtk.Label(label="Show")) - self.grid_box.pack_start(actionbar, False, False, 0) # 700 shows ~3 albums @@ -176,47 +153,8 @@ class AlbumsPanel(Handy.Leaflet): selection_mode=Gtk.SelectionMode.SINGLE) self.grid.connect("child-activated", self.on_album_clicked) - # self.grid.connect( - # "song-clicked", - # lambda _, *args: self.emit("song-clicked", *args), - # ) - # self.grid.connect( - # "refresh-window", - # lambda _, *args: self.emit("refresh-window", *args), - # ) - # self.grid.connect("cover-clicked", self.on_album_clicked) - # self.grid.connect("num-pages-changed", self.on_grid_num_pages_changed) - box.add(self.grid) - bottom_actionbar = Gtk.ActionBar() - - # Add the page widget. - page_widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - self.prev_page = IconButton( - "go-previous-symbolic", "Go to the previous page", sensitive=False - ) - self.prev_page.connect("clicked", self.on_prev_page_clicked) - page_widget.add(self.prev_page) - page_widget.add(Gtk.Label(label="Page")) - self.page_entry = Gtk.Entry() - self.page_entry.set_width_chars(1) - self.page_entry.set_max_width_chars(1) - self.page_entry.connect("changed", self.on_page_entry_changed) - self.page_entry.connect("insert-text", self.on_page_entry_insert_text) - page_widget.add(self.page_entry) - page_widget.add(Gtk.Label(label="of")) - self.page_count_label = Gtk.Label(label="-") - page_widget.add(self.page_count_label) - self.next_page = IconButton( - "go-next-symbolic", "Go to the next page", sensitive=False - ) - self.next_page.connect("clicked", self.on_next_page_clicked) - page_widget.add(self.next_page) - bottom_actionbar.set_center_widget(page_widget) - - box.add(bottom_actionbar) - scrolled_window.add(box) grid_sizer.add(scrolled_window) self.grid_box.pack_start(grid_sizer, True, True, 0) @@ -228,8 +166,6 @@ class AlbumsPanel(Handy.Leaflet): self.album_with_songs = AlbumWithSongs(scroll_contents=True) self.album_with_songs.get_style_context().add_class("details-panel") - self.album_with_songs.connect("song-clicked", lambda _, *args: self.emit("song-clicked", *args)) - def back_clicked(_): self.set_visible_child(self.grid_box) self.album_with_songs.connect("back-clicked", back_clicked) @@ -336,11 +272,8 @@ class AlbumsPanel(Handy.Leaflet): lambda f: GLib.idle_add(self._albums_loaded, f) ) - if (self.album_sort_direction != app_config.state.album_sort_direction - or self.album_page != app_config.state.album_page): - + if self.album_sort_direction != app_config.state.album_sort_direction: self.album_sort_direction = app_config.state.album_sort_direction - self.album_page = app_config.state.album_page self._update_albums() # self.current_query = app_config.state.current_album_search_query @@ -361,12 +294,8 @@ class AlbumsPanel(Handy.Leaflet): # Update the page display if app_config: - self.album_page = app_config.state.album_page self.refresh_button.set_sensitive(not app_config.offline_mode) - self.prev_page.set_sensitive(self.album_page > 0) - self.page_entry.set_text(str(self.album_page + 1)) - # Show/hide the combo boxes. def show_if(sort_type: Iterable[AlbumSearchQuery.Type], *elements): for element in elements: @@ -508,85 +437,50 @@ class AlbumsPanel(Handy.Leaflet): return None def on_sort_toggle_clicked(self, _): - self.emit( - "refresh-window", - { - "album_sort_direction": self._get_opposite_sort_dir( - self.album_sort_direction - ), - "album_page": 0, - "selected_album_id": None, - }, - False, - ) + run_action(self, 'albums.set-search-query', self.current_query, self._get_opposite_sort_dir(self.album_sort_direction)) def on_refresh_clicked(self, _): - self.emit("refresh-window", {}, True) - - class _Genre(API.Genre): - def __init__(self, name: str): - self.name = name - - def on_grid_num_pages_changed(self, grid: Any, pages: int): - self.grid_pages_count = pages - pages_str = str(self.grid_pages_count) - self.page_count_label.set_text(pages_str) - self.next_page.set_sensitive(self.album_page < self.grid_pages_count - 1) - num_digits = len(pages_str) - self.page_entry.set_width_chars(num_digits) - self.page_entry.set_max_width_chars(num_digits) + run_action(self, "app.force-refresh") def on_type_combo_changed(self, combo: Gtk.ComboBox): id = self.get_id(combo) assert id if id == "alphabetical": id += "_" + cast(str, self.get_id(self.alphabetical_type_combo)) - self.emit_if_not_updating( - "refresh-window", - { - "current_album_search_query": AlbumSearchQuery( - _from_str(id), - self.current_query.year_range, - self.current_query.genre, - ), - "album_page": 0, - "selected_album_id": None, - }, - False, - ) + run_action( + self, + 'albums.set-search-query', + AlbumSearchQuery( + _from_str(id), + self.current_query.year_range, + self.current_query.genre, + ), + self.album_sort_direction) def on_alphabetical_type_change(self, combo: Gtk.ComboBox): id = "alphabetical_" + cast(str, self.get_id(combo)) - self.emit_if_not_updating( - "refresh-window", - { - "current_album_search_query": AlbumSearchQuery( - _from_str(id), - self.current_query.year_range, - self.current_query.genre, - ), - "album_page": 0, - "selected_album_id": None, - }, - False, - ) + run_action( + self, + 'albums.set-search-query', + AlbumSearchQuery( + _from_str(id), + self.current_query.year_range, + self.current_query.genre, + ), + self.album_sort_direction) def on_genre_change(self, combo: Gtk.ComboBox): genre = self.get_id(combo) assert genre - self.emit_if_not_updating( - "refresh-window", - { - "current_album_search_query": AlbumSearchQuery( - self.current_query.type, - self.current_query.year_range, - AlbumsPanel._Genre(genre), - ), - "album_page": 0, - "selected_album_id": None, - }, - False, - ) + run_action( + self, + 'albums.set-search-query', + AlbumSearchQuery( + self.current_query.type, + self.current_query.year_range, + AlbumSearchQuery.Genre(genre), + ), + self.album_sort_direction) def on_year_changed(self, entry: Gtk.SpinButton) -> bool: year = int(entry.get_value()) @@ -596,27 +490,19 @@ class AlbumsPanel(Handy.Leaflet): else: new_year_tuple = (year, self.current_query.year_range[1]) - self.emit_if_not_updating( - "refresh-window", - { - "current_album_search_query": AlbumSearchQuery( - self.current_query.type, new_year_tuple, self.current_query.genre - ), - "album_page": 0, - "selected_album_id": None, - }, - False, - ) + run_action( + self, + 'albums.set-search-query', + AlbumSearchQuery( + self.current_query.type, new_year_tuple, self.current_query.genre + ), + self.album_sort_direction) return False def on_page_entry_changed(self, entry: Gtk.Entry) -> bool: if len(text := entry.get_text()) > 0: - self.emit_if_not_updating( - "refresh-window", - {"album_page": int(text) - 1, "selected_album_id": None}, - False, - ) + run_action(self, 'albums.set-page', int(text) - 1) return False def on_page_entry_insert_text( @@ -633,50 +519,18 @@ class AlbumsPanel(Handy.Leaflet): return True return False - def on_prev_page_clicked(self, _): - self.emit_if_not_updating( - "refresh-window", - {"album_page": self.album_page - 1, "selected_album_id": None}, - False, - ) - - def on_next_page_clicked(self, _): - self.emit_if_not_updating( - "refresh-window", - {"album_page": self.album_page + 1, "selected_album_id": None}, - False, - ) - def on_album_clicked(self, _:Any, child: Gtk.FlowBoxChild): album = self.albums[child.get_index()] if self.get_folded() and self.get_visible_child() == self.grid_box: self.set_visible_child(self.album_container) - self.emit( - "refresh-window", - {"selected_album_id": album.id}, - False, - ) - - def on_show_count_dropdown_change(self, combo: Gtk.ComboBox): - show_count = int(self.get_id(combo) or 30) - self.emit( - "refresh-window", - {"album_page_size": show_count, "album_page": 0}, - False, - ) - - def emit_if_not_updating(self, *args): - if self.updating_query: - return - self.emit(*args) - + run_action(self, 'albums.select-album', album.id) +""" # TODO: REMOVE class AlbumsGrid(Gtk.Overlay): - """Defines the albums panel.""" __gsignals__ = { "cover-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,)), @@ -1155,3 +1009,4 @@ class AlbumsGrid(Gtk.Overlay): self.grid_bottom.unselect_all() self.currently_selected_index = selected_index +""" diff --git a/sublime_music/ui/app_styles.css b/sublime_music/ui/app_styles.css index 0e7baa3..ee32618 100644 --- a/sublime_music/ui/app_styles.css +++ b/sublime_music/ui/app_styles.css @@ -236,11 +236,6 @@ entry.invalid { min-width: 35px; } -/* ********** General ********** */ -.menu-button { - padding: 5px; -} - /* ********** Search ********** */ #search-results { min-width: 400px; diff --git a/sublime_music/ui/artists.py b/sublime_music/ui/artists.py index 9717536..7fd8820 100644 --- a/sublime_music/ui/artists.py +++ b/sublime_music/ui/artists.py @@ -636,10 +636,6 @@ class AlbumsListWithSongs(Gtk.Overlay): for album in self.albums: album_with_songs = AlbumWithSongs(show_artist_name=False) album_with_songs.update(album, app_config, force=force) - album_with_songs.connect( - "song-clicked", - lambda _, *args: self.emit("song-clicked", *args), - ) # album_with_songs.connect("song-selected", self.on_song_selected) album_with_songs.show_all() self.box.add(album_with_songs) diff --git a/sublime_music/ui/common/album_with_songs.py b/sublime_music/ui/common/album_with_songs.py index ed5ac5e..a3d416b 100644 --- a/sublime_music/ui/common/album_with_songs.py +++ b/sublime_music/ui/common/album_with_songs.py @@ -11,16 +11,11 @@ from .icon_button import IconButton from .load_error import LoadError from .song_list_column import SongListColumn from .spinner_image import SpinnerImage +from ..actions import run_action class AlbumWithSongs(Gtk.Box): __gsignals__ = { - # "song-selected": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()), - "song-clicked": ( - GObject.SignalFlags.RUN_FIRST, - GObject.TYPE_NONE, - (int, object, object), - ), "back-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()), } @@ -166,8 +161,7 @@ class AlbumWithSongs(Gtk.Box): margin_bottom=10, ) selection = self.album_songs.get_selection() - selection.set_mode(Gtk.SelectionMode.MULTIPLE) - selection.set_select_function(lambda _, model, path, current: model[path[0]][0]) + selection.set_mode(Gtk.SelectionMode.SINGLE) # Song status column. renderer = Gtk.CellRendererPixbuf() @@ -188,20 +182,19 @@ class AlbumWithSongs(Gtk.Box): # Event Handlers # ========================================================================= - def on_song_selection_change(self, event: Any): - if not self.album_songs.has_focus(): - self.emit("song-selected") + def on_song_selection_change(self, selection: Gtk.TreeSelection): + paths = selection.get_selected_rows()[1] + if not paths: + return + + assert len(paths) == 1 + self.play_song(paths[0].get_indices()[0], {}) def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any): if not self.album_song_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.album_song_store], - {}, - ) + + self.play_song(idx.get_indices()[0], {}) def on_song_button_press(self, tree: Any, event: Gdk.EventButton) -> bool: if event.button == 3: # Right click @@ -250,22 +243,14 @@ class AlbumWithSongs(Gtk.Box): ) def play_btn_clicked(self, btn: Any): - song_ids = [x[-1] for x in self.album_song_store] - self.emit( - "song-clicked", - 0, - song_ids, - {"force_shuffle_state": False}, - ) + self.play_song(0, {"force_shuffle_state": False}) def shuffle_btn_clicked(self, btn: Any): - song_ids = [x[-1] for x in self.album_song_store] - self.emit( - "song-clicked", - randint(0, len(self.album_song_store) - 1), - song_ids, - {"force_shuffle_state": True}, - ) + self.play_song(randint(0, len(self.album_song_store) - 1), + {"force_shuffle_state": True}) + + def play_song(self, index: int, metadata: Any): + run_action(self, 'app.play-song', index, [m[-1] for m in self.album_song_store], metadata) # Helper Methods # ========================================================================= @@ -403,8 +388,8 @@ class AlbumWithSongs(Gtk.Box): if any_song_playable: self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids)) self.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids)) - self.play_next_btn.set_action_name("app.play-next") - self.add_to_queue_btn.set_action_name("app.add-to-queue") + self.play_next_btn.set_action_name("app.queue-next-songs") + self.add_to_queue_btn.set_action_name("app.queue-songs") else: self.play_next_btn.set_action_name("") self.add_to_queue_btn.set_action_name("") diff --git a/sublime_music/ui/main.py b/sublime_music/ui/main.py index 39a0966..36eb957 100644 --- a/sublime_music/ui/main.py +++ b/sublime_music/ui/main.py @@ -16,27 +16,11 @@ from ..config import AppConfiguration, ProviderConfiguration from ..players import PlayerManager from ..ui import albums, artists, browse, player_controls, playlists, util from ..ui.common import IconButton, IconToggleButton, IconMenuButton, SpinnerImage - +from .actions import run_action class MainWindow(Handy.ApplicationWindow): """Defines the main window for Sublime Music.""" - __gsignals__ = { - "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), - ), - "notification-closed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()), - "go-to": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str, str)), - } - _updating_settings: bool = False _pending_downloads: Set[str] = set() _failed_downloads: Set[str] = set() @@ -81,14 +65,19 @@ class MainWindow(Handy.ApplicationWindow): # Browse=self.browse_panel, Playlists=self.playlists_panel, ) - self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) + self.stack.set_transition_type(Gtk.StackTransitionType.NONE) self.sidebar_flap = Handy.Flap( orientation=Gtk.Orientation.HORIZONTAL, fold_policy=Handy.FlapFoldPolicy.ALWAYS, transition_type=Handy.FlapTransitionType.OVER, modal=True) - self.stack.connect("notify::visible-child", lambda *_: self.sidebar_flap.set_reveal_flap(False)) + + def stack_changed(*_): + self.sidebar_flap.set_reveal_flap(False) + + run_action(self, 'app.change-tab', self.stack.get_visible_child_name()) + self.stack.connect("notify::visible-child", stack_changed) self.titlebar = self._create_headerbar(self.stack) box.add(self.titlebar) @@ -128,17 +117,7 @@ class MainWindow(Handy.ApplicationWindow): drawer.set_content(notification_container) # Player state - self.player_manager = player_controls.Manager() - self.player_manager.connect( - "song-clicked", lambda _, *a: self.emit("song-clicked", *a) - ) - self.player_manager.connect( - "songs-removed", lambda _, *a: self.emit("songs-removed", *a) - ) - self.player_manager.connect( - "refresh-window", - lambda _, *args: self.emit("refresh-window", *args), - ) + self.player_manager = player_controls.Manager(self) # Player Controls drawer.set_separator(Gtk.Separator()) @@ -595,14 +574,6 @@ class MainWindow(Handy.ApplicationWindow): def _create_stack(self, **kwargs: Gtk.Widget) -> Gtk.Stack: stack = Gtk.Stack(homogeneous=True, transition_type=Gtk.StackTransitionType.NONE) for name, child in kwargs.items(): - child.connect( - "song-clicked", - lambda _, *args: self.emit("song-clicked", *args), - ) - child.connect( - "refresh-window", - lambda _, *args: self.emit("refresh-window", *args), - ) stack.add_titled(child, name.lower(), name) return stack diff --git a/sublime_music/ui/player_controls.py b/sublime_music/ui/player_controls.py deleted file mode 100644 index e69de29..0000000 diff --git a/sublime_music/ui/player_controls/common.py b/sublime_music/ui/player_controls/common.py index 48f5472..c349d53 100644 --- a/sublime_music/ui/player_controls/common.py +++ b/sublime_music/ui/player_controls/common.py @@ -4,6 +4,7 @@ from .. import util from ..common import IconButton, IconToggleButton, SpinnerImage, SpinnerPicture from .manager import Manager from ...util import resolve_path +from ..actions import run_action from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango, Handy @@ -28,7 +29,7 @@ def create_play_button(manager: Manager, large=True, **kwargs): def create_repeat_button(manager: Manager, **kwargs): button = IconToggleButton("media-playlist-repeat", "Switch between repeat modes", **kwargs) - button.set_action_name("app.repeat-press") + button.set_action_name("app.repeat") manager.bind_property("repeat-button-icon", button, "icon-name") def on_active_change(*_): @@ -36,21 +37,21 @@ def create_repeat_button(manager: Manager, **kwargs): # 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") + button.set_action_name("app.repeat") 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") + button.set_action_name("app.shuffle") 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") + button.set_action_name("app.shuffle") manager.connect("notify::shuffle-button-active", on_active_change) return button @@ -132,3 +133,59 @@ def filename_to_pixbuf(manager: Manager): cell.set_property("pixbuf", pixbuf) return f2p + + +def create_play_queue_list(manager: Manager): + view = Gtk.TreeView( + model=manager.play_queue_store, + reorderable=True, + headers_visible=False, + ) + view.get_style_context().add_class("background") + + renderer = Gtk.CellRendererPixbuf() + renderer.set_fixed_size(55, 60) + column = Gtk.TreeViewColumn("", renderer) + column.set_cell_data_func(renderer, filename_to_pixbuf(manager)) + column.set_resizable(True) + view.append_column(column) + + renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END) + column = Gtk.TreeViewColumn("", renderer, markup=2, sensitive=0) + column.set_expand(True) + view.append_column(column) + + renderer = Gtk.CellRendererPixbuf(icon_name="view-more-symbolic") + renderer.set_fixed_size(45, 60) + view_more_column = Gtk.TreeViewColumn("", renderer) + view_more_column.set_resizable(True) + view.append_column(view_more_column) + + renderer = Gtk.CellRendererPixbuf(icon_name="window-close-symbolic") + renderer.set_fixed_size(45, 60) + close_column = Gtk.TreeViewColumn("", renderer) + close_column.set_resizable(True) + view.append_column(close_column) + + def on_play_queue_button_press(tree: Any, event: Gdk.EventButton) -> bool: + if event.button == 1: + path, column, cell_x, cell_y = tree.get_path_at_pos(event.x, event.y) + song_idx = path.get_indices()[0] + + tree_width = tree.get_allocation().width + + if column == close_column: + # manager.emit("songs-removed", [song_idx]) + pass + elif column == view_more_column: + area = tree.get_cell_area(path, view_more_column) + x = area.x + area.width / 2 + y = area.y + area.height / 2 + show_play_queue_popover(manager, view, [path], x, y) + else: + run_action(view, 'app.play-song', song_idx, [], {"no_reshuffle": None}) + + return True + return False + view.connect("button-press-event", on_play_queue_button_press) + return view diff --git a/sublime_music/ui/player_controls/desktop.py b/sublime_music/ui/player_controls/desktop.py index c58c447..6d41acb 100644 --- a/sublime_music/ui/player_controls/desktop.py +++ b/sublime_music/ui/player_controls/desktop.py @@ -199,13 +199,12 @@ class Desktop(Gtk.Box): 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", + + self.get_action_group('app').activate_action( + 'play-song', idx.get_indices()[0], - [m[-1] for m in self.play_queue_store], - {"no_reshuffle": True}, - ) + None, + {"no_reshuffle": True}) def on_play_queue_button_press(self, tree: Any, event: Gdk.EventButton) -> bool: if event.button == 3: # Right click @@ -372,25 +371,7 @@ class Desktop(Gtk.Box): 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]) - - renderer = Gtk.CellRendererPixbuf() - renderer.set_fixed_size(55, 60) - column = Gtk.TreeViewColumn("", renderer) - column.set_cell_data_func(renderer, common.filename_to_pixbuf(self.state)) - 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 = common.create_play_queue_list(self.state) self.play_queue_list.connect("row-activated", self.on_song_activated) self.play_queue_list.connect( @@ -419,12 +400,11 @@ class Desktop(Gtk.Box): 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 = Gtk.Scale.new( + orientation=Gtk.Orientation.HORIZONTAL, adjustment=self.state.volume) 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) diff --git a/sublime_music/ui/player_controls/manager.py b/sublime_music/ui/player_controls/manager.py index bd92f7b..64aae66 100644 --- a/sublime_music/ui/player_controls/manager.py +++ b/sublime_music/ui/player_controls/manager.py @@ -13,24 +13,13 @@ from ...adapters import AdapterManager, Result from ...adapters.api_objects import Song from ...config import AppConfiguration from ..state import RepeatType +from ..actions import run_action 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,)), - } + """ + Common state for player controls. + """ volume = GObject.Property(type=Gtk.Adjustment) scrubber = GObject.Property(type=Gtk.Adjustment) @@ -53,14 +42,19 @@ class Manager(GObject.GObject): cover_art_update_order_token = 0 play_queue_update_order_token = 0 - def __init__(self): + current_song_index = None + + def __init__(self, widget): super().__init__() - self.volume = Gtk.Adjustment.new(1, 0, 1, 0.01, 0, 0) + self.widget = widget + + self.volume = Gtk.Adjustment.new(100, 0, 100, 1, 0, 0) + self.volume.connect('value-changed', self.on_volume_changed) + 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( @@ -115,7 +109,10 @@ class Manager(GObject.GObject): self._controls.append(control) def update(self, app_config: AppConfiguration, force: bool = False): + self.volume.set_value(app_config.state.volume) + self.has_song = app_config.state.current_song is not None + self.current_song_index = app_config.state.current_song_index self.update_song_progress( app_config.state.song_progress, @@ -406,26 +403,28 @@ class Manager(GObject.GObject): button.get_style_context().add_class("menu-button") button.connect( "clicked", - lambda _, player_id: self.state.emit("device-update", player_id), + lambda _, player_id: run_action(button, "app.select-device", player_id), player_id, ) self.device_list.add(button) self.device_list.show_all() + def on_volume_changed(self, _: Any): + run_action(self.widget, 'app.set-volume', self.volume.get_value()) + 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())) + if self.updating_scrubber: + return + + run_action(self.widget, 'app.seek', self.scrubber.get_value()) + def update_duration_label(self, _: Any): upper = self.scrubber.get_upper() if upper == 0: diff --git a/sublime_music/ui/player_controls/mobile.py b/sublime_music/ui/player_controls/mobile.py index 80995bf..d3502cc 100644 --- a/sublime_music/ui/player_controls/mobile.py +++ b/sublime_music/ui/player_controls/mobile.py @@ -47,6 +47,17 @@ class MobileHandle(Gtk.ActionBar): "Menu", relief=True, icon_size=Gtk.IconSize.LARGE_TOOLBAR) + + def on_menu(*_): + x = self.menu_button.get_allocated_width() / 2 + y = self.menu_button.get_allocated_height() + util.show_song_popover( + [self.state.play_queue_store[self.state.current_song_index][-1]], + x, y, + self.menu_button, + self.state.offline_mode) + self.menu_button.connect('clicked', on_menu) + open_buttons.pack_start(self.menu_button, False, False, 5) buttons.add(open_buttons) @@ -249,56 +260,7 @@ class MobileFlap(Gtk.Stack): play_queue_scrollbox = Gtk.ScrolledWindow(vexpand=True) - play_queue_list = Gtk.TreeView( - model=self.state.play_queue_store, - reorderable=True, - headers_visible=False, - ) - play_queue_list.get_style_context().add_class("background") - - renderer = Gtk.CellRendererPixbuf() - renderer.set_fixed_size(55, 60) - column = Gtk.TreeViewColumn("", renderer) - column.set_cell_data_func(renderer, common.filename_to_pixbuf(self.state)) - column.set_resizable(True) - 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) - play_queue_list.append_column(column) - - renderer = Gtk.CellRendererPixbuf(icon_name="view-more-symbolic") - renderer.set_fixed_size(45, 60) - view_more_column = Gtk.TreeViewColumn("", renderer) - view_more_column.set_resizable(True) - play_queue_list.append_column(view_more_column) - - renderer = Gtk.CellRendererPixbuf(icon_name="window-close-symbolic") - renderer.set_fixed_size(45, 60) - close_column = Gtk.TreeViewColumn("", renderer) - close_column.set_resizable(True) - play_queue_list.append_column(close_column) - - def on_play_queue_button_press(tree: Any, event: Gdk.EventButton) -> bool: - if event.button == 1: - clicked_path = tree.get_path_at_pos(event.x, event.y)[0] - song_idx = clicked_path.get_indices()[0] - - tree_width = tree.get_allocation().width - - if event.x > tree_width - close_column.get_width(): - self.state.emit("songs-removed", [song_idx]) - elif event.x > tree_width - close_column.get_width() - view_more_column.get_width(): - common.show_play_queue_popover(self.state, play_queue_list, [clicked_path], event.x, event.y) - else: - self.state.emit("song-clicked", song_idx, [m[-1] for m in self.state.play_queue_store], {"no_reshuffle": True}) - - return True - return False - play_queue_list.connect("button-press-event", on_play_queue_button_press) - - play_queue_scrollbox.add(play_queue_list) + play_queue_scrollbox.add(common.create_play_queue_list(self.state)) self.add(play_queue_scrollbox) def on_play_queue_open(*_): diff --git a/sublime_music/ui/state.py b/sublime_music/ui/state.py index 55aed9a..1c36df0 100644 --- a/sublime_music/ui/state.py +++ b/sublime_music/ui/state.py @@ -82,14 +82,8 @@ class UIState: artist_details_expanded: bool = True loading_play_queue: bool = False - # State for Album sort. - class _DefaultGenre(Genre): - def __init__(self): - self.name = "Rock" - current_album_search_query: AlbumSearchQuery = AlbumSearchQuery( AlbumSearchQuery.Type.RANDOM, - genre=_DefaultGenre(), year_range=this_decade(), ) diff --git a/sublime_music/ui/util.py b/sublime_music/ui/util.py index ead25e7..4690485 100644 --- a/sublime_music/ui/util.py +++ b/sublime_music/ui/util.py @@ -213,9 +213,9 @@ def show_song_popover( play_next_button = Gtk.ModelButton(text="Play next", sensitive=False) add_to_queue_button = Gtk.ModelButton(text="Add to queue", sensitive=False) if not offline_mode: - play_next_button.set_action_name("app.play-next") + play_next_button.set_action_name("app.queue-next-songs") play_next_button.set_action_target_value(GLib.Variant("as", song_ids)) - add_to_queue_button.set_action_name("app.add-to-queue") + add_to_queue_button.set_action_name("app.queue_songs") add_to_queue_button.set_action_target_value(GLib.Variant("as", song_ids)) go_to_album_button = Gtk.ModelButton(text="Go to album", sensitive=False) @@ -248,9 +248,9 @@ def show_song_popover( ): remove_download_button.set_sensitive(True) play_next_button.set_action_target_value(GLib.Variant("as", song_ids)) - play_next_button.set_action_name("app.play-next") + play_next_button.set_action_name("app.queue-next-songs") add_to_queue_button.set_action_target_value(GLib.Variant("as", song_ids)) - add_to_queue_button.set_action_name("app.add-to-queue") + add_to_queue_button.set_action_name("app.queue-songs") albums, artists, parents = set(), set(), set() for song in songs: diff --git a/tests/ui_actions_test.py b/tests/ui_actions_test.py new file mode 100644 index 0000000..7f14880 --- /dev/null +++ b/tests/ui_actions_test.py @@ -0,0 +1,19 @@ +from typing import Any, List, Tuple, Optional, Union, Dict + +from sublime_music.ui import actions + +def test_variant_type_from_python(): + assert actions.variant_type_from_python(bool) == 'b' + assert actions.variant_type_from_python(int) == 'x' + assert actions.variant_type_from_python(float) == 'd' + assert actions.variant_type_from_python(str) == 's' + assert actions.variant_type_from_python(Any) == 'v' + assert actions.variant_type_from_python(List[int]) == 'ax' + assert actions.variant_type_from_python(Tuple[str, int, bool]) == '(sxb)' + assert actions.variant_type_from_python(Optional[str]) == 'ms' + assert actions.variant_type_from_python(Union[str, int]) == '[sx]' + assert actions.variant_type_from_python(Dict[str, int]) == 'a{sx}' + + assert (actions.variant_type_from_python( + Tuple[Dict[Optional[str], List[bool]], List[List[Any]]]) + == '(a{msab}aav)')