Artist view with album and songs

This commit is contained in:
Sumner Evans
2019-08-07 22:17:50 -06:00
parent a20951008c
commit 5160152b6d
5 changed files with 301 additions and 50 deletions

View File

@@ -139,7 +139,7 @@ class CacheManager(metaclass=Singleton):
# Non-ID3 caches
('albums', Child, list),
('album_details', Child, dict),
('album_details', Directory, dict),
('artists', Artist, list),
('artist_details', Directory, dict),
('artist_infos', ArtistInfo, dict),
@@ -432,6 +432,31 @@ class CacheManager(metaclass=Singleton):
return CacheManager.executor.submit(do_get_albums)
def get_album(
self,
album_id,
before_download: Callable[[], None] = lambda: None,
force: bool = False,
) -> Future:
def do_get_album() -> Union[AlbumWithSongsID3, Child]:
# TODO: implement the non-ID3 version
cache_name = self.id3ify('album_details')
server_fn = (self.server.get_album if self.browse_by_tags else
self.server.get_music_directory)
if album_id not in self.cache.get(cache_name, {}) or force:
before_download()
album = server_fn(album_id)
with self.cache_lock:
self.cache[cache_name][album_id] = album
self.save_cache_info()
return self.cache[cache_name][album_id]
return CacheManager.executor.submit(do_get_album)
def batch_delete_cached_songs(
self,
song_ids: List[int],

View File

@@ -143,3 +143,14 @@
min-width: 300px;
min-height: 300px;
}
#artist-album-list-artwork {
margin: 10px;
min-width: 200px;
min-height: 200px;
}
#artist-album-list-album-name {
margin: 10px 10px 5px 10px;
font-size: 25px;
}

View File

