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 # Non-ID3 caches
('albums', Child, list), ('albums', Child, list),
('album_details', Child, dict), ('album_details', Directory, dict),
('artists', Artist, list), ('artists', Artist, list),
('artist_details', Directory, dict), ('artist_details', Directory, dict),
('artist_infos', ArtistInfo, dict), ('artist_infos', ArtistInfo, dict),
@@ -432,6 +432,31 @@ class CacheManager(metaclass=Singleton):
return CacheManager.executor.submit(do_get_albums) 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( def batch_delete_cached_songs(
self, self,
song_ids: List[int], song_ids: List[int],

View File

@@ -143,3 +143,14 @@
min-width: 300px; min-width: 300px;
min-height: 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 import gi
gi.require_version('Gtk', '3.0') 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.state_manager import ApplicationState
from libremsonic.cache_manager import CacheManager from libremsonic.cache_manager import CacheManager
@@ -16,21 +16,14 @@ from libremsonic.server.api_objects import (
ArtistID3, ArtistID3,
ArtistInfo2, ArtistInfo2,
ArtistWithAlbumsID3, ArtistWithAlbumsID3,
AlbumWithSongsID3,
Child, Child,
Directory,
) )
from .albums import AlbumsGrid 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): class ArtistsPanel(Gtk.Paned):
"""Defines the arist panel.""" """Defines the arist panel."""
@@ -48,11 +41,17 @@ class ArtistsPanel(Gtk.Paned):
self.selected_artist = None self.selected_artist = None
self.artist_list = ArtistList() self.artist_list = ArtistList()
self.artist_list.connect('selection-changed', self.artist_list.connect(
self.on_list_selection_changed) 'selection-changed',
self.on_list_selection_changed,
)
self.pack1(self.artist_list, False, False) self.pack1(self.artist_list, False, False)
self.artist_detail_panel = ArtistDetailPanel() 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) self.pack2(self.artist_detail_panel, True, False)
def update(self, state: ApplicationState): 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) artist_info_box.pack_start(self.big_info_panel, False, True, 0)
self.albums_grid = AlbumsGrid() self.albums_list = AlbumsListWithSongs()
self.albums_grid.grid.set_halign(Gtk.Align.START) self.albums_list.connect(
self.albums_grid.get_model_list_future = self.get_model_list_future 'song-clicked',
artist_info_box.pack_start(self.albums_grid, True, True, 0) 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) self.add(artist_info_box)
@@ -252,6 +253,8 @@ class ArtistDetailPanel(Gtk.Box):
return CacheManager.executor.submit(do_get_model_list) 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( @util.async_callback(
lambda *a, **k: CacheManager.get_artist(*a, **k), lambda *a, **k: CacheManager.get_artist(*a, **k),
before_download=lambda self: self.artist_artwork.set_loading(True), before_download=lambda self: self.artist_artwork.set_loading(True),
@@ -267,7 +270,7 @@ class ArtistDetailPanel(Gtk.Box):
self.update_artist_artwork(artist) self.update_artist_artwork(artist)
self.albums = artist.get('album', artist.get('child', [])) self.albums = artist.get('album', artist.get('child', []))
self.albums_grid.update() self.albums_list.update(artist)
@util.async_callback( @util.async_callback(
lambda *a, **k: CacheManager.get_artist_info(*a, **k), lambda *a, **k: CacheManager.get_artist_info(*a, **k),
@@ -341,3 +344,206 @@ class ArtistDetailPanel(Gtk.Box):
] ]
return util.dot_join(*components) 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 functools import lru_cache
from typing import List, OrderedDict from typing import List, OrderedDict
from deepdiff import DeepDiff
from fuzzywuzzy import process from fuzzywuzzy import process
import gi import gi
@@ -542,16 +540,9 @@ class PlaylistsPanel(Gtk.Paned):
# Update the song list model. This requires some fancy diffing to # Update the song list model. This requires some fancy diffing to
# update the list. # update the list.
self.editing_playlist_song_list = True 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 = [[ new_model = [[
cache_icon[CacheManager.get_cached_status(song)], util.get_cached_status_icon(CacheManager.get_cached_status(song)),
song.title, song.title,
song.album, song.album,
song.artist, song.artist,
@@ -559,27 +550,7 @@ class PlaylistsPanel(Gtk.Paned):
song.id, song.id,
] for song in (playlist.entry or [])] ] for song in (playlist.entry or [])]
# Diff the lists to determine what needs to be changed. util.diff_model(self.playlist_song_model, new_model)
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]
self.editing_playlist_song_list = False self.editing_playlist_song_list = False
self.set_playlist_view_loading(False) self.set_playlist_view_loading(False)

View File

@@ -1,8 +1,11 @@
import functools import functools
from typing import Callable, List, Tuple, Any from typing import Callable, List, Tuple, Any
import re
from concurrent.futures import Future from concurrent.futures import Future
from deepdiff import DeepDiff
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gio, Gtk, GObject, GLib, Gdk from gi.repository import Gio, Gtk, GObject, GLib, Gdk
@@ -68,6 +71,41 @@ def dot_join(*items):
return ''.join(map(str, 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( def show_song_popover(
song_ids, song_ids,
x: int, x: int,
@@ -151,7 +189,7 @@ def show_song_popover(
), ),
( (
Gtk.ModelButton( Gtk.ModelButton(
text=f"Demove {pluralize('download', song_count)}", text=f"Remove {pluralize('download', song_count)}",
sensitive=remove_download_sensitive, sensitive=remove_download_sensitive,
), ),
on_remove_downloads_click, on_remove_downloads_click,