diff --git a/libremsonic/cache_manager.py b/libremsonic/cache_manager.py index 2b7c5e6..16bed11 100644 --- a/libremsonic/cache_manager.py +++ b/libremsonic/cache_manager.py @@ -250,12 +250,11 @@ class CacheManager(metaclass=Singleton): return str(abs_path) - def delete_cached(self, relative_path: Union[Path, str]): - """ - :param relative_path: The path to the cached element to delete. - Note that this can be a globed path. - """ + def delete_cached_cover_art(self, id: int): + relative_path = f'cover_art/*{id}_*' + abs_path = self.calculate_abs_path(relative_path) + for path in glob.glob(str(abs_path)): Path(path).unlink() @@ -275,6 +274,14 @@ class CacheManager(metaclass=Singleton): return CacheManager.executor.submit(do_get_playlists) + def invalidate_playlists_cache(self): + if not self.cache.get('playlists'): + return + + with self.cache_lock: + self.cache['playlists'] = [] + self.save_cache_info() + def get_playlist( self, playlist_id: int, @@ -301,17 +308,25 @@ class CacheManager(metaclass=Singleton): # Invalidate the cached photo if we are forcing a retrieval # from the server. if force: - self.delete_cached( - f'cover_art/{playlist_details.coverArt}_*') + self.delete_cached_cover_art(playlist.id) return playlist_details return CacheManager.executor.submit(do_get_playlist) + def create_playlist(self, name: str) -> Future: + def do_create_playlist(): + self.server.create_playlist(name=name) + + return CacheManager.executor.submit(do_create_playlist) + def update_playlist(self, playlist_id, *args, **kwargs): - self.server.update_playlist(playlist_id, *args, **kwargs) - with self.cache_lock: - del self.cache['playlist_details'][playlist_id] + def do_update_playlist(): + self.server.update_playlist(playlist_id, *args, **kwargs) + with self.cache_lock: + del self.cache['playlist_details'][playlist_id] + + return CacheManager.executor.submit(do_update_playlist) def get_artists( self, diff --git a/libremsonic/state_manager.py b/libremsonic/state_manager.py index 3bbf0a3..ea7355e 100644 --- a/libremsonic/state_manager.py +++ b/libremsonic/state_manager.py @@ -51,7 +51,9 @@ class ApplicationState: song_progress: float = 0 current_device: str = 'this device' current_tab: str = 'albums' + selected_album_id: str = None selected_artist_id: str = None + selected_playlist_id: str = None def to_json(self): current_song = (self.current_song.id if @@ -68,7 +70,10 @@ class ApplicationState: 'song_progress': getattr(self, 'song_progress', None), 'current_device': getattr(self, 'current_device', 'this device'), 'current_tab': getattr(self, 'current_tab', 'albums'), + 'selected_album_id': getattr(self, 'selected_album_id', None), 'selected_artist_id': getattr(self, 'selected_artist_id', None), + 'selected_playlist_id': getattr(self, 'selected_playlist_id', + None), } def load_from_json(self, json_object): @@ -88,7 +93,10 @@ class ApplicationState: self.song_progress = json_object.get('song_progress', 0.0) self.current_device = json_object.get('current_device', 'this device') self.current_tab = json_object.get('current_tab', 'albums') + self.selected_album_id = json_object.get('selected_album_id', None) self.selected_artist_id = json_object.get('selected_artist_id', None) + self.selected_playlist_id = json_object.get('selected_playlist_id', + None) def load(self): self.config = self.get_config(self.config_file) diff --git a/libremsonic/ui/albums.py b/libremsonic/ui/albums.py index 143baca..4d276e2 100644 --- a/libremsonic/ui/albums.py +++ b/libremsonic/ui/albums.py @@ -93,8 +93,7 @@ class AlbumsPanel(Gtk.Box): self.grid = AlbumsGrid() self.grid.connect( 'song-clicked', - lambda _, song, queue, metadata: self.emit('song-clicked', song, - queue, metadata), + lambda _, *args: self.emit('song-clicked', *args), ) scrolled_window.add(self.grid) self.add(scrolled_window) diff --git a/libremsonic/ui/artists.py b/libremsonic/ui/artists.py index 29dfb4f..3794235 100644 --- a/libremsonic/ui/artists.py +++ b/libremsonic/ui/artists.py @@ -1,9 +1,9 @@ -from typing import List, Union, Optional +from typing import List, Union import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GObject, Pango, GLib +from gi.repository import Gtk, GObject, Pango, GLib, Gio from libremsonic.state_manager import ApplicationState from libremsonic.cache_manager import CacheManager @@ -28,7 +28,6 @@ class ArtistsPanel(Gtk.Paned): (str, object, object), ), } - artist_id: Optional[str] = None def __init__(self, *args, **kwargs): Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL) @@ -39,18 +38,27 @@ class ArtistsPanel(Gtk.Paned): self.artist_detail_panel = ArtistDetailPanel() self.artist_detail_panel.connect( 'song-clicked', - lambda _, song, queue, metadata: self.emit('song-clicked', song, - queue, metadata), + lambda _, *args: self.emit('song-clicked', *args), ) self.pack2(self.artist_detail_panel, True, False) def update(self, state: ApplicationState): - self.artist_list.update(state) - if state.selected_artist_id: - self.artist_detail_panel.update(state.selected_artist_id) + self.artist_list.update(state=state) + self.artist_detail_panel.update(state=state) class ArtistList(Gtk.Box): + class ArtistModel(GObject.GObject): + artist_id = GObject.property(type=str) + name = GObject.property(type=str) + album_count = GObject.property(type=str) + + def __init__(self, artist_id, name, album_count): + GObject.GObject.__init__(self) + self.artist_id = artist_id + self.name = name + self.album_count = album_count + def __init__(self): Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) @@ -62,52 +70,29 @@ class ArtistList(Gtk.Box): self.add(list_actions) - list_scroll_window = Gtk.ScrolledWindow(min_content_width=250) - self.list = Gtk.ListBox(name='artist-list-listbox') - - self.loading_indicator = Gtk.ListBoxRow( - activatable=False, - selectable=False, + self.loading_indicator = Gtk.ListBox() + spinner_row = Gtk.ListBoxRow() + spinner = Gtk.Spinner( + name='artist-list-spinner', + active=True, ) - loading_spinner = Gtk.Spinner(name='artist-list-spinner', active=True) - self.loading_indicator.add(loading_spinner) - self.list.add(self.loading_indicator) + spinner_row.add(spinner) + self.loading_indicator.add(spinner_row) + self.pack_start(self.loading_indicator, False, False, 0) - list_scroll_window.add(self.list) - self.pack_start(list_scroll_window, True, True, 0) + list_scroll_window = Gtk.ScrolledWindow(min_content_width=250) - def update(self, state=None, force=False): - self.update_list(force=force, state=state) + def create_artist_row(model: ArtistList.ArtistModel): + label_text = [f'{util.esc(model.name)}'] - @util.async_callback( - lambda *a, **k: CacheManager.get_artists(*a, **k), - before_download=lambda self: self.loading_indicator.show(), - on_failure=lambda self, e: self.loading_indicator.hide(), - ) - def update_list(self, artists, state: ApplicationState): - # TODO use a diff here - # Remove everything - for row in self.list.get_children()[1:]: - self.list.remove(row) - self.playlist_map = {} - selected_idx = None - - for i, artist in enumerate(artists): - # Use i + 1 because of the loading indicator in index 0. - if (state.selected_artist_id - and artist.id == (state.selected_artist_id or -1)): - selected_idx = i + 1 - - label_text = [f'{util.esc(artist.name)}'] - - album_count = artist.get('albumCount') + album_count = model.album_count if album_count: label_text.append('{} {}'.format( album_count, util.pluralize('album', album_count))) row = Gtk.ListBoxRow( action_name='app.go-to-artist', - action_target=GLib.Variant('s', artist.id), + action_target=GLib.Variant('s', model.artist_id), ) row.add( Gtk.Label( @@ -118,14 +103,42 @@ class ArtistList(Gtk.Box): ellipsize=Pango.EllipsizeMode.END, max_width_chars=30, )) - self.list.add(row) + row.show_all() + return row - if selected_idx: + self.artists_store = Gio.ListStore() + self.list = Gtk.ListBox(name='artist-list') + self.list.bind_model(self.artists_store, create_artist_row) + list_scroll_window.add(self.list) + + self.pack_start(list_scroll_window, True, True, 0) + + @util.async_callback( + lambda *a, **k: CacheManager.get_artists(*a, **k), + before_download=lambda self: self.loading_indicator.show_all(), + on_failure=lambda self, e: self.loading_indicator.hide(), + ) + def update(self, artists, state: ApplicationState): + new_store = [] + selected_idx = None + for i, artist in enumerate(artists): + if state and state.selected_artist_id == artist.id: + selected_idx = i + + new_store.append( + ArtistList.ArtistModel( + artist.id, + artist.name, + artist.get('albumCount', ''), + )) + + util.diff_model_store(self.artists_store, new_store) + + # Preserve selection + if selected_idx is not None: row = self.list.get_row_at_index(selected_idx) - # TODO scroll to the row self.list.select_row(row) - self.list.show_all() self.loading_indicator.hide() @@ -217,22 +230,24 @@ class ArtistDetailPanel(Gtk.Box): self.albums_list = AlbumsListWithSongs() self.albums_list.connect( 'song-clicked', - lambda _, song, queue, metadata: self.emit('song-clicked', song, - queue, metadata), + lambda _, *args: self.emit('song-clicked', *args), ) artist_info_box.pack_start(self.albums_list, True, True, 0) self.add(artist_info_box) - def update(self, artist_id): - self.update_artist_view(artist_id) - def get_model_list_future(self, before_download): def do_get_model_list() -> List[Child]: return self.albums return CacheManager.executor.submit(do_get_model_list) + def update(self, state: ApplicationState): + if state.selected_artist_id is None: + self.artist_action_buttons.hide() + else: + self.update_artist_view(state.selected_artist_id, state=state) + # TODO need to handle when this is force updated. Need to delete a bunch of # stuff and un-cache things. @util.async_callback( @@ -385,8 +400,7 @@ class AlbumsListWithSongs(Gtk.Overlay): album_with_songs = AlbumWithSongs(album, show_artist_name=False) album_with_songs.connect( 'song-clicked', - lambda _, song, queue, metadata: self.emit( - 'song-clicked', song, queue, metadata), + lambda _, *args: self.emit('song-clicked', *args), ) album_with_songs.connect('song-selected', self.on_song_selected) album_with_songs.show_all() diff --git a/libremsonic/ui/common/album_with_songs.py b/libremsonic/ui/common/album_with_songs.py index eea2d38..fef5ac2 100644 --- a/libremsonic/ui/common/album_with_songs.py +++ b/libremsonic/ui/common/album_with_songs.py @@ -289,5 +289,5 @@ class AlbumWithSongs(Gtk.Box): GLib.Variant('as', song_ids)) self.download_all_btn.set_sensitive(True) - util.diff_store(self.album_song_store, new_store) + util.diff_song_store(self.album_song_store, new_store) self.loading_indicator.hide() diff --git a/libremsonic/ui/common/icon_button.py b/libremsonic/ui/common/icon_button.py index 6643ded..f3fe72d 100644 --- a/libremsonic/ui/common/icon_button.py +++ b/libremsonic/ui/common/icon_button.py @@ -1,6 +1,6 @@ import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gio, Gtk +from gi.repository import Gtk class IconButton(Gtk.Button): diff --git a/libremsonic/ui/main.py b/libremsonic/ui/main.py index 53310b6..0395829 100644 --- a/libremsonic/ui/main.py +++ b/libremsonic/ui/main.py @@ -1,11 +1,8 @@ -from typing import Optional - import gi gi.require_version('Gtk', '3.0') from gi.repository import Gio, Gtk, GObject from . import albums, artists, playlists, player_controls -from libremsonic.server import Server from libremsonic.state_manager import ApplicationState @@ -58,13 +55,11 @@ class MainWindow(Gtk.ApplicationWindow): self.player_controls.update(state) - def on_song_clicked(self, panel, song, queue, metadata): - self.emit('song-clicked', song, queue, metadata) - def create_stack(self, **kwargs): stack = Gtk.Stack() for name, child in kwargs.items(): - child.connect('song-clicked', self.on_song_clicked) + child.connect('song-clicked', + lambda _, *args: self.emit('song-clicked', *args)) stack.add_titled(child, name.lower(), name) return stack diff --git a/libremsonic/ui/playlists.py b/libremsonic/ui/playlists.py index a467539..be05aac 100644 --- a/libremsonic/ui/playlists.py +++ b/libremsonic/ui/playlists.py @@ -1,6 +1,6 @@ from functools import lru_cache from random import randint -from typing import List, OrderedDict +from typing import List from fuzzywuzzy import process @@ -16,115 +16,233 @@ from libremsonic.ui.common import EditFormDialog, IconButton, SpinnerImage class EditPlaylistDialog(EditFormDialog): - __gsignals__ = { - 'delete-playlist': - (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()), - } - entity_name: str = 'Playlist' initial_size = (350, 120) text_fields = [('Name', 'name', False), ('Comment', 'comment', False)] boolean_fields = [('Public', 'public')] - def __init__(self, *args, **kwargs): - delete_playlist = Gtk.Button(label='Delete Playlist') - delete_playlist.connect('clicked', self.on_delete_playlist_click) - self.extra_buttons = [delete_playlist] - super().__init__(*args, **kwargs) + def __init__(self, *args, playlist_id=None, **kwargs): + # TODO this doesn't work + delete_playlist = Gtk.Button( + label='Delete Playlist', + action_name='app.delete-playlist', + action_target=GLib.Variant('s', playlist_id), + ) - def on_delete_playlist_click(self, event): - self.emit('delete-playlist') + def on_delete_playlist(e): + # Delete the playlists and invalidate caches. + CacheManager.delete_playlist(playlist_id) + CacheManager.delete_cached_cover_art(playlist_id) + CacheManager.invalidate_playlists_cache() + self.close() + + delete_playlist.connect('clicked', on_delete_playlist) + + self.extra_buttons = [(delete_playlist, Gtk.ResponseType.DELETE_EVENT)] + super().__init__(*args, **kwargs) class PlaylistsPanel(Gtk.Paned): """Defines the playlists panel.""" __gsignals__ = { - 'song-clicked': (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, - (str, object, object)), + 'song-clicked': ( + GObject.SignalFlags.RUN_FIRST, + GObject.TYPE_NONE, + (str, object, object), + ), } - playlist_map: OrderedDict[int, PlaylistWithSongs] = {} - song_ids: List[int] = [] - - editing_playlist_song_list: bool = False - reordering_playlist_song_list: bool = False - - def __init__(self): + def __init__(self, *args, **kwargs): Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL) - # The playlist list on the left side - # ===================================================================== - playlist_list_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.playlist_list = PlaylistList() + self.pack1(self.playlist_list, False, False) + + self.playlist_detail_panel = PlaylistDetailPanel() + self.playlist_detail_panel.connect( + 'song-clicked', + lambda _, *args: self.emit('song-clicked', *args), + ) + self.pack2(self.playlist_detail_panel, True, False) + + def update(self, state: ApplicationState): + self.playlist_list.update(state=state) + self.playlist_detail_panel.update(state=state) + + +class PlaylistList(Gtk.Box): + class PlaylistModel(GObject.GObject): + playlist_id = GObject.property(type=str) + name = GObject.property(type=str) + + def __init__(self, playlist_id, name): + GObject.GObject.__init__(self) + self.playlist_id = playlist_id + self.name = name + + def __init__(self): + Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) playlist_list_actions = Gtk.ActionBar() - self.new_playlist = IconButton( - icon_name='list-add', - label='New Playlist', - ) - self.new_playlist.connect('clicked', self.on_new_playlist_clicked) - playlist_list_actions.pack_start(self.new_playlist) + new_playlist_button = IconButton('list-add', label='New Playlist') + new_playlist_button.connect('clicked', self.on_new_playlist_clicked) + playlist_list_actions.pack_start(new_playlist_button) list_refresh_button = IconButton('view-refresh') list_refresh_button.connect('clicked', self.on_list_refresh_click) playlist_list_actions.pack_end(list_refresh_button) - playlist_list_vbox.add(playlist_list_actions) + self.add(playlist_list_actions) - list_scroll_window = Gtk.ScrolledWindow(min_content_width=220) - self.playlist_list = Gtk.ListBox(name='playlist-list-listbox') + loading_new_playlist = Gtk.ListBox() - self.playlist_list_loading = Gtk.ListBoxRow(activatable=False, - selectable=False) - playlist_list_loading_spinner = Gtk.Spinner( - name='playlist-list-spinner', active=True) - self.playlist_list_loading.add(playlist_list_loading_spinner) - self.playlist_list.add(self.playlist_list_loading) + self.loading_indicator = Gtk.ListBoxRow( + activatable=False, + selectable=False, + ) + loading_spinner = Gtk.Spinner(name='playlist-list-spinner', + active=True) + self.loading_indicator.add(loading_spinner) + loading_new_playlist.add(self.loading_indicator) self.new_playlist_row = Gtk.ListBoxRow(activatable=False, selectable=False) new_playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=False) - self.playlist_list_new_entry = Gtk.Entry( + self.new_playlist_entry = Gtk.Entry( name='playlist-list-new-playlist-entry') - self.playlist_list_new_entry.connect( - 'activate', self.on_playlist_list_new_entry_activate) - new_playlist_box.add(self.playlist_list_new_entry) + self.new_playlist_entry.connect('activate', self.new_entry_activate) + new_playlist_box.add(self.new_playlist_entry) new_playlist_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - self.playlist_list_new_confirm_button = Gtk.Button.new_from_icon_name( - 'object-select-symbolic', Gtk.IconSize.BUTTON) - self.playlist_list_new_confirm_button.set_name( - 'playlist-list-new-playlist-confirm') - self.playlist_list_new_confirm_button.connect( - 'clicked', self.on_playlist_list_new_confirm_button_clicked) - new_playlist_actions.pack_end(self.playlist_list_new_confirm_button, - False, True, 0) + confirm_button = IconButton( + 'object-select-symbolic', + name='playlist-list-new-playlist-confirm', + relief=True, + ) + confirm_button.connect('clicked', self.confirm_button_clicked) + new_playlist_actions.pack_end(confirm_button, False, True, 0) - self.playlist_list_new_cancel_button = Gtk.Button.new_from_icon_name( - 'process-stop-symbolic', Gtk.IconSize.BUTTON) - self.playlist_list_new_cancel_button.set_name( - 'playlist-list-new-playlist-cancel') - self.playlist_list_new_cancel_button.connect( - 'clicked', self.on_playlist_list_new_cancel_button_clicked) - new_playlist_actions.pack_end(self.playlist_list_new_cancel_button, - False, True, 0) + self.cancel_button = IconButton( + 'process-stop-symbolic', + name='playlist-list-new-playlist-cancel', + relief=True, + ) + self.cancel_button.connect('clicked', self.cancel_button_clicked) + new_playlist_actions.pack_end(self.cancel_button, False, True, 0) new_playlist_box.add(new_playlist_actions) self.new_playlist_row.add(new_playlist_box) - self.playlist_list.add(self.new_playlist_row) - self.playlist_list.connect('row-activated', self.on_playlist_selected) - list_scroll_window.add(self.playlist_list) - playlist_list_vbox.pack_start(list_scroll_window, True, True, 0) + loading_new_playlist.add(self.new_playlist_row) + self.add(loading_new_playlist) - self.pack1(playlist_list_vbox, False, False) + list_scroll_window = Gtk.ScrolledWindow(min_content_width=220) - # The playlist view on the right side - # ===================================================================== - loading_overlay = Gtk.Overlay(name='playlist-view-overlay') + def create_playlist_row(model: PlaylistList.PlaylistModel): + row = Gtk.ListBoxRow( + action_name='app.go-to-playlist', + action_target=GLib.Variant('s', model.playlist_id), + ) + row.add( + Gtk.Label( + label=f'{model.name}', + use_markup=True, + margin=12, + halign=Gtk.Align.START, + ellipsize=Pango.EllipsizeMode.END, + max_width_chars=30, + )) + row.show_all() + return row + + self.playlists_store = Gio.ListStore() + self.list = Gtk.ListBox(name='playlist-list-listbox') + self.list.bind_model(self.playlists_store, create_playlist_row) + list_scroll_window.add(self.list) + self.pack_start(list_scroll_window, True, True, 0) + + def update(self, **kwargs): + self.new_playlist_row.hide() + self.update_list(**kwargs) + + @util.async_callback( + lambda *a, **k: CacheManager.get_playlists(*a, **k), + before_download=lambda self: self.loading_indicator.show_all(), + on_failure=lambda self, e: self.loading_indicator.hide(), + ) + def update_list( + self, + playlists: List[PlaylistWithSongs], + state: ApplicationState, + ): + new_store = [] + selected_idx = None + for i, playlist in enumerate(playlists): + if state and state.selected_playlist_id == playlist.id: + selected_idx = i + + new_store.append( + PlaylistList.PlaylistModel(playlist.id, playlist.name)) + + util.diff_model_store(self.playlists_store, new_store) + + # Preserve selection + if selected_idx is not None: + row = self.list.get_row_at_index(selected_idx) + self.list.select_row(row) + + self.loading_indicator.hide() + + # Event Handlers + # ========================================================================= + def on_new_playlist_clicked(self, new_playlist_button): + self.new_playlist_entry.set_text('Untitled Playlist') + self.new_playlist_entry.grab_focus() + self.new_playlist_row.show() + + def on_list_refresh_click(self, button): + self.update(force=True) + + def new_entry_activate(self, entry): + self.create_playlist(entry.get_text()) + + def cancel_button_clicked(self, button): + self.new_playlist_row.hide() + + def confirm_button_clicked(self, button): + self.create_playlist(self.new_playlist_entry.get_text()) + + def create_playlist(self, playlist_name): + def on_playlist_created(f): + self.update(force=True) + + self.loading_indicator.show() + playlist_ceate_future = CacheManager.create_playlist( + name=playlist_name) + playlist_ceate_future.add_done_callback( + lambda f: GLib.idle_add(on_playlist_created, f)) + + +class PlaylistDetailPanel(Gtk.Overlay): + __gsignals__ = { + 'song-clicked': ( + GObject.SignalFlags.RUN_FIRST, + GObject.TYPE_NONE, + (str, object, object), + ), + } + + playlist_id = None + + editing_playlist_song_list: bool = False + reordering_playlist_song_list: bool = False + + def __init__(self): + Gtk.Overlay.__init__(self, name='playlist-view-overlay') playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # Playlist info panel @@ -293,7 +411,7 @@ class PlaylistsPanel(Gtk.Paned): playlist_view_scroll_window.add(self.playlist_songs) playlist_box.pack_end(playlist_view_scroll_window, True, True, 0) - loading_overlay.add(playlist_box) + self.add(playlist_box) playlist_view_spinner = Gtk.Spinner(active=True) playlist_view_spinner.start() @@ -305,60 +423,114 @@ class PlaylistsPanel(Gtk.Paned): xscale=0.1, yscale=0.1) self.playlist_view_loading_box.add(playlist_view_spinner) - loading_overlay.add_overlay(self.playlist_view_loading_box) + self.add_overlay(self.playlist_view_loading_box) - self.pack2(loading_overlay, True, False) + def update(self, state: ApplicationState): + if state.selected_playlist_id is None: + self.playlist_action_buttons.hide() + self.play_shuffle_buttons.hide() + self.playlist_view_loading_box.hide() + self.playlist_artwork.set_loading(False) + else: + self.update_playlist_view(state.selected_playlist_id, state=state) + + @util.async_callback( + lambda *a, **k: CacheManager.get_playlist(*a, **k), + before_download=lambda self: self.playlist_view_loading_box.show_all(), + on_failure=lambda self, e: self.playlist_view_loading_box.hide(), + ) + def update_playlist_view( + self, + playlist, + state: ApplicationState = None, + force=False, + ): + if self.playlist_id != playlist.id: + self.playlist_songs.get_selection().unselect_all() + + self.playlist_id = playlist.id + + # Update the info display. + self.playlist_indicator.set_markup('PLAYLIST') + self.playlist_name.set_markup(f'{playlist.name}') + if playlist.comment: + self.playlist_comment.set_text(playlist.comment) + self.playlist_comment.show() + else: + self.playlist_comment.hide() + self.playlist_stats.set_markup(self.format_stats(playlist)) + + # Update the artwork. + self.update_playlist_artwork(playlist.coverArt) + + # Update the song list model. This requires some fancy diffing to + # update the list. + self.editing_playlist_song_list = True + + new_store = [[ + util.get_cached_status_icon(CacheManager.get_cached_status(song)), + song.title, + song.album, + song.artist, + util.format_song_duration(song.duration), + song.id, + ] for song in (playlist.entry or [])] + + util.diff_song_store(self.playlist_song_store, new_store) + + self.editing_playlist_song_list = False + + self.playlist_view_loading_box.hide() + self.playlist_action_buttons.show_all() + self.play_shuffle_buttons.show_all() + + @util.async_callback( + lambda *a, **k: CacheManager.get_cover_art_filename(*a, **k), + before_download=lambda self: self.playlist_artwork.set_loading(True), + on_failure=lambda self, e: self.playlist_artwork.set_loading(False), + ) + def update_playlist_artwork( + self, + cover_art_filename, + state: ApplicationState, + ): + self.playlist_artwork.set_from_file(cover_art_filename) + self.playlist_artwork.set_loading(False) # Event Handlers # ========================================================================= - def on_new_playlist_clicked(self, new_playlist_button): - self.playlist_list_new_entry.set_text('Untitled Playlist') - self.playlist_list_new_entry.grab_focus() - self.new_playlist_row.show() - - def on_playlist_selected(self, playlist_list, row): - self.update_playlist_view(self.playlist_map[row.get_index()].id) - - def on_list_refresh_click(self, button): - self.update_playlist_list(force=True) + def on_view_refresh_click(self, button): + self.update_playlist_view(self.playlist_id, force=True) def on_playlist_edit_button_click(self, button): - selected = self.playlist_list.get_selected_row() - playlist = self.playlist_map[selected.get_index()] dialog = EditPlaylistDialog( self.get_toplevel(), - CacheManager.get_playlist(playlist.id).result()) - - def on_delete_playlist(e): - CacheManager.delete_playlist(playlist.id) - dialog.destroy() - self.update_playlist_list(force=True) - - dialog.connect('delete-playlist', on_delete_playlist) + CacheManager.get_playlist(self.playlist_id).result(), + playlist_id=self.playlist_id, + ) result = dialog.run() if result == Gtk.ResponseType.OK: CacheManager.update_playlist( - playlist.id, + self.playlist_id, name=dialog.data['name'].get_text(), comment=dialog.data['comment'].get_text(), public=dialog.data['public'].get_active(), ) - cover_art_filename = f'cover_art/{playlist.coverArt}_*' - CacheManager.delete_cached(cover_art_filename) + # Invalidate the cover art cache. + CacheManager.delete_cached_cover_art(self.playlist_id) - self.update_playlist_list(force=True) - self.update_playlist_view(playlist.id, force=True) + # Invalidate the playlist list + CacheManager.invalidate_playlists_cache() + # TODO force an update on the Playlist List. + + self.update_playlist_view(self.playlist_id, force=True) dialog.destroy() def on_playlist_list_download_all_button_click(self, button): - playlist = self.playlist_map[ - self.playlist_list.get_selected_row().get_index()] - def download_state_change(*args): - # TODO: Only do this if it's the current playlist. - GLib.idle_add(self.update_playlist_view, playlist.id) + GLib.idle_add(self.update_playlist_view, self.playlist_id) song_ids = [s[-1] for s in self.playlist_song_store] CacheManager.batch_download_songs( @@ -367,11 +539,6 @@ class PlaylistsPanel(Gtk.Paned): on_song_download_complete=download_state_change, ) - def on_view_refresh_click(self, button): - playlist = self.playlist_map[ - self.playlist_list.get_selected_row().get_index()] - self.update_playlist_view(playlist.id, force=True) - def on_play_all_clicked(self, btn): song_id = self.playlist_song_store[0][-1] self.emit('song-clicked', song_id, @@ -405,12 +572,8 @@ class PlaylistsPanel(Gtk.Paned): store, paths = tree.get_selection().get_selected_rows() allow_deselect = False - playlist = self.playlist_map[ - self.playlist_list.get_selected_row().get_index()] - def on_download_state_change(song_id=None): - # TODO: Only do this if it's the current playlist. - GLib.idle_add(self.update_playlist_song_list, playlist.id) + GLib.idle_add(self.update_playlist_view, self.playlist_id) # Use the new selection instead of the old one for calculating what # to do the right click on. @@ -428,10 +591,10 @@ class PlaylistsPanel(Gtk.Paned): def on_remove_songs_click(button): CacheManager.update_playlist( - playlist_id=playlist.id, + playlist_id=self.playlist_id, song_index_to_remove=[p.get_indices()[0] for p in paths], ) - self.update_playlist_song_list(playlist.id, force=True) + self.update_playlist_view(self.playlist_id, force=True) remove_text = ('Remove ' + util.pluralize('song', len(song_ids)) + ' from playlist') @@ -450,14 +613,13 @@ class PlaylistsPanel(Gtk.Paned): if not allow_deselect: return True - def on_playlist_list_new_entry_activate(self, entry): - self.create_playlist(entry.get_text()) - - def on_playlist_list_new_cancel_button_clicked(self, button): - self.new_playlist_row.hide() - - def on_playlist_list_new_confirm_button_clicked(self, button): - self.create_playlist(self.playlist_list_new_entry.get_text()) + def make_label(self, text=None, name=None, **params): + return Gtk.Label( + label=text, + name=name, + halign=Gtk.Align.START, + **params, + ) def playlist_model_row_move(self, *args): # If we are programatically editing the song list, don't do anything. @@ -468,172 +630,22 @@ class PlaylistsPanel(Gtk.Paned): # which one comes first, but just in case, we have this # reordering_playlist_song_list flag.. if self.reordering_playlist_song_list: - selected = self.playlist_list.get_selected_row() - playlist = self.playlist_map[selected.get_index()] - self.update_playlist_order(playlist.id) + self.update_playlist_order(self.playlist_id) self.reordering_playlist_song_list = False else: self.reordering_playlist_song_list = True - # Helper Methods - # ========================================================================= - def make_label(self, text=None, name=None, **params): - return Gtk.Label( - label=text, - name=name, - halign=Gtk.Align.START, - **params, - ) - - def update(self, state: ApplicationState): - self.new_playlist_row.hide() - self.set_playlist_view_loading(False) - self.playlist_artwork.set_loading(False) - self.update_playlist_list() - selected = self.playlist_list.get_selected_row() - if selected: - playlist_id = self.playlist_map[selected.get_index()].id - self.update_playlist_view(playlist_id) - self.playlist_action_buttons.show() - self.play_shuffle_buttons.show() - else: - self.playlist_action_buttons.hide() - self.play_shuffle_buttons.hide() - - self.playlist_songs.set_headers_visible(state.config.show_headers) - - def set_playlist_list_loading(self, loading_status): - if loading_status: - self.playlist_list_loading.show() - else: - self.playlist_list_loading.hide() - - def set_playlist_view_loading(self, loading_status): - if loading_status: - self.playlist_view_loading_box.show() - self.playlist_artwork.set_loading(True) - else: - self.playlist_view_loading_box.hide() - - def create_playlist(self, playlist_name): - try: - # TODO make this async eventually - CacheManager.create_playlist(name=playlist_name) - except ConnectionError: - # TODO show a message box - return - - self.update_playlist_list(force=True) - - @util.async_callback( - lambda *a, **k: CacheManager.get_playlists(*a, **k), - before_download=lambda self: self.set_playlist_list_loading(True), - on_failure=lambda self, e: self.set_playlist_list_loading(False), - ) - def update_playlist_list( - self, - playlists: List[PlaylistWithSongs], - state: ApplicationState, - ): - selected_row = self.playlist_list.get_selected_row() - selected_playlist = None - if selected_row: - selected_playlist = self.playlist_map.get(selected_row.get_index()) - - for row in self.playlist_list.get_children()[1:-1]: - self.playlist_list.remove(row) - - self.playlist_map = {} - selected_idx = None - for i, playlist in enumerate(playlists): - # Use i+1 due to loading indicator - if selected_playlist and playlist.id == selected_playlist.id: - selected_idx = i + 1 - self.playlist_map[i + 1] = playlist - self.playlist_list.insert(self.create_playlist_label(playlist), - i + 1) - - if selected_idx: - row = self.playlist_list.get_row_at_index(selected_idx) - self.playlist_list.select_row(row) - - self.playlist_list.show_all() - self.set_playlist_list_loading(False) - self.new_playlist_row.hide() - - @util.async_callback( - lambda *a, **k: CacheManager.get_playlist(*a, **k), - before_download=lambda self: self.set_playlist_view_loading(True), - on_failure=lambda self, e: (self.set_playlist_view_loading(False) or - self.playlist_artwork.set_loading(False)), - ) - def update_playlist_view(self, playlist, state: ApplicationState): - # Update the Playlist Info panel - self.update_playlist_artwork(playlist.coverArt) - self.playlist_indicator.set_markup('PLAYLIST') - self.playlist_name.set_markup(f'{playlist.name}') - if playlist.comment: - self.playlist_comment.set_text(playlist.comment) - self.playlist_comment.show() - else: - self.playlist_comment.hide() - self.playlist_stats.set_markup(self.format_stats(playlist)) - - self.update_playlist_song_list(playlist.id) - self.playlist_action_buttons.show() - self.play_shuffle_buttons.show() - - @util.async_callback( - lambda *a, **k: CacheManager.get_playlist(*a, **k), - ) - def update_playlist_song_list(self, playlist, state: ApplicationState): - # Update the song list model. This requires some fancy diffing to - # update the list. - self.editing_playlist_song_list = True - - new_store = [[ - util.get_cached_status_icon(CacheManager.get_cached_status(song)), - song.title, - song.album, - song.artist, - util.format_song_duration(song.duration), - song.id, - ] for song in (playlist.entry or [])] - - util.diff_store(self.playlist_song_store, new_store) - - self.editing_playlist_song_list = False - self.set_playlist_view_loading(False) - - @util.async_callback( - lambda *a, **k: CacheManager.get_cover_art_filename(*a, **k), - before_download=lambda self: self.playlist_artwork.set_loading(True), - on_failure=lambda self, e: self.playlist_artwork.set_loading(False), - ) - def update_playlist_artwork( - self, - cover_art_filename, - state: ApplicationState, - ): - self.playlist_artwork.set_from_file(cover_art_filename) - self.playlist_artwork.set_loading(False) - - @util.async_callback( - lambda *a, **k: CacheManager.get_playlist(*a, **k), - # TODO make loading here - ) + @util.async_callback(lambda *a, **k: CacheManager.get_playlist(*a, **k)) def update_playlist_order(self, playlist, state: ApplicationState): - CacheManager.update_playlist( + self.playlist_view_loading_box.show_all() + update_playlist_future = CacheManager.update_playlist( playlist_id=playlist.id, song_index_to_remove=list(range(playlist.songCount)), song_id_to_add=[s[-1] for s in self.playlist_song_store], ) - self.update_playlist_song_list(playlist.id, force=True) - def create_playlist_label(self, playlist: PlaylistWithSongs): - return self.make_label(f'{playlist.name}', - use_markup=True, - margin=12) + update_playlist_future.add_done_callback(lambda f: GLib.idle_add( + lambda: self.update_playlist_view(playlist.id, force=True))) def format_stats(self, playlist): created_date = playlist.created.strftime('%B %d, %Y') diff --git a/libremsonic/ui/util.py b/libremsonic/ui/util.py index 4685dc8..8490ebe 100644 --- a/libremsonic/ui/util.py +++ b/libremsonic/ui/util.py @@ -8,7 +8,7 @@ from deepdiff import DeepDiff import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GLib, Gdk +from gi.repository import Gtk, GLib, Gdk, GObject from libremsonic.cache_manager import CacheManager, SongCacheStatus @@ -68,7 +68,16 @@ def get_cached_status_icon(cache_status: SongCacheStatus): return cache_icon[cache_status] -def diff_store(store_to_edit, new_store): +def _parse_diff_location(location): + match = re.match(r'root\[(\d*)\](?:\[(\d*)\]|\.(.*))?', location) + return tuple(g for g in match.groups() if g is not None) + + +def diff_song_store(store_to_edit, new_store): + """ + Diffing song stores is nice, because we can easily make edits by modifying + the underlying store. + """ old_store = [row[:] for row in store_to_edit] # Diff the lists to determine what needs to be changed. @@ -77,22 +86,34 @@ def diff_store(store_to_edit, new_store): added = diff.get('iterable_item_added', {}) removed = diff.get('iterable_item_removed', {}) - def parse_location(location): - match = re.match(r'root\[(\d*)\](?:\[(\d*)\])?', location) - return tuple(map(int, (g for g in match.groups() if g is not None))) - for edit_location, diff in changed.items(): - idx, field = parse_location(edit_location) - store_to_edit[idx][field] = diff['new_value'] + idx, field = _parse_diff_location(edit_location) + store_to_edit[int(idx)][int(field)] = diff['new_value'] for add_location, value in added.items(): store_to_edit.append(value) for remove_location, value in reversed(list(removed.items())): - remove_at = parse_location(remove_location)[0] + remove_at = int(_parse_diff_location(remove_location)[0]) del store_to_edit[remove_at] +def diff_model_store(store_to_edit, new_store): + """ + The diff here is that if there are any differences, then we refresh the + entire list. This is because it is too hard to do editing. + """ + old_store = store_to_edit[:] + + diff = DeepDiff(old_store, new_store) + if diff == {}: + return + + store_to_edit.remove_all() + for model in new_store: + store_to_edit.append(model) + + def show_song_popover( song_ids, x: int,