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,