@@ -4,7 +4,7 @@ from typing import List, Union, Optional
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GObject, Pango
from gi.repository import Gtk, GObject, Pango, GLib
from libremsonic.state_manager import ApplicationState
from libremsonic.cache_manager import CacheManager
@@ -16,21 +16,14 @@ from libremsonic.server.api_objects import (
ArtistID3,
ArtistInfo2,
ArtistWithAlbumsID3,
AlbumWithSongsID3,
Child,
Directory,
)
from .albums import AlbumsGrid
class ArtistModel(GObject.Object):
def __init__(self, id, name, cover_art, album_count=0):
self.id = id
self.name = name
self.cover_art = cover_art
self.album_count = album_count
super().__init__()
class ArtistsPanel(Gtk.Paned):
"""Defines the arist panel."""
@@ -48,11 +41,17 @@ class ArtistsPanel(Gtk.Paned):
self.selected_artist = None
self.artist_list = ArtistList()
self.artist_list.connect('selection-changed',
self.on_list_selection_changed)
self.artist_list.connect(
'selection-changed',
self.on_list_selection_changed,
)
self.pack1(self.artist_list, False, False)
self.artist_detail_panel = ArtistDetailPanel()
self.artist_detail_panel.connect(
'song-clicked',
lambda _, song, queue: self.emit('song-clicked', song, queue),
)
self.pack2(self.artist_detail_panel, True, False)
def update(self, state: ApplicationState):
@@ -239,10 +238,12 @@ class ArtistDetailPanel(Gtk.Box):
artist_info_box.pack_start(self.big_info_panel, False, True, 0)
self.albums_grid = AlbumsGrid()
self.albums_grid.grid.set_halign(Gtk.Align.START)
self.albums_grid.get_model_list_future = self.get_model_list_future
artist_info_box.pack_start(self.albums_grid, True, True, 0)
self.albums_list = AlbumsListWithSongs()
self.albums_list.connect(
'song-clicked',
lambda _, song, queue: self.emit('song-clicked', song, queue),
)
artist_info_box.pack_start(self.albums_list, True, True, 0)
self.add(artist_info_box)
@@ -252,6 +253,8 @@ class ArtistDetailPanel(Gtk.Box):
return CacheManager.executor.submit(do_get_model_list)
# TODO need to handle when this is force updated. Need to delete a bunch of
# stuff and un-cache things.
@util.async_callback(
lambda *a, **k: CacheManager.get_artist(*a, **k),
before_download=lambda self: self.artist_artwork.set_loading(True),
@@ -267,7 +270,7 @@ class ArtistDetailPanel(Gtk.Box):
self.update_artist_artwork(artist)
self.albums = artist.get('album', artist.get('child', []))
self.albums_grid.update()
self.albums_list.update(artist)
@util.async_callback(
lambda *a, **k: CacheManager.get_artist_info(*a, **k),
@@ -341,3 +344,206 @@ class ArtistDetailPanel(Gtk.Box):
]
return util.dot_join(*components)
class AlbumsListWithSongs(Gtk.Overlay):
__gsignals__ = {
'song-clicked': (
GObject.SIGNAL_RUN_FIRST,
GObject.TYPE_NONE,
(str, object),
),
}
def __init__(self):
Gtk.Overlay.__init__(self)
self.scrolled_window = Gtk.ScrolledWindow(vexpand=True)
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.scrolled_window.add(self.box)
self.add(self.scrolled_window)
self.spinner = Gtk.Spinner(
name='albumslist-with-songs-spinner',
active=False,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
self.add_overlay(self.spinner)
def update(self, artist):
for c in self.box.get_children():
self.box.remove(c)
for album in artist.get('album', artist.get('child', [])):
album_with_songs = AlbumWithSongs(album)
album_with_songs.connect(
'song-clicked',
lambda _, song, queue: self.emit('song-clicked', song, queue),
)
self.box.add(album_with_songs)
self.scrolled_window.show_all()
class AlbumWithSongs(Gtk.Box):
__gsignals__ = {
'song-clicked': (
GObject.SIGNAL_RUN_FIRST,
GObject.TYPE_NONE,
(str, object),
),
}
def __init__(self, album):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
self.album = album
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
artist_artwork = SpinnerImage(
loading=False,
image_name='artist-album-list-artwork',
spinner_name='artist-artwork-spinner',
)
box.pack_start(artist_artwork, False, False, 0)
box.pack_start(Gtk.Box(), True, True, 0)
self.pack_start(box, False, False, 0)
def cover_art_future_done(f):
artist_artwork.set_from_file(f.result())
artist_artwork.set_loading(False)
cover_art_filename_future = CacheManager.get_cover_art_filename(
album.coverArt,
before_download=lambda: artist_artwork.set_loading(True),
size=200,
)
cover_art_filename_future.add_done_callback(cover_art_future_done)
album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
album_details.add(
Gtk.Label(
label=album.get('name', album.get('title')),
name='artist-album-list-album-name',
halign=Gtk.Align.START,
))
stats = [album.year, album.genre]
if album.get('duration'):
stats.append(util.format_sequence_duration(album.duration))
album_details.add(
Gtk.Label(
label=util.dot_join(*stats),
halign=Gtk.Align.START,
margin_left=10,
))
self.album_songs_model = Gtk.ListStore(
str, # cache status
str, # title
str, # duration
str, # song ID
)
def create_column(header, text_idx, bold=False, align=0, width=None):
renderer = Gtk.CellRendererText(
xalign=align,
weight=Pango.Weight.BOLD if bold else Pango.Weight.NORMAL,
ellipsize=Pango.EllipsizeMode.END,
)
renderer.set_fixed_size(width or -1, 35)
column = Gtk.TreeViewColumn(header, renderer, text=text_idx)
column.set_resizable(True)
column.set_expand(not width)
return column
album_songs = Gtk.TreeView(
model=self.album_songs_model,
name='album-songs-list',
margin_top=15,
margin_left=10,
margin_right=10,
)
album_songs.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
# Song status column.
renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(30, 35)
column = Gtk.TreeViewColumn('', renderer, icon_name=0)
column.set_resizable(True)
album_songs.append_column(column)
album_songs.append_column(create_column('TITLE', 1, bold=True))
album_songs.append_column(
create_column('DURATION', 2, align=1, width=40))
album_songs.connect('row-activated', self.on_song_activated)
album_songs.connect('button-press-event', self.on_song_button_press)
album_details.add(album_songs)
self.pack_end(album_details, True, True, 0)
self.update_album_songs(album.id)
def on_song_activated(self, treeview, idx, column):
# The song ID is in the last column of the model.
song_id = self.album_songs_model[idx][-1]
self.emit('song-clicked', song_id,
[m[-1] for m in self.album_songs_model])
def on_song_button_press(self, tree, event):
if event.button == 3: # Right click
clicked_path = tree.get_path_at_pos(event.x, event.y)
if not clicked_path:
return False
store, paths = tree.get_selection().get_selected_rows()
allow_deselect = False
def on_download_state_change(song_id=None):
GLib.idle_add(self.update_album_songs, self.album.id)
# Use the new selection instead of the old one for calculating what
# to do the right click on.
if clicked_path[0] not in paths:
paths = [clicked_path[0]]
allow_deselect = True
song_ids = [self.album_songs_model[p][-1] for p in paths]
# Used to adjust for the header row.
bin_coords = tree.convert_tree_to_bin_window_coords(
event.x, event.y)
widget_coords = tree.convert_tree_to_widget_coords(
event.x, event.y)
util.show_song_popover(
song_ids,
event.x,
event.y + abs(bin_coords.by - widget_coords.wy),
tree,
on_download_state_change=on_download_state_change,
)
# If the click was on a selected row, don't deselect anything.
if not allow_deselect:
return True
@util.async_callback(
lambda *a, **k: CacheManager.get_album(*a, **k),
before_download=lambda *a: print('before'),
on_failure=lambda *a: print('failure', *a),
)
def update_album_songs(
self,
album: Union[AlbumWithSongsID3, Child, Directory],
):
new_model = [[
util.get_cached_status_icon(CacheManager.get_cached_status(song)),
util.esc(song.title),
util.format_song_duration(song.duration),
song.id,
] for song in album.get('child', album.get('song', []))]
util.diff_model(self.album_songs_model, new_model)

View File

@@ -1,8 +1,6 @@
import re
from functools import lru_cache
from typing import List, OrderedDict
from deepdiff import DeepDiff
from fuzzywuzzy import process
import gi
@@ -542,16 +540,9 @@ class PlaylistsPanel(Gtk.Paned):
# Update the song list model. This requires some fancy diffing to
# update the list.
self.editing_playlist_song_list = True
old_model = [row[:] for row in self.playlist_song_model]
cache_icon = {
SongCacheStatus.NOT_CACHED: '',
SongCacheStatus.CACHED: 'folder-download-symbolic',
SongCacheStatus.PERMANENTLY_CACHED: 'view-pin-symbolic',
SongCacheStatus.DOWNLOADING: 'emblem-synchronizing-symbolic',
}
new_model = [[
cache_icon[CacheManager.get_cached_status(song)],
util.get_cached_status_icon(CacheManager.get_cached_status(song)),
song.title,
song.album,
song.artist,
@@ -559,27 +550,7 @@ class PlaylistsPanel(Gtk.Paned):
song.id,
] for song in (playlist.entry or [])]
# Diff the lists to determine what needs to be changed.
diff = DeepDiff(old_model, new_model)
changed = diff.get('values_changed', {})
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)
self.playlist_song_model[idx][field] = diff['new_value']
for add_location, value in added.items():
self.playlist_song_model.append(value)
for remove_location, value in reversed(list(removed.items())):
remove_at = parse_location(remove_location)[0]
del self.playlist_song_model[remove_at]
util.diff_model(self.playlist_song_model, new_model)
self.editing_playlist_song_list = False
self.set_playlist_view_loading(False)

View File

@@ -1,8 +1,11 @@
import functools
from typing import Callable, List, Tuple, Any
import re
from concurrent.futures import Future
from deepdiff import DeepDiff
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gio, Gtk, GObject, GLib, Gdk
@@ -68,6 +71,41 @@ def dot_join(*items):
return ''.join(map(str, items))
def get_cached_status_icon(cache_status: SongCacheStatus):
cache_icon = {
SongCacheStatus.NOT_CACHED: '',
SongCacheStatus.CACHED: 'folder-download-symbolic',
SongCacheStatus.PERMANENTLY_CACHED: 'view-pin-symbolic',
SongCacheStatus.DOWNLOADING: 'emblem-synchronizing-symbolic',
}
return cache_icon[cache_status]
def diff_model(model_to_edit, new_model):
old_model = [row[:] for row in model_to_edit]
# Diff the lists to determine what needs to be changed.
diff = DeepDiff(old_model, new_model)
changed = diff.get('values_changed', {})
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)
model_to_edit[idx][field] = diff['new_value']
for add_location, value in added.items():
model_to_edit.append(value)
for remove_location, value in reversed(list(removed.items())):
remove_at = parse_location(remove_location)[0]
del model_to_edit[remove_at]
def show_song_popover(
song_ids,
x: int,
@@ -151,7 +189,7 @@ def show_song_popover(
),
(
Gtk.ModelButton(
text=f"Demove {pluralize('download', song_count)}",
text=f"Remove {pluralize('download', song_count)}",
sensitive=remove_download_sensitive,
),
on_remove_downloads_click,