Files
sublime-music/sublime/ui/main.py
Sumner Evans 1de7ba6327 Improve the handling of cover art caches
Only download the cover art once at size=1000, then use that everywhere by scaling it down
2020-02-22 07:56:40 -07:00

458 lines
16 KiB
Python

from datetime import datetime
from typing import Set
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gio, Gtk, GObject, Gdk, GLib, Pango
from . import albums, artists, browse, playlists, player_controls
from sublime.state_manager import ApplicationState
from sublime.cache_manager import CacheManager, SearchResult
from sublime.server.api_objects import Child
from sublime.ui import util
from sublime.ui.common import SpinnerImage
class MainWindow(Gtk.ApplicationWindow):
"""Defines the main window for Sublime Music."""
__gsignals__ = {
'song-clicked': (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
'songs-removed': (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, ),
),
'refresh-window': (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, bool),
),
'go-to': (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(str, str),
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.set_default_size(1150, 768)
# Create the stack
self.stack = self.create_stack(
Albums=albums.AlbumsPanel(),
Artists=artists.ArtistsPanel(),
Browse=browse.BrowsePanel(),
Playlists=playlists.PlaylistsPanel(),
)
self.stack.set_transition_type(
Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
self.titlebar = self.create_headerbar(self.stack)
self.set_titlebar(self.titlebar)
self.player_controls = player_controls.PlayerControls()
self.player_controls.connect(
'song-clicked', lambda _, *a: self.emit('song-clicked', *a))
self.player_controls.connect(
'songs-removed', lambda _, *a: self.emit('songs-removed', *a))
self.player_controls.connect(
'refresh-window',
lambda _, *args: self.emit('refresh-window', *args),
)
flowbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
flowbox.pack_start(self.stack, True, True, 0)
flowbox.pack_start(self.player_controls, False, True, 0)
self.add(flowbox)
self.connect('button-release-event', self.on_button_release)
def update(self, state: ApplicationState, force=False):
# Update the Connected to label on the popup menu.
if state.config.current_server >= 0:
server_name = state.config.servers[
state.config.current_server].name
self.connected_to_label.set_markup(
f'<b>Connected to {server_name}</b>')
else:
self.connected_to_label.set_markup(
f'<span style="italic">Not Connected to a Server</span>')
self.stack.set_visible_child_name(state.current_tab)
active_panel = self.stack.get_visible_child()
if hasattr(active_panel, 'update'):
active_panel.update(state, force=force)
self.player_controls.update(state)
def create_stack(self, **kwargs):
stack = Gtk.Stack()
for name, child in kwargs.items():
child.connect(
'song-clicked',
lambda _, *args: self.emit('song-clicked', *args),
)
child.connect(
'refresh-window',
lambda _, *args: self.emit('refresh-window', *args),
)
stack.add_titled(child, name.lower(), name)
return stack
def create_headerbar(self, stack):
"""
Configure the header bar for the window.
"""
header = Gtk.HeaderBar()
header.set_show_close_button(True)
header.props.title = 'Sublime Music'
# Search
self.search_entry = Gtk.SearchEntry(
placeholder_text='Search everything...')
self.search_entry.connect('focus-in-event', self.on_search_entry_focus)
self.search_entry.connect(
'button-press-event', self.on_search_entry_button_press)
self.search_entry.connect(
'focus-out-event', self.on_search_entry_loose_focus)
self.search_entry.connect('changed', self.on_search_entry_changed)
self.search_entry.connect(
'stop-search', self.on_search_entry_stop_search)
header.pack_start(self.search_entry)
# Search popup
self.create_search_popup()
# Stack switcher
switcher = Gtk.StackSwitcher(stack=stack)
header.set_custom_title(switcher)
# Menu button
menu_button = Gtk.MenuButton()
menu_button.set_use_popover(True)
menu_button.set_popover(self.create_menu())
menu_button.connect('clicked', self.on_menu_clicked)
self.menu.set_relative_to(menu_button)
icon = Gio.ThemedIcon(name='open-menu-symbolic')
image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
menu_button.add(image)
header.pack_end(menu_button)
return header
def create_label(self, text, *args, **kwargs):
label = Gtk.Label(
use_markup=True,
halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END,
*args,
**kwargs,
)
label.set_markup(text)
label.get_style_context().add_class('search-result-row')
return label
def create_menu(self):
self.menu = Gtk.PopoverMenu()
self.connected_to_label = self.create_label(
'', name='connected-to-label')
self.connected_to_label.set_markup(
f'<span style="italic">Not Connected to a Server</span>')
menu_items = [
(None, self.connected_to_label),
(
'app.configure-servers',
Gtk.ModelButton(text='Configure Servers'),
),
('app.settings', Gtk.ModelButton(text='Settings')),
]
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
for name, item in menu_items:
if name:
item.set_action_name(name)
item.get_style_context().add_class('menu-button')
vbox.pack_start(item, False, True, 0)
self.menu.add(vbox)
return self.menu
def create_search_popup(self):
self.search_popup = Gtk.PopoverMenu(modal=False)
results_scrollbox = Gtk.ScrolledWindow(
min_content_width=500,
min_content_height=750,
)
def make_search_result_header(text):
label = self.create_label(text)
label.get_style_context().add_class('search-result-header')
return label
search_results_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
name='search-results',
)
self.search_results_loading = Gtk.Spinner(
active=False, name='search-spinner')
search_results_box.add(self.search_results_loading)
search_results_box.add(make_search_result_header('Songs'))
self.song_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
search_results_box.add(self.song_results)
search_results_box.add(make_search_result_header('Albums'))
self.album_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
search_results_box.add(self.album_results)
search_results_box.add(make_search_result_header('Artists'))
self.artist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
search_results_box.add(self.artist_results)
search_results_box.add(make_search_result_header('Playlists'))
self.playlist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
search_results_box.add(self.playlist_results)
results_scrollbox.add(search_results_box)
self.search_popup.add(results_scrollbox)
self.search_popup.set_relative_to(self.search_entry)
rect = Gdk.Rectangle()
rect.x = 22
rect.y = 28
rect.width = 1
rect.height = 1
self.search_popup.set_pointing_to(rect)
self.search_popup.set_position(Gtk.PositionType.BOTTOM)
# Event Listeners
# =========================================================================
def on_button_release(self, win, event):
if not self.event_in_widgets(
event,
self.search_entry,
self.search_popup,
):
self.hide_search()
if not self.event_in_widgets(
event,
self.player_controls.device_button,
self.player_controls.device_popover,
):
self.player_controls.device_popover.popdown()
if not self.event_in_widgets(
event,
self.player_controls.play_queue_button,
self.player_controls.play_queue_popover,
):
self.player_controls.play_queue_popover.popdown()
return False
def on_menu_clicked(self, button):
self.menu.popup()
self.menu.show_all()
def on_search_entry_focus(self, entry, event):
self.show_search()
def on_search_entry_button_press(self, *args):
self.show_search()
def on_search_entry_loose_focus(self, entry, event):
self.hide_search()
search_idx = 0
latest_returned_search_idx = 0
last_search_change_time = datetime.now()
searches: Set[SearchResult] = set()
def on_search_entry_changed(self, entry):
now = datetime.now()
if (now - self.last_search_change_time).seconds < 0.5:
while len(self.searches) > 0:
search = self.searches.pop()
if search:
search.cancel()
self.last_search_change_time = now
if not self.search_popup.is_visible():
self.search_popup.show_all()
self.search_popup.popup()
def create_search_callback(idx):
def search_result_calback(result, is_last_in_batch):
# Ignore slow returned searches.
if idx < self.latest_returned_search_idx:
return
# If all results are back, the stop the loading indicator.
if is_last_in_batch:
if idx == self.search_idx - 1:
self.set_search_loading(False)
self.latest_returned_search_idx = idx
self.update_search_results(result)
return lambda *a: GLib.idle_add(search_result_calback, *a)
self.searches.add(
CacheManager.search(
entry.get_text(),
search_callback=create_search_callback(self.search_idx),
before_download=lambda: self.set_search_loading(True),
))
self.search_idx += 1
def on_search_entry_stop_search(self, entry):
self.search_popup.popdown()
# Helper Functions
# =========================================================================
def show_search(self):
self.search_entry.set_size_request(300, -1)
self.search_popup.show_all()
self.search_results_loading.hide()
self.search_popup.popup()
def hide_search(self):
self.search_popup.popdown()
self.search_entry.set_size_request(-1, -1)
def set_search_loading(self, loading_state):
if loading_state:
self.search_results_loading.start()
self.search_results_loading.show_all()
else:
self.search_results_loading.stop()
self.search_results_loading.hide()
def remove_all_from_widget(self, widget):
for c in widget.get_children():
widget.remove(c)
def create_search_result_row(
self,
text,
action_name,
value,
artwork_future,
):
def on_search_row_button_press(btn, event):
if action_name == 'song':
goto_action_name, goto_id = 'album', value.albumId
else:
goto_action_name, goto_id = action_name, value.id
self.emit('go-to', goto_action_name, goto_id)
self.hide_search()
row = Gtk.Button(relief=Gtk.ReliefStyle.NONE)
row.connect('button-press-event', on_search_row_button_press)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
image = SpinnerImage(image_name='search-artwork', image_size=30)
box.add(image)
box.add(self.create_label(text))
row.add(box)
def image_callback(f):
image.set_loading(False)
image.set_from_file(f.result())
artwork_future.add_done_callback(
lambda f: GLib.idle_add(image_callback, f))
return row
def update_search_results(self, search_results):
# Songs
if search_results.song is not None:
self.remove_all_from_widget(self.song_results)
for song in search_results.song or []:
label_text = util.dot_join(
f'<b>{util.esc(song.title)}</b>',
util.esc(song.artist),
)
cover_art_future = CacheManager.get_cover_art_filename(
song.coverArt)
self.song_results.add(
self.create_search_result_row(
label_text, 'song', song, cover_art_future))
self.song_results.show_all()
# Albums
if search_results.album is not None:
self.remove_all_from_widget(self.album_results)
for album in search_results.album or []:
name = album.title if type(album) == Child else album.name
label_text = util.dot_join(
f'<b>{util.esc(name)}</b>',
util.esc(album.artist),
)
cover_art_future = CacheManager.get_cover_art_filename(
album.coverArt)
self.album_results.add(
self.create_search_result_row(
label_text, 'album', album, cover_art_future))
self.album_results.show_all()
# Artists
if search_results.artist is not None:
self.remove_all_from_widget(self.artist_results)
for artist in search_results.artist or []:
label_text = util.esc(artist.name)
cover_art_future = CacheManager.get_artist_artwork(artist)
self.artist_results.add(
self.create_search_result_row(
label_text, 'artist', artist, cover_art_future))
self.artist_results.show_all()
# Playlists
if search_results.playlist is not None:
self.remove_all_from_widget(self.playlist_results)
for playlist in search_results.playlist or []:
label_text = util.esc(playlist.name)
cover_art_future = CacheManager.get_cover_art_filename(
playlist.coverArt)
self.playlist_results.add(
self.create_search_result_row(
label_text, 'playlist', playlist, cover_art_future))
self.playlist_results.show_all()
def event_in_widgets(self, event, *widgets):
for widget in widgets:
if not widget.is_visible():
continue
_, win_x, win_y = Gdk.Window.get_origin(self.get_window())
widget_x, widget_y = widget.translate_coordinates(self, 0, 0)
allocation = widget.get_allocation()
bound_x = (win_x + widget_x, win_x + widget_x + allocation.width)
bound_y = (win_y + widget_y, win_y + widget_y + allocation.height)
# If the event is in this widget, return True immediately.
if ((bound_x[0] <= event.x_root <= bound_x[1])
and (bound_y[0] <= event.y_root <= bound_y[1])):
return True
return False