916 lines
34 KiB
Python
916 lines
34 KiB
Python
import math
|
|
from functools import lru_cache, partial
|
|
from random import randint
|
|
from typing import Any, cast, Dict, List, Tuple
|
|
|
|
from fuzzywuzzy import fuzz
|
|
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango, Handy
|
|
|
|
from ..adapters import AdapterManager, api_objects as API
|
|
from ..config import AppConfiguration
|
|
from . import util
|
|
from .common import (
|
|
IconButton,
|
|
LoadError,
|
|
SongListColumn,
|
|
SpinnerImage,
|
|
Sizer,
|
|
)
|
|
from .actions import run_action
|
|
|
|
|
|
class EditPlaylistWindow(Handy.Window):
|
|
def __init__(self, main_window: Any, playlist: API.Playlist):
|
|
Handy.Window.__init__(
|
|
self,
|
|
modal=True,
|
|
window_position=Gtk.WindowPosition.CENTER_ON_PARENT,
|
|
destroy_with_parent=True,
|
|
type_hint=Gdk.WindowTypeHint.DIALOG,
|
|
default_width=640,
|
|
default_height=576)
|
|
self.main_window = main_window
|
|
self.playlist = playlist
|
|
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
|
# HEADER
|
|
self.header_bar = Handy.HeaderBar()
|
|
self._set_title(playlist.name)
|
|
|
|
cancel_button = Gtk.Button(label="Cancel")
|
|
cancel_button.connect("clicked", lambda _: self.close())
|
|
self.header_bar.pack_start(cancel_button)
|
|
|
|
self.save_button = Gtk.Button(label="Save")
|
|
self.save_button.get_style_context().add_class("suggested-action")
|
|
self.save_button.connect("clicked", self._on_save_clicked)
|
|
self.header_bar.pack_end(self.save_button)
|
|
|
|
box.add(self.header_bar)
|
|
|
|
clamp = Handy.Clamp(margin=12)
|
|
|
|
inner_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
|
list_box = Gtk.ListBox()
|
|
list_box.get_style_context().add_class('content')
|
|
|
|
row = Handy.ActionRow(title="Playlist Name")
|
|
self.name_entry = Gtk.Entry(valign=Gtk.Align.CENTER, text=(playlist.name or ''))
|
|
self.name_entry.connect("changed", self._on_name_change)
|
|
row.add(self.name_entry)
|
|
list_box.add(row)
|
|
|
|
row = Handy.ActionRow(title="Comment")
|
|
self.comment_entry = Gtk.Entry(valign=Gtk.Align.CENTER, text=(playlist.comment or ''))
|
|
row.add(self.comment_entry)
|
|
list_box.add(row)
|
|
|
|
row = Handy.ActionRow(title="Public")
|
|
self.public_switch = Gtk.Switch(valign=Gtk.Align.CENTER, active=playlist.public)
|
|
row.add(self.public_switch)
|
|
list_box.add(row)
|
|
|
|
inner_box.add(list_box)
|
|
|
|
delete_button = IconButton(label="Delete", icon_name="user-trash-symbolic", relief=True, halign=Gtk.Align.END)
|
|
delete_button.get_style_context().add_class('destructive-action')
|
|
delete_button.connect('clicked', self._on_delete_clicked)
|
|
inner_box.pack_start(delete_button, False, False, 10)
|
|
|
|
clamp.add(inner_box)
|
|
|
|
box.add(clamp)
|
|
|
|
self.add(box)
|
|
|
|
def _on_delete_clicked(self, *_):
|
|
# Confirm
|
|
confirm_dialog = Gtk.MessageDialog(
|
|
transient_for=self,
|
|
message_type=Gtk.MessageType.WARNING,
|
|
buttons=Gtk.ButtonsType.NONE,
|
|
text="Confirm deletion",
|
|
)
|
|
confirm_dialog.add_buttons(
|
|
Gtk.STOCK_CANCEL,
|
|
Gtk.ResponseType.CANCEL,
|
|
Gtk.STOCK_DELETE,
|
|
Gtk.ResponseType.YES,
|
|
)
|
|
confirm_dialog.format_secondary_markup(
|
|
f'Are you sure you want to delete the "{self.playlist.name}" playlist?'
|
|
)
|
|
result = confirm_dialog.run()
|
|
confirm_dialog.destroy()
|
|
if result == Gtk.ResponseType.YES:
|
|
AdapterManager.delete_playlist(self.playlist.id)
|
|
run_action(self.main_window, 'app.go-to-playlist', '')
|
|
self.close()
|
|
|
|
def _on_save_clicked(self, *_):
|
|
AdapterManager.update_playlist(
|
|
self.playlist.id,
|
|
name=self.name_entry.get_text(),
|
|
comment=self.comment_entry.get_text(),
|
|
public=self.public_switch.get_active())
|
|
run_action(self.main_window, 'app.refresh')
|
|
self.close()
|
|
|
|
def _on_name_change(self, entry: Gtk.Entry):
|
|
text = entry.get_text()
|
|
if len(text) > 0:
|
|
self._set_title(text)
|
|
self.save_button.set_sensitive(len(text) > 0)
|
|
|
|
def _set_title(self, playlist_name: str):
|
|
self.header_bar.props.title = f"Edit {playlist_name}"
|
|
|
|
|
|
class PlaylistsPanel(Handy.Leaflet):
|
|
"""Defines the playlists panel."""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
Gtk.Paned.__init__(self, transition_type=Handy.LeafletTransitionType.SLIDE, can_swipe_forward=False, interpolate_size=False)
|
|
|
|
list_sizer = Sizer(natural_width=400)
|
|
self.playlist_list = PlaylistList()
|
|
list_sizer.add(self.playlist_list)
|
|
self.add(list_sizer)
|
|
|
|
details_sizer = Sizer(hexpand=True, natural_width=800)
|
|
self.playlist_detail_panel = PlaylistDetailPanel()
|
|
details_sizer.add(self.playlist_detail_panel)
|
|
self.add(details_sizer)
|
|
|
|
def playlist_clicked(_):
|
|
if self.get_folded():
|
|
self.set_visible_child(details_sizer)
|
|
self.playlist_list.connect("playlist-clicked", playlist_clicked)
|
|
|
|
def back_clicked(_):
|
|
self.set_visible_child(list_sizer)
|
|
self.playlist_detail_panel.connect("back-clicked", back_clicked)
|
|
|
|
def folded_changed(*_):
|
|
if not self.get_folded():
|
|
self.set_visible_child(list_sizer)
|
|
|
|
self.playlist_detail_panel.show_mobile = self.get_folded()
|
|
self.connect("notify::folded", folded_changed)
|
|
|
|
def update(self, app_config: AppConfiguration = None, force: bool = False):
|
|
self.playlist_list.update(app_config=app_config, force=force)
|
|
self.playlist_detail_panel.update(app_config=app_config, force=force)
|
|
|
|
|
|
class PlaylistList(Gtk.Box):
|
|
__gsignals__ = {
|
|
"refresh-window": (
|
|
GObject.SignalFlags.RUN_FIRST,
|
|
GObject.TYPE_NONE,
|
|
(object, bool),
|
|
),
|
|
"playlist-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
|
}
|
|
|
|
offline_mode = False
|
|
|
|
class PlaylistModel(GObject.GObject):
|
|
playlist_id = GObject.Property(type=str)
|
|
name = GObject.Property(type=str)
|
|
|
|
def __init__(self, playlist_id: str, name: str):
|
|
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_button = IconButton("list-add-symbolic", label="New Playlist")
|
|
self.new_playlist_button.connect("clicked", self.on_new_playlist_clicked)
|
|
playlist_list_actions.pack_start(self.new_playlist_button)
|
|
|
|
self.list_refresh_button = IconButton(
|
|
"view-refresh-symbolic", "Refresh list of playlists"
|
|
)
|
|
self.list_refresh_button.connect("clicked", self.on_list_refresh_click)
|
|
playlist_list_actions.pack_end(self.list_refresh_button)
|
|
|
|
self.add(playlist_list_actions)
|
|
|
|
self.error_container = Gtk.Box()
|
|
self.add(self.error_container)
|
|
|
|
loading_new_playlist = Gtk.ListBox()
|
|
|
|
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.new_playlist_entry = Gtk.Entry(name="playlist-list-new-playlist-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)
|
|
|
|
confirm_button = IconButton(
|
|
"object-select-symbolic",
|
|
"Create playlist",
|
|
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.cancel_button = IconButton(
|
|
"process-stop-symbolic",
|
|
"Cancel create playlist",
|
|
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)
|
|
|
|
loading_new_playlist.add(self.new_playlist_row)
|
|
self.add(loading_new_playlist)
|
|
|
|
list_scroll_window = Gtk.ScrolledWindow(min_content_width=220)
|
|
|
|
def create_playlist_row(model: PlaylistList.PlaylistModel) -> Gtk.ListBoxRow:
|
|
row = Gtk.ListBoxRow(
|
|
action_name="app.go-to-playlist",
|
|
action_target=GLib.Variant("s", model.playlist_id),
|
|
)
|
|
row.add(
|
|
Gtk.Label(
|
|
label=f"<b>{model.name}</b>",
|
|
use_markup=True,
|
|
margin=10,
|
|
halign=Gtk.Align.START,
|
|
ellipsize=Pango.EllipsizeMode.END,
|
|
)
|
|
)
|
|
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)
|
|
self.list.connect("row-selected", lambda *_: self.emit("playlist-clicked"))
|
|
list_scroll_window.add(self.list)
|
|
self.pack_start(list_scroll_window, True, True, 0)
|
|
|
|
def update(self, app_config: AppConfiguration = None, force: bool = False):
|
|
if app_config:
|
|
self.offline_mode = app_config.offline_mode
|
|
self.new_playlist_button.set_sensitive(not app_config.offline_mode)
|
|
self.list_refresh_button.set_sensitive(not app_config.offline_mode)
|
|
self.new_playlist_row.hide()
|
|
self.update_list(app_config=app_config, force=force)
|
|
|
|
@util.async_callback(
|
|
AdapterManager.get_playlists,
|
|
before_download=lambda self: self.loading_indicator.show_all(),
|
|
on_failure=lambda self, e: self.loading_indicator.hide(),
|
|
)
|
|
def update_list(
|
|
self,
|
|
playlists: List[API.Playlist],
|
|
app_config: AppConfiguration = None,
|
|
force: bool = False,
|
|
order_token: int = None,
|
|
is_partial: bool = False,
|
|
):
|
|
for c in self.error_container.get_children():
|
|
self.error_container.remove(c)
|
|
if is_partial:
|
|
load_error = LoadError(
|
|
"Playlist list",
|
|
"load playlists",
|
|
has_data=len(playlists) > 0,
|
|
offline_mode=self.offline_mode,
|
|
)
|
|
self.error_container.pack_start(load_error, True, True, 0)
|
|
self.error_container.show_all()
|
|
else:
|
|
self.error_container.hide()
|
|
|
|
new_store = []
|
|
selected_idx = None
|
|
for i, playlist in enumerate(playlists or []):
|
|
if (
|
|
app_config
|
|
and app_config.state
|
|
and app_config.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, _):
|
|
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, _):
|
|
self.update(force=True)
|
|
|
|
def new_entry_activate(self, entry: Gtk.Entry):
|
|
self.create_playlist(entry.get_text())
|
|
|
|
def cancel_button_clicked(self, _):
|
|
self.new_playlist_row.hide()
|
|
|
|
def confirm_button_clicked(self, _):
|
|
self.create_playlist(self.new_playlist_entry.get_text())
|
|
|
|
def create_playlist(self, playlist_name: str):
|
|
def on_playlist_created(_):
|
|
self.update(force=True)
|
|
|
|
self.loading_indicator.show()
|
|
playlist_ceate_future = AdapterManager.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__ = {
|
|
"back-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
|
}
|
|
|
|
show_mobile = GObject.Property(type=bool, default=False)
|
|
|
|
playlist_id = None
|
|
offline_mode = False
|
|
|
|
editing_playlist_song_list: bool = False
|
|
reordering_playlist_song_list: bool = False
|
|
|
|
def __init__(self):
|
|
Gtk.Overlay.__init__(self, name="playlist-view-overlay")
|
|
|
|
self.connect("notify::show-mobile", self.on_show_mobile_changed)
|
|
|
|
self.playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
|
action_bar = Gtk.ActionBar()
|
|
|
|
back_button_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.CROSSFADE)
|
|
self.bind_property("show-mobile", back_button_revealer, "reveal-child", GObject.BindingFlags.SYNC_CREATE)
|
|
|
|
back_button = IconButton("go-previous-symbolic")
|
|
back_button.connect("clicked", lambda *_: self.emit("back-clicked"))
|
|
back_button_revealer.add(back_button)
|
|
|
|
action_bar.pack_start(back_button_revealer)
|
|
|
|
self.view_refresh_button = IconButton(
|
|
"view-refresh-symbolic", "Refresh playlist info"
|
|
)
|
|
self.view_refresh_button.connect("clicked", self.on_view_refresh_click)
|
|
action_bar.pack_end(self.view_refresh_button)
|
|
|
|
self.playlist_edit_button = IconButton("document-edit-symbolic", "Edit paylist")
|
|
self.playlist_edit_button.connect("clicked", self.on_playlist_edit_button_click)
|
|
action_bar.pack_end(self.playlist_edit_button)
|
|
|
|
self.download_all_button = IconButton(
|
|
"folder-download-symbolic", "Download all songs in the playlist"
|
|
)
|
|
self.download_all_button.connect(
|
|
"clicked", self.on_playlist_list_download_all_button_click
|
|
)
|
|
action_bar.pack_end(self.download_all_button)
|
|
|
|
self.shuffle_all_button = IconButton(
|
|
"media-playlist-shuffle-symbolic",
|
|
)
|
|
self.shuffle_all_button.connect("clicked", self.on_shuffle_all_button)
|
|
action_bar.pack_end(self.shuffle_all_button)
|
|
|
|
self.play_all_button = IconButton(
|
|
"media-playback-start-symbolic",
|
|
)
|
|
self.play_all_button.connect("clicked", self.on_play_all_clicked)
|
|
action_bar.pack_end(self.play_all_button)
|
|
|
|
self.playlist_box.add(action_bar)
|
|
|
|
self.error_container = Gtk.Box()
|
|
self.playlist_box.add(self.error_container)
|
|
|
|
self.scrolled_window = Gtk.ScrolledWindow()
|
|
|
|
scrolled_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
|
playlist_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
|
|
|
self.playlist_artwork = SpinnerImage(
|
|
image_name="playlist-album-artwork",
|
|
spinner_name="playlist-artwork-spinner",
|
|
image_size=200,
|
|
)
|
|
playlist_info_box.add(self.playlist_artwork)
|
|
|
|
# Name, comment, number of songs, etc.
|
|
playlist_details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
|
playlist_details_box.pack_start(Gtk.Box(), True, False, 0)
|
|
|
|
self.playlist_indicator = self.make_label(name="playlist-indicator")
|
|
playlist_details_box.add(self.playlist_indicator)
|
|
|
|
self.playlist_name = self.make_label(name="playlist-name")
|
|
playlist_details_box.add(self.playlist_name)
|
|
|
|
self.playlist_comment = self.make_label(name="playlist-comment")
|
|
playlist_details_box.add(self.playlist_comment)
|
|
|
|
self.playlist_stats = self.make_label(name="playlist-stats")
|
|
playlist_details_box.add(self.playlist_stats)
|
|
|
|
playlist_info_box.pack_start(playlist_details_box, True, True, 0)
|
|
|
|
scrolled_box.add(playlist_info_box)
|
|
|
|
# Playlist songs list
|
|
self.playlist_song_store = Gtk.ListStore(
|
|
bool, # clickable
|
|
str, # cache status
|
|
str, # title
|
|
str, # album
|
|
str, # artist
|
|
str, # duration
|
|
str, # song ID
|
|
)
|
|
|
|
@lru_cache(maxsize=1024)
|
|
def row_score(key: str, row_items: Tuple[str]) -> int:
|
|
return fuzz.partial_ratio(key, " ".join(row_items).lower())
|
|
|
|
def playlist_song_list_search_fn(
|
|
store: Gtk.ListStore,
|
|
col: int,
|
|
key: str,
|
|
treeiter: Gtk.TreeIter,
|
|
data: Any = None,
|
|
) -> bool:
|
|
threshold = math.ceil(math.ceil(len(key) * 0.8) / len(key) * 100)
|
|
return row_score(key.lower(), tuple(store[treeiter][2:5])) < threshold
|
|
|
|
self.playlist_songs = Gtk.TreeView(
|
|
model=self.playlist_song_store,
|
|
reorderable=True,
|
|
margin_top=15,
|
|
enable_search=True,
|
|
)
|
|
self.playlist_songs.set_search_equal_func(playlist_song_list_search_fn)
|
|
selection = self.playlist_songs.get_selection()
|
|
selection.set_mode(Gtk.SelectionMode.MULTIPLE)
|
|
selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
|
|
|
|
# Song status column.
|
|
renderer = Gtk.CellRendererPixbuf()
|
|
renderer.set_fixed_size(30, 35)
|
|
column = Gtk.TreeViewColumn("", renderer, icon_name=1)
|
|
column.set_resizable(True)
|
|
self.playlist_songs.append_column(column)
|
|
|
|
self.playlist_songs.append_column(SongListColumn("TITLE", 2, bold=True))
|
|
self.playlist_songs.append_column(SongListColumn("ALBUM", 3))
|
|
self.playlist_songs.append_column(SongListColumn("ARTIST", 4))
|
|
self.playlist_songs.append_column(
|
|
SongListColumn("DURATION", 5, align=1, width=40)
|
|
)
|
|
|
|
self.playlist_songs.connect("row-activated", self.on_song_activated)
|
|
self.playlist_songs.connect("button-press-event", self.on_song_button_press)
|
|
|
|
# Set up drag-and-drop on the song list for editing the order of the
|
|
# playlist.
|
|
self.playlist_song_store.connect(
|
|
"row-inserted", self.on_playlist_model_row_move
|
|
)
|
|
self.playlist_song_store.connect("row-deleted", self.on_playlist_model_row_move)
|
|
|
|
scrolled_box.pack_start(self.playlist_songs, True, True, 0)
|
|
|
|
self.scrolled_window.add(scrolled_box)
|
|
self.playlist_box.pack_start(self.scrolled_window, True, True, 0)
|
|
|
|
self.add(self.playlist_box)
|
|
|
|
playlist_view_spinner = Gtk.Spinner(active=True)
|
|
playlist_view_spinner.start()
|
|
|
|
self.playlist_view_loading_box = Gtk.Alignment(
|
|
name="playlist-view-overlay", xalign=0.5, yalign=0.5, xscale=0.1, yscale=0.1
|
|
)
|
|
self.playlist_view_loading_box.add(playlist_view_spinner)
|
|
self.add_overlay(self.playlist_view_loading_box)
|
|
|
|
def on_show_mobile_changed(self, *_):
|
|
self.playlist_artwork.set_image_size( 120 if self.show_mobile else 200)
|
|
|
|
update_playlist_view_order_token = 0
|
|
|
|
def update(self, app_config: AppConfiguration, force: bool = False):
|
|
# Deselect everything if switching online to offline.
|
|
if self.offline_mode != app_config.offline_mode:
|
|
self.playlist_songs.get_selection().unselect_all()
|
|
|
|
self.offline_mode = app_config.offline_mode
|
|
if app_config.state.selected_playlist_id is None:
|
|
self.playlist_box.hide()
|
|
self.playlist_view_loading_box.hide()
|
|
else:
|
|
self.update_playlist_view_order_token += 1
|
|
self.playlist_box.show()
|
|
self.update_playlist_view(
|
|
app_config.state.selected_playlist_id,
|
|
app_config=app_config,
|
|
force=force,
|
|
order_token=self.update_playlist_view_order_token,
|
|
)
|
|
self.download_all_button.set_sensitive(not app_config.offline_mode)
|
|
self.playlist_edit_button.set_sensitive(not app_config.offline_mode)
|
|
self.view_refresh_button.set_sensitive(not app_config.offline_mode)
|
|
|
|
_current_song_ids: List[str] = []
|
|
|
|
@util.async_callback(
|
|
AdapterManager.get_playlist_details,
|
|
before_download=lambda self: self.show_loading_all(),
|
|
on_failure=lambda self, e: self.hide_loading_all(),
|
|
)
|
|
def update_playlist_view(
|
|
self,
|
|
playlist: API.Playlist,
|
|
app_config: AppConfiguration = None,
|
|
force: bool = False,
|
|
order_token: int = None,
|
|
is_partial: bool = False,
|
|
):
|
|
if self.update_playlist_view_order_token != order_token:
|
|
return
|
|
|
|
# If the selected playlist has changed, then clear the selections in
|
|
# the song list.
|
|
if self.playlist_id != playlist.id:
|
|
self.playlist_songs.get_selection().unselect_all()
|
|
|
|
self.playlist_id = playlist.id
|
|
|
|
# Update the info display.
|
|
self.playlist_name.set_markup(f"<b>{playlist.name}</b>")
|
|
self.playlist_name.set_tooltip_text(playlist.name)
|
|
|
|
if playlist.comment:
|
|
self.playlist_comment.set_text(playlist.comment)
|
|
self.playlist_comment.set_tooltip_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.cover_art, order_token=order_token)
|
|
|
|
for c in self.error_container.get_children():
|
|
self.error_container.remove(c)
|
|
if is_partial:
|
|
has_data = len(playlist.songs) > 0
|
|
load_error = LoadError(
|
|
"Playlist data",
|
|
"load playlist details",
|
|
has_data=has_data,
|
|
offline_mode=self.offline_mode,
|
|
)
|
|
self.error_container.pack_start(load_error, True, True, 0)
|
|
self.error_container.show_all()
|
|
if not has_data:
|
|
self.scrolled_window.hide()
|
|
else:
|
|
self.error_container.hide()
|
|
self.scrolled_window.show()
|
|
|
|
# Update the song list model. This requires some fancy diffing to
|
|
# update the list.
|
|
self.editing_playlist_song_list = True
|
|
|
|
# This doesn't look efficient, since it's doing a ton of passses over the data,
|
|
# but there is some annoying memory overhead for generating the stores to diff,
|
|
# so we are short-circuiting by checking to see if any of the the IDs have
|
|
# changed.
|
|
#
|
|
# The entire algorithm ends up being O(2n), but the first loop is very tight,
|
|
# and the expensive parts of the second loop are avoided if the IDs haven't
|
|
# changed.
|
|
song_ids, songs = [], []
|
|
if len(self._current_song_ids) != len(playlist.songs):
|
|
force = True
|
|
|
|
for i, c in enumerate(playlist.songs):
|
|
if i >= len(self._current_song_ids) or c.id != self._current_song_ids[i]:
|
|
force = True
|
|
song_ids.append(c.id)
|
|
songs.append(c)
|
|
|
|
new_songs_store = []
|
|
can_play_any_song = False
|
|
cached_status_icons = ("folder-download-symbolic", "view-pin-symbolic")
|
|
|
|
if force:
|
|
self._current_song_ids = song_ids
|
|
|
|
# Regenerate the store from the actual song data (this is more expensive
|
|
# because when coming from the cache, we are doing 2N fk requests to
|
|
# albums).
|
|
for status_icon, song in zip(
|
|
util.get_cached_status_icons(song_ids),
|
|
[cast(API.Song, s) for s in songs],
|
|
):
|
|
playable = not self.offline_mode or status_icon in cached_status_icons
|
|
can_play_any_song |= playable
|
|
new_songs_store.append(
|
|
[
|
|
playable,
|
|
status_icon,
|
|
song.title,
|
|
album.name if (album := song.album) else None,
|
|
artist.name if (artist := song.artist) else None,
|
|
util.format_song_duration(song.duration),
|
|
song.id,
|
|
]
|
|
)
|
|
else:
|
|
# Just update the clickable state and download state.
|
|
for status_icon, song_model in zip(
|
|
util.get_cached_status_icons(song_ids), self.playlist_song_store
|
|
):
|
|
playable = not self.offline_mode or status_icon in cached_status_icons
|
|
can_play_any_song |= playable
|
|
new_songs_store.append([playable, status_icon, *song_model[2:]])
|
|
|
|
util.diff_song_store(self.playlist_song_store, new_songs_store)
|
|
|
|
self.play_all_button.set_sensitive(can_play_any_song)
|
|
self.shuffle_all_button.set_sensitive(can_play_any_song)
|
|
|
|
self.editing_playlist_song_list = False
|
|
|
|
self.playlist_view_loading_box.hide()
|
|
|
|
@util.async_callback(
|
|
partial(AdapterManager.get_cover_art_uri, scheme="file"),
|
|
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: str,
|
|
app_config: AppConfiguration,
|
|
force: bool = False,
|
|
order_token: int = None,
|
|
is_partial: bool = False,
|
|
):
|
|
if self.update_playlist_view_order_token != order_token:
|
|
return
|
|
|
|
self.playlist_artwork.set_from_file(cover_art_filename)
|
|
self.playlist_artwork.set_loading(False)
|
|
|
|
# Event Handlers
|
|
# =========================================================================
|
|
def on_view_refresh_click(self, _):
|
|
self.update_playlist_view(
|
|
self.playlist_id,
|
|
force=True,
|
|
order_token=self.update_playlist_view_order_token,
|
|
)
|
|
|
|
def on_playlist_edit_button_click(self, _):
|
|
assert self.playlist_id
|
|
playlist = AdapterManager.get_playlist_details(self.playlist_id).result()
|
|
window = EditPlaylistWindow(self.get_toplevel(), playlist)
|
|
|
|
window.set_transient_for(self.get_toplevel())
|
|
window.show_all()
|
|
|
|
def on_playlist_list_download_all_button_click(self, _):
|
|
def download_state_change(song_id: str):
|
|
GLib.idle_add(
|
|
lambda: self.update_playlist_view(
|
|
self.playlist_id, order_token=self.update_playlist_view_order_token
|
|
)
|
|
)
|
|
|
|
song_ids = [s[-1] for s in self.playlist_song_store]
|
|
AdapterManager.batch_download_songs(
|
|
song_ids,
|
|
before_download=download_state_change,
|
|
on_song_download_complete=download_state_change,
|
|
)
|
|
|
|
def play_song(self, index: int, metadata: Dict[str, Any]):
|
|
metadata["active_playlist_id"] = GLib.Variant('s', self.playlist_id)
|
|
run_action(self, 'app.play-song', index, [m[-1] for m in self.playlist_song_store], metadata)
|
|
|
|
def on_play_all_clicked(self, _):
|
|
self.play_song(0, {"force_shuffle_state": GLib.Variant('b', False)})
|
|
|
|
def on_shuffle_all_button(self, _):
|
|
self.play_song(
|
|
randint(0, len(self.playlist_song_store) - 1),
|
|
{"force_shuffle_state": GLib.Variant('b', True)})
|
|
|
|
def on_expand_collapse_click(self, _):
|
|
run_action(self, 'playlists.set-details-expanded', not self.playlist_details_expanded)
|
|
|
|
def on_song_activated(self, _, idx: Gtk.TreePath, col: Any):
|
|
if not self.playlist_song_store[idx[0]][0]:
|
|
return
|
|
# The song ID is in the last column of the model.
|
|
self.play_song(idx.get_indices()[0], {})
|
|
|
|
def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton) -> bool:
|
|
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: str):
|
|
GLib.idle_add(
|
|
lambda: self.update_playlist_view(
|
|
self.playlist_id,
|
|
order_token=self.update_playlist_view_order_token,
|
|
)
|
|
)
|
|
|
|
# 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.playlist_song_store[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)
|
|
|
|
def on_remove_songs_click(_):
|
|
assert self.playlist_id
|
|
delete_idxs = {p.get_indices()[0] for p in paths}
|
|
new_song_ids = [
|
|
model[-1]
|
|
for i, model in enumerate(self.playlist_song_store)
|
|
if i not in delete_idxs
|
|
]
|
|
AdapterManager.update_playlist(
|
|
playlist_id=self.playlist_id, song_ids=new_song_ids
|
|
).result()
|
|
self.update_playlist_view(
|
|
self.playlist_id,
|
|
force=True,
|
|
order_token=self.update_playlist_view_order_token,
|
|
)
|
|
|
|
remove_text = (
|
|
"Remove " + util.pluralize("song", len(song_ids)) + " from playlist"
|
|
)
|
|
util.show_song_popover(
|
|
song_ids,
|
|
event.x,
|
|
event.y + abs(bin_coords.by - widget_coords.wy),
|
|
tree,
|
|
self.offline_mode,
|
|
on_download_state_change=on_download_state_change,
|
|
on_remove_downloads_click=(
|
|
lambda: (
|
|
self.offline_mode
|
|
and self.playlist_songs.get_selection().unselect_all()
|
|
)
|
|
),
|
|
extra_menu_items=[
|
|
(
|
|
Gtk.ModelButton(
|
|
text=remove_text, sensitive=not self.offline_mode
|
|
),
|
|
on_remove_songs_click,
|
|
)
|
|
],
|
|
on_playlist_state_change=lambda: run_action(self, 'app.refresh'),
|
|
)
|
|
|
|
# If the click was on a selected row, don't deselect anything.
|
|
if not allow_deselect:
|
|
return True
|
|
|
|
return False
|
|
|
|
def on_playlist_model_row_move(self, *args):
|
|
# If we are programatically editing the song list, don't do anything.
|
|
if self.editing_playlist_song_list:
|
|
return
|
|
|
|
# We get both a delete and insert event, I think it's deterministic
|
|
# which one comes first, but just in case, we have this
|
|
# reordering_playlist_song_list flag.
|
|
if self.reordering_playlist_song_list:
|
|
self._update_playlist_order(self.playlist_id)
|
|
self.reordering_playlist_song_list = False
|
|
else:
|
|
self.reordering_playlist_song_list = True
|
|
|
|
# Helper Methods
|
|
# =========================================================================
|
|
def show_loading_all(self):
|
|
self.playlist_artwork.set_loading(True)
|
|
self.playlist_view_loading_box.show_all()
|
|
|
|
def hide_loading_all(self):
|
|
self.playlist_artwork.set_loading(False)
|
|
self.playlist_view_loading_box.hide()
|
|
|
|
def make_label(self, text: str = None, name: str = None, **params) -> Gtk.Label:
|
|
return Gtk.Label(
|
|
label=text,
|
|
name=name,
|
|
halign=Gtk.Align.START,
|
|
ellipsize=Pango.EllipsizeMode.END,
|
|
**params,
|
|
)
|
|
|
|
@util.async_callback(AdapterManager.get_playlist_details)
|
|
def _update_playlist_order(
|
|
self,
|
|
playlist: API.Playlist,
|
|
app_config: AppConfiguration,
|
|
**kwargs,
|
|
):
|
|
self.playlist_view_loading_box.show_all()
|
|
update_playlist_future = AdapterManager.update_playlist(
|
|
playlist.id, song_ids=[s[-1] for s in self.playlist_song_store]
|
|
)
|
|
|
|
update_playlist_future.add_done_callback(
|
|
lambda f: GLib.idle_add(
|
|
lambda: self.update_playlist_view(
|
|
playlist.id,
|
|
force=True,
|
|
order_token=self.update_playlist_view_order_token,
|
|
)
|
|
)
|
|
)
|
|
|
|
def _format_stats(self, playlist: API.Playlist) -> str:
|
|
created_date_text = ""
|
|
if playlist.created:
|
|
created_date_text = f" on {playlist.created.strftime('%B %d, %Y')}"
|
|
created_text = f"Created by {playlist.owner}{created_date_text}"
|
|
|
|
lines = [
|
|
util.dot_join(
|
|
created_text,
|
|
f"{'Not v' if not playlist.public else 'V'}isible to others",
|
|
),
|
|
util.dot_join(
|
|
"{} {}".format(
|
|
playlist.song_count,
|
|
util.pluralize("song", playlist.song_count or 0),
|
|
),
|
|
util.format_sequence_duration(playlist.duration),
|
|
),
|
|
]
|
|
return "\n".join(lines)
|