More cleanup; pulled out song list column to its own class

This commit is contained in:
Sumner Evans
2020-02-22 22:55:09 -07:00
parent c5f7e69028
commit 0952984310
12 changed files with 299 additions and 253 deletions

View File

@@ -2,6 +2,7 @@ import logging
import math
import os
import random
from typing import Any, Callable, Dict, Iterable, List, Optional
import gi
gi.require_version('Gtk', '3.0')
@@ -11,7 +12,7 @@ from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk, Notify
from .cache_manager import CacheManager
from .dbus_manager import dbus_propagate, DBusManager
from .players import ChromecastPlayer, MPVPlayer, PlayerEvent
from .server.api_objects import Child, Directory
from .server.api_objects import Child, Directory, Playlist
from .state_manager import ApplicationState, RepeatType
from .ui.configure_servers import ConfigureServersDialog
from .ui.main import MainWindow
@@ -32,7 +33,7 @@ class SublimeMusicApp(Gtk.Application):
def do_startup(self):
Gtk.Application.do_startup(self)
def add_action(name: str, fn, parameter_type=None):
def add_action(name: str, fn: Callable, parameter_type: str = None):
"""Registers an action with the application."""
if type(parameter_type) == str:
parameter_type = GLib.VariantType(parameter_type)
@@ -124,8 +125,9 @@ class SublimeMusicApp(Gtk.Application):
self.loading_state = False
self.should_scrobble_song = False
def time_observer(value):
if self.loading_state:
def time_observer(value: Optional[float]):
if (self.loading_state or not self.window
or not self.state.current_song):
return
if value is None:
@@ -161,7 +163,6 @@ class SublimeMusicApp(Gtk.Application):
if event.name == 'play_state_change':
self.state.playing = event.value
elif event.name == 'volume_change':
self.state.old_volume = self.state.volume
self.state.volume = event.value
GLib.idle_add(self.update_window)
@@ -197,15 +198,13 @@ class SublimeMusicApp(Gtk.Application):
self.dbus_manager.property_diff()
# ########## DBUS MANAGMENT ########## #
def do_dbus_register(self, connection, path):
def get_state_and_player():
return (self.state, getattr(self, 'player', None))
def do_dbus_register(
self, connection: Gio.DBusConnection, path: str) -> bool:
self.dbus_manager = DBusManager(
connection,
self.on_dbus_method_call,
self.on_dbus_set_property,
get_state_and_player,
lambda: (self.state, getattr(self, 'player', None)),
)
return True
@@ -221,13 +220,15 @@ class SublimeMusicApp(Gtk.Application):
):
second_microsecond_conversion = 1000000
def seek_fn(offset):
def seek_fn(offset: float):
if not self.state.current_song:
return
offset_seconds = offset / second_microsecond_conversion
new_seconds = self.state.song_progress + offset_seconds
self.on_song_scrub(
None, new_seconds / self.state.current_song.duration * 100)
def set_pos_fn(track_id, position=0):
def set_pos_fn(track_id: str, position: float = 0):
if self.state.playing:
self.on_play_pause()
pos_seconds = position / second_microsecond_conversion
@@ -242,18 +243,29 @@ class SublimeMusicApp(Gtk.Application):
self.play_song(song_index)
def get_tracks_metadata(track_ids):
def get_tracks_metadata(track_ids: List[str]) -> GLib.Variant:
if len(track_ids):
# We are lucky, just return an empty list.
return GLib.Variant('(aa{sv})', ([], ))
# Have to calculate all of the metadatas so that we can deal with
# repeat song IDs.
metadatas = [
metadatas: Iterable[Any] = [
self.dbus_manager.get_mpris_metadata(i, self.state.play_queue)
for i in range(len(self.state.play_queue))
]
# Get rid of all of the tracks that were not requested.
metadatas = filter(
lambda m: m['mpris:trackid'] in track_ids, metadatas)
# Sort them so they get returned in the same order as they were
# requested.
metadatas = sorted(
metadatas, key=lambda m: track_ids.index(m['mpris:trackid']))
# Turn them into dictionaries that can actually be serialized into
# a GLib.Variant.
metadatas = map(
lambda m: {k: DBusManager.to_variant(v)
for k, v in m.items()},
@@ -262,7 +274,7 @@ class SublimeMusicApp(Gtk.Application):
return GLib.Variant('(aa{sv})', (list(metadatas), ))
def activate_playlist(playlist_id):
def activate_playlist(playlist_id: str):
playlist_id = playlist_id.split('/')[-1]
playlist = CacheManager.get_playlist(playlist_id).result()
@@ -278,7 +290,12 @@ class SublimeMusicApp(Gtk.Application):
{'active_playlist_id': playlist_id},
)
def get_playlists(index, max_count, order, reverse_order):
def get_playlists(
index: int,
max_count: int,
order: str,
reverse_order: bool,
) -> GLib.Variant:
playlists_result = CacheManager.get_playlists()
if playlists_result.is_future:
# We don't want to wait for the response in this case, so just
@@ -297,20 +314,19 @@ class SublimeMusicApp(Gtk.Application):
reverse=reverse_order,
)
def make_playlist_tuple(p: Playlist):
cover_art_filename = CacheManager.get_cover_art_filename(
p.coverArt,
allow_download=False,
).result()
return (f'/playlist/{p.id}', p.name, cover_art_filename or '')
return GLib.Variant(
'(a(oss))', (
[
(
'/playlist/' + p.id,
p.name,
CacheManager.get_cover_art_filename(
p.coverArt,
allow_download=False,
).result() or '',
)
for p in playlists[index:(index + max_count)]
if p.songCount > 0
], ))
list(
map(
make_playlist_tuple,
playlists[index:(index + max_count)])), ))
method_call_map = {
'org.mpris.MediaPlayer2': {
@@ -381,15 +397,20 @@ class SublimeMusicApp(Gtk.Application):
# ########## ACTION HANDLERS ########## #
@dbus_propagate()
def on_refresh_window(self, _, state_updates, force=False):
def on_refresh_window(
self,
_: Any,
state_updates: Dict[str, Any],
force: bool = False,
):
for k, v in state_updates.items():
setattr(self.state, k, v)
self.update_window(force=force)
def on_configure_servers(self, action, param):
def on_configure_servers(self, *args):
self.show_configure_servers_dialog()
def on_settings(self, action, param):
def on_settings(self, *args):
"""Show the Settings dialog."""
dialog = SettingsDialog(self.window, self.state.config)
result = dialog.run()
@@ -412,7 +433,7 @@ class SublimeMusicApp(Gtk.Application):
self.reset_state()
dialog.destroy()
def on_window_go_to(self, win, action, value):
def on_window_go_to(self, win: Any, action: str, value: str):
{
'album': self.on_go_to_album,
'artist': self.on_go_to_artist,
@@ -467,14 +488,14 @@ class SublimeMusicApp(Gtk.Application):
self.play_song(song_index_to_play, reset=True)
@dbus_propagate()
def on_repeat_press(self, action, params):
def on_repeat_press(self, *args):
# Cycle through the repeat types.
new_repeat_type = RepeatType((self.state.repeat_type.value + 1) % 3)
self.state.repeat_type = new_repeat_type
self.update_window()
@dbus_propagate()
def on_shuffle_press(self, action, params):
def on_shuffle_press(self, *args):
if self.state.shuffle_on:
# Revert to the old play queue.
self.state.current_song_index = self.state.old_play_queue.index(
@@ -494,7 +515,7 @@ class SublimeMusicApp(Gtk.Application):
self.update_window()
@dbus_propagate()
def on_play_next(self, action, song_ids):
def on_play_next(self, action: Any, song_ids: List[str]):
if self.state.current_song is None:
insert_at = 0
else:
@@ -507,12 +528,12 @@ class SublimeMusicApp(Gtk.Application):
self.update_window()
@dbus_propagate()
def on_add_to_queue(self, action, song_ids):
def on_add_to_queue(self, action: Any, song_ids: GLib.Variant):
self.state.play_queue.extend(song_ids)
self.state.old_play_queue.extend(song_ids)
self.update_window()
def on_go_to_album(self, action, album_id):
def on_go_to_album(self, action: Any, album_id: GLib.Variant):
# Switch to the By Year view (or genre, if year is not available) to
# guarantee that the album is there.
album = CacheManager.get_album(album_id.get_string()).result()
@@ -545,26 +566,30 @@ class SublimeMusicApp(Gtk.Application):
self.state.selected_album_id = album_id.get_string()
self.update_window(force=True)
def on_go_to_artist(self, action, artist_id):
def on_go_to_artist(self, action: Any, artist_id: GLib.Variant):
self.state.current_tab = 'artists'
self.state.selected_artist_id = artist_id.get_string()
self.update_window()
def browse_to(self, action, item_id):
def browse_to(self, action: Any, item_id: GLib.Variant):
self.state.current_tab = 'browse'
self.state.selected_browse_element_id = item_id.get_string()
self.update_window()
def on_go_to_playlist(self, action, playlist_id):
def on_go_to_playlist(self, action: Any, playlist_id: GLib.Variant):
self.state.current_tab = 'playlists'
self.state.selected_playlist_id = playlist_id.get_string()
self.update_window()
def on_server_list_changed(self, action, servers):
def on_server_list_changed(self, action: Any, servers: GLib.Variant):
self.state.config.servers = servers
self.state.save_config()
def on_connected_server_changed(self, action, current_server):
def on_connected_server_changed(
self,
action: Any,
current_server: GLib.Variant,
):
if self.state.config.server:
self.state.save()
self.state.config.current_server = current_server
@@ -583,7 +608,7 @@ class SublimeMusicApp(Gtk.Application):
# Update the window according to the new server configuration.
self.update_window()
def on_stack_change(self, stack, child):
def on_stack_change(self, stack: Gtk.Stack, _: Any):
self.state.current_tab = stack.get_visible_child_name()
self.update_window()

View File

@@ -18,6 +18,7 @@ from typing import (
Any,
Callable,
DefaultDict,
Dict,
Generic,
Iterable,
List,
@@ -245,7 +246,7 @@ class CacheManager(metaclass=Singleton):
return self.future is not None
@staticmethod
def ready():
def ready() -> bool:
return CacheManager._instance is not None
@staticmethod
@@ -257,14 +258,15 @@ class CacheManager(metaclass=Singleton):
logging.info('CacheManager shutdown complete')
@staticmethod
def calculate_server_hash(server: Optional[ServerConfiguration]):
def calculate_server_hash(
server: Optional[ServerConfiguration]) -> Optional[str]:
if server is None:
return None
server_info = (server.name + server.server_address + server.username)
return hashlib.md5(server_info.encode('utf-8')).hexdigest()[:8]
class CacheEncoder(json.JSONEncoder):
def default(self, obj):
def default(self, obj: Any) -> Optional[Union[int, List, Dict]]:
"""
Encodes Python objects to JSON.
@@ -413,13 +415,13 @@ class CacheManager(metaclass=Singleton):
with open(absolute_path, 'wb+') as f:
f.write(data)
def calculate_abs_path(self, *relative_paths):
def calculate_abs_path(self, *relative_paths) -> Path:
return Path(self.app_config.cache_location).joinpath(
CacheManager.calculate_server_hash(self.server_config),
*relative_paths,
)
def calculate_download_path(self, *relative_paths):
def calculate_download_path(self, *relative_paths) -> Path:
"""
Determine where to temporarily put the file as it is downloading.
"""
@@ -473,7 +475,7 @@ class CacheManager(metaclass=Singleton):
self.save_file(download_path, download_fn())
except requests.exceptions.ConnectionError:
with self.download_set_lock:
self.current_downloads.discard(abs_path)
self.current_downloads.discard(abs_path_str)
# Move the file to its cache download location.
os.makedirs(abs_path.parent, exist_ok=True)
@@ -494,7 +496,7 @@ class CacheManager(metaclass=Singleton):
)
@staticmethod
def create_future(fn, *args):
def create_future(fn: Callable, *args) -> Future:
"""
Creates a future on the CacheManager's executor.
"""
@@ -516,7 +518,7 @@ class CacheManager(metaclass=Singleton):
if self.cache.get('playlists') and not force:
return CacheManager.Result.from_data(self.cache['playlists'])
def after_download(playlists):
def after_download(playlists: List[Playlist]):
with self.cache_lock:
self.cache['playlists'] = playlists
self.save_cache_info()
@@ -546,7 +548,7 @@ class CacheManager(metaclass=Singleton):
return CacheManager.Result.from_data(
playlist_details[playlist_id])
def after_download(playlist):
def after_download(playlist: PlaylistWithSongs):
with self.cache_lock:
self.cache['playlist_details'][playlist_id] = playlist
@@ -597,7 +599,7 @@ class CacheManager(metaclass=Singleton):
artists.extend(index.artist)
return artists
def after_download(artists):
def after_download(artists: List[ArtistID3]):
with self.cache_lock:
self.cache[cache_name] = artists
self.save_cache_info()
@@ -610,7 +612,7 @@ class CacheManager(metaclass=Singleton):
def get_artist(
self,
artist_id,
artist_id: int,
before_download: Callable[[], None] = lambda: None,
force: bool = False,
) -> 'CacheManager.Result[ArtistWithAlbumsID3]':
@@ -620,7 +622,7 @@ class CacheManager(metaclass=Singleton):
return CacheManager.Result.from_data(
self.cache[cache_name][artist_id])
def after_download(artist):
def after_download(artist: ArtistWithAlbumsID3):
with self.cache_lock:
self.cache[cache_name][artist_id] = artist
self.save_cache_info()
@@ -647,7 +649,7 @@ class CacheManager(metaclass=Singleton):
artists.extend(index.artist)
return artists
def after_download(artists):
def after_download(artists: List[Artist]):
with self.cache_lock:
self.cache[cache_name] = artists
self.save_cache_info()
@@ -670,9 +672,9 @@ class CacheManager(metaclass=Singleton):
return CacheManager.Result.from_data(
self.cache[cache_name][id])
def after_download(album):
def after_download(directory: Directory):
with self.cache_lock:
self.cache[cache_name][id] = album
self.cache[cache_name][id] = directory
self.save_cache_info()
return CacheManager.Result.from_server(
@@ -683,7 +685,7 @@ class CacheManager(metaclass=Singleton):
def get_artist_info(
self,
artist_id,
artist_id: int,
before_download: Callable[[], None] = lambda: None,
force: bool = False,
) -> 'CacheManager.Result[ArtistInfo2]':
@@ -693,7 +695,7 @@ class CacheManager(metaclass=Singleton):
return CacheManager.Result.from_data(
self.cache[cache_name][artist_id])
def after_download(artist_info: Optional[ArtistInfo2]):
def after_download(artist_info: ArtistInfo2):
if not artist_info:
return
@@ -714,7 +716,9 @@ class CacheManager(metaclass=Singleton):
before_download: Callable[[], None] = lambda: None,
force: bool = False,
) -> 'CacheManager.Result[Optional[str]]':
def do_get_artist_artwork(artist_info):
def do_get_artist_artwork(
artist_info: ArtistInfo2
) -> 'CacheManager.Result[Optional[str]]':
lastfm_url = ''.join(artist_info.largeImageUrl or [])
# If it is the placeholder LastFM image, try and use the cover
@@ -741,7 +745,9 @@ class CacheManager(metaclass=Singleton):
force=force,
)
def download_fn(artist_info):
def download_fn(
artist_info: CacheManager.Result[ArtistInfo2]
) -> Optional[str]:
# In this case, artist_info is a future, so we have to wait for
# its result before calculating. Then, immediately unwrap the
# result() because we are already within a future.
@@ -763,7 +769,7 @@ class CacheManager(metaclass=Singleton):
force: bool = False,
# Look at documentation for get_album_list in server.py:
**params,
) -> 'CacheManager.Result[List[AlbumWithSongsID3]]':
) -> 'CacheManager.Result[List[AlbumID3]]':
cache_name = 'albums'
if (len(self.cache.get(cache_name, {}).get(type_, [])) > 0
@@ -771,8 +777,11 @@ class CacheManager(metaclass=Singleton):
return CacheManager.Result.from_data(
self.cache[cache_name][type_])
def do_get_album_list() -> List[AlbumWithSongsID3]:
def get_page(offset, page_size=500):
def do_get_album_list() -> List[AlbumID3]:
def get_page(
offset: int,
page_size: int = 500,
) -> List[AlbumID3]:
return self.server.get_album_list2(
type_,
size=page_size,
@@ -794,7 +803,7 @@ class CacheManager(metaclass=Singleton):
return albums
def after_download(albums):
def after_download(albums: List[AlbumID3]):
with self.cache_lock:
if not self.cache[cache_name].get(type_):
self.cache[cache_name][type_] = []
@@ -807,18 +816,9 @@ class CacheManager(metaclass=Singleton):
after_download=after_download,
)
def invalidate_album_list(self, type_):
# TODO (#24): make this invalidate instead of delete
cache_name = 'albums'
if not self.cache.get(cache_name, {}).get(type_):
return
with self.cache_lock:
self.cache[cache_name][type_] = []
self.save_cache_info()
def get_album(
self,
album_id,
album_id: int,
before_download: Callable[[], None] = lambda: None,
force: bool = False,
) -> 'CacheManager.Result[AlbumWithSongsID3]':
@@ -828,7 +828,7 @@ class CacheManager(metaclass=Singleton):
return CacheManager.Result.from_data(
self.cache[cache_name][album_id])
def after_download(album):
def after_download(album: AlbumWithSongsID3):
with self.cache_lock:
self.cache[cache_name][album_id] = album
@@ -865,24 +865,28 @@ class CacheManager(metaclass=Singleton):
before_download: Callable[[], None],
on_song_download_complete: Callable[[int], None],
) -> Future:
def do_download_song(song_id):
# If a song download is already in the queue and then the ap is
# exited, this prevents the download.
if CacheManager.should_exit:
return
def do_download_song(song_id: int):
try:
# If a song download is already in the queue and then the
# app is exited, this prevents the download.
if CacheManager.should_exit:
return
# Do the actual download. Call .result() because we are already
# inside of a future.
song = CacheManager.get_song_details(song_id).result()
self.return_cached_or_download(
song.path,
lambda: self.server.download(song.id),
before_download=before_download,
).result()
# Allow the next song in the queue to be downloaded.
self.download_limiter_semaphore.release()
on_song_download_complete(song_id)
# Do the actual download. Call .result() because we are
# already inside of a future.
song = CacheManager.get_song_details(song_id).result()
self.return_cached_or_download(
song.path,
lambda: self.server.download(song.id),
before_download=before_download,
).result()
on_song_download_complete(song_id)
finally:
# Release the semaphore lock. This will allow the next song
# in the queue to be downloaded. I'm doing this in the
# finally block so that it always runs, regardless of
# whether an exception is thrown or the function returns.
self.download_limiter_semaphore.release()
def do_batch_download_songs():
for song_id in song_ids:
@@ -927,7 +931,7 @@ class CacheManager(metaclass=Singleton):
return CacheManager.Result.from_data(
self.cache[cache_name][song_id])
def after_download(song_details):
def after_download(song_details: Child):
with self.cache_lock:
self.cache[cache_name][song_id] = song_details
self.save_cache_info()
@@ -959,7 +963,7 @@ class CacheManager(metaclass=Singleton):
def get_song_filename_or_stream(
self,
song: Child,
format=None,
format: str = None,
force_stream: bool = False,
) -> Tuple[str, bool]:
abs_path = self.calculate_abs_path(song.path)
@@ -979,7 +983,7 @@ class CacheManager(metaclass=Singleton):
if self.cache.get('genres') and not force:
return CacheManager.Result.from_data(self.cache['genres'])
def after_download(genres):
def after_download(genres: List[Genre]):
with self.cache_lock:
self.cache['genres'] = genres
self.save_cache_info()
@@ -992,7 +996,7 @@ class CacheManager(metaclass=Singleton):
def search(
self,
query,
query: str,
search_callback: Callable[[SearchResult, bool], None],
before_download: Callable[[], None] = lambda: None,
) -> 'CacheManager.Result':

View File

@@ -3,16 +3,17 @@ import os
import re
from collections import defaultdict
from typing import Dict
from typing import Any, Callable, Dict, Tuple
from deepdiff import DeepDiff
from gi.repository import Gio, GLib
from .cache_manager import CacheManager
from .state_manager import RepeatType
from .players import Player
from .state_manager import ApplicationState, RepeatType
def dbus_propagate(param_self=None):
def dbus_propagate(param_self: Any = None) -> Callable:
"""
Wraps a function which causes changes to DBus properties.
"""
@@ -34,10 +35,10 @@ class DBusManager:
def __init__(
self,
connection,
connection: Gio.DBusConnection,
do_on_method_call,
on_set_property,
get_state_and_player,
get_state_and_player: Callable[[], Tuple[ApplicationState, Player]],
):
self.get_state_and_player = get_state_and_player
self.do_on_method_call = do_on_method_call
@@ -84,22 +85,22 @@ class DBusManager:
def on_get_property(
self,
connection,
connection: Gio.DBusConnection,
sender,
path,
interface,
property_name,
interface: str,
property_name: str,
):
value = self.property_dict().get(interface, {}).get(property_name)
return DBusManager.to_variant(value)
def on_method_call(
self,
connection,
connection: Gio.DBusConnection,
sender,
path,
interface,
method,
interface: str,
method: str,
params,
invocation,
):
@@ -132,7 +133,7 @@ class DBusManager:
)
@staticmethod
def to_variant(value):
def to_variant(value: Any) -> GLib.Variant:
if callable(value):
return DBusManager.to_variant(value())
@@ -160,7 +161,7 @@ class DBusManager:
return value
return GLib.Variant(variant_type, value)
def property_dict(self):
def property_dict(self) -> Dict[str, Any]:
state, player = self.get_state_and_player()
has_current_song = state.current_song is not None
has_next_song = False

View File

@@ -306,7 +306,7 @@ class ArtistDetailPanel(Gtk.Box):
artist: ArtistWithAlbumsID3,
state: ApplicationState,
force: bool = False,
order_token: Optional[int] = None,
order_token: int = None,
):
if order_token != self.update_order_token:
return
@@ -337,7 +337,7 @@ class ArtistDetailPanel(Gtk.Box):
artist_info: ArtistInfo2,
state: ApplicationState,
force: bool = False,
order_token: Optional[int] = None,
order_token: int = None,
):
if order_token != self.update_order_token:
return
@@ -372,7 +372,7 @@ class ArtistDetailPanel(Gtk.Box):
cover_art_filename: str,
state: ApplicationState,
force: bool = False,
order_token: Optional[int] = None,
order_token: int = None,
):
if order_token != self.update_order_token:
return

View File

@@ -1,14 +1,14 @@
from typing import List, Tuple, Union
from typing import Any, List, Optional, Tuple, Type, Union
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gio, GLib, GObject, Gtk, Pango
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
from sublime.cache_manager import CacheManager
from sublime.server.api_objects import Artist, Child
from sublime.server.api_objects import Artist, Child, Directory
from sublime.state_manager import ApplicationState
from sublime.ui import util
from sublime.ui.common import IconButton
from sublime.ui.common import IconButton, SongListColumn
class BrowsePanel(Gtk.Overlay):
@@ -55,13 +55,13 @@ class BrowsePanel(Gtk.Overlay):
)
self.add_overlay(self.spinner)
def update(self, state: ApplicationState, force=False):
def update(self, state: ApplicationState, force: bool = False):
if not CacheManager.ready:
return
self.update_order_token += 1
def do_update(id_stack, update_order_token):
def do_update(id_stack: List[int], update_order_token: int):
if self.update_order_token != update_order_token:
return
@@ -72,7 +72,7 @@ class BrowsePanel(Gtk.Overlay):
)
self.spinner.hide()
def calculate_path(update_order_token) -> Tuple[List[str], int]:
def calculate_path(update_order_token: int) -> Tuple[List[str], int]:
if state.selected_browse_element_id is None:
return [], update_order_token
@@ -113,7 +113,7 @@ class ListAndDrilldown(Gtk.Paned):
id_stack = None
def __init__(self, list_type):
def __init__(self, list_type: Type):
Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
self.list = list_type()
@@ -132,10 +132,10 @@ class ListAndDrilldown(Gtk.Paned):
def update(
self,
id_stack,
id_stack: List[int],
state: ApplicationState,
force=False,
directory_id=None,
force: bool = False,
directory_id: int = None,
):
self.list.update(
None if len(id_stack) == 0 else id_stack[-1],
@@ -237,19 +237,6 @@ class DrilldownList(Gtk.Box):
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
self.directory_song_list = Gtk.TreeView(
model=self.directory_song_store,
name='album-songs-list',
@@ -266,9 +253,9 @@ class DrilldownList(Gtk.Box):
self.directory_song_list.append_column(column)
self.directory_song_list.append_column(
create_column('TITLE', 1, bold=True))
SongListColumn('TITLE', 1, bold=True))
self.directory_song_list.append_column(
create_column('DURATION', 2, align=1, width=40))
SongListColumn('DURATION', 2, align=1, width=40))
self.directory_song_list.connect(
'row-activated', self.on_song_activated)
@@ -279,7 +266,7 @@ class DrilldownList(Gtk.Box):
self.scroll_window.add(scrollbox)
self.pack_start(self.scroll_window, True, True, 0)
def on_song_activated(self, treeview, idx, column):
def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
# The song ID is in the last column of the model.
self.emit(
'song-clicked',
@@ -288,7 +275,11 @@ class DrilldownList(Gtk.Box):
{},
)
def on_song_button_press(self, tree, event):
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:
@@ -323,7 +314,9 @@ class DrilldownList(Gtk.Box):
if not allow_deselect:
return True
def do_update_store(self, elements):
return False
def do_update_store(self, elements: Optional[List[Any]]):
new_directories_store = []
new_songs_store = []
selected_dir_idx = None
@@ -368,7 +361,8 @@ class DrilldownList(Gtk.Box):
self.loading_indicator.hide()
def create_row(self, model: 'DrilldownList.DrilldownElement'):
def create_row(
self, model: 'DrilldownList.DrilldownElement') -> Gtk.ListBoxRow:
row = Gtk.ListBoxRow(
action_name='app.browse-to',
action_target=GLib.Variant('s', model.id),
@@ -396,9 +390,9 @@ class IndexList(DrilldownList):
def update(
self,
selected_id,
selected_id: int,
state: ApplicationState = None,
force=False,
force: bool = False,
**kwargs,
):
self.update_order_token += 1
@@ -409,7 +403,7 @@ class IndexList(DrilldownList):
order_token=self.update_order_token,
)
def on_refresh_clicked(self, _):
def on_refresh_clicked(self, _: Any):
self.update(self.selected_id, force=True)
@util.async_callback(
@@ -419,17 +413,17 @@ class IndexList(DrilldownList):
)
def update_store(
self,
artists,
artists: List[Artist],
state: ApplicationState = None,
force=False,
order_token=None,
force: bool = False,
order_token: int = None,
):
if order_token != self.update_order_token:
return
self.do_update_store(artists)
def on_download_state_change(self, song_id=None):
def on_download_state_change(self, song_id: int = None):
self.update(self.selected_id)
@@ -438,10 +432,10 @@ class MusicDirectoryList(DrilldownList):
def update(
self,
selected_id,
selected_id: int,
state: ApplicationState = None,
force=False,
directory_id=None,
force: bool = False,
directory_id: int = None,
):
self.directory_id = directory_id
self.selected_id = selected_id
@@ -452,7 +446,7 @@ class MusicDirectoryList(DrilldownList):
order_token=self.update_order_token,
)
def on_refresh_clicked(self, _):
def on_refresh_clicked(self, _: Any):
self.update(
self.selected_id, force=True, directory_id=self.directory_id)
@@ -463,15 +457,15 @@ class MusicDirectoryList(DrilldownList):
)
def update_store(
self,
directory,
directory: Directory,
state: ApplicationState = None,
force=False,
order_token=None,
force: bool = False,
order_token: int = None,
):
if order_token != self.update_order_token:
return
self.do_update_store(directory.child)
def on_download_state_change(self, song_id=None):
def on_download_state_change(self, song_id: int = None):
self.update(self.selected_id, directory_id=self.directory_id)

View File

@@ -1,11 +1,13 @@
from .album_with_songs import AlbumWithSongs
from .edit_form_dialog import EditFormDialog
from .icon_button import IconButton
from .song_list_column import SongListColumn
from .spinner_image import SpinnerImage
__all__ = (
'AlbumWithSongs',
'EditFormDialog',
'IconButton',
'SongListColumn',
'SpinnerImage',
)

View File

@@ -1,5 +1,5 @@
from random import randint
from typing import Any, Optional, Union
from typing import Any, Union
import gi
gi.require_version('Gtk', '3.0')
@@ -10,6 +10,7 @@ from sublime.server.api_objects import AlbumWithSongsID3, Child, Directory
from sublime.state_manager import ApplicationState
from sublime.ui import util
from sublime.ui.common.icon_button import IconButton
from sublime.ui.common.song_list_column import SongListColumn
from sublime.ui.common.spinner_image import SpinnerImage
@@ -123,25 +124,6 @@ class AlbumWithSongs(Gtk.Box):
str, # song ID
)
def create_column(
header: str,
text_idx: int,
bold: bool = False,
align: int = 0,
width: Optional[int] = None,
) -> Gtk.TreeViewColumn:
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
self.loading_indicator = Gtk.Spinner(
name='album-list-song-list-spinner')
album_details.add(self.loading_indicator)
@@ -164,9 +146,9 @@ class AlbumWithSongs(Gtk.Box):
column.set_resizable(True)
self.album_songs.append_column(column)
self.album_songs.append_column(create_column('TITLE', 1, bold=True))
self.album_songs.append_column(SongListColumn('TITLE', 1, bold=True))
self.album_songs.append_column(
create_column('DURATION', 2, align=1, width=40))
SongListColumn('DURATION', 2, align=1, width=40))
self.album_songs.connect('row-activated', self.on_song_activated)
self.album_songs.connect(
@@ -286,7 +268,7 @@ class AlbumWithSongs(Gtk.Box):
album: Union[AlbumWithSongsID3, Child, Directory],
state: ApplicationState,
force: bool = False,
order_token: Optional[int] = None,
order_token: int = None,
):
new_store = [
[

View File

@@ -31,5 +31,5 @@ class IconButton(Gtk.Button):
self.add(box)
def set_icon(self, icon_name: str):
def set_icon(self, icon_name: Optional[str]):
self.image.set_from_icon_name(icon_name, self.icon_size)

View File

@@ -0,0 +1,24 @@
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, Pango
class SongListColumn(Gtk.TreeViewColumn):
def __init__(
self,
header: str,
text_idx: int,
bold: bool = False,
align: int = 0,
width: int = 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)
super().__init__(header, renderer, text=text_idx)
self.set_resizable(True)
self.set_expand(not width)

View File

@@ -2,15 +2,16 @@ import math
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from typing import Any, Callable, List
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import GdkPixbuf, GLib, GObject, Gtk, Pango
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango
from pychromecast import Chromecast
from sublime.cache_manager import CacheManager
from sublime.players import ChromecastPlayer
from sublime.server.api_objects import Child
from sublime.state_manager import ApplicationState, RepeatType
from sublime.ui import util
from sublime.ui.common import IconButton, SpinnerImage
@@ -169,42 +170,53 @@ class PlayerControls(Gtk.ActionBar):
new_store = []
def calculate_label(song_details) -> str:
def calculate_label(song_details: Child) -> str:
title = util.esc(song_details.title)
album = util.esc(song_details.album)
artist = util.esc(song_details.artist)
return f'<b>{title}</b>\n{util.dot_join(album, artist)}'
def make_idle_index_capturing_function(idx, order_token, fn):
return lambda f: GLib.idle_add(
fn, idx, order_token, f.result())
def make_idle_index_capturing_function(
idx: int,
order_tok: int,
fn: Callable[[int, int, Any], None],
) -> Callable[[CacheManager.Result], None]:
return lambda f: GLib.idle_add(fn, idx, order_tok, f.result())
def on_cover_art_future_done(idx, order_token, cover_art_filename):
def on_cover_art_future_done(
idx: int,
order_token: int,
cover_art_filename: str,
):
if order_token != self.play_queue_update_order_token:
return
self.play_queue_store[idx][0] = cover_art_filename
def on_song_details_future_done(idx, order_token, song_details):
def on_song_details_future_done(
idx: int,
order_token: int,
song_details: Child,
):
if order_token != self.play_queue_update_order_token:
return
self.play_queue_store[idx][1] = calculate_label(song_details)
# Cover Art
cover_art_future = CacheManager.get_cover_art_filename(
cover_art_result = CacheManager.get_cover_art_filename(
song_details.coverArt)
if cover_art_result.is_future:
# We don't have the cover art already cached.
cover_art_result.add_done_callback(
make_idle_index_capturing_function(
i,
idx,
order_token,
on_cover_art_future_done,
))
else:
# We have the cover art already cached.
self.play_queue_store[idx][0] = cover_art_future.result()
self.play_queue_store[idx][0] = cover_art_result.result()
if state.play_queue != [x[-1] for x in self.play_queue_store]:
self.play_queue_update_order_token += 1
@@ -268,7 +280,7 @@ class PlayerControls(Gtk.ActionBar):
cover_art_filename: str,
state: ApplicationState,
force: bool = False,
order_token: Optional[int] = None,
order_token: int = None,
):
if order_token != self.cover_art_update_order_token:
return
@@ -276,7 +288,7 @@ class PlayerControls(Gtk.ActionBar):
self.album_art.set_from_file(cover_art_filename)
self.album_art.set_loading(False)
def update_scrubber(self, current, duration):
def update_scrubber(self, current: float, duration: int):
if current is None or duration is None:
self.song_duration_label.set_text('-:--')
self.song_progress_label.set_text('-:--')
@@ -292,14 +304,11 @@ class PlayerControls(Gtk.ActionBar):
self.song_progress_label.set_text(
util.format_song_duration(math.floor(current)))
def on_scrub_change_value(self, scale, scroll_type, value):
self.emit('song-scrub', value)
def on_volume_change(self, scale):
def on_volume_change(self, scale: Gtk.Scale):
if not self.editing:
self.emit('volume-change', scale.get_value())
def on_play_queue_click(self, button):
def on_play_queue_click(self, _: Any):
if self.play_queue_popover.is_visible():
self.play_queue_popover.popdown()
else:
@@ -307,7 +316,7 @@ class PlayerControls(Gtk.ActionBar):
self.play_queue_popover.popup()
self.play_queue_popover.show_all()
def on_song_activated(self, treeview, idx, column):
def on_song_activated(self, t: Any, idx: Gtk.TreePath, c: Any):
# The song ID is in the last column of the model.
self.emit(
'song-clicked',
@@ -348,17 +357,17 @@ class PlayerControls(Gtk.ActionBar):
self.last_device_list_update = datetime.now()
update_diff = (
None if not self.last_device_list_update else
(datetime.now() - self.last_device_list_update).seconds)
self.last_device_list_update
and (datetime.now() - self.last_device_list_update).seconds > 60)
if (force or len(self.chromecasts) == 0
or (self.last_device_list_update and update_diff > 60)):
or (update_diff and update_diff > 60)):
future = ChromecastPlayer.get_chromecasts()
future.add_done_callback(
lambda f: GLib.idle_add(chromecast_callback, f.result()))
else:
chromecast_callback(self.chromecasts)
def on_device_click(self, button):
def on_device_click(self, _: Any):
self.devices_requested = True
if self.device_popover.is_visible():
self.device_popover.popdown()
@@ -367,17 +376,17 @@ class PlayerControls(Gtk.ActionBar):
self.device_popover.show_all()
self.update_device_list()
def on_device_refresh_click(self, button):
def on_device_refresh_click(self, _: Any):
self.update_device_list(force=True)
def on_play_queue_button_press(self, tree, event):
def on_play_queue_button_press(self, tree: Any, event: Gdk.EventButton):
if event.button == 3: # Right click
clicked_path = tree.get_path_at_pos(event.x, event.y)
store, paths = tree.get_selection().get_selected_rows()
allow_deselect = False
def on_download_state_change(song_id=None):
def on_download_state_change(song_id: int = None):
# Refresh the entire window (no force) because the song could
# be in a list anywhere in the window.
self.emit('refresh-window', {}, False)
@@ -394,7 +403,7 @@ class PlayerControls(Gtk.ActionBar):
'Remove ' + util.pluralize('song', len(song_ids))
+ ' from queue')
def on_remove_songs_click(_):
def on_remove_songs_click(_: Any):
self.emit('songs-removed', [p.get_indices()[0] for p in paths])
util.show_song_popover(
@@ -436,7 +445,7 @@ class PlayerControls(Gtk.ActionBar):
else:
self.reordering_play_queue_song_list = True
def create_song_display(self):
def create_song_display(self) -> Gtk.Box:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.album_art = SpinnerImage(
@@ -448,7 +457,7 @@ class PlayerControls(Gtk.ActionBar):
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
details_box.pack_start(Gtk.Box(), True, True, 0)
def make_label(name):
def make_label(name: str) -> Gtk.Label:
return Gtk.Label(
name=name,
halign=Gtk.Align.START,
@@ -471,7 +480,7 @@ class PlayerControls(Gtk.ActionBar):
return box
def create_playback_controls(self):
def create_playback_controls(self) -> Gtk.Box:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Scrubber and song progress/length labels
@@ -484,7 +493,8 @@ class PlayerControls(Gtk.ActionBar):
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5)
self.song_scrubber.set_name('song-scrubber')
self.song_scrubber.set_draw_value(False)
self.song_scrubber.connect('change-value', self.on_scrub_change_value)
self.song_scrubber.connect(
'change-value', lambda s, t, v: self.emit('song-scrub', v))
scrubber_box.pack_start(self.song_scrubber, True, True, 0)
self.song_duration_label = Gtk.Label(label='-:--')
@@ -533,7 +543,7 @@ class PlayerControls(Gtk.ActionBar):
return box
def create_play_queue_volume(self):
def create_play_queue_volume(self) -> Gtk.Box:
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
vbox.pack_start(Gtk.Box(), True, True, 0)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
@@ -645,7 +655,13 @@ class PlayerControls(Gtk.ActionBar):
Gtk.SelectionMode.MULTIPLE)
# Album Art column.
def filename_to_pixbuf(column, cell, model, iter, flags):
def filename_to_pixbuf(
column: Any,
cell: Gtk.CellRendererPixbuf,
model: Gtk.ListStore,
iter: Gtk.TreeIter,
flags: Any,
):
filename = model.get_value(iter, 0)
if not filename:
cell.set_property('icon_name', '')

View File

@@ -1,6 +1,6 @@
from functools import lru_cache
from random import randint
from typing import Any, Iterable, List, Optional, Tuple
from typing import Any, Iterable, List, Tuple
import gi
gi.require_version('Gtk', '3.0')
@@ -11,7 +11,12 @@ from sublime.cache_manager import CacheManager
from sublime.server.api_objects import PlaylistWithSongs
from sublime.state_manager import ApplicationState
from sublime.ui import util
from sublime.ui.common import EditFormDialog, IconButton, SpinnerImage
from sublime.ui.common import (
EditFormDialog,
IconButton,
SongListColumn,
SpinnerImage,
)
class EditPlaylistDialog(EditFormDialog):
@@ -181,7 +186,7 @@ class PlaylistList(Gtk.Box):
playlists: List[PlaylistWithSongs],
state: ApplicationState,
force: bool = False,
order_token: Optional[int] = None,
order_token: int = None,
):
new_store = []
selected_idx = None
@@ -340,25 +345,6 @@ class PlaylistDetailPanel(Gtk.Overlay):
# Playlist songs list
playlist_view_scroll_window = Gtk.ScrolledWindow()
def create_column(
header: str,
text_idx: int,
bold: bool = False,
align: int = 0,
width: int = 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
self.playlist_song_store = Gtk.ListStore(
str, # cache status
str, # title
@@ -404,11 +390,12 @@ class PlaylistDetailPanel(Gtk.Overlay):
column.set_resizable(True)
self.playlist_songs.append_column(column)
self.playlist_songs.append_column(create_column('TITLE', 1, bold=True))
self.playlist_songs.append_column(create_column('ALBUM', 2))
self.playlist_songs.append_column(create_column('ARTIST', 3))
self.playlist_songs.append_column(
create_column('DURATION', 4, align=1, width=40))
SongListColumn('TITLE', 1, bold=True))
self.playlist_songs.append_column(SongListColumn('ALBUM', 2))
self.playlist_songs.append_column(SongListColumn('ARTIST', 3))
self.playlist_songs.append_column(
SongListColumn('DURATION', 4, align=1, width=40))
self.playlist_songs.connect('row-activated', self.on_song_activated)
self.playlist_songs.connect(
@@ -470,7 +457,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
playlist: PlaylistWithSongs,
state: ApplicationState = None,
force: bool = False,
order_token: Optional[int] = None,
order_token: int = None,
):
if self.update_playlist_view_order_token != order_token:
return
@@ -532,7 +519,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
cover_art_filename: str,
state: ApplicationState,
force: bool = False,
order_token: Optional[int] = None,
order_token: int = None,
):
if self.update_playlist_view_order_token != order_token:
return

View File

@@ -1,7 +1,17 @@
import functools
import re
from concurrent.futures import Future
from typing import Any, Callable, cast, List, Match, Optional, Tuple, Union
from typing import (
Any,
Callable,
cast,
Iterable,
List,
Match,
Optional,
Tuple,
Union,
)
import gi
from deepdiff import DeepDiff
@@ -9,6 +19,7 @@ gi.require_version('Gtk', '3.0')
from gi.repository import Gdk, GLib, Gtk
from sublime.cache_manager import CacheManager, SongCacheStatus
from sublime.server.api_objects import Playlist
from sublime.state_manager import ApplicationState
@@ -28,7 +39,7 @@ def format_song_duration(duration_secs: int) -> str:
def pluralize(
string: str,
number: int,
pluralized_form: Optional[str] = None,
pluralized_form: str = None,
) -> str:
"""
Pluralize the given string given the count as a number.
@@ -102,12 +113,12 @@ def get_cached_status_icon(cache_status: SongCacheStatus) -> str:
return cache_icon[cache_status]
def _parse_diff_location(location: str):
def _parse_diff_location(location: str) -> Tuple:
match = re.match(r'root\[(\d*)\](?:\[(\d*)\]|\.(.*))?', location)
return tuple(g for g in cast(Match, match).groups() if g is not None)
def diff_song_store(store_to_edit, new_store):
def diff_song_store(store_to_edit: Any, new_store: Iterable[Any]):
"""
Diffing song stores is nice, because we can easily make edits by modifying
the underlying store.
@@ -132,7 +143,7 @@ def diff_song_store(store_to_edit, new_store):
del store_to_edit[remove_at]
def diff_model_store(store_to_edit, new_store):
def diff_model_store(store_to_edit: Any, new_store: Iterable[Any]):
"""
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.
@@ -158,20 +169,20 @@ def show_song_popover(
show_remove_from_playlist_button: bool = False,
extra_menu_items: List[Tuple[Gtk.ModelButton, Any]] = [],
):
def on_download_songs_click(button):
def on_download_songs_click(_: Any):
CacheManager.batch_download_songs(
song_ids,
before_download=on_download_state_change,
on_song_download_complete=on_download_state_change,
)
def on_remove_downloads_click(button):
def on_remove_downloads_click(_: Any):
CacheManager.batch_delete_cached_songs(
song_ids,
on_song_delete=on_download_state_change,
)
def on_add_to_playlist_click(button, playlist):
def on_add_to_playlist_click(_: Any, playlist: Playlist):
CacheManager.executor.submit(
CacheManager.update_playlist,
playlist_id=playlist.id,
@@ -327,7 +338,7 @@ def async_callback(
*args,
state: ApplicationState = None,
force: bool = False,
order_token: Optional[int] = None,
order_token: int = None,
**kwargs,
):
if before_download: