32 Commits

Author SHA1 Message Date
b644989601 bump dependencies (poetry update) 2023-05-11 08:02:14 +00:00
9d5f1885ba gobjectIntrospection -> gobject-introspection (former is deprecated) 2023-05-11 08:01:58 +00:00
5d8eb1f15c update dependencies to match upstream sublime-music 2022-12-01 23:10:08 +00:00
Benjamin Schaaf
4ce2f222f1 WIP 2022-01-12 21:24:40 +11:00
Benjamin Schaaf
82a881ebfd WIP 2022-01-12 16:44:55 +11:00
Benjamin Schaaf
1f31e62340 WIP 2022-01-11 17:16:53 +11:00
Benjamin Schaaf
c7b0bc0bc8 WIP 2022-01-11 02:55:35 +11:00
Benjamin Schaaf
5ce4f1c944 WIP 2022-01-09 19:24:03 +11:00
Benjamin Schaaf
6a68cd1c99 WIP 2022-01-09 17:51:19 +11:00
Benjamin Schaaf
6c2dbf192e WIP 2022-01-09 17:34:46 +11:00
Benjamin Schaaf
d0861460fd Fix crashes when batch downloading
The callbacks from AdapterManager.batch_download_songs happen on a
separate thread, which means any GTK-related functions must be avoided.
2022-01-09 02:14:59 +11:00
Benjamin Schaaf
d35dd2c96d WIP 2022-01-09 00:33:09 +11:00
Benjamin Schaaf
827636ade6 WIP 2022-01-09 00:03:38 +11:00
Benjamin Schaaf
47850356b3 Auto-disable salt_auth for ampache 2022-01-08 18:06:38 +11:00
Benjamin Schaaf
4ced8e607b WIP 2022-01-06 20:40:40 +11:00
Benjamin Schaaf
0f3e04007f WIP 2022-01-03 02:34:46 +11:00
Benjamin Schaaf
5c4a29e6ae WIP 2022-01-02 20:40:07 +11:00
Benjamin Schaaf
87d32ddc7f WIP 2021-12-26 17:51:16 +11:00
Benjamin Schaaf
7c050555e3 WIP 2021-12-24 18:08:37 +11:00
Benjamin Schaaf
a8c5fc2f4d WIP 2021-12-24 12:00:31 +11:00
Benjamin Schaaf
cec0bf1285 WIP 2021-12-21 00:17:46 +11:00
Benjamin Schaaf
bef07bcdf1 WIP 2021-12-20 22:09:33 +11:00
Benjamin Schaaf
e511d471fc WIP 2021-12-20 22:09:33 +11:00
Benjamin Schaaf
8329cd3cfc WIP 2021-12-20 22:09:28 +11:00
Benjamin Schaaf
9fe6cb4519 WIP 2021-12-20 22:09:28 +11:00
Benjamin Schaaf
14a7b2bb77 WIP 2021-12-20 22:09:28 +11:00
Benjamin Schaaf
95c3e5a018 WIP 2021-12-20 22:09:20 +11:00
Benjamin Schaaf
4ba2e09cf1 WIP 2021-12-20 22:09:08 +11:00
Benjamin Schaaf
56ae24b479 WIP 2021-12-20 22:07:35 +11:00
Benjamin Schaaf
d7d774c579 WIP 2021-12-20 22:07:35 +11:00
Benjamin Schaaf
c612f31f42 WIP 2021-12-20 22:07:06 +11:00
Benjamin Schaaf
e1dcf8da4c WIP 2021-12-20 22:07:06 +11:00
37 changed files with 6044 additions and 4786 deletions

2488
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,16 +36,16 @@ python = "^3.8"
bleach = ">=3.3.0"
bottle = {version = "^0.12.18", optional = true}
dataclasses-json = "^0.5.2"
deepdiff = "^5.0.2"
deepdiff = "^5.8.1"
fuzzywuzzy = "^0.18.0"
keyring = {version = "^23.0.0", optional = true}
peewee = "^3.13.3"
pychromecast = {version = "^9.1.1", optional = true}
PyGObject = "^3.38.0"
PyGObject = "^3.42.0"
python-dateutil = "^2.8.1"
python-Levenshtein = "^0.12.0"
python-mpv = "^0.5.2"
requests = "^2.24.0"
python-mpv = "^1.0.1"
requests = "^2.28.1"
semver = "^2.10.2"
[tool.poetry.dev-dependencies]

View File

@@ -13,7 +13,7 @@ pkgs.mkShell {
gcc
git
glib
gobjectIntrospection
gobject-introspection
gtk3
libnotify
pango

View File

@@ -65,3 +65,6 @@ def main():
app = SublimeMusicApp(Path(config_file))
app.run(unknown_args)
if __name__ == '__main__':
main()

View File

@@ -6,8 +6,8 @@ from .adapter_base import (
ConfigurationStore,
SongCacheStatus,
UIInfo,
ConfigParamDescriptor,
)
from .configure_server_form import ConfigParamDescriptor, ConfigureServerForm
from .manager import AdapterManager, DownloadProgress, Result, SearchResult
__all__ = (
@@ -18,7 +18,6 @@ __all__ = (
"CachingAdapter",
"ConfigParamDescriptor",
"ConfigurationStore",
"ConfigureServerForm",
"DownloadProgress",
"Result",
"SearchResult",

View File

@@ -16,13 +16,11 @@ from typing import (
Sequence,
Set,
Tuple,
Union,
Type,
Callable,
)
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
try:
import keyring
@@ -81,9 +79,12 @@ class AlbumSearchQuery:
:class:`AlbumSearchQuery.Type.GENRE`) return albums of the given genre
"""
class _Genre(Genre):
def __init__(self, name: str):
self.name = name
@dataclass
class Genre:
name: str
# Pickle backwards compatibility
_Genre = Genre
class Type(Enum):
"""
@@ -116,7 +117,7 @@ class AlbumSearchQuery:
type: Type
year_range: Tuple[int, int] = this_decade()
genre: Genre = _Genre("Rock")
genre: Genre = Genre("Rock")
_strhash: Optional[str] = None
@@ -255,6 +256,57 @@ class UIInfo:
return f"{self.icon_basename}-{status.lower()}-symbolic"
@dataclass
class ConfigParamDescriptor:
"""
Describes a parameter that can be used to configure an adapter. The
:class:`description`, :class:`required` and :class:`default:` should be self-evident
as to what they do.
The :class:`helptext` parameter is optional detailed text that will be shown in a
help bubble corresponding to the field.
The :class:`type` must be one of the following:
* The literal type ``str``: corresponds to a freeform text entry field in the UI.
* The literal type ``bool``: corresponds to a toggle in the UI.
* The literal type ``int``: corresponds to a numeric input in the UI.
* The literal string ``"password"``: corresponds to a password entry field in the
UI.
* The literal string ``"option"``: corresponds to dropdown in the UI.
* The literal type ``Path``: corresponds to a file picker in the UI.
The :class:`advanced` parameter specifies whether the setting should be behind an
"Advanced" expander.
The :class:`numeric_bounds` parameter only has an effect if the :class:`type` is
`int`. It specifies the min and max values that the UI control can have.
The :class:`numeric_step` parameter only has an effect if the :class:`type` is
`int`. It specifies the step that will be taken using the "+" and "-" buttons on the
UI control (if supported).
The :class:`options` parameter only has an effect if the :class:`type` is
``"option"``. It specifies the list of options that will be available in the
dropdown in the UI.
The :class:`pathtype` parameter only has an effect if the :class:`type` is
``Path``. It can be either ``"file"`` or ``"directory"`` corresponding to a file
picker and a directory picker, respectively.
"""
type: Union[Type, str]
description: str
required: bool = True
helptext: Optional[str] = None
advanced: Optional[bool] = None
default: Any = None
numeric_bounds: Optional[Tuple[int, int]] = None
numeric_step: Optional[int] = None
options: Optional[Iterable[str]] = None
pathtype: Optional[str] = None
class Adapter(abc.ABC):
"""
Defines the interface for a Sublime Music Adapter.
@@ -277,7 +329,7 @@ class Adapter(abc.ABC):
@staticmethod
@abc.abstractmethod
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
def get_configuration_form(config_store: ConfigurationStore) -> Tuple[Dict[str, ConfigParamDescriptor], Callable[[ConfigurationStore], Dict[str, Optional[str]]]]:
"""
This function should return a :class:`Gtk.Box` that gets any inputs required
from the user and uses the given ``config_store`` to store the configuration

View File

@@ -5,6 +5,7 @@ import abc
import logging
from datetime import datetime, timedelta
from functools import lru_cache, partial
from enum import Enum
from typing import (
Any,
Callable,
@@ -145,18 +146,23 @@ class SearchResult:
both server and local results.
"""
class Kind(Enum):
ARTIST = 0
ALBUM = 1
SONG = 2
PLAYLIST = 2
ValueType = Union[Artist, Album, Song, Playlist]
def __init__(self, query: str = None):
self.query = query
self.similiarity_partial = partial(
similarity_ratio, self.query.lower() if self.query else ""
)
self._artists: Dict[str, Artist] = {}
self._albums: Dict[str, Album] = {}
self._songs: Dict[str, Song] = {}
self._playlists: Dict[str, Playlist] = {}
self._results: Dict[Tuple[Kind, str], ValueType] = {}
def __repr__(self) -> str:
fields = ("query", "_artists", "_albums", "_songs", "_playlists")
fields = ("query", "_results")
formatted_fields = map(lambda f: f"{f}={getattr(self, f)}", fields)
return f"<SearchResult {' '.join(formatted_fields)}>"
@@ -165,76 +171,57 @@ class SearchResult:
if results is None:
return
member = f"_{result_type}"
cast(Dict[str, Any], getattr(self, member)).update({r.id: r for r in results})
for result in results:
if result_type == 'artists':
kind = self.Kind.ARTIST
elif result_type == 'albums':
kind = self.Kind.ALBUM
elif result_type == 'songs':
kind = self.Kind.SONG
elif result_type == 'playlists':
kind = self.Kind.PLAYLIST
else:
assert False
self._results[(kind, result.id)] = result
def update(self, other: "SearchResult"):
assert self.query == other.query
self._artists.update(other._artists)
self._albums.update(other._albums)
self._songs.update(other._songs)
self._playlists.update(other._playlists)
self._results.update(other._results)
_S = TypeVar("_S")
def _transform(self, kind: Kind, value: ValueType) -> Tuple[str, ...]:
if kind is self.Kind.ARTIST:
return (value.name,)
elif kind is self.Kind.ALBUM:
return (value.name, value.artist and value.artist.name)
elif kind is self.Kind.SONG:
return (value.title, value.artist and value.artist.name)
elif kind is self.Kind.PLAYLIST:
return (value.name,)
else:
assert False
def _to_result(
self,
it: Dict[str, _S],
transform: Callable[[_S], Tuple[Optional[str], ...]],
) -> List[_S]:
def get_results(self) -> List[Tuple[Kind, ValueType]]:
assert self.query
all_results = []
for value in it.values():
transformed = transform(value)
if any(t is None for t in transformed):
for (kind, _), value in self._results.items():
try:
transformed = self._transform(kind, value)
except Exception:
continue
max_similarity = max(
self.similiarity_partial(t.lower())
for t in transformed
if t is not None
)
(self.similiarity_partial(t.lower()) for t in transformed
if t is not None),
default=0)
if max_similarity < 60:
continue
all_results.append((max_similarity, value))
all_results.append((max_similarity, (kind, value)))
all_results.sort(key=lambda rx: rx[0], reverse=True)
result: List[SearchResult._S] = []
for ratio, x in all_results:
if ratio >= 60 and len(result) < 20:
result.append(x)
else:
# No use going on, all the rest are less.
break
logging.debug(similarity_ratio.cache_info())
return result
@property
def artists(self) -> List[Artist]:
return self._to_result(self._artists, lambda a: (a.name,))
def _try_get_artist_name(self, obj: Union[Album, Song]) -> Optional[str]:
try:
assert obj.artist
return obj.artist.name
except Exception:
return None
@property
def albums(self) -> List[Album]:
return self._to_result(
self._albums, lambda a: (a.name, self._try_get_artist_name(a))
)
@property
def songs(self) -> List[Song]:
return self._to_result(
self._songs, lambda s: (s.title, self._try_get_artist_name(s))
)
@property
def playlists(self) -> List[Playlist]:
return self._to_result(self._playlists, lambda p: (p.name,))
return [r for _, r in all_results]

View File

@@ -1,368 +0,0 @@
"""
This file contains all of the classes related for a shared server configuration form.
"""
from dataclasses import dataclass
from functools import partial
from pathlib import Path
from time import sleep
from typing import Any, Callable, cast, Dict, Iterable, Optional, Tuple, Type, Union
import bleach
from gi.repository import GLib, GObject, Gtk, Pango
from . import ConfigurationStore
@dataclass
class ConfigParamDescriptor:
"""
Describes a parameter that can be used to configure an adapter. The
:class:`description`, :class:`required` and :class:`default:` should be self-evident
as to what they do.
The :class:`helptext` parameter is optional detailed text that will be shown in a
help bubble corresponding to the field.
The :class:`type` must be one of the following:
* The literal type ``str``: corresponds to a freeform text entry field in the UI.
* The literal type ``bool``: corresponds to a toggle in the UI.
* The literal type ``int``: corresponds to a numeric input in the UI.
* The literal string ``"password"``: corresponds to a password entry field in the
UI.
* The literal string ``"option"``: corresponds to dropdown in the UI.
* The literal type ``Path``: corresponds to a file picker in the UI.
The :class:`advanced` parameter specifies whether the setting should be behind an
"Advanced" expander.
The :class:`numeric_bounds` parameter only has an effect if the :class:`type` is
`int`. It specifies the min and max values that the UI control can have.
The :class:`numeric_step` parameter only has an effect if the :class:`type` is
`int`. It specifies the step that will be taken using the "+" and "-" buttons on the
UI control (if supported).
The :class:`options` parameter only has an effect if the :class:`type` is
``"option"``. It specifies the list of options that will be available in the
dropdown in the UI.
The :class:`pathtype` parameter only has an effect if the :class:`type` is
``Path``. It can be either ``"file"`` or ``"directory"`` corresponding to a file
picker and a directory picker, respectively.
"""
type: Union[Type, str]
description: str
required: bool = True
helptext: Optional[str] = None
advanced: Optional[bool] = None
default: Any = None
numeric_bounds: Optional[Tuple[int, int]] = None
numeric_step: Optional[int] = None
options: Optional[Iterable[str]] = None
pathtype: Optional[str] = None
class ConfigureServerForm(Gtk.Box):
__gsignals__ = {
"config-valid-changed": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(bool,),
),
}
def __init__(
self,
config_store: ConfigurationStore,
config_parameters: Dict[str, ConfigParamDescriptor],
verify_configuration: Callable[[], Dict[str, Optional[str]]],
is_networked: bool = True,
):
"""
Inititialize a :class:`ConfigureServerForm` with the given configuration
parameters.
:param config_store: The :class:`ConfigurationStore` to use to store
configuration values for this adapter.
:param config_parameters: An dictionary where the keys are the name of the
configuration paramter and the values are the :class:`ConfigParamDescriptor`
object corresponding to that configuration parameter. The order of the keys
in the dictionary correspond to the order that the configuration parameters
will be shown in the UI.
:param verify_configuration: A function that verifies whether or not the
current state of the ``config_store`` is valid. The output should be a
dictionary containing verification errors. The keys of the returned
dictionary should be the same as the keys passed in via the
``config_parameters`` parameter. The values should be strings describing
why the corresponding value in the ``config_store`` is invalid.
If the adapter ``is_networked``, and the special ``"__ping__"`` key is
returned, then the error will be shown below all of the other settings in
the ping status box.
:param is_networked: whether or not the adapter is networked.
"""
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
self.config_store = config_store
self.required_config_parameter_keys = set()
self.verify_configuration = verify_configuration
self.entries = {}
self.is_networked = is_networked
content_grid = Gtk.Grid(
column_spacing=10,
row_spacing=5,
margin_left=10,
margin_right=10,
)
advanced_grid = Gtk.Grid(column_spacing=10, row_spacing=10)
def create_string_input(is_password: bool, key: str) -> Gtk.Entry:
entry = Gtk.Entry(
text=cast(
Callable[[str], None],
(config_store.get_secret if is_password else config_store.get),
)(key),
hexpand=True,
)
if is_password:
entry.set_visibility(False)
entry.connect(
"changed",
lambda e: self._on_config_change(key, e.get_text(), secret=is_password),
)
return entry
def create_bool_input(key: str) -> Gtk.Switch:
switch = Gtk.Switch(active=config_store.get(key), halign=Gtk.Align.START)
switch.connect(
"notify::active",
lambda s, _: self._on_config_change(key, s.get_active()),
)
return switch
def create_int_input(key: str) -> Gtk.SpinButton:
raise NotImplementedError()
def create_option_input(key: str) -> Gtk.ComboBox:
raise NotImplementedError()
def create_path_input(key: str) -> Gtk.FileChooser:
raise NotImplementedError()
content_grid_i = 0
advanced_grid_i = 0
for key, cpd in config_parameters.items():
if cpd.required:
self.required_config_parameter_keys.add(key)
if cpd.default is not None:
config_store[key] = config_store.get(key, cpd.default)
label = Gtk.Label(label=cpd.description, halign=Gtk.Align.END)
input_el_box = Gtk.Box()
self.entries[key] = cast(
Callable[[str], Gtk.Widget],
{
str: partial(create_string_input, False),
"password": partial(create_string_input, True),
bool: create_bool_input,
int: create_int_input,
"option": create_option_input,
Path: create_path_input,
}[cpd.type],
)(key)
input_el_box.add(self.entries[key])
if cpd.helptext:
help_icon = Gtk.Image.new_from_icon_name(
"help-about",
Gtk.IconSize.BUTTON,
)
help_icon.get_style_context().add_class("configure-form-help-icon")
help_icon.set_tooltip_markup(cpd.helptext)
input_el_box.add(help_icon)
if not cpd.advanced:
content_grid.attach(label, 0, content_grid_i, 1, 1)
content_grid.attach(input_el_box, 1, content_grid_i, 1, 1)
content_grid_i += 1
else:
advanced_grid.attach(label, 0, advanced_grid_i, 1, 1)
advanced_grid.attach(input_el_box, 1, advanced_grid_i, 1, 1)
advanced_grid_i += 1
# Add a button and revealer for the advanced section of the configuration.
if advanced_grid_i > 0:
advanced_component = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
advanced_expander = Gtk.Revealer()
advanced_expander_icon = Gtk.Image.new_from_icon_name(
"go-down-symbolic", Gtk.IconSize.BUTTON
)
revealed = False
def toggle_expander(*args):
nonlocal revealed
revealed = not revealed
advanced_expander.set_reveal_child(revealed)
icon_dir = "up" if revealed else "down"
advanced_expander_icon.set_from_icon_name(
f"go-{icon_dir}-symbolic", Gtk.IconSize.BUTTON
)
advanced_expander_button = Gtk.Button(relief=Gtk.ReliefStyle.NONE)
advanced_expander_button_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=10
)
advanced_label = Gtk.Label(
label="<b>Advanced Settings</b>", use_markup=True
)
advanced_expander_button_box.add(advanced_label)
advanced_expander_button_box.add(advanced_expander_icon)
advanced_expander_button.add(advanced_expander_button_box)
advanced_expander_button.connect("clicked", toggle_expander)
advanced_component.add(advanced_expander_button)
advanced_expander.add(advanced_grid)
advanced_component.add(advanced_expander)
content_grid.attach(advanced_component, 0, content_grid_i, 2, 1)
content_grid_i += 1
content_grid.attach(
Gtk.Separator(name="config-verification-separator"), 0, content_grid_i, 2, 1
)
content_grid_i += 1
self.config_verification_box = Gtk.Box(spacing=10)
content_grid.attach(self.config_verification_box, 0, content_grid_i, 2, 1)
self.pack_start(content_grid, False, False, 10)
self._verification_status_ratchet = 0
self._verify_config(self._verification_status_ratchet)
had_all_required_keys = False
verifying_in_progress = False
def _set_verification_status(
self, verifying: bool, is_valid: bool = False, error_text: str = None
):
if verifying:
if not self.verifying_in_progress:
for c in self.config_verification_box.get_children():
self.config_verification_box.remove(c)
self.config_verification_box.add(
Gtk.Spinner(active=True, name="verify-config-spinner")
)
self.config_verification_box.add(
Gtk.Label(
label="<b>Verifying configuration...</b>", use_markup=True
)
)
self.verifying_in_progress = True
else:
self.verifying_in_progress = False
for c in self.config_verification_box.get_children():
self.config_verification_box.remove(c)
def set_icon_and_label(icon_name: str, label_text: str):
self.config_verification_box.add(
Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DND)
)
label = Gtk.Label(
label=label_text,
use_markup=True,
ellipsize=Pango.EllipsizeMode.END,
)
label.set_tooltip_markup(label_text)
self.config_verification_box.add(label)
if is_valid:
set_icon_and_label(
"config-ok-symbolic", "<b>Configuration is valid</b>"
)
elif escaped := bleach.clean(error_text or ""):
set_icon_and_label("config-error-symbolic", escaped)
self.config_verification_box.show_all()
def _on_config_change(self, key: str, value: Any, secret: bool = False):
if secret:
self.config_store.set_secret(key, value)
else:
self.config_store[key] = value
self._verification_status_ratchet += 1
self._verify_config(self._verification_status_ratchet)
def _verify_config(self, ratchet: int):
self.emit("config-valid-changed", False)
from sublime_music.adapters import Result
if self.required_config_parameter_keys.issubset(set(self.config_store.keys())):
if self._verification_status_ratchet != ratchet:
return
self._set_verification_status(True)
has_empty = False
if self.had_all_required_keys:
for key in self.required_config_parameter_keys:
if self.config_store.get(key) == "":
self.entries[key].get_style_context().add_class("invalid")
self.entries[key].set_tooltip_markup("This field is required")
has_empty = True
else:
self.entries[key].get_style_context().remove_class("invalid")
self.entries[key].set_tooltip_markup(None)
self.had_all_required_keys = True
if has_empty:
self._set_verification_status(
False,
error_text="<b>There are missing fields</b>\n"
"Please fill out all required fields.",
)
return
def on_verify_result(verification_errors: Dict[str, Optional[str]]):
if self._verification_status_ratchet != ratchet:
return
if len(verification_errors) == 0:
self.emit("config-valid-changed", True)
for entry in self.entries.values():
entry.get_style_context().remove_class("invalid")
self._set_verification_status(False, is_valid=True)
return
for key, entry in self.entries.items():
if error_text := verification_errors.get(key):
entry.get_style_context().add_class("invalid")
entry.set_tooltip_markup(error_text)
else:
entry.get_style_context().remove_class("invalid")
entry.set_tooltip_markup(None)
self._set_verification_status(
False, error_text=verification_errors.get("__ping__")
)
def verify_with_delay() -> Dict[str, Optional[str]]:
sleep(0.75)
if self._verification_status_ratchet != ratchet:
return {}
return self.verify_configuration()
errors_result: Result[Dict[str, Optional[str]]] = Result(verify_with_delay)
errors_result.add_done_callback(
lambda f: GLib.idle_add(on_verify_result, f.result())
)

View File

@@ -18,7 +18,6 @@ from .. import (
CachingAdapter,
ConfigParamDescriptor,
ConfigurationStore,
ConfigureServerForm,
SongCacheStatus,
UIInfo,
)
@@ -42,19 +41,16 @@ class FilesystemAdapter(CachingAdapter):
)
@staticmethod
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
def verify_config_store() -> Dict[str, Optional[str]]:
def get_configuration_form() -> Gtk.Box:
configs = {
"directory": ConfigParamDescriptor(
type=Path, description="Music Directory", pathtype="directory"
)
}
def verify_config_store(config_store: ConfigurationStore) -> Dict[str, Optional[str]]:
return {}
return ConfigureServerForm(
config_store,
{
"directory": ConfigParamDescriptor(
type=Path, description="Music Directory", pathtype="directory"
)
},
verify_config_store,
)
return configs, verify_config_store
@staticmethod
def migrate_configuration(config_store: ConfigurationStore):
@@ -779,17 +775,15 @@ class FilesystemAdapter(CachingAdapter):
elif data_key == KEYS.SEARCH_RESULTS:
data = cast(API.SearchResult, data)
for a in data._artists.values():
self._do_ingest_new_data(KEYS.ARTIST, a.id, a, partial=True)
for a in data._albums.values():
self._do_ingest_new_data(KEYS.ALBUM, a.id, a, partial=True)
for s in data._songs.values():
self._do_ingest_new_data(KEYS.SONG, s.id, s, partial=True)
for p in data._playlists.values():
self._do_ingest_new_data(KEYS.PLAYLIST_DETAILS, p.id, p, partial=True)
for (kind, id), v in data._results.items():
if kind is API.SearchResult.Kind.ARTIST:
self._do_ingest_new_data(KEYS.ARTIST, id, v, partial=True)
elif kind is API.SearchResult.Kind.ALBUM:
self._do_ingest_new_data(KEYS.ALBUM, id, v, partial=True)
elif kind is API.SearchResult.Kind.SONG:
self._do_ingest_new_data(KEYS.SONG, id, v, partial=True)
elif kind is API.SearchResult.Kind.PLAYLIST:
self._do_ingest_new_data(KEYS.PLAYLIST_DETAILS, id, v, partial=True)
elif data_key == KEYS.SONG:
api_song = cast(API.Song, data)

View File

@@ -458,7 +458,7 @@ class AdapterManager:
f"expected size ({expected_size})."
)
block_size = 1024 # 1 KiB
block_size = 512 * 1024 # 512 KiB
total_consumed = 0
with open(download_tmp_filename, "wb+") as f:
@@ -473,21 +473,18 @@ class AdapterManager:
)
raise Exception("Download Cancelled")
if i % 100 == 0:
# Only delay (if configured) and update the progress UI
# every 100 KiB.
if DOWNLOAD_BLOCK_DELAY is not None:
sleep(DOWNLOAD_BLOCK_DELAY)
if DOWNLOAD_BLOCK_DELAY is not None:
sleep(DOWNLOAD_BLOCK_DELAY)
if expected_size_exists:
AdapterManager._instance.song_download_progress(
id,
DownloadProgress(
DownloadProgress.Type.PROGRESS,
total_bytes=total_size,
current_bytes=total_consumed,
),
)
if expected_size_exists:
AdapterManager._instance.song_download_progress(
id,
DownloadProgress(
DownloadProgress.Type.PROGRESS,
total_bytes=total_size,
current_bytes=total_consumed,
),
)
# Everything succeeded.
if expected_size_exists:

View File

@@ -38,7 +38,6 @@ from .. import (
api_objects as API,
ConfigParamDescriptor,
ConfigurationStore,
ConfigureServerForm,
UIInfo,
)
@@ -90,7 +89,7 @@ class SubsonicAdapter(Adapter):
)
@staticmethod
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
def get_configuration_form():
configs = {
"server_address": ConfigParamDescriptor(str, "Server Address"),
"username": ConfigParamDescriptor(str, "Username"),
@@ -145,7 +144,7 @@ class SubsonicAdapter(Adapter):
}
)
def verify_configuration() -> Dict[str, Optional[str]]:
def verify_configuration(config_store: ConfigurationStore) -> Dict[str, Optional[str]]:
errors: Dict[str, Optional[str]] = {}
with tempfile.TemporaryDirectory() as tmp_dir_name:
@@ -168,10 +167,11 @@ class SubsonicAdapter(Adapter):
"Double check the server address."
)
except ServerError as e:
if e.status_code in (10, 41) and config_store["salt_auth"]:
if e.status_code in (10, 40, 41) and config_store["salt_auth"]:
# status code 10: if salt auth is not enabled, server will
# return error server error with status_code 10 since it'll
# interpret it as a missing (password) parameter
# status code 41: returned by ampache
# status code 41: as per subsonic api docs, description of
# status_code 41 is "Token authentication not supported for
# LDAP users." so fall back to password auth
@@ -205,7 +205,7 @@ class SubsonicAdapter(Adapter):
return errors
return ConfigureServerForm(config_store, configs, verify_configuration)
return configs, verify_configuration
@staticmethod
def migrate_configuration(config_store: ConfigurationStore):

View File

@@ -6,7 +6,7 @@ import sys
from datetime import timedelta
from functools import partial
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union
from urllib.parse import urlparse
import bleach
@@ -18,11 +18,19 @@ try:
except Exception:
tap_imported = False
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk
import gi
gi.require_version('GIRepository', '2.0')
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk, GIRepository
# Temporary for development
repo = GIRepository.Repository.get_default()
repo.prepend_library_path('../libhandy/_build/src')
repo.prepend_search_path('../libhandy/_build/src')
gi.require_version('Handy', '1')
from gi.repository import Handy
try:
import gi
gi.require_version("Notify", "0.7")
from gi.repository import Notify
@@ -42,14 +50,16 @@ from .adapters import (
DownloadProgress,
Result,
SongCacheStatus,
ConfigurationStore,
)
from .adapters.filesystem import FilesystemAdapter
from .adapters.api_objects import Playlist, PlayQueue, Song
from .config import AppConfiguration, ProviderConfiguration
from .dbus import dbus_propagate, DBusManager
from .players import PlayerDeviceEvent, PlayerEvent, PlayerManager
from .ui.configure_provider import ConfigureProviderDialog
from .ui.main import MainWindow
from .ui.state import RepeatType, UIState
from .ui.actions import register_action, register_dataclass_actions
from .util import resolve_path
@@ -59,12 +69,16 @@ class SublimeMusicApp(Gtk.Application):
if glib_notify_exists:
Notify.init("Sublime Music")
Handy.init()
self.window: Optional[Gtk.Window] = None
self.app_config = AppConfiguration.load_from_file(config_file)
self.dbus_manager: Optional[DBusManager] = None
self.connect("shutdown", self.on_app_shutdown)
self._download_progress = {}
player_manager: Optional[PlayerManager] = None
exiting: bool = False
@@ -79,38 +93,40 @@ class SublimeMusicApp(Gtk.Application):
action.connect("activate", fn)
self.add_action(action)
# Add action for menu items.
add_action("add-new-music-provider", self.on_add_new_music_provider)
add_action("edit-current-music-provider", self.on_edit_current_music_provider)
add_action(
"switch-music-provider", self.on_switch_music_provider, parameter_type="s"
)
add_action(
"remove-music-provider", self.on_remove_music_provider, parameter_type="s"
)
register_action(self, self.quit, types=tuple())
register_action(self, self.change_tab)
# Connect after we know there's a server configured.
# self.window.connect("notification-closed", self.on_notification_closed)
# self.window.connect("key-press-event", self.on_window_key_press)
# Add actions for player controls
add_action("play-pause", self.on_play_pause)
add_action("next-track", self.on_next_track)
add_action("prev-track", self.on_prev_track)
add_action("repeat-press", self.on_repeat_press)
add_action("shuffle-press", self.on_shuffle_press)
register_action(self, self.seek)
register_action(self, self.play_pause)
register_action(self, self.next_track)
register_action(self, self.prev_track)
register_action(self, self.repeat)
register_action(self, self.shuffle)
register_action(self, self.play_song_action, name='play-song')
# self.window.connect("songs-removed", self.on_songs_removed)
register_action(self, self.select_device)
register_action(self, self.toggle_mute)
register_action(self, self.set_volume)
# Navigation actions.
add_action("play-next", self.on_play_next, parameter_type="as")
add_action("add-to-queue", self.on_add_to_queue, parameter_type="as")
add_action("go-to-album", self.on_go_to_album, parameter_type="s")
add_action("go-to-artist", self.on_go_to_artist, parameter_type="s")
add_action("browse-to", self.browse_to, parameter_type="s")
add_action("go-to-playlist", self.on_go_to_playlist, parameter_type="s")
register_action(self, self.queue_next_songs)
register_action(self, self.queue_songs)
register_action(self, self.go_to_album)
register_action(self, self.go_to_artist)
register_action(self, self.browse_to)
register_action(self, self.go_to_playlist)
add_action("go-online", self.on_go_online)
add_action("refresh-devices", self.on_refresh_devices)
add_action(
"refresh-window",
lambda *a: self.on_refresh_window(None, {}, True),
)
add_action("mute-toggle", self.on_mute_toggle)
register_action(self, self.refresh)
register_action(self, self.force_refresh)
add_action(
"update-play-queue-from-server",
lambda a, p: self.update_play_state_from_server(),
@@ -118,11 +134,19 @@ class SublimeMusicApp(Gtk.Application):
if tap_imported:
self.tap = osxmmkeys.Tap()
self.tap.on("play_pause", self.on_play_pause)
self.tap.on("next_track", self.on_next_track)
self.tap.on("prev_track", self.on_prev_track)
self.tap.on("play_pause", self.play_pause)
self.tap.on("next_track", self.next_track)
self.tap.on("prev_track", self.prev_track)
self.tap.start()
# self.set_accels_for_action('app.play-pause', ["<Ctrl>F"])
# self.set_accels_for_action('app.play-pause', ["space"])
# self.set_accels_for_action('app.prev-track', ["Home"])
# self.set_accels_for_action('app.next-track', ["End"])
# self.set_accels_for_action('app.quit', ["<Ctrl>q"])
# self.set_accels_for_action('app.quit', ["<Ctrl>w"])
def do_activate(self):
# We only allow a single window and raise any existing ones
if self.window:
@@ -143,6 +167,36 @@ class SublimeMusicApp(Gtk.Application):
# closed the application shuts down.
self.window = MainWindow(application=self, title="Sublime Music")
albums = Gio.SimpleActionGroup()
register_action(albums, self.albums_set_search_query, 'set-search-query')
register_action(albums, self.albums_set_page, 'set-page')
register_action(albums, self.albums_select_album, 'select-album')
self.window.insert_action_group('albums', albums)
playlists = Gio.SimpleActionGroup()
register_action(playlists, self.playlists_set_details_expanded, 'set-details-expanded')
self.window.insert_action_group('playlists', playlists)
search = Gio.SimpleActionGroup()
register_action(search, self.search_set_query, 'set-query')
self.window.insert_action_group('search', search)
settings = Gio.SimpleActionGroup()
register_dataclass_actions(settings, self.app_config, after=self._save_and_refresh)
self.window.insert_action_group('settings', settings)
players = Gio.SimpleActionGroup()
register_action(players, self.players_set_option, name='set-str-option', types=(str, str, str))
register_action(players, self.players_set_option, name='set-bool-option', types=(str, str, bool))
register_action(players, self.players_set_option, name='set-int-option', types=(str, str, int))
self.window.insert_action_group('players', players)
providers = Gio.SimpleActionGroup()
register_action(providers, self.providers_set_config, name='set-config')
register_action(providers, self.providers_switch, name='switch')
register_action(providers, self.providers_remove, name='remove')
self.window.insert_action_group('providers', providers)
# Configure the CSS provider so that we can style elements on the
# window.
css_provider = Gtk.CssProvider()
@@ -163,27 +217,9 @@ class SublimeMusicApp(Gtk.Application):
# configured, and if none are configured, then show the dialog to create a new
# one.
if self.app_config.provider is None:
if len(self.app_config.providers) == 0:
self.show_configure_servers_dialog()
# If they didn't add one with the dialog, close the window.
if len(self.app_config.providers) == 0:
self.window.close()
return
AdapterManager.reset(self.app_config, self.on_song_download_progress)
# Connect after we know there's a server configured.
self.window.stack.connect("notify::visible-child", self.on_stack_change)
self.window.connect("song-clicked", self.on_song_clicked)
self.window.connect("songs-removed", self.on_songs_removed)
self.window.connect("refresh-window", self.on_refresh_window)
self.window.connect("notification-closed", self.on_notification_closed)
self.window.connect("go-to", self.on_window_go_to)
self.window.connect("key-press-event", self.on_window_key_press)
self.window.player_controls.connect("song-scrub", self.on_song_scrub)
self.window.player_controls.connect("device-update", self.on_device_update)
self.window.player_controls.connect("volume-change", self.on_volume_change)
self.window.show_providers_window()
else:
AdapterManager.reset(self.app_config, self.on_song_download_progress)
# Configure the players
self.last_play_queue_update = timedelta(0)
@@ -204,7 +240,7 @@ class SublimeMusicApp(Gtk.Application):
self.app_config.state.song_progress = timedelta(seconds=value)
GLib.idle_add(
self.window.player_controls.update_scrubber,
self.window.update_song_progress,
self.app_config.state.song_progress,
self.app_config.state.current_song.duration,
self.app_config.state.song_stream_cache_progress,
@@ -233,7 +269,7 @@ class SublimeMusicApp(Gtk.Application):
self.update_window()
return
GLib.idle_add(self.on_next_track)
GLib.idle_add(self.next_track)
def on_player_event(event: PlayerEvent):
if event.type == PlayerEvent.EventType.PLAY_STATE_CHANGE:
@@ -262,7 +298,7 @@ class SublimeMusicApp(Gtk.Application):
seconds=event.stream_cache_duration
)
GLib.idle_add(
self.window.player_controls.update_scrubber,
self.window.update_song_progress,
self.app_config.state.song_progress,
self.app_config.state.current_song.duration,
self.app_config.state.song_stream_cache_progress,
@@ -326,20 +362,21 @@ class SublimeMusicApp(Gtk.Application):
GLib.timeout_add(10000, check_if_connected)
# Update after Adapter Initial Sync
def after_initial_sync(_):
self.update_window()
if self.app_config.provider:
def after_initial_sync(_):
self.update_window()
# Prompt to load the play queue from the server.
if AdapterManager.can_get_play_queue():
self.update_play_state_from_server(prompt_confirm=True)
# Prompt to load the play queue from the server.
if AdapterManager.can_get_play_queue():
self.update_play_state_from_server(prompt_confirm=True)
# Get the playlists, just so that we don't have tons of cache misses from
# DBus trying to get the playlists.
if AdapterManager.can_get_playlists():
AdapterManager.get_playlists()
# Get the playlists, just so that we don't have tons of cache misses from
# DBus trying to get the playlists.
if AdapterManager.can_get_playlists():
AdapterManager.get_playlists()
inital_sync_result = AdapterManager.initial_sync()
inital_sync_result.add_done_callback(after_initial_sync)
inital_sync_result = AdapterManager.initial_sync()
inital_sync_result.add_done_callback(after_initial_sync)
# Send out to the bus that we exist.
if self.dbus_manager:
@@ -376,18 +413,11 @@ class SublimeMusicApp(Gtk.Application):
# a duration, but the Child object has `duration` optional because
# it could be a directory.
assert self.app_config.state.current_song.duration is not None
self.on_song_scrub(
None,
(
new_seconds.total_seconds()
/ self.app_config.state.current_song.duration.total_seconds()
)
* 100,
)
self.window.player_manager.scrubber = new_seconds.total_seconds()
def set_pos_fn(track_id: str, position: float = 0):
if self.app_config.state.playing:
self.on_play_pause()
self.play_pause()
pos_seconds = timedelta(microseconds=position)
self.app_config.state.song_progress = pos_seconds
track_id, occurrence = track_id.split("/")[-2:]
@@ -451,7 +481,7 @@ class SublimeMusicApp(Gtk.Application):
if self.app_config.state.shuffle_on:
song_idx = random.randint(0, len(playlist.songs) - 1)
self.on_song_clicked(
self.play_song(
None,
song_idx,
tuple(s.id for s in playlist.songs),
@@ -502,11 +532,11 @@ class SublimeMusicApp(Gtk.Application):
def play():
if not self.app_config.state.playing:
self.on_play_pause()
self.play_pause()
def pause():
if self.app_config.state.playing:
self.on_play_pause()
self.play_pause()
method_call_map: Dict[str, Dict[str, Any]] = {
"org.mpris.MediaPlayer2": {
@@ -514,10 +544,10 @@ class SublimeMusicApp(Gtk.Application):
"Quit": self.window and self.window.destroy,
},
"org.mpris.MediaPlayer2.Player": {
"Next": self.on_next_track,
"Previous": self.on_prev_track,
"Next": self.next_track,
"Previous": self.prev_track,
"Pause": pause,
"PlayPause": self.on_play_pause,
"PlayPause": self.play_pause,
"Stop": pause,
"Play": play,
"Seek": seek_fn,
@@ -555,10 +585,10 @@ class SublimeMusicApp(Gtk.Application):
def set_shuffle(new_val: GLib.Variant):
if new_val.get_boolean() != self.app_config.state.shuffle_on:
self.on_shuffle_press(None, None)
self.shuffle()
def set_volume(new_val: GLib.Variant):
self.on_volume_change(None, new_val.get_double() * 100)
self.set_volume(new_val.get_double() * 100)
setter_map: Dict[str, Dict[str, Any]] = {
"org.mpris.MediaPlayer2.Player": {
@@ -577,81 +607,49 @@ class SublimeMusicApp(Gtk.Application):
setter(value)
# ########## ACTION HANDLERS ########## #
# @dbus_propagate()
# def on_refresh_window(self, _, state_updates: Dict[str, Any], force: bool = False):
# if settings := state_updates.get("__settings__"):
# for k, v in settings.items():
# setattr(self.app_config, k, v)
# if (offline_mode := settings.get("offline_mode")) is not None:
# AdapterManager.on_offline_mode_change(offline_mode)
# del state_updates["__settings__"]
# self.app_config.save()
# if player_setting := state_updates.get("__player_setting__"):
# player_name, option_name, value = player_setting
# self.app_config.player_config[player_name][option_name] = value
# del state_updates["__player_setting__"]
# if pm := self.player_manager:
# pm.change_settings(self.app_config.player_config)
# self.app_config.save()
# for k, v in state_updates.items():
# setattr(self.app_config.state, k, v)
# self.update_window(force=force)
# @dbus_propagate()
def refresh(self):
self.update_window(force=False)
@dbus_propagate()
def on_refresh_window(self, _, state_updates: Dict[str, Any], force: bool = False):
if settings := state_updates.get("__settings__"):
for k, v in settings.items():
setattr(self.app_config, k, v)
if (offline_mode := settings.get("offline_mode")) is not None:
AdapterManager.on_offline_mode_change(offline_mode)
def force_refresh(self):
self.update_window(force=True)
del state_updates["__settings__"]
self.app_config.save()
if player_setting := state_updates.get("__player_setting__"):
player_name, option_name, value = player_setting
self.app_config.player_config[player_name][option_name] = value
del state_updates["__player_setting__"]
if pm := self.player_manager:
pm.change_settings(self.app_config.player_config)
self.app_config.save()
for k, v in state_updates.items():
setattr(self.app_config.state, k, v)
self.update_window(force=force)
def _save_and_refresh(self):
self.app_config.save()
self.refresh()
def on_notification_closed(self, _):
self.app_config.state.current_notification = None
self.update_window()
def on_add_new_music_provider(self, *args):
self.show_configure_servers_dialog()
def on_edit_current_music_provider(self, *args):
self.show_configure_servers_dialog(self.app_config.provider.clone())
def on_switch_music_provider(self, _, provider_id: GLib.Variant):
if self.app_config.state.playing:
self.on_play_pause()
self.app_config.save()
self.app_config.current_provider_id = provider_id.get_string()
self.reset_state()
self.app_config.save()
def on_remove_music_provider(self, _, provider_id: GLib.Variant):
provider = self.app_config.providers[provider_id.get_string()]
confirm_dialog = Gtk.MessageDialog(
transient_for=self.window,
message_type=Gtk.MessageType.WARNING,
buttons=(
Gtk.STOCK_CANCEL,
Gtk.ResponseType.CANCEL,
Gtk.STOCK_DELETE,
Gtk.ResponseType.YES,
),
text=f"Are you sure you want to delete the {provider.name} music provider?",
)
confirm_dialog.format_secondary_markup(
"Deleting this music provider will delete all cached songs and metadata "
"associated with this provider."
)
if confirm_dialog.run() == Gtk.ResponseType.YES:
assert self.app_config.cache_location
provider_dir = self.app_config.cache_location.joinpath(provider.id)
shutil.rmtree(str(provider_dir), ignore_errors=True)
del self.app_config.providers[provider.id]
confirm_dialog.destroy()
def on_window_go_to(self, win: Any, action: str, value: str):
{
"album": self.on_go_to_album,
"artist": self.on_go_to_artist,
"playlist": self.on_go_to_playlist,
}[action](None, GLib.Variant("s", value))
_inhibit_cookie = None
@dbus_propagate()
def on_play_pause(self, *args):
def play_pause(self):
if self.app_config.state.current_song_index < 0:
return
@@ -666,9 +664,17 @@ class SublimeMusicApp(Gtk.Application):
# This is from a restart, start playing the file.
self.play_song(self.app_config.state.current_song_index)
# Inhibit suspend when playing
if self._inhibit_cookie:
self.uninhibit(self._inhibit_cookie)
self._inhibit_cookie = None
if self.app_config.state.playing:
self._inhibit_cookie = self.inhibit(None, Gtk.ApplicationInhibitFlags.SUSPEND, "Playing music")
self.update_window()
def on_next_track(self, *args):
def next_track(self):
if self.app_config.state.current_song is None:
# This may happen due to DBUS, ignore.
return
@@ -694,7 +700,7 @@ class SublimeMusicApp(Gtk.Application):
else:
self.update_window()
def on_prev_track(self, *args):
def prev_track(self):
if self.app_config.state.current_song is None:
# This may happen due to DBUS, ignore.
return
@@ -727,39 +733,39 @@ class SublimeMusicApp(Gtk.Application):
self.update_window()
@dbus_propagate()
def on_repeat_press(self, *args):
def repeat(self):
# Cycle through the repeat types.
new_repeat_type = RepeatType((self.app_config.state.repeat_type.value + 1) % 3)
self.app_config.state.repeat_type = new_repeat_type
self.update_window()
@dbus_propagate()
def on_shuffle_press(self, *args):
if self.app_config.state.shuffle_on:
# Revert to the old play queue.
old_play_queue_copy = self.app_config.state.old_play_queue
self.app_config.state.current_song_index = old_play_queue_copy.index(
self.app_config.state.current_song.id
)
self.app_config.state.play_queue = old_play_queue_copy
else:
self.app_config.state.old_play_queue = self.app_config.state.play_queue
def shuffle(self):
if self.app_config.state.current_song:
if self.app_config.state.shuffle_on:
# Revert to the old play queue.
old_play_queue_copy = self.app_config.state.old_play_queue
self.app_config.state.current_song_index = old_play_queue_copy.index(
self.app_config.state.current_song.id
)
self.app_config.state.play_queue = old_play_queue_copy
else:
self.app_config.state.old_play_queue = self.app_config.state.play_queue
mutable_play_queue = list(self.app_config.state.play_queue)
mutable_play_queue = list(self.app_config.state.play_queue)
# Remove the current song, then shuffle and put the song back.
song_id = self.app_config.state.current_song.id
del mutable_play_queue[self.app_config.state.current_song_index]
random.shuffle(mutable_play_queue)
self.app_config.state.play_queue = (song_id,) + tuple(mutable_play_queue)
self.app_config.state.current_song_index = 0
# Remove the current song, then shuffle and put the song back.
song_id = self.app_config.state.current_song.id
del mutable_play_queue[self.app_config.state.current_song_index]
random.shuffle(mutable_play_queue)
self.app_config.state.play_queue = (song_id,) + tuple(mutable_play_queue)
self.app_config.state.current_song_index = 0
self.app_config.state.shuffle_on = not self.app_config.state.shuffle_on
self.update_window()
@dbus_propagate()
def on_play_next(self, action: Any, song_ids: GLib.Variant):
song_ids = tuple(song_ids)
def queue_next_songs(self, song_ids: List[str]):
if self.app_config.state.current_song is None:
insert_at = 0
else:
@@ -774,13 +780,13 @@ class SublimeMusicApp(Gtk.Application):
self.update_window()
@dbus_propagate()
def on_add_to_queue(self, action: Any, song_ids: GLib.Variant):
def queue_songs(self, song_ids: List[str]):
song_ids = tuple(song_ids)
self.app_config.state.play_queue += tuple(song_ids)
self.app_config.state.old_play_queue += tuple(song_ids)
self.update_window()
def on_go_to_album(self, action: Any, album_id: GLib.Variant):
def go_to_album(self, album_id: str):
# Switch to the Alphabetical by Name view to guarantee that the album is there.
self.app_config.state.current_album_search_query = AlbumSearchQuery(
AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME,
@@ -789,22 +795,22 @@ class SublimeMusicApp(Gtk.Application):
)
self.app_config.state.current_tab = "albums"
self.app_config.state.selected_album_id = album_id.get_string()
self.app_config.state.selected_album_id = album_id
self.update_window()
def on_go_to_artist(self, action: Any, artist_id: GLib.Variant):
def go_to_artist(self, artist_id: str):
self.app_config.state.current_tab = "artists"
self.app_config.state.selected_artist_id = artist_id.get_string()
self.app_config.state.selected_artist_id = artist_id
self.update_window()
def browse_to(self, action: Any, item_id: GLib.Variant):
def browse_to(self, item_id: str):
self.app_config.state.current_tab = "browse"
self.app_config.state.selected_browse_element_id = item_id.get_string()
self.app_config.state.selected_browse_element_id = item_id
self.update_window()
def on_go_to_playlist(self, action: Any, playlist_id: GLib.Variant):
def go_to_playlist(self, playlist_id: str):
self.app_config.state.current_tab = "playlists"
self.app_config.state.selected_playlist_id = playlist_id.get_string()
self.app_config.state.selected_playlist_id = playlist_id or None
self.update_window()
def on_go_online(self, *args):
@@ -815,7 +821,7 @@ class SublimeMusicApp(Gtk.Application):
def reset_state(self):
if self.app_config.state.playing:
self.on_play_pause()
self.play_pause()
self.loading_state = True
self.player_manager.reset()
AdapterManager.reset(self.app_config, self.on_song_download_progress)
@@ -824,17 +830,14 @@ class SublimeMusicApp(Gtk.Application):
# Update the window according to the new server configuration.
self.update_window()
def on_stack_change(self, stack: Gtk.Stack, _):
self.app_config.state.current_tab = stack.get_visible_child_name()
def change_tab(self, tab_id: str):
self.app_config.state.current_tab = tab_id
self.update_window()
def on_song_clicked(
self,
win: Any,
song_index: int,
song_queue: Tuple[str, ...],
metadata: Dict[str, Any],
):
def play_song_action(self, song_index: int, song_queue: List[str], metadata: Dict[str, Any]):
if not song_queue:
song_queue = self.app_config.state.play_queue
song_queue = tuple(song_queue)
# Reset the play queue so that we don't ever revert back to the
# previous one.
@@ -879,7 +882,7 @@ class SublimeMusicApp(Gtk.Application):
if self.app_config.state.current_song_index in song_indexes_to_remove:
if len(self.app_config.state.play_queue) == 0:
self.on_play_pause()
self.play_pause()
self.app_config.state.current_song_index = -1
self.update_window()
return
@@ -892,7 +895,7 @@ class SublimeMusicApp(Gtk.Application):
self.save_play_queue()
@dbus_propagate()
def on_song_scrub(self, _, scrub_value: float):
def seek(self, value: float):
if not self.app_config.state.current_song or not self.window:
return
@@ -900,14 +903,9 @@ class SublimeMusicApp(Gtk.Application):
# a duration, but the Child object has `duration` optional because
# it could be a directory.
assert self.app_config.state.current_song.duration is not None
new_time = self.app_config.state.current_song.duration * (scrub_value / 100)
new_time = timedelta(seconds=value)
self.app_config.state.song_progress = new_time
self.window.player_controls.update_scrubber(
self.app_config.state.song_progress,
self.app_config.state.current_song.duration,
self.app_config.state.song_stream_cache_progress,
)
# If already playing, then make the player itself seek.
if self.player_manager and self.player_manager.song_loaded:
@@ -915,14 +913,14 @@ class SublimeMusicApp(Gtk.Application):
self.save_play_queue()
def on_device_update(self, _, device_id: str):
def select_device(self, device_id: str):
assert self.player_manager
if device_id == self.app_config.state.current_device:
return
self.app_config.state.current_device = device_id
if was_playing := self.app_config.state.playing:
self.on_play_pause()
self.play_pause()
self.player_manager.set_current_device_id(self.app_config.state.current_device)
@@ -932,55 +930,38 @@ class SublimeMusicApp(Gtk.Application):
self.update_window()
if was_playing:
self.on_play_pause()
self.play_pause()
if self.dbus_manager:
self.dbus_manager.property_diff()
@dbus_propagate()
def on_mute_toggle(self, *args):
def toggle_mute(self):
self.app_config.state.is_muted = not self.app_config.state.is_muted
self.player_manager.set_muted(self.app_config.state.is_muted)
self.update_window()
@dbus_propagate()
def on_volume_change(self, _, value: float):
assert self.player_manager
def set_volume(self, value: float):
self.app_config.state.volume = value
self.player_manager.set_volume(self.app_config.state.volume)
self.update_window()
def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey) -> bool:
# Need to use bitwise & here to see if CTRL is pressed.
if event.keyval == 102 and event.state & Gdk.ModifierType.CONTROL_MASK:
# Ctrl + F
window.search_entry.grab_focus()
return False
def on_song_download_progress(self, song_id: str, progress: DownloadProgress):
if len(self._download_progress) == 0:
GLib.timeout_add(100, self._propagate_download_progress)
# Allow spaces to work in the text entry boxes.
if (
window.search_entry.has_focus()
or window.playlists_panel.playlist_list.new_playlist_entry.has_focus()
):
return False
# Amortize progress updates
self._download_progress[song_id] = progress
# Spacebar, home/prev
keymap = {
32: self.on_play_pause,
65360: self.on_prev_track,
65367: self.on_next_track,
}
def _propagate_download_progress(self):
items = list(self._download_progress.items())
self._download_progress = {}
action = keymap.get(event.keyval)
if action:
action()
return True
for song_id, progress in items:
self.window.update_song_download_progress(song_id, progress)
return False
def on_song_download_progress(self, song_id: str, progress: DownloadProgress):
assert self.window
GLib.idle_add(self.window.update_song_download_progress, song_id, progress)
def on_app_shutdown(self, app: "SublimeMusicApp"):
self.exiting = True
if glib_notify_exists:
@@ -1003,33 +984,126 @@ class SublimeMusicApp(Gtk.Application):
self.dbus_manager.shutdown()
AdapterManager.shutdown()
def albums_set_search_query(self, query: AlbumSearchQuery, sort_direction: str):
self.app_config.state.current_album_search_query = query
self.app_config.state.album_sort_direction = sort_direction
self.app_config.state.album_page = 0
self.app_config.state.selected_album_id = None
self.update_window()
def albums_set_page(self, page: int):
self.app_config.state.album_page = page
self.app_config.state.selected_album_id = None
self.update_window()
def albums_select_album(self, album_id: str):
self.app_config.state.selected_album_id = album_id
self.update_window()
def playlists_set_details_expanded(self, expanded: bool):
self.app_config.state.playlist_details_expanded = expanded
self.update_window()
def search_set_query(self, query: str):
self.app_config.state.search_query = query
self.update_window()
def players_set_option(self, player: str, option: str, value: Any):
self.app_config.player_config[player][option] = value
if pm := self.player_manager:
pm.change_settings(self.app_config.player_config)
self.app_config.save()
self.update_window()
def providers_set_config(self, id: str, name: str, adapter_name: str, config: Dict[str, Any]):
adapter_type = None
for adapter in AdapterManager.available_adapters:
if adapter.get_ui_info().name == adapter_name:
adapter_type = adapter
break
assert adapter_type is not None
provider_config = ProviderConfiguration(
id,
name,
adapter_type,
ConfigurationStore(**config))
if adapter_type.can_be_cached:
provider_config.caching_adapter_type = FilesystemAdapter
provider_config.caching_adapter_config = ConfigurationStore()
provider_config.persist_secrets()
self.app_config.providers[provider_config.id] = provider_config
self.app_config.current_provider_id = provider_config.id
self.reset_state()
self.app_config.save()
self.update_window(force=True)
def providers_switch(self, provider_id: str):
if self.app_config.state.playing:
self.play_pause()
self.app_config.save()
self.app_config.current_provider_id = provider_id
self.reset_state()
self.app_config.save()
def providers_remove(self, provider_id: str):
provider = self.app_config.providers[provider_id]
confirm_dialog = Gtk.MessageDialog(
transient_for=self.window,
message_type=Gtk.MessageType.WARNING,
buttons=(
Gtk.STOCK_CANCEL,
Gtk.ResponseType.CANCEL,
Gtk.STOCK_DELETE,
Gtk.ResponseType.YES,
),
text=f"Are you sure you want to delete the {provider.name} music provider?",
)
confirm_dialog.format_secondary_markup(
"Deleting this music provider will delete all cached songs and metadata "
"associated with this provider."
)
if confirm_dialog.run() == Gtk.ResponseType.YES:
assert self.app_config.cache_location
provider_dir = self.app_config.cache_location.joinpath(provider.id)
shutil.rmtree(str(provider_dir), ignore_errors=True)
del self.app_config.providers[provider.id]
self.update_window()
confirm_dialog.destroy()
# ########## HELPER METHODS ########## #
def show_configure_servers_dialog(
self,
provider_config: Optional[ProviderConfiguration] = None,
):
"""Show the Connect to Server dialog."""
dialog = ConfigureProviderDialog(self.window, provider_config)
result = dialog.run()
if result == Gtk.ResponseType.APPLY:
assert dialog.provider_config is not None
provider_id = dialog.provider_config.id
dialog.provider_config.persist_secrets()
self.app_config.providers[provider_id] = dialog.provider_config
self.app_config.save()
# def show_configure_servers_dialog(
# self,
# provider_config: Optional[ProviderConfiguration] = None,
# ):
# """Show the Connect to Server dialog."""
# dialog = ConfigureProviderDialog(self.window, provider_config)
# result = dialog.run()
# if result == Gtk.ResponseType.APPLY:
# assert dialog.provider_config is not None
# provider_id = dialog.provider_config.id
# dialog.provider_config.persist_secrets()
# self.app_config.providers[provider_id] = dialog.provider_config
# self.app_config.save()
if provider_id == self.app_config.current_provider_id:
# Just update the window.
self.update_window()
else:
# Switch to the new provider.
if self.app_config.state.playing:
self.on_play_pause()
self.app_config.current_provider_id = provider_id
self.app_config.save()
self.update_window(force=True)
# if provider_id == self.app_config.current_provider_id:
# # Just update the window.
# self.update_window()
# else:
# # Switch to the new provider.
# if self.app_config.state.playing:
# self.play_pause()
# self.app_config.current_provider_id = provider_id
# self.app_config.save()
# self.update_window(force=True)
dialog.destroy()
# dialog.destroy()
def update_window(self, force: bool = False):
if not self.window:
@@ -1064,7 +1138,7 @@ class SublimeMusicApp(Gtk.Application):
def do_resume(clear_notification: bool):
assert self.player_manager
if was_playing := self.app_config.state.playing:
self.on_play_pause()
self.play_pause()
self.app_config.state.play_queue = new_play_queue
self.app_config.state.song_progress = play_queue.position
@@ -1076,7 +1150,7 @@ class SublimeMusicApp(Gtk.Application):
self.update_window()
if was_playing:
self.on_play_pause()
self.play_pause()
if prompt_confirm:
# If there's not a significant enough difference in the song state,
@@ -1383,13 +1457,13 @@ class SublimeMusicApp(Gtk.Application):
# There are no songs that can be played. Show a notification that you
# have to go online to play anything and then don't go further.
if was_playing := self.app_config.state.playing:
self.on_play_pause()
self.play_pause()
def go_online_clicked():
self.app_config.state.current_notification = None
self.on_go_online()
if was_playing:
self.on_play_pause()
self.play_pause()
if all(s == SongCacheStatus.NOT_CACHED for s in statuses):
markup = (

View File

@@ -191,7 +191,7 @@ class AppConfiguration(DataClassJsonMixin):
if self.version < 6:
self.player_config = {
"Local Playback": {"Replay Gain": ["no", "track", "album"][self._rg]},
"Local Playback": {"Replay Gain": ["Disabled", "Track", "Album"][self._rg]},
"Chromecast": {
"Serve Local Files to Chromecasts on the LAN": self._sol,
"LAN Server Port Number": self._pn,

288
sublime_music/ui/actions.py Normal file
View File

@@ -0,0 +1,288 @@
import inspect
import enum
import dataclasses
import pathlib
from typing import Callable, Optional, Tuple, Any, Union, List, Type
from gi.repository import Gio, GLib
NoneType = type(None)
def run_action(widget, name, *args):
print('run action', name, args)
group, action = name.split('.')
action_group = widget.get_action_group(group)
if args:
type_str = action_group.get_action_parameter_type(action)
assert type_str
if len(args) > 1:
param = _create_variant(type_str.dup_string(), tuple(args))
else:
param = _create_variant(type_str.dup_string(), args[0])
else:
param = None
action_group.activate_action(action, param)
def register_dataclass_actions(group, data, after=None):
fields = dataclasses.fields(type(data))
for field in fields:
if field.name[0] == '_':
continue
def set_field(value, name=field.name):
setattr(data, name, value)
if after:
after()
name = field.name.replace('_', '-')
try:
register_action(group, set_field, name=f'set-{name}', types=(field.type,))
except ValueError:
continue
def register_action(group, fn: Callable, name: Optional[str] = None, types: Tuple[Type] = None):
if name is None:
name = fn.__name__.replace('_', '-')
# Determine the type from the signature
if types is None:
signature = inspect.signature(fn)
types = tuple(p.annotation for p in signature.parameters.values())
if types:
if inspect.Parameter.empty in types:
raise ValueError('Missing parameter annotation for action ' + name)
has_multiple = len(types) > 1
if has_multiple:
param_type = Tuple.__getitem__(types)
else:
param_type = types[0]
type_str = variant_type_from_python(param_type)
var_type = GLib.VariantType(type_str)
build = generate_build_function(param_type)
if not build:
build = lambda a: a
else:
var_type = None
action = Gio.SimpleAction.new(name, var_type)
def activate(action, param):
if param is not None:
if has_multiple:
fn(*build(param.unpack()))
else:
fn(build(param.unpack()))
else:
fn()
action.connect('activate', activate)
if hasattr(group, 'add_action'):
group.add_action(action)
else:
group.insert(action)
def variant_type_from_python(py_type: type) -> str:
if py_type is bool:
return 'b'
elif py_type is int:
return 'x'
elif py_type is float:
return 'd'
elif py_type is str:
return 's'
elif py_type is Any:
return 'v'
elif isinstance(py_type, type) and issubclass(py_type, pathlib.PurePath):
return 's'
elif isinstance(py_type, type) and issubclass(py_type, enum.Enum):
return variant_type_from_python(type(list(py_type)[0].value))
elif dataclasses.is_dataclass(py_type):
types = (f.type for f in dataclasses.fields(py_type))
return '(' + ''.join(map(variant_type_from_python, types)) + ')'
else:
origin = py_type.__origin__
if origin is list:
assert len(py_type.__args__) == 1
return 'a' + variant_type_from_python(py_type.__args__[0])
elif origin is tuple:
return '(' + ''.join(map(variant_type_from_python, py_type.__args__)) + ')'
elif origin is dict:
assert len(py_type.__args__) == 2
key = variant_type_from_python(py_type.__args__[0])
value = variant_type_from_python(py_type.__args__[1])
return 'a{' + key + value + '}'
elif origin is Union:
non_maybe = [t for t in py_type.__args__ if t is not NoneType]
has_maybe = len(non_maybe) != len(py_type.__args__)
if has_maybe and len(non_maybe) == 1:
return 'm' + variant_type_from_python(non_maybe[0])
return ('m[' if has_maybe else '[') + ''.join(''.join(map(variant_type_from_python, non_maybe))) + ']'
else:
raise ValueError('{} does not have an equivalent'.format(py_type))
def unbuilt_type(py_type: type) -> type:
if isinstance(py_type, type) and issubclass(py_type, pathlib.PurePath):
return str
elif isinstance(py_type, type) and issubclass(py_type, enum.Enum):
return type(list(py_type)[0].value)
elif dataclasses.is_dataclass(py_type):
return tuple
return py_type
def generate_build_function(py_type: type) -> Optional[Callable]:
"""
Return a function for reconstructing dataclasses and enumerations after
unpacking a GVariant. When no reconstruction is needed None is returned.
"""
if isinstance(py_type, type) and issubclass(py_type, pathlib.PurePath):
return py_type
elif isinstance(py_type, type) and issubclass(py_type, enum.Enum):
return py_type
elif dataclasses.is_dataclass(py_type):
types = tuple(f.type for f in dataclasses.fields(py_type))
tuple_build = generate_build_function(Tuple.__getitem__(types))
if not tuple_build:
return lambda values: py_type(*values)
return lambda values: py_type(*tuple_build(values))
elif hasattr(py_type, '__origin__'):
origin = py_type.__origin__
if origin is list:
assert len(py_type.__args__) == 1
build = generate_build_function(py_type.__args__[0])
if build:
return lambda values: [build(v) for v in values]
elif origin is tuple:
builds = list(map(generate_build_function, py_type.__args__))
if not any(builds):
return None
return lambda values: tuple((build(value) if build else value) for build, value in zip(builds, values))
elif origin is dict:
assert len(py_type.__args__) == 2
build_key = generate_build_function(py_type.__args__[0])
build_value = generate_build_function(py_type.__args__[1])
if not build_key and not build_value:
return None
return lambda values: {
(build_key(key) if build_key else key): (build_value(value) if build_value else value)
for key, value in values.items()}
elif origin is Union:
builds = list(map(generate_build_function, py_type.__args__))
if not any(builds):
return None
unbuilt_types = list(map(unbuilt_type, py_type.__args__))
def build(value):
for bld, type_ in zip(builds, unbuilt_types):
if isinstance(value, type_):
if bld:
return bld(value)
else:
return value
return value
return build
return None
_VARIANT_CONSTRUCTORS = {
'b': GLib.Variant.new_boolean,
'y': GLib.Variant.new_byte,
'n': GLib.Variant.new_int16,
'q': GLib.Variant.new_uint16,
'i': GLib.Variant.new_int32,
'u': GLib.Variant.new_uint32,
'x': GLib.Variant.new_int64,
't': GLib.Variant.new_uint64,
'h': GLib.Variant.new_handle,
'd': GLib.Variant.new_double,
's': GLib.Variant.new_string,
'o': GLib.Variant.new_object_path,
'g': GLib.Variant.new_signature,
'v': GLib.Variant.new_variant,
}
from gi._gi import variant_type_from_string
def _create_variant(type_str, value):
assert type_str
if isinstance(value, enum.Enum):
value = value.value
elif isinstance(value, pathlib.PurePath):
value = str(value)
elif dataclasses.is_dataclass(type(value)):
fields = dataclasses.fields(type(value))
value = tuple(getattr(value, field.name) for field in fields)
vtype = GLib.VariantType(type_str)
if type_str in _VARIANT_CONSTRUCTORS:
return _VARIANT_CONSTRUCTORS[type_str](value)
builder = GLib.VariantBuilder.new(vtype)
if value is None:
return builder.end()
if vtype.is_maybe():
builder.add_value(_create_variant(vtype.element().dup_string(), value))
return builder.end()
try:
iter(value)
except TypeError:
raise TypeError("Could not create array, tuple or dictionary entry from non iterable value %s %s" %
(type_str, value))
if vtype.is_tuple() and vtype.n_items() != len(value):
raise TypeError("Tuple mismatches value's number of elements %s %s" % (type_str, value))
if vtype.is_dict_entry() and len(value) != 2:
raise TypeError("Dictionary entries must have two elements %s %s" % (type_str, value))
if vtype.is_array():
element_type = vtype.element().dup_string()
if isinstance(value, dict):
value = value.items()
for i in value:
builder.add_value(_create_variant(element_type, i))
else:
remainer_format = type_str[1:]
for i in value:
dup = variant_type_from_string(remainer_format).dup_string()
builder.add_value(_create_variant(dup, i))
remainer_format = remainer_format[len(dup):]
return builder.end()

View File

@@ -4,7 +4,7 @@ import logging
import math
from typing import Any, Callable, cast, Iterable, List, Optional, Tuple
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango, Handy
from ..adapters import (
AdapterManager,
@@ -14,8 +14,12 @@ from ..adapters import (
Result,
)
from ..config import AppConfiguration
from ..ui import util
from ..ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage
from . import util
from .common import AlbumWithSongs, IconButton, LoadError, SpinnerImage, Sizer
from .actions import run_action
COVER_ART_WIDTH = 150
def _to_type(query_type: AlbumSearchQuery.Type) -> str:
@@ -46,30 +50,23 @@ def _from_str(type_str: str) -> AlbumSearchQuery.Type:
}[type_str]
class AlbumsPanel(Gtk.Box):
__gsignals__ = {
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
"refresh-window": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, bool),
),
}
class AlbumsPanel(Handy.Leaflet):
current_query: AlbumSearchQuery = AlbumSearchQuery(AlbumSearchQuery.Type.RANDOM)
offline_mode = False
provider_id: Optional[str] = None
populating_genre_combo = False
grid_order_token: int = 0
album_sort_direction: str = "ascending"
album_page_size: int = 30
album_page: int = 0
grid_pages_count: int = 0
current_albums_result: Result = None
albums = []
albums_by_id = {}
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
super().__init__(transition_type=Handy.LeafletTransitionType.SLIDE, can_swipe_forward=False, interpolate_size=False)
self.grid_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
actionbar = Gtk.ActionBar()
@@ -90,30 +87,37 @@ class AlbumsPanel(Gtk.Box):
)
actionbar.pack_start(self.sort_type_combo)
self.filter_stack = Gtk.Stack(no_show_all=True)
self.alphabetical_type_combo, _ = self.make_combobox(
(("by_name", "by album name", True), ("by_artist", "by artist name", True)),
self.on_alphabetical_type_change,
)
actionbar.pack_start(self.alphabetical_type_combo)
self.filter_stack.add(self.alphabetical_type_combo)
self.genre_combo, self.genre_combo_store = self.make_combobox(
(), self.on_genre_change
)
actionbar.pack_start(self.genre_combo)
self.filter_stack.add(self.genre_combo)
filter_time_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
next_decade = (datetime.datetime.now().year // 10) * 10 + 10
self.from_year_label = Gtk.Label(label="from")
actionbar.pack_start(self.from_year_label)
filter_time_box.add(self.from_year_label)
self.from_year_spin_button = Gtk.SpinButton.new_with_range(0, next_decade, 1)
self.from_year_spin_button.connect("value-changed", self.on_year_changed)
actionbar.pack_start(self.from_year_spin_button)
filter_time_box.add(self.from_year_spin_button)
self.to_year_label = Gtk.Label(label="to")
actionbar.pack_start(self.to_year_label)
filter_time_box.add(self.to_year_label)
self.to_year_spin_button = Gtk.SpinButton.new_with_range(0, next_decade, 1)
self.to_year_spin_button.connect("value-changed", self.on_year_changed)
actionbar.pack_start(self.to_year_spin_button)
filter_time_box.add(self.to_year_spin_button)
self.filter_stack.add(filter_time_box)
actionbar.pack_start(self.filter_stack)
self.sort_toggle = IconButton(
"view-sort-descending-symbolic", "Sort descending", relief=True
@@ -121,60 +125,62 @@ class AlbumsPanel(Gtk.Box):
self.sort_toggle.connect("clicked", self.on_sort_toggle_clicked)
actionbar.pack_start(self.sort_toggle)
# Add the page widget.
page_widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
self.prev_page = IconButton(
"go-previous-symbolic", "Go to the previous page", sensitive=False
)
self.prev_page.connect("clicked", self.on_prev_page_clicked)
page_widget.add(self.prev_page)
page_widget.add(Gtk.Label(label="Page"))
self.page_entry = Gtk.Entry()
self.page_entry.set_width_chars(1)
self.page_entry.set_max_width_chars(1)
self.page_entry.connect("changed", self.on_page_entry_changed)
self.page_entry.connect("insert-text", self.on_page_entry_insert_text)
page_widget.add(self.page_entry)
page_widget.add(Gtk.Label(label="of"))
self.page_count_label = Gtk.Label(label="-")
page_widget.add(self.page_count_label)
self.next_page = IconButton(
"go-next-symbolic", "Go to the next page", sensitive=False
)
self.next_page.connect("clicked", self.on_next_page_clicked)
page_widget.add(self.next_page)
actionbar.set_center_widget(page_widget)
self.refresh_button = IconButton(
"view-refresh-symbolic", "Refresh list of albums", relief=True
)
self.refresh_button.connect("clicked", self.on_refresh_clicked)
actionbar.pack_end(self.refresh_button)
actionbar.pack_end(Gtk.Label(label="albums per page"))
self.show_count_dropdown, _ = self.make_combobox(
((x, x, True) for x in ("20", "30", "40", "50")),
self.on_show_count_dropdown_change,
)
actionbar.pack_end(self.show_count_dropdown)
actionbar.pack_end(Gtk.Label(label="Show"))
self.grid_box.pack_start(actionbar, False, False, 0)
self.add(actionbar)
# 700 shows ~3 albums
grid_sizer = Sizer(natural_width=700)
scrolled_window = Gtk.ScrolledWindow(
hscrollbar_policy=Gtk.PolicyType.NEVER)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.grid = Gtk.FlowBox(
hexpand=True,
margin_top=5,
row_spacing=5,
column_spacing=5,
homogeneous=True,
max_children_per_line=999,
valign=Gtk.Align.START,
halign=Gtk.Align.CENTER,
selection_mode=Gtk.SelectionMode.SINGLE)
self.grid.connect("child-activated", self.on_album_clicked)
box.add(self.grid)
scrolled_window.add(box)
grid_sizer.add(scrolled_window)
self.grid_box.pack_start(grid_sizer, True, True, 0)
self.add(self.grid_box)
self.album_container = Sizer(natural_width=500)
self.album_with_songs = AlbumWithSongs(scroll_contents=True)
self.album_with_songs.get_style_context().add_class("details-panel")
def back_clicked(_):
self.grid.unselect_all()
self.set_visible_child(self.grid_box)
self.album_with_songs.connect("back-clicked", back_clicked)
self.album_container.add(self.album_with_songs)
self.add(self.album_container)
def folded_changed(*_):
if not self.get_folded():
self.set_visible_child(self.grid_box)
self.album_with_songs.show_back_button = self.get_folded()
self.connect("notify::folded", folded_changed)
scrolled_window = Gtk.ScrolledWindow()
self.grid = AlbumsGrid()
self.grid.connect(
"song-clicked",
lambda _, *args: self.emit("song-clicked", *args),
)
self.grid.connect(
"refresh-window",
lambda _, *args: self.emit("refresh-window", *args),
)
self.grid.connect("cover-clicked", self.on_grid_cover_clicked)
self.grid.connect("num-pages-changed", self.on_grid_num_pages_changed)
scrolled_window.add(self.grid)
self.add(scrolled_window)
def make_combobox(
self,
@@ -231,7 +237,7 @@ class AlbumsPanel(Gtk.Box):
except Exception:
self.updating_query = False
def update(self, app_config: AppConfiguration = None, force: bool = False):
def update(self, app_config: AppConfiguration, force: bool = False):
self.updating_query = True
supported_type_strings = {
@@ -243,9 +249,36 @@ class AlbumsPanel(Gtk.Box):
# (En|Dis)able getting genres.
self.sort_type_combo_store[1][2] = AdapterManager.can_get_genres()
if app_config:
if (self.current_query != app_config.state.current_album_search_query
or self.offline_mode != app_config.offline_mode
or self.provider_id != app_config.current_provider_id
or force):
self.current_query = app_config.state.current_album_search_query
self.offline_mode = app_config.offline_mode
self.provider_id = app_config.current_provider_id
if self.current_albums_result is not None:
self.current_albums_result.cancel()
self.current_albums_result = AdapterManager.get_albums(
self.current_query, use_ground_truth_adapter=force)
if self.current_albums_result.data_is_available:
# Don't idle add if the data is already available.
self.current_albums_result.add_done_callback(self._albums_loaded)
else:
# self.spinner.show()
self.current_albums_result.add_done_callback(
lambda f: GLib.idle_add(self._albums_loaded, f)
)
if self.album_sort_direction != app_config.state.album_sort_direction:
self.album_sort_direction = app_config.state.album_sort_direction
self._update_albums()
# self.current_query = app_config.state.current_album_search_query
# self.offline_mode = app_config.offline_mode
self.alphabetical_type_combo.set_active_id(
{
@@ -262,13 +295,8 @@ class AlbumsPanel(Gtk.Box):
# Update the page display
if app_config:
self.album_page = app_config.state.album_page
self.album_page_size = app_config.state.album_page_size
self.refresh_button.set_sensitive(not app_config.offline_mode)
self.prev_page.set_sensitive(self.album_page > 0)
self.page_entry.set_text(str(self.album_page + 1))
# Show/hide the combo boxes.
def show_if(sort_type: Iterable[AlbumSearchQuery.Type], *elements):
for element in elements:
@@ -306,16 +334,100 @@ class AlbumsPanel(Gtk.Box):
+ self._get_opposite_sort_dir(self.album_sort_direction)
)
self.show_count_dropdown.set_active_id(
str(app_config.state.album_page_size)
)
# Has to be last because it resets self.updating_query
self.populate_genre_combo(app_config, force=force)
# At this point, the current query should be totally updated.
self.grid_order_token = self.grid.update_params(app_config)
self.grid.update(self.grid_order_token, app_config, force=force)
selected_album = self.albums_by_id.get(app_config.state.selected_album_id, None) or (self.albums and self.albums[0])
if selected_album is not None:
self.album_with_songs.update(selected_album, app_config, force=force)
def _albums_loaded(self, result: Result[Iterable[API.Album]]):
self.current_albums_result = None
# TODO: Error handling
self.albums = []
try:
self.albums = list(result.result())
except CacheMissError as e:
print(e)
except Exception as e:
print(e)
self.albums_by_id = {album.id: album for album in self.albums}
self._update_albums()
def _update_albums(self):
# Update ordering
if self.album_sort_direction == "descending":
self.albums.reverse()
# TODO: Update instead of re-create
for child in self.grid.get_children():
self.grid.remove(child)
for album in self.albums:
self.grid.add(self._create_cover_art_widget(album))
def _make_label(self, text: str, name: str) -> Gtk.Label:
return Gtk.Label(
name=name,
label=text,
tooltip_text=text,
ellipsize=Pango.EllipsizeMode.END,
single_line_mode=True,
halign=Gtk.Align.START,
)
def _create_cover_art_widget(self, album) -> Gtk.Box:
sizer = Sizer(natural_width=COVER_ART_WIDTH)
widget_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, width_request=COVER_ART_WIDTH)
# Cover art image
artwork = SpinnerImage(
loading=False,
image_name="grid-artwork",
spinner_name="grid-artwork-spinner",
image_size=COVER_ART_WIDTH,
)
widget_box.pack_start(artwork, True, False, 0)
# Header for the widget
header_label = self._make_label(album.name, "grid-header-label")
header_label.set_size_request(COVER_ART_WIDTH, -1)
widget_box.pack_start(header_label, False, False, 0)
# Extra info for the widget
info_text = util.dot_join(
album.artist.name if album.artist else "-", album.year
)
if info_text:
info_label = self._make_label(info_text, "grid-info-label")
info_label.set_size_request(COVER_ART_WIDTH, -1)
widget_box.pack_start(info_label, False, False, 0)
# Download the cover art.
def on_artwork_downloaded(filename: Result[str]):
artwork.set_from_file(filename.result())
artwork.set_loading(False)
cover_art_filename_future = AdapterManager.get_cover_art_uri(
album.cover_art, "file"
)
if cover_art_filename_future.data_is_available:
on_artwork_downloaded(cover_art_filename_future)
else:
artwork.set_loading(True)
cover_art_filename_future.add_done_callback(
lambda f: GLib.idle_add(on_artwork_downloaded, f)
)
sizer.add(widget_box)
sizer.show_all()
return sizer
def _get_opposite_sort_dir(self, sort_dir: str) -> str:
return ("ascending", "descending")[0 if sort_dir == "descending" else 1]
@@ -327,85 +439,50 @@ class AlbumsPanel(Gtk.Box):
return None
def on_sort_toggle_clicked(self, _):
self.emit(
"refresh-window",
{
"album_sort_direction": self._get_opposite_sort_dir(
self.album_sort_direction
),
"album_page": 0,
"selected_album_id": None,
},
False,
)
run_action(self, 'albums.set-search-query', self.current_query, self._get_opposite_sort_dir(self.album_sort_direction))
def on_refresh_clicked(self, _):
self.emit("refresh-window", {}, True)
class _Genre(API.Genre):
def __init__(self, name: str):
self.name = name
def on_grid_num_pages_changed(self, grid: Any, pages: int):
self.grid_pages_count = pages
pages_str = str(self.grid_pages_count)
self.page_count_label.set_text(pages_str)
self.next_page.set_sensitive(self.album_page < self.grid_pages_count - 1)
num_digits = len(pages_str)
self.page_entry.set_width_chars(num_digits)
self.page_entry.set_max_width_chars(num_digits)
run_action(self, "app.force-refresh")
def on_type_combo_changed(self, combo: Gtk.ComboBox):
id = self.get_id(combo)
assert id
if id == "alphabetical":
id += "_" + cast(str, self.get_id(self.alphabetical_type_combo))
self.emit_if_not_updating(
"refresh-window",
{
"current_album_search_query": AlbumSearchQuery(
_from_str(id),
self.current_query.year_range,
self.current_query.genre,
),
"album_page": 0,
"selected_album_id": None,
},
False,
)
run_action(
self,
'albums.set-search-query',
AlbumSearchQuery(
_from_str(id),
self.current_query.year_range,
self.current_query.genre,
),
self.album_sort_direction)
def on_alphabetical_type_change(self, combo: Gtk.ComboBox):
id = "alphabetical_" + cast(str, self.get_id(combo))
self.emit_if_not_updating(
"refresh-window",
{
"current_album_search_query": AlbumSearchQuery(
_from_str(id),
self.current_query.year_range,
self.current_query.genre,
),
"album_page": 0,
"selected_album_id": None,
},
False,
)
run_action(
self,
'albums.set-search-query',
AlbumSearchQuery(
_from_str(id),
self.current_query.year_range,
self.current_query.genre,
),
self.album_sort_direction)
def on_genre_change(self, combo: Gtk.ComboBox):
genre = self.get_id(combo)
assert genre
self.emit_if_not_updating(
"refresh-window",
{
"current_album_search_query": AlbumSearchQuery(
self.current_query.type,
self.current_query.year_range,
AlbumsPanel._Genre(genre),
),
"album_page": 0,
"selected_album_id": None,
},
False,
)
run_action(
self,
'albums.set-search-query',
AlbumSearchQuery(
self.current_query.type,
self.current_query.year_range,
AlbumSearchQuery.Genre(genre),
),
self.album_sort_direction)
def on_year_changed(self, entry: Gtk.SpinButton) -> bool:
year = int(entry.get_value())
@@ -415,27 +492,19 @@ class AlbumsPanel(Gtk.Box):
else:
new_year_tuple = (year, self.current_query.year_range[1])
self.emit_if_not_updating(
"refresh-window",
{
"current_album_search_query": AlbumSearchQuery(
self.current_query.type, new_year_tuple, self.current_query.genre
),
"album_page": 0,
"selected_album_id": None,
},
False,
)
run_action(
self,
'albums.set-search-query',
AlbumSearchQuery(
self.current_query.type, new_year_tuple, self.current_query.genre
),
self.album_sort_direction)
return False
def on_page_entry_changed(self, entry: Gtk.Entry) -> bool:
if len(text := entry.get_text()) > 0:
self.emit_if_not_updating(
"refresh-window",
{"album_page": int(text) - 1, "selected_album_id": None},
False,
)
run_action(self, 'albums.set-page', int(text) - 1)
return False
def on_page_entry_insert_text(
@@ -452,43 +521,18 @@ class AlbumsPanel(Gtk.Box):
return True
return False
def on_prev_page_clicked(self, _):
self.emit_if_not_updating(
"refresh-window",
{"album_page": self.album_page - 1, "selected_album_id": None},
False,
)
def on_album_clicked(self, _:Any, child: Gtk.FlowBoxChild):
album = self.albums[child.get_index()]
def on_next_page_clicked(self, _):
self.emit_if_not_updating(
"refresh-window",
{"album_page": self.album_page + 1, "selected_album_id": None},
False,
)
if self.get_folded() and self.get_visible_child() == self.grid_box:
self.set_visible_child(self.album_container)
def on_grid_cover_clicked(self, grid: Any, id: str):
self.emit(
"refresh-window",
{"selected_album_id": id},
False,
)
def on_show_count_dropdown_change(self, combo: Gtk.ComboBox):
show_count = int(self.get_id(combo) or 30)
self.emit(
"refresh-window",
{"album_page_size": show_count, "album_page": 0},
False,
)
def emit_if_not_updating(self, *args):
if self.updating_query:
return
self.emit(*args)
run_action(self, 'albums.select-album', album.id)
"""
# TODO: REMOVE
class AlbumsGrid(Gtk.Overlay):
"""Defines the albums panel."""
__gsignals__ = {
"cover-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,)),
@@ -824,7 +868,7 @@ class AlbumsGrid(Gtk.Overlay):
loading=False,
image_name="grid-artwork",
spinner_name="grid-artwork-spinner",
image_size=200,
image_size=150,
)
widget_box.pack_start(artwork, False, False, 0)
@@ -967,3 +1011,4 @@ class AlbumsGrid(Gtk.Overlay):
self.grid_bottom.unselect_all()
self.currently_selected_index = selected_index
"""

View File

@@ -49,12 +49,12 @@
min-width: 230px;
}
#icon-button-box image {
.icon-button-box image {
margin: 5px 2px;
min-width: 15px;
}
#icon-button-box label {
.icon-button-box label {
margin-left: 5px;
margin-right: 3px;
}
@@ -161,12 +161,12 @@ entry.invalid {
/* ********** Playback Controls ********** */
#player-controls-album-artwork {
min-height: 70px;
min-width: 70px;
/*min-height: 70px;
min-width: 70px;*/
margin-right: 10px;
}
#player-controls-bar #play-button {
.play-button-large {
min-height: 45px;
min-width: 35px;
border-width: 1px;
@@ -174,7 +174,7 @@ entry.invalid {
}
/* Make the play icon look centered. */
#player-controls-bar #play-button image {
.play-button-large image {
margin-left: 5px;
margin-right: 5px;
margin-top: 1px;
@@ -182,7 +182,7 @@ entry.invalid {
}
#player-controls-bar #song-scrubber {
min-width: 400px;
min-width: 200px;
}
#player-controls-bar #volume-slider {
@@ -194,6 +194,7 @@ entry.invalid {
}
#player-controls-bar #song-title {
min-width: 150px;
margin-bottom: 3px;
font-weight: bold;
}
@@ -235,11 +236,6 @@ entry.invalid {
min-width: 35px;
}
/* ********** General ********** */
.menu-button {
padding: 5px;
}
/* ********** Search ********** */
#search-results {
min-width: 400px;
@@ -276,34 +272,12 @@ entry.invalid {
}
/* ********** Artists & Albums ********** */
#grid-artwork-spinner, #album-list-song-list-spinner {
min-height: 35px;
min-width: 35px;
}
#grid-artwork {
min-height: 200px;
min-width: 200px;
margin: 10px;
}
#grid-spinner {
min-height: 50px;
min-width: 50px;
margin: 20px;
}
#grid-header-label {
margin-left: 10px;
margin-right: 10px;
margin-bottom: 3px;
margin-top: 5px;
font-weight: bold;
}
#grid-info-label {
margin-left: 10px;
margin-right: 10px;
margin-bottom: 10px;
}
#artist-album-artwork {
@@ -338,3 +312,7 @@ entry.invalid {
inset 0 -5px 5px @box_shadow_color;
background-color: @box_shadow_color;
}
.details-panel {
box-shadow: inset 5px 0 5px @box_shadow_color;
}

View File

@@ -5,7 +5,7 @@ from typing import cast, List, Sequence
import bleach
from gi.repository import Gio, GLib, GObject, Gtk, Pango
from gi.repository import Gio, GLib, GObject, Gtk, Pango, Handy
from ..adapters import (
AdapterManager,
@@ -15,41 +15,44 @@ from ..adapters import (
)
from ..config import AppConfiguration
from ..ui import util
from ..ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage
from ..ui.common import AlbumWithSongs, IconButton, IconToggleButton, LoadError, SpinnerImage, Sizer
from .actions import run_action
class ArtistsPanel(Gtk.Paned):
class ArtistsPanel(Handy.Leaflet):
"""Defines the arist panel."""
__gsignals__ = {
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
"refresh-window": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, bool),
),
}
def __init__(self, *args, **kwargs):
Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
def __init__(self):
super().__init__(
transition_type=Handy.LeafletTransitionType.SLIDE,
can_swipe_forward=False,
interpolate_size=False)
list_sizer = Sizer(natural_width=400)
self.artist_list = ArtistList()
self.pack1(self.artist_list, False, False)
list_sizer.add(self.artist_list)
self.add(list_sizer)
details_sizer = Sizer(hexpand=True, natural_width=800)
self.artist_detail_panel = ArtistDetailPanel()
self.artist_detail_panel.connect(
"song-clicked",
lambda _, *args: self.emit("song-clicked", *args),
)
self.artist_detail_panel.connect(
"refresh-window",
lambda _, *args: self.emit("refresh-window", *args),
)
self.pack2(self.artist_detail_panel, True, False)
details_sizer.add(self.artist_detail_panel)
self.add(details_sizer)
def artist_clicked(_):
if self.get_folded():
self.set_visible_child(details_sizer)
self.artist_list.connect("artist-clicked", artist_clicked)
def back_clicked(_):
self.set_visible_child(list_sizer)
self.artist_detail_panel.connect("back-clicked", back_clicked)
def folded_changed(*_):
if not self.get_folded():
self.set_visible_child(list_sizer)
self.artist_detail_panel.show_mobile = self.get_folded()
self.connect("notify::folded", folded_changed)
def update(self, app_config: AppConfiguration, force: bool = False):
self.artist_list.update(app_config=app_config)
@@ -69,6 +72,10 @@ class _ArtistModel(GObject.GObject):
class ArtistList(Gtk.Box):
__gsignals__ = {
"artist-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
}
def __init__(self):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
@@ -119,8 +126,9 @@ class ArtistList(Gtk.Box):
return row
self.artists_store = Gio.ListStore()
self.list = Gtk.ListBox(name="artist-list")
self.list = Gtk.ListBox(name="artist-list", selection_mode=Gtk.SelectionMode.BROWSE)
self.list.bind_model(self.artists_store, create_artist_row)
self.list.connect("row-selected", lambda *_: self.emit("artist-clicked"))
list_scroll_window.add(self.list)
self.pack_start(list_scroll_window, True, True, 0)
@@ -180,24 +188,20 @@ class ArtistList(Gtk.Box):
self.loading_indicator.hide()
ARTIST_ARTWORK_SIZE_DESKTOP=200
ARTIST_ARTWORK_SIZE_MOBILE=80
class ArtistDetailPanel(Gtk.Box):
"""Defines the artists list."""
__gsignals__ = {
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
"refresh-window": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, bool),
),
"back-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
}
show_mobile = GObject.Property(type=bool, default=False)
update_order_token = 0
artist_details_expanded = False
def __init__(self, *args, **kwargs):
super().__init__(
@@ -206,134 +210,136 @@ class ArtistDetailPanel(Gtk.Box):
orientation=Gtk.Orientation.VERTICAL,
**kwargs,
)
self.connect("notify::show-mobile", self.on_show_mobile_changed)
self.albums: Sequence[API.Album] = []
self.artist_id = None
# Artist info panel
self.big_info_panel = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, name="artist-info-panel"
)
action_bar = Gtk.ActionBar()
self.artist_artwork = SpinnerImage(
loading=False,
image_name="artist-album-artwork",
spinner_name="artist-artwork-spinner",
image_size=300,
)
self.big_info_panel.pack_start(self.artist_artwork, False, False, 0)
back_button_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.CROSSFADE)
self.bind_property("show-mobile", back_button_revealer, "reveal-child", GObject.BindingFlags.SYNC_CREATE)
# Action buttons, name, comment, number of songs, etc.
artist_details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
back_button = IconButton("go-previous-symbolic")
back_button.connect("clicked", lambda *_: self.emit("back-clicked"))
back_button_revealer.add(back_button)
artist_details_box.pack_start(Gtk.Box(), True, False, 0)
action_bar.pack_start(back_button_revealer)
self.artist_indicator = self.make_label(name="artist-indicator")
artist_details_box.add(self.artist_indicator)
self.artist_name = self.make_label(
name="artist-name", ellipsize=Pango.EllipsizeMode.END
)
artist_details_box.add(self.artist_name)
self.artist_bio = self.make_label(
name="artist-bio", justify=Gtk.Justification.LEFT
)
self.artist_bio.set_line_wrap(True)
artist_details_box.add(self.artist_bio)
self.similar_artists_scrolledwindow = Gtk.ScrolledWindow()
similar_artists_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.similar_artists_label = self.make_label(name="similar-artists")
similar_artists_box.add(self.similar_artists_label)
self.similar_artists_button_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL
)
similar_artists_box.add(self.similar_artists_button_box)
self.similar_artists_scrolledwindow.add(similar_artists_box)
artist_details_box.add(self.similar_artists_scrolledwindow)
self.artist_stats = self.make_label(name="artist-stats")
artist_details_box.add(self.artist_stats)
self.play_shuffle_buttons = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
name="playlist-play-shuffle-buttons",
)
self.play_button = IconButton(
"media-playback-start-symbolic", label="Play All", relief=True
)
self.play_button.connect("clicked", self.on_play_all_clicked)
self.play_shuffle_buttons.pack_start(self.play_button, False, False, 0)
self.shuffle_button = IconButton(
"media-playlist-shuffle-symbolic", label="Shuffle All", relief=True
)
self.shuffle_button.connect("clicked", self.on_shuffle_all_button)
self.play_shuffle_buttons.pack_start(self.shuffle_button, False, False, 5)
artist_details_box.add(self.play_shuffle_buttons)
self.big_info_panel.pack_start(artist_details_box, True, True, 0)
# Action buttons
action_buttons_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.artist_action_buttons = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=10
)
self.refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info")
self.refresh_button.connect("clicked", self.on_view_refresh_click)
action_bar.pack_end(self.refresh_button)
self.download_all_button = IconButton(
"folder-download-symbolic", "Download all songs by this artist"
)
self.download_all_button.connect("clicked", self.on_download_all_click)
self.artist_action_buttons.add(self.download_all_button)
action_bar.pack_end(self.download_all_button)
self.refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info")
self.refresh_button.connect("clicked", self.on_view_refresh_click)
self.artist_action_buttons.add(self.refresh_button)
self.shuffle_button = IconButton("media-playlist-shuffle-symbolic")
self.shuffle_button.connect("clicked", self.on_shuffle_all_button)
action_bar.pack_end(self.shuffle_button)
action_buttons_container.pack_start(
self.artist_action_buttons, False, False, 10
self.play_button = IconButton("media-playback-start-symbolic")
self.play_button.connect("clicked", self.on_play_all_clicked)
action_bar.pack_end(self.play_button)
self.pack_start(action_bar, False, False, 0)
self.pack_start(Gtk.Separator(), False, False, 0)
self.scrolled_window = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Artist info panel
info_panel = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, name="artist-info-panel"
)
action_buttons_container.pack_start(Gtk.Box(), True, True, 0)
expand_button_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.expand_collapse_button = IconButton(
"pan-up-symbolic", "Expand playlist details"
self.artist_artwork = SpinnerImage(
loading=False,
image_size=ARTIST_ARTWORK_SIZE_DESKTOP,
valign=Gtk.Align.START,
)
self.expand_collapse_button.connect("clicked", self.on_expand_collapse_click)
expand_button_container.pack_end(self.expand_collapse_button, False, False, 0)
action_buttons_container.add(expand_button_container)
info_panel.pack_start(self.artist_artwork, False, False, 10)
self.big_info_panel.pack_start(action_buttons_container, False, False, 5)
# Action buttons, name, comment, number of songs, etc.
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.pack_start(self.big_info_panel, False, True, 0)
self.artist_name = self.make_label(
name="artist-name", wrap=True,
)
details_box.add(self.artist_name)
self.artist_bio_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN, reveal_child=True)
self.artist_bio = self.make_label(
name="artist-bio", justify=Gtk.Justification.LEFT
)
self.artist_bio.set_line_wrap(True)
self.artist_bio_revealer.add(self.artist_bio)
details_box.add(self.artist_bio_revealer)
details_bottom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
artist_stats_squeezer = Handy.Squeezer(homogeneous=False)
self.artist_stats_long = self.make_label(name="artist-stats")
artist_stats_squeezer.add(self.artist_stats_long)
self.artist_stats_medium = self.make_label(name="artist-stats")
artist_stats_squeezer.add(self.artist_stats_medium)
self.artist_stats_short = self.make_label(name="artist-stats")
artist_stats_squeezer.add(self.artist_stats_short)
details_bottom_box.pack_start(artist_stats_squeezer, False, False, 0)
self.expand_button_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.CROSSFADE, margin_left=10)
self.expand_button = IconToggleButton(
"pan-down-symbolic", "Expand"
)
self.expand_button.bind_property("active", self.artist_bio_revealer, "reveal-child")
self.expand_button.connect("clicked", self.on_expand_button_clicked)
self.expand_button_revealer.add(self.expand_button)
details_bottom_box.pack_end(self.expand_button_revealer, False, False, 0)
details_box.pack_end(details_bottom_box, False, False, 0)
info_panel.pack_start(details_box, True, True, 0)
box.pack_start(info_panel, False, False, 0)
self.error_container = Gtk.Box()
self.add(self.error_container)
# self.add(self.error_container)
self.album_list_scrolledwindow = Gtk.ScrolledWindow()
self.albums_list = AlbumsListWithSongs()
self.albums_list.connect(
"song-clicked",
lambda _, *args: self.emit("song-clicked", *args),
box.pack_start(self.albums_list, True, True, 0)
self.scrolled_window.add(box)
self.pack_start(self.scrolled_window, True, True, 0)
def on_show_mobile_changed(self, *_):
self.expand_button.set_active(not self.show_mobile)
self.artist_bio_revealer.set_reveal_child(not self.show_mobile)
self.expand_button_revealer.set_reveal_child(self.show_mobile)
self.artist_artwork.set_image_size(ARTIST_ARTWORK_SIZE_MOBILE if self.show_mobile else ARTIST_ARTWORK_SIZE_DESKTOP)
def on_expand_button_clicked(self, *_):
up_down = "up" if self.expand_button.get_active() else "down"
self.expand_button.set_icon(f"pan-{up_down}-symbolic")
self.expand_button.set_tooltip_text(
"Collapse" if self.expand_button.get_active() else "Expand"
)
self.album_list_scrolledwindow.add(self.albums_list)
self.pack_start(self.album_list_scrolledwindow, True, True, 0)
def update(self, app_config: AppConfiguration):
self.artist_id = app_config.state.selected_artist_id
self.offline_mode = app_config.offline_mode
if app_config.state.selected_artist_id is None:
self.big_info_panel.hide()
self.album_list_scrolledwindow.hide()
self.play_shuffle_buttons.hide()
self.shuffle_button.set_sensitive(False)
self.play_button.set_sensitive(False)
else:
self.update_order_token += 1
self.album_list_scrolledwindow.show()
self.update_artist_view(
app_config.state.selected_artist_id,
app_config=app_config,
@@ -358,58 +364,28 @@ class ArtistDetailPanel(Gtk.Box):
if order_token != self.update_order_token:
return
self.big_info_panel.show_all()
if app_config:
self.artist_details_expanded = app_config.state.artist_details_expanded
up_down = "up" if self.artist_details_expanded else "down"
self.expand_collapse_button.set_icon(f"pan-{up_down}-symbolic")
self.expand_collapse_button.set_tooltip_text(
"Collapse" if self.artist_details_expanded else "Expand"
)
# Scroll to top
self.scrolled_window.get_vadjustment().set_value(0)
self.artist_name.set_markup(bleach.clean(f"<b>{artist.name}</b>"))
self.artist_name.set_tooltip_text(artist.name)
if self.artist_details_expanded:
self.artist_artwork.get_style_context().remove_class("collapsed")
self.artist_name.get_style_context().remove_class("collapsed")
self.artist_indicator.set_text("ARTIST")
self.artist_stats.set_markup(self.format_stats(artist))
self.artist_stats_long.set_markup(self.format_stats(artist, short_time=False))
self.artist_stats_medium.set_markup(self.format_stats(artist, short_time=True))
self.artist_stats_short.set_markup(self.format_stats(artist, short_time=True, short_count=True))
if artist.biography:
self.artist_bio.set_markup(bleach.clean(artist.biography))
self.artist_bio.show()
else:
self.artist_bio.hide()
biography = ""
if artist.biography:
biography += bleach.clean(artist.biography)
if len(artist.similar_artists or []) > 0:
self.similar_artists_label.set_markup("<b>Similar Artists:</b> ")
for c in self.similar_artists_button_box.get_children():
self.similar_artists_button_box.remove(c)
if artist.similar_artists:
biography += "\n\n<b>Similar Artists:</b> "
for similar_artist in (artist.similar_artists or [])[:5]:
self.similar_artists_button_box.add(
Gtk.LinkButton(
label=similar_artist.name,
name="similar-artist-button",
action_name="app.go-to-artist",
action_target=GLib.Variant("s", similar_artist.id),
)
)
self.similar_artists_scrolledwindow.show_all()
else:
self.similar_artists_scrolledwindow.hide()
else:
self.artist_artwork.get_style_context().add_class("collapsed")
self.artist_name.get_style_context().add_class("collapsed")
self.artist_indicator.hide()
self.artist_stats.hide()
self.artist_bio.hide()
self.similar_artists_scrolledwindow.hide()
# TODO: Make links work
biography += ", ".join(f"<a href=\"{a.id}\">{bleach.clean(a.name)}</a>" for a in artist.similar_artists[:6])
self.play_shuffle_buttons.show_all()
self.artist_bio.set_markup(biography)
self.expand_button.set_sensitive(bool(biography))
self.update_artist_artwork(
artist.artist_image_url,
@@ -429,11 +405,8 @@ class ArtistDetailPanel(Gtk.Box):
)
self.error_container.pack_start(load_error, True, True, 0)
self.error_container.show_all()
if not has_data:
self.album_list_scrolledwindow.hide()
else:
self.error_container.hide()
self.album_list_scrolledwindow.show()
self.albums = artist.albums or []
@@ -486,10 +459,10 @@ class ArtistDetailPanel(Gtk.Box):
self.artist_artwork.set_from_file(cover_art_filename)
self.artist_artwork.set_loading(False)
if self.artist_details_expanded:
self.artist_artwork.set_image_size(300)
else:
self.artist_artwork.set_image_size(70)
# if self.artist_details_expanded:
# self.artist_artwork.set_image_size(300)
# else:
# self.artist_artwork.set_image_size(70)
# Event Handlers
# =========================================================================
@@ -503,40 +476,28 @@ class ArtistDetailPanel(Gtk.Box):
def on_download_all_click(self, _):
AdapterManager.batch_download_songs(
self.get_artist_song_ids(),
before_download=lambda _: self.update_artist_view(
self.artist_id,
order_token=self.update_order_token,
before_download=lambda _: GLib.idle_add(
lambda: self.update_artist_view(
self.artist_id,
order_token=self.update_order_token,
)
),
on_song_download_complete=lambda _: self.update_artist_view(
self.artist_id,
order_token=self.update_order_token,
on_song_download_complete=lambda _: GLib.idle_add(
lambda: self.update_artist_view(
self.artist_id,
order_token=self.update_order_token,
)
),
)
def on_play_all_clicked(self, _):
songs = self.get_artist_song_ids()
self.emit(
"song-clicked",
0,
songs,
{"force_shuffle_state": False},
)
run_action(self, 'app.play-song', 0, songs, {"force_shuffle_state": GLib.Variant('b', False)})
def on_shuffle_all_button(self, _):
songs = self.get_artist_song_ids()
self.emit(
"song-clicked",
randint(0, len(songs) - 1),
songs,
{"force_shuffle_state": True},
)
def on_expand_collapse_click(self, _):
self.emit(
"refresh-window",
{"artist_details_expanded": not self.artist_details_expanded},
False,
)
song_idx = randint(0, len(songs) - 1)
run_action(self, 'app.play-song', song_idx, songs, {"force_shuffle_state": GLib.Variant('b', True)})
# Helper Methods
# =========================================================================
@@ -554,18 +515,27 @@ class ArtistDetailPanel(Gtk.Box):
label=text, name=name, halign=Gtk.Align.START, xalign=0, **params
)
def format_stats(self, artist: API.Artist) -> str:
def format_stats(self, artist: API.Artist, short_time=False, short_count=False) -> str:
album_count = artist.album_count or len(artist.albums or [])
song_count, duration = 0, timedelta(0)
for album in artist.albums or []:
song_count += album.song_count or 0
duration += album.duration or timedelta(0)
return util.dot_join(
"{} {}".format(album_count, util.pluralize("album", album_count)),
"{} {}".format(song_count, util.pluralize("song", song_count)),
util.format_sequence_duration(duration),
)
parts = []
if short_count:
parts.append(f"{album_count}/{song_count}")
else:
parts.append("{} {}".format(album_count, util.pluralize("album", album_count)))
parts.append("{} {}".format(song_count, util.pluralize("song", song_count)))
if short_time:
parts.append(util.format_song_duration(duration))
else:
parts.append(util.format_sequence_duration(duration))
return util.dot_join(*parts)
def get_artist_song_ids(self) -> List[str]:
try:
@@ -592,14 +562,6 @@ class ArtistDetailPanel(Gtk.Box):
class AlbumsListWithSongs(Gtk.Overlay):
__gsignals__ = {
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
}
def __init__(self):
Gtk.Overlay.__init__(self)
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
@@ -633,8 +595,8 @@ class AlbumsListWithSongs(Gtk.Overlay):
if self.albums == new_albums:
# Just go through all of the colidren and update them.
for c in self.box.get_children():
c.update(app_config=app_config, force=force)
for c, album in zip(self.box.get_children(), self.albums):
c.update(album, app_config=app_config, force=force)
self.spinner.hide()
return
@@ -644,19 +606,16 @@ class AlbumsListWithSongs(Gtk.Overlay):
remove_all()
for album in self.albums:
album_with_songs = AlbumWithSongs(album, show_artist_name=False)
album_with_songs.connect(
"song-clicked",
lambda _, *args: self.emit("song-clicked", *args),
)
album_with_songs.connect("song-selected", self.on_song_selected)
album_with_songs = AlbumWithSongs(show_artist_name=False)
album_with_songs.update(album, app_config, force=force)
# album_with_songs.connect("song-selected", self.on_song_selected)
album_with_songs.show_all()
self.box.add(album_with_songs)
# Update everything (no force to ensure that if we are online, then everything
# is clickable)
for c in self.box.get_children():
c.update(app_config=app_config)
# for c in self.box.get_children():
# c.update(app_config=app_config)
self.spinner.hide()

View File

@@ -440,7 +440,7 @@ class MusicDirectoryList(Gtk.Box):
self.loading_indicator.hide()
def on_download_state_change(self, _):
self.update()
GLib.idle_add(self.update)
# Create Element Helper Functions
# ==================================================================================

View File

@@ -1,8 +1,10 @@
from .album_with_songs import AlbumWithSongs
from .icon_button import IconButton, IconMenuButton, IconToggleButton
from .load_error import LoadError
from .sizer import Sizer
from .song_list_column import SongListColumn
from .spinner_image import SpinnerImage
from .spinner_picture import SpinnerPicture
__all__ = (
"AlbumWithSongs",
@@ -10,6 +12,8 @@ __all__ = (
"IconMenuButton",
"IconToggleButton",
"LoadError",
"Sizer",
"SongListColumn",
"SpinnerImage",
"SpinnerPicture",
)

View File

@@ -1,7 +1,7 @@
from random import randint
from typing import Any, cast, List
from typing import Any, cast, List, Dict
from gi.repository import Gdk, GLib, GObject, Gtk, Pango
from gi.repository import Gdk, GLib, GObject, Gtk, Pango, Handy
from sublime_music.adapters import AdapterManager, api_objects as API, Result
from sublime_music.config import AppConfiguration
@@ -11,97 +11,36 @@ from .icon_button import IconButton
from .load_error import LoadError
from .song_list_column import SongListColumn
from .spinner_image import SpinnerImage
from ..actions import run_action
class AlbumWithSongs(Gtk.Box):
__gsignals__ = {
"song-selected": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
"back-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
}
show_back_button = GObject.Property(type=bool, default=False)
album = None
offline_mode = True
def __init__(
self,
album: API.Album,
cover_art_size: int = 200,
show_artist_name: bool = True,
):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
self.album = album
cover_art_result = None
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
artist_artwork = SpinnerImage(
loading=False,
image_name="artist-album-list-artwork",
spinner_name="artist-artwork-spinner",
image_size=cover_art_size,
)
# Account for 10px margin on all sides with "+ 20".
artist_artwork.set_size_request(cover_art_size + 20, cover_art_size + 20)
box.pack_start(artist_artwork, False, False, 0)
box.pack_start(Gtk.Box(), True, True, 0)
self.pack_start(box, False, False, 0)
def __init__(self, show_artist_name: bool = True, scroll_contents: bool = False, **kwargs):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
def cover_art_future_done(f: Result):
artist_artwork.set_from_file(f.result())
artist_artwork.set_loading(False)
self.show_artist_name = show_artist_name
cover_art_filename_future = AdapterManager.get_cover_art_uri(
album.cover_art,
"file",
before_download=lambda: artist_artwork.set_loading(True),
)
cover_art_filename_future.add_done_callback(
lambda f: GLib.idle_add(cover_art_future_done, f)
)
action_bar = Gtk.ActionBar()
album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
album_title_and_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
back_button_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.CROSSFADE)
self.bind_property("show-back-button", back_button_revealer, "reveal-child", GObject.BindingFlags.SYNC_CREATE)
# TODO (#43): deal with super long-ass titles
album_title_and_buttons.add(
Gtk.Label(
label=album.name,
name="artist-album-list-album-name",
halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END,
)
)
back_button = IconButton("go-previous-symbolic")
back_button.connect("clicked", lambda *_: self.emit("back-clicked"))
back_button_revealer.add(back_button)
self.play_btn = IconButton(
"media-playback-start-symbolic",
"Play all songs in this album",
sensitive=False,
)
self.play_btn.connect("clicked", self.play_btn_clicked)
album_title_and_buttons.pack_start(self.play_btn, False, False, 5)
self.shuffle_btn = IconButton(
"media-playlist-shuffle-symbolic",
"Shuffle all songs in this album",
sensitive=False,
)
self.shuffle_btn.connect("clicked", self.shuffle_btn_clicked)
album_title_and_buttons.pack_start(self.shuffle_btn, False, False, 5)
self.play_next_btn = IconButton(
"queue-front-symbolic",
"Play all of the songs in this album next",
sensitive=False,
)
album_title_and_buttons.pack_start(self.play_next_btn, False, False, 5)
self.add_to_queue_btn = IconButton(
"queue-back-symbolic",
"Add all the songs in this album to the end of the play queue",
sensitive=False,
)
album_title_and_buttons.pack_start(self.add_to_queue_btn, False, False, 5)
action_bar.pack_start(back_button_revealer)
self.download_all_btn = IconButton(
"folder-download-symbolic",
@@ -109,30 +48,112 @@ class AlbumWithSongs(Gtk.Box):
sensitive=False,
)
self.download_all_btn.connect("clicked", self.on_download_all_click)
album_title_and_buttons.pack_end(self.download_all_btn, False, False, 5)
action_bar.pack_end(self.download_all_btn)
album_details.add(album_title_and_buttons)
stats: List[Any] = [
album.artist.name if show_artist_name and album.artist else None,
album.year,
album.genre.name if album.genre else None,
util.format_sequence_duration(album.duration) if album.duration else None,
]
album_details.add(
Gtk.Label(
label=util.dot_join(*stats),
halign=Gtk.Align.START,
margin_left=10,
)
self.add_to_queue_btn = IconButton(
"queue-back-symbolic",
"Add all the songs in this album to the end of the play queue",
sensitive=False,
)
action_bar.pack_end(self.add_to_queue_btn)
self.play_next_btn = IconButton(
"queue-front-symbolic",
"Play all of the songs in this album next",
sensitive=False,
)
action_bar.pack_end(self.play_next_btn)
self.shuffle_btn = IconButton(
"media-playlist-shuffle-symbolic",
"Shuffle all songs in this album",
sensitive=False,
)
self.shuffle_btn.connect("clicked", self.shuffle_btn_clicked)
action_bar.pack_end(self.shuffle_btn)
self.play_btn = IconButton(
"media-playback-start-symbolic",
"Play all songs in this album",
sensitive=False,
)
self.play_btn.connect("clicked", self.play_btn_clicked)
action_bar.pack_end(self.play_btn)
self.pack_start(action_bar, False, False, 0)
if scroll_contents:
self.pack_start(Gtk.Separator(), False, False, 0)
scrolled_window = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER)
self.pack_start(scrolled_window, True, True, 0)
contents_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
scrolled_window.add(contents_box)
else:
contents_box = self
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.artist_artwork = SpinnerImage(
loading=False,
image_name="artist-album-list-artwork",
spinner_name="artist-artwork-spinner",
image_size=80,
)
# Account for 10px margin on all sides with "+ 20".
# self.artist_artwork.set_size_request(cover_art_size + 20, cover_art_size + 20)
box.pack_start(self.artist_artwork, False, False, 0)
album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.title = Gtk.Label(
name="artist-album-list-album-name",
halign=Gtk.Align.START,
wrap=True,
)
album_details.pack_start(self.title, False, False, 0)
self.artist_and_year = Gtk.Label(
halign=Gtk.Align.START,
margin_left=10,
margin_right=10,
ellipsize=Pango.EllipsizeMode.END,
)
album_details.pack_start(self.artist_and_year, False, False, 0)
squeezer = Handy.Squeezer(homogeneous=False)
details_bottom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.genre_and_song_count_long = Gtk.Label()
details_bottom_box.pack_start(self.genre_and_song_count_long, False, False, 10)
self.song_duration_long = Gtk.Label(halign=Gtk.Align.END)
details_bottom_box.pack_end(self.song_duration_long, False, False, 10)
squeezer.add(details_bottom_box)
details_bottom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.genre_and_song_count_short = Gtk.Label(ellipsize=Pango.EllipsizeMode.END)
details_bottom_box.pack_start(self.genre_and_song_count_short, False, False, 10)
self.song_duration_short = Gtk.Label(halign=Gtk.Align.END)
details_bottom_box.pack_end(self.song_duration_short, False, False, 10)
squeezer.add(details_bottom_box)
album_details.pack_start(squeezer, False, False, 0)
self.loading_indicator_container = Gtk.Box()
album_details.add(self.loading_indicator_container)
album_details.pack_start(self.loading_indicator_container, False, False, 0)
self.error_container = Gtk.Box()
album_details.add(self.error_container)
album_details.pack_start(self.error_container, False, False, 0)
box.pack_start(album_details, True, True, 0)
contents_box.pack_start(box, False, False, 0)
# clickable, cache status, title, duration, song ID
self.album_song_store = Gtk.ListStore(bool, str, str, str, str)
@@ -147,8 +168,7 @@ class AlbumWithSongs(Gtk.Box):
margin_bottom=10,
)
selection = self.album_songs.get_selection()
selection.set_mode(Gtk.SelectionMode.MULTIPLE)
selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
selection.set_mode(Gtk.SelectionMode.SINGLE)
# Song status column.
renderer = Gtk.CellRendererPixbuf()
@@ -165,28 +185,23 @@ class AlbumWithSongs(Gtk.Box):
self.album_songs.get_selection().connect(
"changed", self.on_song_selection_change
)
album_details.add(self.album_songs)
self.pack_end(album_details, True, True, 0)
self.update_album_songs(album.id)
contents_box.pack_start(self.album_songs, True, True, 0)
# Event Handlers
# =========================================================================
def on_song_selection_change(self, event: Any):
if not self.album_songs.has_focus():
self.emit("song-selected")
def on_song_selection_change(self, selection: Gtk.TreeSelection):
paths = selection.get_selected_rows()[1]
if not paths:
return
assert len(paths) == 1
self.play_song(paths[0].get_indices()[0], {})
def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
if not self.album_song_store[idx[0]][0]:
return
# The song ID is in the last column of the model.
self.emit(
"song-clicked",
idx.get_indices()[0],
[m[-1] for m in self.album_song_store],
{},
)
self.play_song(idx.get_indices()[0], {})
def on_song_button_press(self, tree: Any, event: Gdk.EventButton) -> bool:
if event.button == 3: # Right click
@@ -198,7 +213,7 @@ class AlbumWithSongs(Gtk.Box):
allow_deselect = False
def on_download_state_change(song_id: str):
self.update_album_songs(self.album.id)
GLib.idle_add(lambda: 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.
@@ -230,34 +245,28 @@ class AlbumWithSongs(Gtk.Box):
def on_download_all_click(self, btn: Any):
AdapterManager.batch_download_songs(
[x[-1] for x in self.album_song_store],
before_download=lambda _: self.update(),
on_song_download_complete=lambda _: self.update(),
before_download=lambda _: GLib.idle_add(self.update),
on_song_download_complete=lambda _: GLib.idle_add(self.update),
)
def play_btn_clicked(self, btn: Any):
song_ids = [x[-1] for x in self.album_song_store]
self.emit(
"song-clicked",
0,
song_ids,
{"force_shuffle_state": False},
)
self.play_song(0, {"force_shuffle_state": GLib.Variant('b', False)})
def shuffle_btn_clicked(self, btn: Any):
song_ids = [x[-1] for x in self.album_song_store]
self.emit(
"song-clicked",
randint(0, len(self.album_song_store) - 1),
song_ids,
{"force_shuffle_state": True},
)
self.play_song(randint(0, len(self.album_song_store) - 1),
{"force_shuffle_state": GLib.Variant('b', True)})
def play_song(self, index: int, metadata: Dict[str, GLib.Variant]):
run_action(self, 'app.play-song', index, [m[-1] for m in self.album_song_store], metadata)
# Helper Methods
# =========================================================================
def deselect_all(self):
self.album_songs.get_selection().unselect_all()
def update(self, app_config: AppConfiguration = None, force: bool = False):
def update(self, album: API.Album, app_config: AppConfiguration, force: bool = False):
update_songs = False
if app_config:
# Deselect everything and reset the error container if switching between
# online and offline.
@@ -266,9 +275,53 @@ class AlbumWithSongs(Gtk.Box):
for c in self.error_container.get_children():
self.error_container.remove(c)
update_songs = True
self.offline_mode = app_config.offline_mode
self.update_album_songs(self.album.id, app_config=app_config, force=force)
if album != self.album:
self.album = album
self.title.set_label(album.name)
artist = album.artist.name if self.show_artist_name and album.artist else None
self.artist_and_year.set_label(util.dot_join(artist, album.year))
self.genre_and_song_count_long.set_label(util.dot_join(
f"{album.song_count} " + util.pluralize("song", album.song_count),
album.genre.name if album.genre else None))
self.genre_and_song_count_short.set_label(util.dot_join(
f"{album.song_count}",
album.genre.name if album.genre else None))
self.song_duration_long.set_label(
util.format_sequence_duration(album.duration) if album.duration else "")
self.song_duration_short.set_label(
util.format_song_duration(album.duration) if album.duration else "")
if self.cover_art_result is not None:
self.cover_art_result.cancel()
def cover_art_future_done(f: Result):
self.artist_artwork.set_from_file(f.result())
self.artist_artwork.set_loading(False)
self.cover_art_result = None
self.cover_art_result = AdapterManager.get_cover_art_uri(
album.cover_art,
"file",
before_download=lambda: self.artist_artwork.set_loading(True),
)
self.cover_art_result.add_done_callback(
lambda f: GLib.idle_add(cover_art_future_done, f)
)
update_songs = True
if update_songs and self.album:
self.update_album_songs(self.album.id, app_config=app_config, force=force)
def set_loading(self, loading: bool):
if loading:
@@ -346,8 +399,8 @@ class AlbumWithSongs(Gtk.Box):
if any_song_playable:
self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids))
self.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids))
self.play_next_btn.set_action_name("app.play-next")
self.add_to_queue_btn.set_action_name("app.add-to-queue")
self.play_next_btn.set_action_name("app.queue-next-songs")
self.add_to_queue_btn.set_action_name("app.queue-songs")
else:
self.play_next_btn.set_action_name("")
self.add_to_queue_btn.set_action_name("")

View File

@@ -1,6 +1,6 @@
from typing import Any, Optional
from gi.repository import Gtk
from gi.repository import Gtk, GObject
class IconButton(Gtk.Button):
@@ -16,8 +16,10 @@ class IconButton(Gtk.Button):
Gtk.Button.__init__(self, **kwargs)
self.icon_size = icon_size
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
box.get_style_context().add_class("icon-button-box")
self._icon_name = icon_name
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
box.pack_start(self.image, False, False, 0)
@@ -30,7 +32,21 @@ class IconButton(Gtk.Button):
self.add(box)
self.set_tooltip_text(tooltip_text)
# TODO: Remove
def set_icon(self, icon_name: Optional[str]):
self.icon_name = icon_name
# self.image.set_from_icon_name(icon_name, self.icon_size)
@GObject.Property(type=str)
def icon_name(self):
return self._icon_name
@icon_name.setter
def icon_name(self, icon_name):
if icon_name == self._icon_name:
return
self._icon_name = icon_name
self.image.set_from_icon_name(icon_name, self.icon_size)
@@ -46,8 +62,10 @@ class IconToggleButton(Gtk.ToggleButton):
):
Gtk.ToggleButton.__init__(self, **kwargs)
self.icon_size = icon_size
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
box.get_style_context().add_class("icon-button-box")
self._icon_name = icon_name
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
box.add(self.image)
@@ -60,15 +78,22 @@ class IconToggleButton(Gtk.ToggleButton):
self.add(box)
self.set_tooltip_text(tooltip_text)
# TODO: Remove
def set_icon(self, icon_name: Optional[str]):
self.icon_name = icon_name
@GObject.Property(type=str)
def icon_name(self):
return self._icon_name
@icon_name.setter
def icon_name(self, icon_name):
if icon_name == self._icon_name:
return
self._icon_name = icon_name
self.image.set_from_icon_name(icon_name, self.icon_size)
def get_active(self) -> bool:
return super().get_active()
def set_active(self, active: bool):
super().set_active(active)
class IconMenuButton(Gtk.MenuButton):
def __init__(
@@ -88,7 +113,8 @@ class IconMenuButton(Gtk.MenuButton):
self.set_popover(popover)
self.icon_size = icon_size
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
box.get_style_context().add_class("icon-button-box")
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
box.add(self.image)

View File

@@ -0,0 +1,44 @@
from gi.repository import Gtk, GObject
class Sizer(Gtk.Bin):
""" A widget that lets you control the natural size like the size request """
natural_width = GObject.Property(type=int, default=0)
natural_height = GObject.Property(type=int, default=0)
def __init__(self, **kwargs):
Gtk.Bin.__init__(self, **kwargs)
def do_get_preferred_width(self) -> (int, int):
minimum, natural = Gtk.Bin.do_get_preferred_width(self)
if self.natural_width > 0:
natural = max(minimum, self.natural_width)
return (minimum, natural)
def do_get_preferred_height(self) -> (int, int):
minimum, natural = Gtk.Bin.do_get_preferred_height(self)
if self.natural_height > 0:
natural = max(minimum, self.natural_height)
return (minimum, natural)
def do_get_preferred_width_for_height(self, height: int) -> (int, int):
minimum, natural = Gtk.Bin.do_get_preferred_width_for_height(self, height)
if self.natural_width > 0:
natural = max(minimum, self.natural_width)
return (minimum, natural)
def do_get_preferred_height_for_width(self, width: int) -> (int, int):
minimum, natural = Gtk.Bin.do_get_preferred_height_for_width(self, width)
if self.natural_height > 0:
natural = max(minimum, self.natural_height)
return (minimum, natural)

View File

@@ -0,0 +1,81 @@
from typing import Optional
from gi.repository import Gdk, GdkPixbuf, Gtk
class SpinnerPicture(Gtk.Bin):
def __init__(
self,
loading: bool = True,
spinner_name: str = None,
**kwargs,
):
"""An picture with a loading overlay."""
super().__init__()
self.filename: Optional[str] = None
self.pixbuf = None
self.offset = (0, 0)
self.sized_pixbuf = None
self.spinner = Gtk.Spinner(
name=spinner_name,
active=loading,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
self.add(self.spinner)
def set_from_file(self, filename: Optional[str]):
"""Set the picture to the given filename."""
filename = filename or None
if self.filename == filename:
return
self.filename = filename
if self.filename:
self.pixbuf = GdkPixbuf.Pixbuf.new_from_file(self.filename)
else:
self.pixbuf = None
self._update_sized_pixbuf()
def set_loading(self, loading_status: bool):
if loading_status:
self.spinner.start()
self.spinner.show()
else:
self.spinner.stop()
self.spinner.hide()
def do_size_allocate(self, allocation):
Gtk.Bin.do_size_allocate(self, allocation)
self._update_sized_pixbuf()
def do_draw(self, cr):
if self.sized_pixbuf:
Gdk.cairo_set_source_pixbuf(cr, self.sized_pixbuf, self.offset[0], self.offset[1])
cr.paint()
Gtk.Bin.do_draw(self, cr)
def _update_sized_pixbuf(self):
if self.pixbuf is None:
self.sized_pixbuf = None
return
pix_width = self.pixbuf.get_width()
pix_height = self.pixbuf.get_height()
alloc_width = self.get_allocated_width()
alloc_height = self.get_allocated_height()
scale = max(alloc_width / pix_width, alloc_height / pix_height)
scaled_width = pix_width * scale
scaled_height = pix_height * scale
self.sized_pixbuf = self.pixbuf.scale_simple(scaled_width, scaled_height, GdkPixbuf.InterpType.BILINEAR)
self.offset = (
(alloc_width - scaled_width) / 2,
(alloc_height - scaled_height) / 2,
)

View File

@@ -1,228 +0,0 @@
import uuid
from enum import Enum
from typing import Any, Optional, Type
from gi.repository import Gio, GObject, Gtk, Pango
from ..adapters import AdapterManager, UIInfo
from ..adapters.filesystem import FilesystemAdapter
from ..config import ConfigurationStore, ProviderConfiguration
class AdapterTypeModel(GObject.GObject):
adapter_type = GObject.Property(type=object)
def __init__(self, adapter_type: Type):
GObject.GObject.__init__(self)
self.adapter_type = adapter_type
class DialogStage(Enum):
SELECT_ADAPTER = "select"
CONFIGURE_ADAPTER = "configure"
class ConfigureProviderDialog(Gtk.Dialog):
_current_index = -1
stage = DialogStage.SELECT_ADAPTER
def set_title(self, editing: bool, provider_config: ProviderConfiguration = None):
if editing:
assert provider_config is not None
title = f"Edit {provider_config.name}"
else:
title = "Add New Music Source"
self.header.props.title = title
def __init__(self, parent: Any, provider_config: Optional[ProviderConfiguration]):
Gtk.Dialog.__init__(self, transient_for=parent, flags=Gtk.DialogFlags.MODAL)
self.provider_config = provider_config
self.editing = provider_config is not None
self.set_default_size(400, 350)
# HEADER
self.header = Gtk.HeaderBar()
self.set_title(self.editing, provider_config)
self.cancel_back_button = Gtk.Button(label="Cancel")
self.cancel_back_button.connect("clicked", self._on_cancel_back_clicked)
self.header.pack_start(self.cancel_back_button)
self.next_add_button = Gtk.Button(label="Edit" if self.editing else "Next")
self.next_add_button.get_style_context().add_class("suggested-action")
self.next_add_button.connect("clicked", self._on_next_add_clicked)
self.header.pack_end(self.next_add_button)
self.set_titlebar(self.header)
content_area = self.get_content_area()
self.stack = Gtk.Stack()
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
# ADAPTER TYPE OPTIONS
adapter_type_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.adapter_type_store = Gio.ListStore()
self.adapter_options_list = Gtk.ListBox(
name="ground-truth-adapter-options-list", activate_on_single_click=False
)
self.adapter_options_list.connect("row-activated", self._on_next_add_clicked)
def create_row(model: AdapterTypeModel) -> Gtk.ListBoxRow:
ui_info: UIInfo = model.adapter_type.get_ui_info()
row = Gtk.ListBoxRow()
rowbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
rowbox.pack_start(
Gtk.Image.new_from_icon_name(ui_info.icon_name(), Gtk.IconSize.DND),
False,
False,
5,
)
rowbox.add(
Gtk.Label(
label=f"<b>{ui_info.name}</b>\n{ui_info.description}",
use_markup=True,
margin=8,
halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END,
)
)
row.add(rowbox)
row.show_all()
return row
self.adapter_options_list.bind_model(self.adapter_type_store, create_row)
available_ground_truth_adapters = filter(
lambda a: a.can_be_ground_truth, AdapterManager.available_adapters
)
for adapter_type in sorted(
available_ground_truth_adapters, key=lambda a: a.get_ui_info().name
):
self.adapter_type_store.append(AdapterTypeModel(adapter_type))
adapter_type_box.pack_start(self.adapter_options_list, True, True, 10)
self.stack.add_named(adapter_type_box, "select")
self.configure_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.stack.add_named(self.configure_box, "configure")
content_area.pack_start(self.stack, True, True, 0)
self.show_all()
if self.editing:
assert self.provider_config
for i, adapter_type in enumerate(self.adapter_type_store):
if (
adapter_type.adapter_type
== self.provider_config.ground_truth_adapter_type
):
row = self.adapter_options_list.get_row_at_index(i)
self.adapter_options_list.select_row(row)
break
self._name_is_valid = True
self._on_next_add_clicked()
def _on_cancel_back_clicked(self, _):
if self.stage == DialogStage.SELECT_ADAPTER:
self.close()
else:
self.stack.set_visible_child_name("select")
self.stage = DialogStage.SELECT_ADAPTER
self.cancel_back_button.set_label("Cancel")
self.next_add_button.set_label("Next")
self.next_add_button.set_sensitive(True)
def _on_next_add_clicked(self, *args):
if self.stage == DialogStage.SELECT_ADAPTER:
index = self.adapter_options_list.get_selected_row().get_index()
if index != self._current_index:
for c in self.configure_box.get_children():
self.configure_box.remove(c)
name_entry_grid = Gtk.Grid(
column_spacing=10,
row_spacing=5,
margin_left=10,
margin_right=10,
name="music-source-config-name-entry-grid",
)
name_label = Gtk.Label(label="Music Source Name:")
name_entry_grid.attach(name_label, 0, 0, 1, 1)
self.name_field = Gtk.Entry(
text=self.provider_config.name if self.provider_config else "",
hexpand=True,
)
self.name_field.connect("changed", self._on_name_change)
name_entry_grid.attach(self.name_field, 1, 0, 1, 1)
self.configure_box.add(name_entry_grid)
self.configure_box.add(Gtk.Separator())
self.adapter_type = self.adapter_type_store[index].adapter_type
self.config_store = (
self.provider_config.ground_truth_adapter_config
if self.provider_config
else ConfigurationStore()
)
form = self.adapter_type.get_configuration_form(self.config_store)
form.connect("config-valid-changed", self._on_config_form_valid_changed)
self.configure_box.pack_start(form, True, True, 0)
self.configure_box.show_all()
self._adapter_config_is_valid = False
self.stack.set_visible_child_name("configure")
self.stage = DialogStage.CONFIGURE_ADAPTER
self.cancel_back_button.set_label("Change Type" if self.editing else "Back")
self.next_add_button.set_label("Edit" if self.editing else "Add")
self.next_add_button.set_sensitive(
index == self._current_index and self._adapter_config_is_valid
)
self._current_index = index
else:
if self.provider_config is None:
self.provider_config = ProviderConfiguration(
str(uuid.uuid4()),
self.name_field.get_text(),
self.adapter_type,
self.config_store,
)
if self.adapter_type.can_be_cached:
self.provider_config.caching_adapter_type = FilesystemAdapter
self.provider_config.caching_adapter_config = ConfigurationStore()
else:
self.provider_config.name = self.name_field.get_text()
self.provider_config.ground_truth_adapter_config = self.config_store
self.response(Gtk.ResponseType.APPLY)
_name_is_valid = False
_adapter_config_is_valid = False
def _update_add_button_sensitive(self):
self.next_add_button.set_sensitive(
self._name_is_valid and self._adapter_config_is_valid
)
def _on_name_change(self, entry: Gtk.Entry):
if entry.get_text():
self._name_is_valid = True
entry.get_style_context().remove_class("invalid")
entry.set_tooltip_markup(None)
if self.editing:
assert self.provider_config
self.provider_config.name = entry.get_text()
self.set_title(self.editing, self.provider_config)
else:
self._name_is_valid = False
entry.get_style_context().add_class("invalid")
entry.set_tooltip_markup("This field is required")
self._update_add_button_sensitive()
def _on_config_form_valid_changed(self, _, valid: bool):
self._adapter_config_is_valid = valid
self._update_add_button_sensitive()

File diff suppressed because it is too large Load Diff

View File

@@ -1,845 +0,0 @@
import copy
import math
from datetime import timedelta
from functools import partial
from typing import Any, Callable, Dict, Optional, Set, Tuple
import bleach
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango
from . import util
from .common import IconButton, IconToggleButton, SpinnerImage
from .state import RepeatType
from ..adapters import AdapterManager, Result, SongCacheStatus
from ..adapters.api_objects import Song
from ..config import AppConfiguration
from ..util import resolve_path
class PlayerControls(Gtk.ActionBar):
"""
Defines the player controls panel that appears at the bottom of the window.
"""
__gsignals__ = {
"song-scrub": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,)),
"volume-change": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (float,)),
"device-update": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str,)),
"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),
),
}
editing: bool = False
editing_play_queue_song_list: bool = False
reordering_play_queue_song_list: bool = False
current_song = None
current_device = None
current_playing_index: Optional[int] = None
current_play_queue: Tuple[str, ...] = ()
cover_art_update_order_token = 0
play_queue_update_order_token = 0
offline_mode = False
def __init__(self):
Gtk.ActionBar.__init__(self)
self.set_name("player-controls-bar")
song_display = self.create_song_display()
playback_controls = self.create_playback_controls()
play_queue_volume = self.create_play_queue_volume()
self.last_device_list_update = None
self.pack_start(song_display)
self.set_center_widget(playback_controls)
self.pack_end(play_queue_volume)
connecting_to_device_token = 0
connecting_icon_index = 0
def update(self, app_config: AppConfiguration, force: bool = False):
self.current_device = app_config.state.current_device
self.update_device_list(app_config)
duration = (
app_config.state.current_song.duration
if app_config.state.current_song
else None
)
song_stream_cache_progress = (
app_config.state.song_stream_cache_progress
if app_config.state.current_song
else None
)
self.update_scrubber(
app_config.state.song_progress, duration, song_stream_cache_progress
)
icon = "pause" if app_config.state.playing else "start"
self.play_button.set_icon(f"media-playback-{icon}-symbolic")
self.play_button.set_tooltip_text(
"Pause" if app_config.state.playing else "Play"
)
has_current_song = app_config.state.current_song is not None
has_next_song = False
if app_config.state.repeat_type in (
RepeatType.REPEAT_QUEUE,
RepeatType.REPEAT_SONG,
):
has_next_song = True
elif has_current_song:
last_idx_in_queue = len(app_config.state.play_queue) - 1
has_next_song = app_config.state.current_song_index < last_idx_in_queue
# Toggle button states.
self.repeat_button.set_action_name(None)
self.shuffle_button.set_action_name(None)
repeat_on = app_config.state.repeat_type in (
RepeatType.REPEAT_QUEUE,
RepeatType.REPEAT_SONG,
)
self.repeat_button.set_active(repeat_on)
self.repeat_button.set_icon(app_config.state.repeat_type.icon)
self.shuffle_button.set_active(app_config.state.shuffle_on)
self.repeat_button.set_action_name("app.repeat-press")
self.shuffle_button.set_action_name("app.shuffle-press")
self.song_scrubber.set_sensitive(has_current_song)
self.prev_button.set_sensitive(has_current_song)
self.play_button.set_sensitive(has_current_song)
self.next_button.set_sensitive(has_current_song and has_next_song)
self.connecting_to_device = app_config.state.connecting_to_device
def cycle_connecting(connecting_to_device_token: int):
if (
self.connecting_to_device_token != connecting_to_device_token
or not self.connecting_to_device
):
return
icon = f"chromecast-connecting-{self.connecting_icon_index}-symbolic"
self.device_button.set_icon(icon)
self.connecting_icon_index = (self.connecting_icon_index + 1) % 3
GLib.timeout_add(350, cycle_connecting, connecting_to_device_token)
icon = ""
if app_config.state.connecting_to_device:
icon = "-connecting-0"
self.connecting_icon_index = 0
self.connecting_to_device_token += 1
GLib.timeout_add(350, cycle_connecting, self.connecting_to_device_token)
elif app_config.state.current_device != "this device":
icon = "-connected"
self.device_button.set_icon(f"chromecast{icon}-symbolic")
# Volume button and slider
if app_config.state.is_muted:
icon_name = "muted"
elif app_config.state.volume < 30:
icon_name = "low"
elif app_config.state.volume < 70:
icon_name = "medium"
else:
icon_name = "high"
self.volume_mute_toggle.set_icon(f"audio-volume-{icon_name}-symbolic")
self.editing = True
self.volume_slider.set_value(
0 if app_config.state.is_muted else app_config.state.volume
)
self.editing = False
# Update the current song information.
# TODO (#126): add popup of bigger cover art photo here
if app_config.state.current_song is not None:
self.cover_art_update_order_token += 1
self.update_cover_art(
app_config.state.current_song.cover_art,
order_token=self.cover_art_update_order_token,
)
self.song_title.set_markup(
bleach.clean(app_config.state.current_song.title)
)
# TODO (#71): use walrus once MYPY gets its act together
album = app_config.state.current_song.album
artist = app_config.state.current_song.artist
if album:
self.album_name.set_markup(bleach.clean(album.name))
self.artist_name.show()
else:
self.album_name.set_markup("")
self.album_name.hide()
if artist:
self.artist_name.set_markup(bleach.clean(artist.name))
self.artist_name.show()
else:
self.artist_name.set_markup("")
self.artist_name.hide()
else:
# Clear out the cover art and song tite if no song
self.album_art.set_from_file(None)
self.album_art.set_loading(False)
self.song_title.set_markup("")
self.album_name.set_markup("")
self.artist_name.set_markup("")
self.load_play_queue_button.set_sensitive(not self.offline_mode)
if app_config.state.loading_play_queue:
self.play_queue_spinner.start()
self.play_queue_spinner.show()
else:
self.play_queue_spinner.stop()
self.play_queue_spinner.hide()
# Short circuit if no changes to the play queue
force |= self.offline_mode != app_config.offline_mode
self.offline_mode = app_config.offline_mode
if not force and (
self.current_play_queue == app_config.state.play_queue
and self.current_playing_index == app_config.state.current_song_index
):
return
self.current_play_queue = app_config.state.play_queue
self.current_playing_index = app_config.state.current_song_index
# Set the Play Queue button popup.
play_queue_len = len(app_config.state.play_queue)
if play_queue_len == 0:
self.popover_label.set_markup("<b>Play Queue</b>")
else:
song_label = util.pluralize("song", play_queue_len)
self.popover_label.set_markup(
f"<b>Play Queue:</b> {play_queue_len} {song_label}"
)
# TODO (#207) this is super freaking stupid inefficient.
# IDEAS: batch it, don't get the queue until requested
self.editing_play_queue_song_list = True
new_store = []
def calculate_label(song_details: Song) -> str:
title = song_details.title
# TODO (#71): use walrus once MYPY gets its act together
# album = a.name if (a := song_details.album) else None
# artist = a.name if (a := song_details.artist) else None
album = song_details.album.name if song_details.album else None
artist = song_details.artist.name if song_details.artist else None
return bleach.clean(f"<b>{title}</b>\n{util.dot_join(album, artist)}")
def make_idle_index_capturing_function(
idx: int,
order_tok: int,
fn: Callable[[int, int, Any], None],
) -> Callable[[Result], None]:
return lambda f: GLib.idle_add(fn, idx, order_tok, f.result())
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][1] = cover_art_filename
def get_cover_art_filename_or_create_future(
cover_art_id: Optional[str], idx: int, order_token: int
) -> Optional[str]:
cover_art_result = AdapterManager.get_cover_art_uri(cover_art_id, "file")
if not cover_art_result.data_is_available:
cover_art_result.add_done_callback(
make_idle_index_capturing_function(
idx, order_token, on_cover_art_future_done
)
)
return None
# The cover art is already cached.
return cover_art_result.result()
def on_song_details_future_done(idx: int, order_token: int, song_details: Song):
if order_token != self.play_queue_update_order_token:
return
self.play_queue_store[idx][2] = calculate_label(song_details)
# Cover Art
filename = get_cover_art_filename_or_create_future(
song_details.cover_art, idx, order_token
)
if filename:
self.play_queue_store[idx][1] = filename
current_play_queue = [x[-1] for x in self.play_queue_store]
if app_config.state.play_queue != current_play_queue:
self.play_queue_update_order_token += 1
song_details_results = []
for i, (song_id, cached_status) in enumerate(
zip(
app_config.state.play_queue,
AdapterManager.get_cached_statuses(app_config.state.play_queue),
)
):
song_details_result = AdapterManager.get_song_details(song_id)
cover_art_filename = ""
label = "\n"
if song_details_result.data_is_available:
# We have the details of the song already cached.
song_details = song_details_result.result()
label = calculate_label(song_details)
filename = get_cover_art_filename_or_create_future(
song_details.cover_art, i, self.play_queue_update_order_token
)
if filename:
cover_art_filename = filename
else:
song_details_results.append((i, song_details_result))
new_store.append(
[
(
not self.offline_mode
or cached_status
in (SongCacheStatus.CACHED, SongCacheStatus.PERMANENTLY_CACHED)
),
cover_art_filename,
label,
i == app_config.state.current_song_index,
song_id,
]
)
util.diff_song_store(self.play_queue_store, new_store)
# Do this after the diff to avoid race conditions.
for idx, song_details_result in song_details_results:
song_details_result.add_done_callback(
make_idle_index_capturing_function(
idx,
self.play_queue_update_order_token,
on_song_details_future_done,
)
)
self.editing_play_queue_song_list = False
@util.async_callback(
partial(AdapterManager.get_cover_art_uri, scheme="file"),
before_download=lambda self: self.album_art.set_loading(True),
on_failure=lambda self, e: self.album_art.set_loading(False),
)
def update_cover_art(
self,
cover_art_filename: str,
app_config: AppConfiguration,
force: bool = False,
order_token: int = None,
is_partial: bool = False,
):
if order_token != self.cover_art_update_order_token:
return
self.album_art.set_from_file(cover_art_filename)
self.album_art.set_loading(False)
def update_scrubber(
self,
current: Optional[timedelta],
duration: Optional[timedelta],
song_stream_cache_progress: Optional[timedelta],
):
if current is None or duration is None:
self.song_duration_label.set_text("-:--")
self.song_progress_label.set_text("-:--")
self.song_scrubber.set_value(0)
return
percent_complete = current / duration * 100
if not self.editing:
self.song_scrubber.set_value(percent_complete)
self.song_scrubber.set_show_fill_level(song_stream_cache_progress is not None)
if song_stream_cache_progress is not None:
percent_cached = song_stream_cache_progress / duration * 100
self.song_scrubber.set_fill_level(percent_cached)
self.song_duration_label.set_text(util.format_song_duration(duration))
self.song_progress_label.set_text(
util.format_song_duration(math.floor(current.total_seconds()))
)
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, _: Any):
if self.play_queue_popover.is_visible():
self.play_queue_popover.popdown()
else:
# TODO (#88): scroll the currently playing song into view.
self.play_queue_popover.popup()
self.play_queue_popover.show_all()
# Hide the load play queue button if the adapter can't do that.
if not AdapterManager.can_get_play_queue():
self.load_play_queue_button.hide()
def on_song_activated(self, t: Any, idx: Gtk.TreePath, c: Any):
if not self.play_queue_store[idx[0]][0]:
return
# The song ID is in the last column of the model.
self.emit(
"song-clicked",
idx.get_indices()[0],
[m[-1] for m in self.play_queue_store],
{"no_reshuffle": True},
)
_current_player_id = None
_current_available_players: Dict[type, Set[Tuple[str, str]]] = {}
def update_device_list(self, app_config: AppConfiguration):
if (
self._current_available_players == app_config.state.available_players
and self._current_player_id == app_config.state.current_device
):
return
self._current_player_id = app_config.state.current_device
self._current_available_players = copy.deepcopy(
app_config.state.available_players
)
for c in self.device_list.get_children():
self.device_list.remove(c)
for i, (player_type, players) in enumerate(
app_config.state.available_players.items()
):
if len(players) == 0:
continue
if i > 0:
self.device_list.add(
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
)
self.device_list.add(
Gtk.Label(
label=f"{player_type.name} Devices",
halign=Gtk.Align.START,
name="device-type-section-title",
)
)
for player_id, player_name in sorted(players, key=lambda p: p[1]):
icon = (
"audio-volume-high-symbolic"
if player_id == self.current_device
else None
)
button = IconButton(icon, label=player_name)
button.get_style_context().add_class("menu-button")
button.connect(
"clicked",
lambda _, player_id: self.emit("device-update", player_id),
player_id,
)
self.device_list.add(button)
self.device_list.show_all()
def on_device_click(self, _: Any):
if self.device_popover.is_visible():
self.device_popover.popdown()
else:
self.device_popover.popup()
self.device_popover.show_all()
def on_play_queue_button_press(self, tree: Any, event: Gdk.EventButton) -> bool:
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: str):
# Refresh the entire window (no force) because the song could
# be in a list anywhere in the window.
self.emit("refresh-window", {}, False)
# 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.play_queue_store[p][-1] for p in paths]
remove_text = (
"Remove " + util.pluralize("song", len(song_ids)) + " from queue"
)
def on_remove_songs_click(_: Any):
self.emit("songs-removed", [p.get_indices()[0] for p in paths])
util.show_song_popover(
song_ids,
event.x,
event.y,
tree,
self.offline_mode,
on_download_state_change=on_download_state_change,
extra_menu_items=[
(Gtk.ModelButton(text=remove_text), on_remove_songs_click),
],
)
# If the click was on a selected row, don't deselect anything.
if not allow_deselect:
return True
return False
def on_play_queue_model_row_move(self, *args):
# If we are programatically editing the song list, don't do anything.
if self.editing_play_queue_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_play_queue_song_list flag.
if self.reordering_play_queue_song_list:
currently_playing_index = [
i for i, s in enumerate(self.play_queue_store) if s[3] # playing
][0]
self.emit(
"refresh-window",
{
"current_song_index": currently_playing_index,
"play_queue": tuple(s[-1] for s in self.play_queue_store),
},
False,
)
self.reordering_play_queue_song_list = False
else:
self.reordering_play_queue_song_list = True
def create_song_display(self) -> Gtk.Box:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.album_art = SpinnerImage(
image_name="player-controls-album-artwork",
image_size=70,
)
box.pack_start(self.album_art, False, False, 0)
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
details_box.pack_start(Gtk.Box(), True, True, 0)
def make_label(name: str) -> Gtk.Label:
return Gtk.Label(
name=name,
halign=Gtk.Align.START,
xalign=0,
use_markup=True,
ellipsize=Pango.EllipsizeMode.END,
)
self.song_title = make_label("song-title")
details_box.add(self.song_title)
self.album_name = make_label("album-name")
details_box.add(self.album_name)
self.artist_name = make_label("artist-name")
details_box.add(self.artist_name)
details_box.pack_start(Gtk.Box(), True, True, 0)
box.pack_start(details_box, False, False, 5)
return box
def create_playback_controls(self) -> Gtk.Box:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Scrubber and song progress/length labels
scrubber_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.song_progress_label = Gtk.Label(label="-:--")
scrubber_box.pack_start(self.song_progress_label, False, False, 5)
self.song_scrubber = Gtk.Scale.new_with_range(
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.set_restrict_to_fill_level(False)
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="-:--")
scrubber_box.pack_start(self.song_duration_label, False, False, 5)
box.add(scrubber_box)
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
buttons.pack_start(Gtk.Box(), True, True, 0)
# Repeat button
repeat_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.repeat_button = IconToggleButton(
"media-playlist-repeat", "Switch between repeat modes"
)
self.repeat_button.set_action_name("app.repeat-press")
repeat_button_box.pack_start(Gtk.Box(), True, True, 0)
repeat_button_box.pack_start(self.repeat_button, False, False, 0)
repeat_button_box.pack_start(Gtk.Box(), True, True, 0)
buttons.pack_start(repeat_button_box, False, False, 5)
# Previous button
self.prev_button = IconButton(
"media-skip-backward-symbolic",
"Go to previous song",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.prev_button.set_action_name("app.prev-track")
buttons.pack_start(self.prev_button, False, False, 5)
# Play button
self.play_button = IconButton(
"media-playback-start-symbolic",
"Play",
relief=True,
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.play_button.set_name("play-button")
self.play_button.set_action_name("app.play-pause")
buttons.pack_start(self.play_button, False, False, 0)
# Next button
self.next_button = IconButton(
"media-skip-forward-symbolic",
"Go to next song",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.next_button.set_action_name("app.next-track")
buttons.pack_start(self.next_button, False, False, 5)
# Shuffle button
shuffle_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.shuffle_button = IconToggleButton(
"media-playlist-shuffle-symbolic", "Toggle playlist shuffling"
)
self.shuffle_button.set_action_name("app.shuffle-press")
shuffle_button_box.pack_start(Gtk.Box(), True, True, 0)
shuffle_button_box.pack_start(self.shuffle_button, False, False, 0)
shuffle_button_box.pack_start(Gtk.Box(), True, True, 0)
buttons.pack_start(shuffle_button_box, False, False, 5)
buttons.pack_start(Gtk.Box(), True, True, 0)
box.add(buttons)
return box
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)
# Device button (for chromecast)
self.device_button = IconButton(
"chromecast-symbolic",
"Show available audio output devices",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.device_button.connect("clicked", self.on_device_click)
box.pack_start(self.device_button, False, True, 5)
self.device_popover = Gtk.PopoverMenu(modal=False, name="device-popover")
self.device_popover.set_relative_to(self.device_button)
device_popover_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
name="device-popover-box",
)
device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.popover_label = Gtk.Label(
label="<b>Devices</b>",
use_markup=True,
halign=Gtk.Align.START,
margin=5,
)
device_popover_header.add(self.popover_label)
refresh_devices = IconButton("view-refresh-symbolic", "Refresh device list")
refresh_devices.set_action_name("app.refresh-devices")
device_popover_header.pack_end(refresh_devices, False, False, 0)
device_popover_box.add(device_popover_header)
device_list_and_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
device_list_and_loading.add(self.device_list)
device_popover_box.pack_end(device_list_and_loading, True, True, 0)
self.device_popover.add(device_popover_box)
# Play Queue button
self.play_queue_button = IconButton(
"view-list-symbolic",
"Open play queue",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.play_queue_button.connect("clicked", self.on_play_queue_click)
box.pack_start(self.play_queue_button, False, True, 5)
self.play_queue_popover = Gtk.PopoverMenu(modal=False, name="up-next-popover")
self.play_queue_popover.set_relative_to(self.play_queue_button)
play_queue_popover_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
play_queue_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.popover_label = Gtk.Label(
label="<b>Play Queue</b>",
use_markup=True,
halign=Gtk.Align.START,
margin=10,
)
play_queue_popover_header.add(self.popover_label)
self.load_play_queue_button = IconButton(
"folder-download-symbolic", "Load Queue from Server", margin=5
)
self.load_play_queue_button.set_action_name("app.update-play-queue-from-server")
play_queue_popover_header.pack_end(self.load_play_queue_button, False, False, 0)
play_queue_popover_box.add(play_queue_popover_header)
play_queue_loading_overlay = Gtk.Overlay()
play_queue_scrollbox = Gtk.ScrolledWindow(
min_content_height=600,
min_content_width=400,
)
self.play_queue_store = Gtk.ListStore(
bool, # playable
str, # image filename
str, # title, album, artist
bool, # playing
str, # song ID
)
self.play_queue_list = Gtk.TreeView(
model=self.play_queue_store,
reorderable=True,
headers_visible=False,
)
selection = self.play_queue_list.get_selection()
selection.set_mode(Gtk.SelectionMode.MULTIPLE)
selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
# Album Art column. This function defines what image to use for the play queue
# song icon.
def filename_to_pixbuf(
column: Any,
cell: Gtk.CellRendererPixbuf,
model: Gtk.ListStore,
tree_iter: Gtk.TreeIter,
flags: Any,
):
cell.set_property("sensitive", model.get_value(tree_iter, 0))
filename = model.get_value(tree_iter, 1)
if not filename:
cell.set_property("icon_name", "")
return
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename, 50, 50, True)
# If this is the playing song, then overlay the play icon.
if model.get_value(tree_iter, 3):
play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file(
str(resolve_path("ui/images/play-queue-play.png"))
)
play_overlay_pixbuf.composite(
pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 200
)
cell.set_property("pixbuf", pixbuf)
renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(55, 60)
column = Gtk.TreeViewColumn("", renderer)
column.set_cell_data_func(renderer, filename_to_pixbuf)
column.set_resizable(True)
self.play_queue_list.append_column(column)
renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END)
column = Gtk.TreeViewColumn("", renderer, markup=2, sensitive=0)
self.play_queue_list.append_column(column)
self.play_queue_list.connect("row-activated", self.on_song_activated)
self.play_queue_list.connect(
"button-press-event", self.on_play_queue_button_press
)
# Set up drag-and-drop on the song list for editing the order of the
# playlist.
self.play_queue_store.connect("row-inserted", self.on_play_queue_model_row_move)
self.play_queue_store.connect("row-deleted", self.on_play_queue_model_row_move)
play_queue_scrollbox.add(self.play_queue_list)
play_queue_loading_overlay.add(play_queue_scrollbox)
self.play_queue_spinner = Gtk.Spinner(
name="play-queue-spinner",
active=False,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
play_queue_loading_overlay.add_overlay(self.play_queue_spinner)
play_queue_popover_box.pack_end(play_queue_loading_overlay, True, True, 0)
self.play_queue_popover.add(play_queue_popover_box)
# Volume mute toggle
self.volume_mute_toggle = IconButton(
"audio-volume-high-symbolic", "Toggle mute"
)
self.volume_mute_toggle.set_action_name("app.mute-toggle")
box.pack_start(self.volume_mute_toggle, False, True, 0)
# Volume slider
self.volume_slider = Gtk.Scale.new_with_range(
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5
)
self.volume_slider.set_name("volume-slider")
self.volume_slider.set_draw_value(False)
self.volume_slider.connect("value-changed", self.on_volume_change)
box.pack_start(self.volume_slider, True, True, 0)
vbox.pack_start(box, False, True, 0)
vbox.pack_start(Gtk.Box(), True, True, 0)
return vbox

View File

@@ -0,0 +1,3 @@
from .desktop import Desktop
from .mobile import MobileHandle, MobileFlap
from .manager import Manager

View File

@@ -0,0 +1,191 @@
from typing import List, Any
from .. import util
from ..common import IconButton, IconToggleButton, SpinnerImage, SpinnerPicture
from .manager import Manager
from ...util import resolve_path
from ..actions import run_action
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango, Handy
def create_play_button(manager: Manager, large=True, **kwargs):
button = IconButton(
"media-playback-start-symbolic",
"Play",
relief=large,
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
**kwargs,
)
if large:
button.get_style_context().add_class("play-button-large")
button.set_action_name("app.play-pause")
manager.bind_property("has-song", button, "sensitive")
manager.bind_property("play-button-icon", button, "icon-name")
manager.bind_property("play-button-tooltip", button, "tooltip-text")
return button
def create_repeat_button(manager: Manager, **kwargs):
button = IconToggleButton("media-playlist-repeat", "Switch between repeat modes", **kwargs)
button.set_action_name("app.repeat")
manager.bind_property("repeat-button-icon", button, "icon-name")
def on_active_change(*_):
if manager.repeat_button_active != button.get_active():
# Don't run the action, just update visual state
button.set_action_name(None)
button.set_active(manager.repeat_button_active)
button.set_action_name("app.repeat")
manager.connect("notify::repeat-button-active", on_active_change)
return button
def create_shuffle_button(manager: Manager, **kwargs):
button = IconToggleButton("media-playlist-shuffle-symbolic", "Toggle playlist shuffling", **kwargs)
button.set_action_name("app.shuffle")
def on_active_change(*_):
if manager.shuffle_button_active != button.get_active():
# Don't run the action, just update visual state
button.set_action_name(None)
button.set_active(manager.shuffle_button_active)
button.set_action_name("app.shuffle")
manager.connect("notify::shuffle-button-active", on_active_change)
return button
def create_next_button(manager: Manager, **kwargs):
button = IconButton(
"media-skip-forward-symbolic",
"Go to next song",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
**kwargs,
)
button.set_action_name("app.next-track")
manager.bind_property("has-song", button, "sensitive")
return button
def create_prev_button(manager: Manager, **kwargs):
button = IconButton(
"media-skip-backward-symbolic",
"Go to previous song",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
**kwargs,
)
button.set_action_name("app.prev-track")
manager.bind_property("has-song", button, "sensitive")
return button
def create_label(manager: Manager, property: str, **kwargs):
label = Gtk.Label(**kwargs)
manager.bind_property(property, label, "label")
return label
def show_play_queue_popover(manager: Manager, relative_to, paths: List[str], pos_x: int, pos_y: int):
song_ids = [manager.play_queue_store[p][-1] for p in paths]
remove_text = (
"Remove " + util.pluralize("song", len(song_ids)) + " from queue"
)
def on_download_state_change(song_id: str):
# Refresh the entire window (no force) because the song could
# be in a list anywhere in the window.
manager.emit("refresh-window", {}, False)
def on_remove_songs_click(_: Any):
manager.emit("songs-removed", [p.get_indices()[0] for p in paths])
util.show_song_popover(
song_ids,
pos_x,
pos_y,
relative_to,
manager.offline_mode,
on_download_state_change=on_download_state_change,
extra_menu_items=[
(Gtk.ModelButton(text=remove_text), on_remove_songs_click),
],
)
# Album Art column. This function defines what image to use for the play queue
# song icon.
def filename_to_pixbuf(manager: Manager):
def f2p(
column: Any,
cell: Gtk.CellRendererPixbuf,
model: Gtk.ListStore,
tree_iter: Gtk.TreeIter,
flags: Any,
):
cell.set_property("sensitive", model.get_value(tree_iter, 0))
filename = model.get_value(tree_iter, 1)
playing = model.get_value(tree_iter, 3)
pixbuf = manager.get_play_queue_cover_art(filename, playing)
if not pixbuf:
cell.set_property("icon_name", "")
else:
cell.set_property("pixbuf", pixbuf)
return f2p
def create_play_queue_list(manager: Manager):
view = Gtk.TreeView(
model=manager.play_queue_store,
reorderable=True,
headers_visible=False,
)
view.get_style_context().add_class("background")
renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(55, 60)
column = Gtk.TreeViewColumn("", renderer)
column.set_cell_data_func(renderer, filename_to_pixbuf(manager))
column.set_resizable(True)
view.append_column(column)
renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END)
column = Gtk.TreeViewColumn("", renderer, markup=2, sensitive=0)
column.set_expand(True)
view.append_column(column)
renderer = Gtk.CellRendererPixbuf(icon_name="view-more-symbolic")
renderer.set_fixed_size(45, 60)
view_more_column = Gtk.TreeViewColumn("", renderer)
view_more_column.set_resizable(True)
view.append_column(view_more_column)
renderer = Gtk.CellRendererPixbuf(icon_name="window-close-symbolic")
renderer.set_fixed_size(45, 60)
close_column = Gtk.TreeViewColumn("", renderer)
close_column.set_resizable(True)
view.append_column(close_column)
def on_play_queue_button_press(tree: Any, event: Gdk.EventButton) -> bool:
if event.button == 1:
path, column, cell_x, cell_y = tree.get_path_at_pos(event.x, event.y)
song_idx = path.get_indices()[0]
tree_width = tree.get_allocation().width
if column == close_column:
# manager.emit("songs-removed", [song_idx])
pass
elif column == view_more_column:
area = tree.get_cell_area(path, view_more_column)
x = area.x + area.width / 2
y = area.y + area.height / 2
show_play_queue_popover(manager, view, [path], x, y)
else:
run_action(view, 'app.play-song', song_idx, [], {"no_reshuffle": None})
return True
return False
view.connect("button-press-event", on_play_queue_button_press)
return view

View File

@@ -0,0 +1,402 @@
import copy
import math
from datetime import timedelta
from functools import partial
from typing import Any, Callable, Dict, Optional, Set, Tuple
import bleach
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango
from .. import util
from ..common import IconButton, IconToggleButton, SpinnerImage
from ..state import RepeatType
from ...adapters import AdapterManager, Result, SongCacheStatus
from ...adapters.api_objects import Song
from ...config import AppConfiguration
from ...util import resolve_path
from . import common
class Desktop(Gtk.Box):
"""
Defines the player controls panel that appears at the bottom of the window
on the desktop view.
"""
editing: bool = False
# editing_play_queue_song_list: bool = False
reordering_play_queue_song_list: bool = False
current_song = None
current_device = None
current_playing_index: Optional[int] = None
current_play_queue: Tuple[str, ...] = ()
# play_queue_update_order_token = 0
offline_mode = False
def __init__(self, state):
super().__init__(orientation=Gtk.Orientation.HORIZONTAL)
self.set_name("player-controls-bar")
self.state = state
self.state.add_control(self)
song_display = self.create_song_display()
playback_controls = self.create_playback_controls()
play_queue_volume = self.create_play_queue_volume()
self.pack_start(song_display, False, False, 0)
self.set_center_widget(playback_controls)
self.child_set_property(playback_controls, "expand", True)
self.pack_end(play_queue_volume, False, False, 0)
connecting_to_device_token = 0
connecting_icon_index = 0
def update(self, app_config: AppConfiguration, force: bool = False):
self.current_device = app_config.state.current_device
has_current_song = app_config.state.current_song is not None
has_next_song = False
if app_config.state.repeat_type in (
RepeatType.REPEAT_QUEUE,
RepeatType.REPEAT_SONG,
):
has_next_song = True
elif has_current_song:
last_idx_in_queue = len(app_config.state.play_queue) - 1
has_next_song = app_config.state.current_song_index < last_idx_in_queue
# Toggle button states.
self.song_scrubber.set_sensitive(has_current_song)
self.connecting_to_device = app_config.state.connecting_to_device
def cycle_connecting(connecting_to_device_token: int):
if (
self.connecting_to_device_token != connecting_to_device_token
or not self.connecting_to_device
):
return
icon = f"chromecast-connecting-{self.connecting_icon_index}-symbolic"
self.device_button.set_icon(icon)
self.connecting_icon_index = (self.connecting_icon_index + 1) % 3
GLib.timeout_add(350, cycle_connecting, connecting_to_device_token)
icon = ""
if app_config.state.connecting_to_device:
icon = "-connecting-0"
self.connecting_icon_index = 0
self.connecting_to_device_token += 1
GLib.timeout_add(350, cycle_connecting, self.connecting_to_device_token)
elif app_config.state.current_device != "this device":
icon = "-connected"
self.device_button.set_icon(f"chromecast{icon}-symbolic")
self.play_queue_button.set_sensitive(len(app_config.state.play_queue) > 0)
# Volume button and slider
if app_config.state.is_muted:
icon_name = "muted"
elif app_config.state.volume < 30:
icon_name = "low"
elif app_config.state.volume < 70:
icon_name = "medium"
else:
icon_name = "high"
self.volume_mute_toggle.set_icon(f"audio-volume-{icon_name}-symbolic")
self.volume_slider.set_value(
0 if app_config.state.is_muted else app_config.state.volume
)
self.volume_slider.set_sensitive(not app_config.state.is_muted)
# Update the current song information.
# TODO (#126): add popup of bigger cover art photo here
if app_config.state.current_song is not None:
self.song_title.set_markup(bleach.clean(app_config.state.current_song.title))
# TODO (#71): use walrus once MYPY gets its act together
album = app_config.state.current_song.album
artist = app_config.state.current_song.artist
if album:
self.album_name.set_markup(bleach.clean(album.name))
self.artist_name.show()
else:
self.album_name.set_markup("")
self.album_name.hide()
if artist:
self.artist_name.set_markup(bleach.clean(artist.name))
self.artist_name.show()
else:
self.artist_name.set_markup("")
self.artist_name.hide()
else:
# Clear out the cover art and song tite if no song
self.song_title.set_markup("")
self.album_name.set_markup("")
self.artist_name.set_markup("")
self.load_play_queue_button.set_sensitive(not self.offline_mode)
if app_config.state.loading_play_queue:
self.play_queue_spinner.start()
self.play_queue_spinner.show()
else:
self.play_queue_spinner.stop()
self.play_queue_spinner.hide()
# Short circuit if no changes to the play queue
force |= self.offline_mode != app_config.offline_mode
self.offline_mode = app_config.offline_mode
if not force and (
self.current_play_queue == app_config.state.play_queue
and self.current_playing_index == app_config.state.current_song_index
):
return
self.current_play_queue = app_config.state.play_queue
self.current_playing_index = app_config.state.current_song_index
# Set the Play Queue button popup.
play_queue_len = len(app_config.state.play_queue)
if play_queue_len == 0:
self.popover_label.set_markup("<b>Play Queue</b>")
else:
song_label = util.pluralize("song", play_queue_len)
self.popover_label.set_markup(
f"<b>Play Queue:</b> {play_queue_len} {song_label}"
)
# TODO (#207) this is super freaking stupid inefficient.
# IDEAS: batch it, don't get the queue until requested
# self.editing_play_queue_song_list = True
# self.editing_play_queue_song_list = False
def set_cover_art(self, cover_art_filename: str, loading: bool):
self.album_art.set_from_file(cover_art_filename)
self.album_art.set_loading(loading)
def on_play_queue_open(self, *_):
if not self.get_child_visible():
self.play_queue_popover.popdown()
return
if not self.state.play_queue_open:
self.play_queue_popover.popdown()
else:
# TODO (#88): scroll the currently playing song into view.
self.play_queue_popover.popup()
self.play_queue_popover.show_all()
# Hide the load play queue button if the adapter can't do that.
if not AdapterManager.can_get_play_queue():
self.load_play_queue_button.hide()
def on_song_activated(self, t: Any, idx: Gtk.TreePath, c: Any):
if not self.play_queue_store[idx[0]][0]:
return
self.get_action_group('app').activate_action(
'play-song',
idx.get_indices()[0],
None,
{"no_reshuffle": True})
def on_play_queue_button_press(self, tree: Any, event: Gdk.EventButton) -> bool:
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
# 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
common.show_play_queue_popover(self.state, tree, paths, event.x, event.y)
# If the click was on a selected row, don't deselect anything.
if not allow_deselect:
return True
return False
def create_song_display(self) -> Gtk.Box:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.album_art = SpinnerImage(
image_name="player-controls-album-artwork",
image_size=70,
)
box.pack_start(self.album_art, False, False, 0)
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
details_box.pack_start(Gtk.Box(), True, True, 0)
def make_label(name: str) -> Gtk.Label:
return Gtk.Label(
name=name,
halign=Gtk.Align.START,
xalign=0,
use_markup=True,
ellipsize=Pango.EllipsizeMode.END,
)
self.song_title = make_label("song-title")
details_box.add(self.song_title)
self.album_name = make_label("album-name")
details_box.add(self.album_name)
self.artist_name = make_label("artist-name")
details_box.add(self.artist_name)
details_box.pack_start(Gtk.Box(), True, True, 0)
box.pack_start(details_box, False, False, 5)
return box
def create_playback_controls(self) -> Gtk.Box:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Scrubber and song progress/length labels
scrubber_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
scrubber_box.pack_start(common.create_label(self.state, "progress-label"), False, False, 5)
self.song_scrubber = Gtk.Scale(
orientation=Gtk.Orientation.HORIZONTAL,
adjustment=self.state.scrubber,
hexpand=True,
draw_value=False,
restrict_to_fill_level=False,
show_fill_level=True)
self.song_scrubber.set_name("song-scrubber")
self.state.bind_property("scrubber-cache", self.song_scrubber, "fill-level")
scrubber_box.pack_start(self.song_scrubber, True, True, 0)
scrubber_box.pack_start(common.create_label(self.state, "duration-label"), False, False, 5)
box.add(scrubber_box)
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
buttons.pack_start(Gtk.Box(), True, True, 0)
# Repeat button
repeat_button = common.create_repeat_button(self.state, valign=Gtk.Align.CENTER)
buttons.pack_start(repeat_button, False, False, 5)
# Previous button
prev_button = common.create_prev_button(self.state, valign=Gtk.Align.CENTER)
buttons.pack_start(prev_button, False, False, 5)
buttons.pack_start(common.create_play_button(self.state), False, False, 0)
# Next button
next_button = common.create_next_button(self.state, valign=Gtk.Align.CENTER)
buttons.pack_start(next_button, False, False, 5)
# Shuffle button
shuffle_button = common.create_shuffle_button(self.state, valign=Gtk.Align.CENTER)
buttons.pack_start(shuffle_button, False, False, 5)
buttons.pack_start(Gtk.Box(), True, True, 0)
box.add(buttons)
return box
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)
# Device button (for chromecast)
self.device_button = IconButton(
"chromecast-symbolic",
"Show available audio output devices",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.device_button.connect("clicked", self.state.popup_devices)
box.pack_start(self.device_button, False, True, 5)
# Play Queue button
self.play_queue_button = IconToggleButton(
"view-list-symbolic",
"Open play queue",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
self.state.bind_property("play-queue-open", self.play_queue_button, "active", GObject.BindingFlags.BIDIRECTIONAL)
self.state.connect("notify::play-queue-open", self.on_play_queue_open)
box.pack_start(self.play_queue_button, False, True, 5)
self.play_queue_popover = Gtk.PopoverMenu(modal=False, name="up-next-popover", constrain_to=Gtk.PopoverConstraint.WINDOW)
self.play_queue_popover.set_relative_to(self.play_queue_button)
play_queue_popover_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
play_queue_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.popover_label = Gtk.Label(
label="<b>Play Queue</b>",
use_markup=True,
halign=Gtk.Align.START,
margin=10,
)
play_queue_popover_header.add(self.popover_label)
self.load_play_queue_button = IconButton(
"folder-download-symbolic", "Load Queue from Server", margin=5
)
self.load_play_queue_button.set_action_name("app.update-play-queue-from-server")
play_queue_popover_header.pack_end(self.load_play_queue_button, False, False, 0)
play_queue_popover_box.add(play_queue_popover_header)
play_queue_loading_overlay = Gtk.Overlay()
play_queue_scrollbox = Gtk.ScrolledWindow(
# min_content_height=600,
min_content_width=400,
propagate_natural_height=True,
)
self.play_queue_list = common.create_play_queue_list(self.state)
self.play_queue_list.connect("row-activated", self.on_song_activated)
self.play_queue_list.connect(
"button-press-event", self.on_play_queue_button_press
)
play_queue_scrollbox.add(self.play_queue_list)
play_queue_loading_overlay.add(play_queue_scrollbox)
self.play_queue_spinner = Gtk.Spinner(
name="play-queue-spinner",
active=False,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
play_queue_loading_overlay.add_overlay(self.play_queue_spinner)
play_queue_popover_box.pack_end(play_queue_loading_overlay, True, True, 0)
self.play_queue_popover.add(play_queue_popover_box)
# Volume mute toggle
self.volume_mute_toggle = IconButton(
"audio-volume-high-symbolic", "Toggle mute"
)
self.volume_mute_toggle.set_action_name("app.toggle-mute")
box.pack_start(self.volume_mute_toggle, False, True, 0)
# Volume slider
self.volume_slider = Gtk.Scale.new(
orientation=Gtk.Orientation.HORIZONTAL, adjustment=self.state.volume)
self.volume_slider.set_name("volume-slider")
self.volume_slider.set_draw_value(False)
box.pack_start(self.volume_slider, True, True, 0)
vbox.pack_start(box, False, True, 0)
vbox.pack_start(Gtk.Box(), True, True, 0)
return vbox

View File

@@ -0,0 +1,444 @@
import copy
from datetime import timedelta
from typing import Any, Optional, Callable, Dict, Set, Tuple
from functools import partial
import bleach
from gi.repository import GObject, Gtk, GLib, GdkPixbuf
from .. import util
from ...util import resolve_path
from ...adapters import AdapterManager, Result
from ...adapters.api_objects import Song
from ...config import AppConfiguration
from ..state import RepeatType
from ..actions import run_action
from ..common import IconButton, IconToggleButton, SpinnerImage, SpinnerPicture
class Manager(GObject.GObject):
"""
Common state for player controls.
"""
volume = GObject.Property(type=Gtk.Adjustment)
scrubber = GObject.Property(type=Gtk.Adjustment)
scrubber_cache = GObject.Property(type=int, default=0)
play_queue_open = GObject.Property(type=bool, default=False)
play_queue_store = GObject.Property(type=Gtk.ListStore)
flap_reveal_progress = GObject.Property(type=float, default=False)
offline_mode = GObject.Property(type=bool, default=False)
has_song = GObject.Property(type=bool, default=False)
play_button_icon = GObject.Property(type=str)
play_button_tooltip = GObject.Property(type=str)
repeat_button_icon = GObject.Property(type=str)
repeat_button_active = GObject.Property(type=bool, default=False)
shuffle_button_active = GObject.Property(type=bool, default=False)
duration_label = GObject.Property(type=str, default="-:--")
progress_label = GObject.Property(type=str, default="-:--")
updating_scrubber = False
cover_art_update_order_token = 0
play_queue_update_order_token = 0
current_song_index = None
_updating = False
_updating_song_progress = False
def __init__(self, widget):
super().__init__()
self.widget = widget
self.volume = Gtk.Adjustment.new(100, 0, 100, 1, 0, 0)
self.volume.connect('value-changed', self.on_volume_changed)
self.scrubber = Gtk.Adjustment()
self.scrubber.set_step_increment(1)
self.scrubber.connect("value-changed", self.on_scrubber_changed)
self.scrubber.connect("changed", self.update_duration_label)
self.play_queue_store = Gtk.ListStore(
bool, # playable
str, # image filename
str, # title, album, artist
bool, # playing
str, # song ID
)
self.play_queue_store_art_cache = {}
# Set up drag-and-drop on the song list for editing the order of the
# playlist.
self.play_queue_store.connect("row-inserted", self.on_play_queue_model_row_move)
self.play_queue_store.connect("row-deleted", self.on_play_queue_model_row_move)
self._controls = []
# Device popover
self.device_popover = Gtk.PopoverMenu(modal=True, name="device-popover")
device_popover_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
name="device-popover-box",
)
device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
popover_label = Gtk.Label(
label="<b>Devices</b>",
use_markup=True,
halign=Gtk.Align.START,
margin=5,
)
device_popover_header.add(popover_label)
refresh_devices = IconButton("view-refresh-symbolic", "Refresh device list")
refresh_devices.set_action_name("app.refresh-devices")
device_popover_header.pack_end(refresh_devices, False, False, 0)
device_popover_box.add(device_popover_header)
device_list_and_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
device_list_and_loading.add(self.device_list)
device_popover_box.pack_end(device_list_and_loading, True, True, 0)
self.device_popover.add(device_popover_box)
def add_control(self, control):
self._controls.append(control)
def update(self, app_config: AppConfiguration, force: bool = False):
self._updating = True
self.volume.set_value(app_config.state.volume)
self.has_song = app_config.state.current_song is not None
self.current_song_index = app_config.state.current_song_index
self.update_song_progress(
app_config.state.song_progress,
app_config.state.current_song and app_config.state.current_song.duration,
app_config.state.song_stream_cache_progress)
if self.has_song:
self.cover_art_update_order_token += 1
self.update_cover_art(
app_config.state.current_song.cover_art,
order_token=self.cover_art_update_order_token)
for control in self._controls:
control.update(app_config, force=force)
if not self.has_song:
self.set_cover_art(None, False)
self.update_player_queue(app_config)
self.update_device_list(app_config)
icon = "pause" if app_config.state.playing else "start"
self.play_button_icon = f"media-playback-{icon}-symbolic"
self.play_button_tooltip = "Pause" if app_config.state.playing else "Play"
repeat_on = app_config.state.repeat_type in (
RepeatType.REPEAT_QUEUE,
RepeatType.REPEAT_SONG,
)
self.repeat_button_icon = app_config.state.repeat_type.icon
self.repeat_button_active = repeat_on
self.shuffle_button_active = app_config.state.shuffle_on
self._updating = False
def update_song_progress(self,
progress: Optional[timedelta],
duration: Optional[timedelta],
cache_progess: Optional[timedelta]):
self._updating_song_progress = True
if progress is None or duration is None:
self.scrubber.set_value(0)
self.scrubber.set_upper(0)
self.scrubber_cache = 0
self._updating_song_progress = False
return
self.scrubber.set_value(progress.total_seconds())
self.scrubber.set_upper(duration.total_seconds())
if cache_progess is not None:
self.scrubber_cache = cache_progess.total_seconds()
else:
self.scrubber_cache = duration.total_seconds()
self._updating_song_progress = False
@util.async_callback(
partial(AdapterManager.get_cover_art_uri, scheme="file"),
before_download=lambda self: self.set_cover_art(None, True),
on_failure=lambda self, e: self.set_cover_art(None, False),
)
def update_cover_art(
self,
cover_art_filename: str,
app_config: AppConfiguration,
force: bool = False,
order_token: int = None,
is_partial: bool = False,
):
if order_token != self.cover_art_update_order_token:
return
self.set_cover_art(cover_art_filename, False)
def set_cover_art(self, cover_art_filename: Optional[str], loading: bool):
for control in self._controls:
control.set_cover_art(cover_art_filename, loading)
def get_play_queue_cover_art(self, filename: Optional[str], playing: bool):
if filename in self.play_queue_store_art_cache:
return self.play_queue_store_art_cache[filename]
if not filename:
return None
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(filename, 50, 50, True)
# If this is the playing song, then overlay the play icon.
if playing:
play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file(
str(resolve_path("ui/images/play-queue-play.png"))
)
play_overlay_pixbuf.composite(
pixbuf, 0, 0, 50, 50, 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 200
)
self.play_queue_store_art_cache[filename] = pixbuf
return pixbuf
def on_play_queue_model_row_move(self, *args):
self.play_queue_store_art_cache = {}
# TODO
return
# If we are programatically editing the song list, don't do anything.
if self.editing_play_queue_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_play_queue_song_list flag.
if self.reordering_play_queue_song_list:
currently_playing_index = [
i for i, s in enumerate(self.play_queue_store) if s[3] # playing
][0]
self.state.emit(
"refresh-window",
{
"current_song_index": currently_playing_index,
"play_queue": tuple(s[-1] for s in self.play_queue_store),
},
False,
)
self.reordering_play_queue_song_list = False
else:
self.reordering_play_queue_song_list = True
def update_player_queue(self, app_config: AppConfiguration):
new_store = []
def calculate_label(song_details: Song) -> str:
title = bleach.clean(song_details.title)
# TODO (#71): use walrus once MYPY works with this
# album = bleach.clean(album.name if (album := song_details.album) else None)
# artist = bleach.clean(artist.name if (artist := song_details.artist) else None) # noqa
album = bleach.clean(song_details.album.name if song_details.album else None)
artist = bleach.clean(song_details.artist.name if song_details.artist else None)
return f"<b>{title}</b>\n{util.dot_join(album, artist)}"
def make_idle_index_capturing_function(
idx: int,
order_tok: int,
fn: Callable[[int, int, Any], None],
) -> Callable[[Result], None]:
return lambda f: GLib.idle_add(fn, idx, order_tok, f.result())
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][1] = cover_art_filename
def get_cover_art_filename_or_create_future(
cover_art_id: Optional[str], idx: int, order_token: int
) -> Optional[str]:
cover_art_result = AdapterManager.get_cover_art_uri(cover_art_id, "file")
if not cover_art_result.data_is_available:
cover_art_result.add_done_callback(
make_idle_index_capturing_function(
idx, order_token, on_cover_art_future_done
)
)
return None
# The cover art is already cached.
return cover_art_result.result()
def on_song_details_future_done(idx: int, order_token: int, song_details: Song):
if order_token != self.play_queue_update_order_token:
return
self.play_queue_store[idx][2] = calculate_label(song_details)
# Cover Art
filename = get_cover_art_filename_or_create_future(
song_details.cover_art, idx, order_token
)
if filename:
self.play_queue_store[idx][1] = filename
current_play_queue = [x[-1] for x in self.play_queue_store]
if app_config.state.play_queue != current_play_queue:
self.play_queue_update_order_token += 1
song_details_results = []
for i, (song_id, cached_status) in enumerate(
zip(
app_config.state.play_queue,
AdapterManager.get_cached_statuses(app_config.state.play_queue),
)
):
song_details_result = AdapterManager.get_song_details(song_id)
cover_art_filename = ""
label = "\n"
if song_details_result.data_is_available:
# We have the details of the song already cached.
song_details = song_details_result.result()
label = calculate_label(song_details)
filename = get_cover_art_filename_or_create_future(
song_details.cover_art, i, self.play_queue_update_order_token
)
if filename:
cover_art_filename = filename
else:
song_details_results.append((i, song_details_result))
new_store.append(
[
(
not self.offline_mode
or cached_status
in (SongCacheStatus.CACHED, SongCacheStatus.PERMANENTLY_CACHED)
),
cover_art_filename,
label,
i == app_config.state.current_song_index,
song_id,
]
)
util.diff_song_store(self.play_queue_store, new_store)
# Do this after the diff to avoid race conditions.
for idx, song_details_result in song_details_results:
song_details_result.add_done_callback(
make_idle_index_capturing_function(
idx,
self.play_queue_update_order_token,
on_song_details_future_done,
)
)
def popup_devices(self, relative_to):
self.device_popover.set_relative_to(relative_to)
self.device_popover.popup()
self.device_popover.show_all()
_current_player_id = None
_current_available_players: Dict[type, Set[Tuple[str, str]]] = {}
def update_device_list(self, app_config: AppConfiguration):
if (
self._current_available_players == app_config.state.available_players
and self._current_player_id == app_config.state.current_device
):
return
self._current_player_id = app_config.state.current_device
self._current_available_players = copy.deepcopy(
app_config.state.available_players
)
for c in self.device_list.get_children():
self.device_list.remove(c)
for i, (player_type, players) in enumerate(
app_config.state.available_players.items()
):
if len(players) == 0:
continue
if i > 0:
self.device_list.add(
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
)
self.device_list.add(
Gtk.Label(
label=f"{player_type.name} Devices",
halign=Gtk.Align.START,
name="device-type-section-title",
)
)
for player_id, player_name in sorted(players, key=lambda p: p[1]):
icon = (
"audio-volume-high-symbolic"
if player_id == app_config.state.current_device
else None
)
button = IconButton(icon, label=player_name)
button.get_style_context().add_class("menu-button")
button.connect(
"clicked",
lambda _, player_id: run_action(button, "app.select-device", player_id),
player_id,
)
self.device_list.add(button)
self.device_list.show_all()
def on_volume_changed(self, _: Any):
if self._updating:
return
run_action(self.widget, 'app.set-volume', self.volume.get_value())
def on_scrubber_changed(self, _: Any):
if self.scrubber.get_upper() == 0:
self.progress_label = "-:--"
else:
self.progress_label = util.format_song_duration(
int(self.scrubber.get_value()))
if self._updating_song_progress:
return
run_action(self.widget, 'app.seek', self.scrubber.get_value())
def update_duration_label(self, _: Any):
upper = self.scrubber.get_upper()
if upper == 0:
self.duration_label = "-:--"
else:
self.duration_label = util.format_song_duration(int(upper))

View File

@@ -0,0 +1,289 @@
from typing import Any, Callable, Dict, Optional, Set, Tuple
from gi.repository import Gdk, GdkPixbuf, GLib, GObject, Gtk, Pango, Handy
import bleach
from .. import util
from ..common import IconButton, IconToggleButton, SpinnerImage, SpinnerPicture
from ...config import AppConfiguration
from ...util import resolve_path
from . import common
class MobileHandle(Gtk.ActionBar):
def __init__(self, state):
super().__init__()
self.get_style_context().add_class("background")
self.state = state
self.state.add_control(self)
self.pack_start(self.create_song_display())
buttons = Gtk.Overlay()
open_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.END)
self.play_queue_button = IconToggleButton(
"view-list-symbolic",
"Open play queue",
relief=True,
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
def play_queue_button_toggled(*_):
if self.play_queue_button.get_active() != self.state.play_queue_open:
self.state.play_queue_open = self.play_queue_button.get_active()
self.play_queue_button.connect("toggled", play_queue_button_toggled)
def on_play_queue_open(*_):
self.play_queue_button.set_active(self.state.play_queue_open)
self.state.connect("notify::play-queue-open", on_play_queue_open)
open_buttons.pack_start(self.play_queue_button, False, False, 5)
self.menu_button = IconButton(
"view-more-symbolic",
"Menu",
relief=True,
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
def on_menu(*_):
x = self.menu_button.get_allocated_width() / 2
y = self.menu_button.get_allocated_height()
util.show_song_popover(
[self.state.play_queue_store[self.state.current_song_index][-1]],
x, y,
self.menu_button,
self.state.offline_mode)
self.menu_button.connect('clicked', on_menu)
open_buttons.pack_start(self.menu_button, False, False, 5)
buttons.add(open_buttons)
close_buttons = Handy.Squeezer(halign=Gtk.Align.END, homogeneous=False)
expanded_close_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
expanded_close_buttons.pack_start(common.create_prev_button(self.state), False, False, 5)
expanded_close_buttons.pack_start(common.create_play_button(self.state, large=False), False, False, 5)
expanded_close_buttons.pack_start(common.create_next_button(self.state), False, False, 5)
close_buttons.add(expanded_close_buttons)
close_buttons.add(common.create_play_button(self.state, large=False, halign=Gtk.Align.END))
close_buttons.get_style_context().add_class("background")
buttons.add_overlay(close_buttons)
def flap_reveal(*_):
progress = 1 - self.state.flap_reveal_progress
close_buttons.set_opacity(progress)
close_buttons.set_visible(progress > 0.05)
if progress < 0.8:
self.state.play_queue_open = False
self.state.connect("notify::flap-reveal-progress", flap_reveal)
self.pack_end(buttons)
def create_song_display(self):
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, hexpand=True)
box.set_property('width-request', 200)
self.cover_art = SpinnerImage(
image_name="player-controls-album-artwork",
image_size=50,
)
box.pack_start(self.cover_art, False, False, 0)
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
def make_label(name: str) -> Gtk.Label:
return Gtk.Label(
name=name,
halign=Gtk.Align.START,
xalign=0,
use_markup=True,
ellipsize=Pango.EllipsizeMode.END,
)
self.song_title = make_label("song-title")
details_box.add(self.song_title)
self.artist_name = make_label("artist-name")
details_box.add(self.artist_name)
box.pack_start(details_box, False, False, 5)
return box
def update(self, app_config: AppConfiguration, force: bool = False):
empty_queue = len(app_config.state.play_queue) == 0
self.play_queue_button.set_sensitive(not empty_queue)
self.menu_button.set_sensitive(not empty_queue)
if empty_queue:
self.state.play_queue_open = False
if app_config.state.current_song is not None:
self.song_title.set_markup(bleach.clean(app_config.state.current_song.title))
# TODO (#71): use walrus once MYPY gets its act together
album = app_config.state.current_song.album
artist = app_config.state.current_song.artist
if artist:
self.artist_name.set_markup(bleach.clean(artist.name))
self.artist_name.show()
elif album:
self.artist_name.set_markup(bleach.clean(album.name))
self.artist_name.show()
else:
self.artist_name.set_markup("")
self.artist_name.hide()
else:
# Clear out the cover art and song tite if no song
self.song_title.set_markup("")
self.artist_name.set_markup("")
def set_cover_art(self, cover_art_filename: str, loading: bool):
self.cover_art.set_from_file(cover_art_filename)
self.cover_art.set_loading(loading)
class MobileFlap(Gtk.Stack):
def __init__(self, state):
super().__init__(
transition_type=Gtk.StackTransitionType.OVER_DOWN_UP, vexpand=True)
self.state = state
self.state.add_control(self)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, vexpand=True)
box.get_style_context().add_class("background")
box.pack_start(Gtk.Separator(), False, False, 0)
overlay = Gtk.Overlay(hexpand=True, vexpand=True, height_request=100)
self.cover_art = SpinnerPicture(image_name="player-controls-album-artwork")
overlay.add(self.cover_art)
overlay_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
overlay.add_overlay(overlay_box)
overlay_row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
overlay_box.pack_end(overlay_row_box, False, False, 0)
overlay_row_box.pack_start(common.create_label(self.state, "progress-label"), False, False, 5)
overlay_row_box.pack_end(common.create_label(self.state, "duration-label"), False, False, 5)
# Device
self.device_button = IconButton(
"chromecast-symbolic",
"Show available audio output devices",
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
)
def on_device_click(_: Any):
if self.device_popover.is_visible():
self.device_popover.popdown()
else:
self.device_popover.popup()
self.device_popover.show_all()
self.device_button.connect("clicked", self.state.popup_devices)
overlay_row_box.set_center_widget(self.device_button)
self.device_popover = Gtk.PopoverMenu(modal=False, name="device-popover")
self.device_popover.set_relative_to(self.device_button)
device_popover_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
name="device-popover-box",
)
device_popover_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.popover_label = Gtk.Label(
label="<b>Devices</b>",
use_markup=True,
halign=Gtk.Align.START,
margin=5,
)
device_popover_header.add(self.popover_label)
refresh_devices = IconButton("view-refresh-symbolic", "Refresh device list")
refresh_devices.set_action_name("app.refresh-devices")
device_popover_header.pack_end(refresh_devices, False, False, 0)
device_popover_box.add(device_popover_header)
device_list_and_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.device_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
device_list_and_loading.add(self.device_list)
device_popover_box.pack_end(device_list_and_loading, True, True, 0)
self.device_popover.add(device_popover_box)
box.pack_start(overlay, True, True, 0)
# Song Scrubber
scrubber = Gtk.Scale(
orientation=Gtk.Orientation.HORIZONTAL,
adjustment=self.state.scrubber,
name="song-scrubber",
draw_value=False,
restrict_to_fill_level=False,
show_fill_level=True)
self.state.bind_property("scrubber-cache", scrubber, "fill-level")
box.pack_start(scrubber, False, False, 0)
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
# Repeat button
repeat_button = common.create_repeat_button(self.state, valign=Gtk.Align.CENTER, margin_left=10)
buttons.pack_start(repeat_button, False, False, 0)
center_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
# Previous button
center_buttons.add(common.create_prev_button(self.state, valign=Gtk.Align.CENTER))
# Play button
center_buttons.add(common.create_play_button(self.state))
# Next button
center_buttons.add(common.create_next_button(self.state, valign=Gtk.Align.CENTER))
buttons.set_center_widget(center_buttons)
# Shuffle button
shuffle_button = common.create_shuffle_button(self.state, valign=Gtk.Align.CENTER, margin_right=10)
buttons.pack_end(shuffle_button, False, False, 0)
box.pack_start(buttons, False, False, 0)
box.pack_start(Gtk.Box(), False, False, 5)
self.add(box)
play_queue_scrollbox = Gtk.ScrolledWindow(vexpand=True)
play_queue_scrollbox.add(common.create_play_queue_list(self.state))
self.add(play_queue_scrollbox)
def on_play_queue_open(*_):
if self.state.play_queue_open:
self.set_visible_child(play_queue_scrollbox)
else:
self.set_visible_child(box)
on_play_queue_open()
self.state.connect("notify::play-queue-open", on_play_queue_open)
def update(self, app_config: AppConfiguration, force: bool = False):
pass
def set_cover_art(self, cover_art_filename: str, loading: bool):
self.cover_art.set_from_file(cover_art_filename)
self.cover_art.set_loading(loading)

View File

@@ -4,114 +4,161 @@ 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
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango, Handy
from ..adapters import AdapterManager, api_objects as API
from ..config import AppConfiguration
from ..ui import util
from ..ui.common import (
from . import util
from .common import (
IconButton,
LoadError,
SongListColumn,
SpinnerImage,
Sizer,
)
from .actions import run_action
class EditPlaylistDialog(Gtk.Dialog):
def __init__(self, parent: Any, playlist: API.Playlist):
Gtk.Dialog.__init__(self, transient_for=parent, flags=Gtk.DialogFlags.MODAL)
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 = Gtk.HeaderBar()
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.pack_start(cancel_button)
self.header_bar.pack_start(cancel_button)
self.edit_button = Gtk.Button(label="Edit")
self.edit_button.get_style_context().add_class("suggested-action")
self.edit_button.connect(
"clicked", lambda *a: self.response(Gtk.ResponseType.APPLY)
)
self.header.pack_end(self.edit_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)
self.set_titlebar(self.header)
box.add(self.header_bar)
content_area = self.get_content_area()
content_grid = Gtk.Grid(column_spacing=10, row_spacing=10, margin=10)
clamp = Handy.Clamp(margin=12)
make_label = lambda label_text: Gtk.Label(label_text, halign=Gtk.Align.END)
inner_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
content_grid.attach(make_label("Playlist Name"), 0, 0, 1, 1)
self.name_entry = Gtk.Entry(text=playlist.name, hexpand=True)
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)
content_grid.attach(self.name_entry, 1, 0, 1, 1)
row.add(self.name_entry)
list_box.add(row)
content_grid.attach(make_label("Comment"), 0, 1, 1, 1)
self.comment_entry = Gtk.Entry(text=playlist.comment, hexpand=True)
content_grid.attach(self.comment_entry, 1, 1, 1, 1)
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)
content_grid.attach(make_label("Public"), 0, 2, 1, 1)
self.public_switch = Gtk.Switch(active=playlist.public, halign=Gtk.Align.START)
content_grid.attach(self.public_switch, 1, 2, 1, 1)
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)
delete_button = Gtk.Button(label="Delete")
delete_button.connect("clicked", lambda *a: self.response(Gtk.ResponseType.NO))
content_grid.attach(delete_button, 0, 3, 1, 2)
inner_box.add(list_box)
content_area.add(content_grid)
self.show_all()
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.edit_button.set_sensitive(len(text) > 0)
self.save_button.set_sensitive(len(text) > 0)
def _set_title(self, playlist_name: str):
self.header.props.title = f"Edit {playlist_name}"
def get_data(self) -> Dict[str, Any]:
return {
"name": self.name_entry.get_text(),
"comment": self.comment_entry.get_text(),
"public": self.public_switch.get_active(),
}
self.header_bar.props.title = f"Edit {playlist_name}"
class PlaylistsPanel(Gtk.Paned):
class PlaylistsPanel(Handy.Leaflet):
"""Defines the playlists panel."""
__gsignals__ = {
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
"refresh-window": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, bool),
),
}
def __init__(self, *args, **kwargs):
Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
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()
self.pack1(self.playlist_list, False, False)
list_sizer.add(self.playlist_list)
self.add(list_sizer)
details_sizer = Sizer(hexpand=True, natural_width=800)
self.playlist_detail_panel = PlaylistDetailPanel()
self.playlist_detail_panel.connect(
"song-clicked",
lambda _, *args: self.emit("song-clicked", *args),
)
self.playlist_detail_panel.connect(
"refresh-window",
lambda _, *args: self.emit("refresh-window", *args),
)
self.pack2(self.playlist_detail_panel, True, False)
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)
@@ -125,6 +172,7 @@ class PlaylistList(Gtk.Box):
GObject.TYPE_NONE,
(object, bool),
),
"playlist-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
}
offline_mode = False
@@ -220,6 +268,7 @@ class PlaylistList(Gtk.Box):
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)
@@ -311,20 +360,12 @@ class PlaylistList(Gtk.Box):
class PlaylistDetailPanel(Gtk.Overlay):
__gsignals__ = {
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
"refresh-window": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, bool),
),
"back-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
}
show_mobile = GObject.Property(type=bool, default=False)
playlist_id = None
playlist_details_expanded = False
offline_mode = False
editing_playlist_song_list: bool = False
@@ -332,8 +373,61 @@ class PlaylistDetailPanel(Gtk.Overlay):
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(
@@ -360,79 +454,11 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.playlist_stats = self.make_label(name="playlist-stats")
playlist_details_box.add(self.playlist_stats)
self.play_shuffle_buttons = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
name="playlist-play-shuffle-buttons",
)
self.play_all_button = IconButton(
"media-playback-start-symbolic",
label="Play All",
relief=True,
)
self.play_all_button.connect("clicked", self.on_play_all_clicked)
self.play_shuffle_buttons.pack_start(self.play_all_button, False, False, 0)
self.shuffle_all_button = IconButton(
"media-playlist-shuffle-symbolic",
label="Shuffle All",
relief=True,
)
self.shuffle_all_button.connect("clicked", self.on_shuffle_all_button)
self.play_shuffle_buttons.pack_start(self.shuffle_all_button, False, False, 5)
playlist_details_box.add(self.play_shuffle_buttons)
playlist_info_box.pack_start(playlist_details_box, True, True, 0)
# Action buttons & expand/collapse button
action_buttons_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.playlist_action_buttons = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=10
)
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
)
self.playlist_action_buttons.add(self.download_all_button)
self.playlist_edit_button = IconButton("document-edit-symbolic", "Edit paylist")
self.playlist_edit_button.connect("clicked", self.on_playlist_edit_button_click)
self.playlist_action_buttons.add(self.playlist_edit_button)
self.view_refresh_button = IconButton(
"view-refresh-symbolic", "Refresh playlist info"
)
self.view_refresh_button.connect("clicked", self.on_view_refresh_click)
self.playlist_action_buttons.add(self.view_refresh_button)
action_buttons_container.pack_start(
self.playlist_action_buttons, False, False, 10
)
action_buttons_container.pack_start(Gtk.Box(), True, True, 0)
expand_button_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
self.expand_collapse_button = IconButton(
"pan-up-symbolic", "Expand playlist details"
)
self.expand_collapse_button.connect("clicked", self.on_expand_collapse_click)
expand_button_container.pack_end(self.expand_collapse_button, False, False, 0)
action_buttons_container.add(expand_button_container)
playlist_info_box.pack_end(action_buttons_container, False, False, 5)
self.playlist_box.add(playlist_info_box)
self.error_container = Gtk.Box()
self.playlist_box.add(self.error_container)
scrolled_box.add(playlist_info_box)
# Playlist songs list
self.playlist_song_scroll_window = Gtk.ScrolledWindow()
self.playlist_song_store = Gtk.ListStore(
bool, # clickable
str, # cache status
@@ -492,9 +518,11 @@ class PlaylistDetailPanel(Gtk.Overlay):
)
self.playlist_song_store.connect("row-deleted", self.on_playlist_model_row_move)
self.playlist_song_scroll_window.add(self.playlist_songs)
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.playlist_box.pack_start(self.playlist_song_scroll_window, True, True, 0)
self.add(self.playlist_box)
playlist_view_spinner = Gtk.Spinner(active=True)
@@ -506,6 +534,9 @@ class PlaylistDetailPanel(Gtk.Overlay):
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):
@@ -555,40 +586,18 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.playlist_id = playlist.id
if app_config:
self.playlist_details_expanded = app_config.state.playlist_details_expanded
up_down = "up" if self.playlist_details_expanded else "down"
self.expand_collapse_button.set_icon(f"pan-{up_down}-symbolic")
self.expand_collapse_button.set_tooltip_text(
"Collapse" if self.playlist_details_expanded else "Expand"
)
# Update the info display.
self.playlist_name.set_markup(f"<b>{playlist.name}</b>")
self.playlist_name.set_tooltip_text(playlist.name)
if self.playlist_details_expanded:
self.playlist_artwork.get_style_context().remove_class("collapsed")
self.playlist_name.get_style_context().remove_class("collapsed")
self.playlist_box.show_all()
self.playlist_indicator.set_markup("PLAYLIST")
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))
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_artwork.get_style_context().add_class("collapsed")
self.playlist_name.get_style_context().add_class("collapsed")
self.playlist_box.show_all()
self.playlist_indicator.hide()
self.playlist_comment.hide()
self.playlist_stats.hide()
self.playlist_stats.set_markup(self._format_stats(playlist))
# Update the artwork.
self.update_playlist_artwork(playlist.cover_art, order_token=order_token)
@@ -606,10 +615,10 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.error_container.pack_start(load_error, True, True, 0)
self.error_container.show_all()
if not has_data:
self.playlist_song_scroll_window.hide()
self.scrolled_window.hide()
else:
self.error_container.hide()
self.playlist_song_scroll_window.show()
self.scrolled_window.show()
# Update the song list model. This requires some fancy diffing to
# update the list.
@@ -677,7 +686,6 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.editing_playlist_song_list = False
self.playlist_view_loading_box.hide()
self.playlist_action_buttons.show_all()
@util.async_callback(
partial(AdapterManager.get_cover_art_uri, scheme="file"),
@@ -698,11 +706,6 @@ class PlaylistDetailPanel(Gtk.Overlay):
self.playlist_artwork.set_from_file(cover_art_filename)
self.playlist_artwork.set_loading(False)
if self.playlist_details_expanded:
self.playlist_artwork.set_image_size(200)
else:
self.playlist_artwork.set_image_size(70)
# Event Handlers
# =========================================================================
def on_view_refresh_click(self, _):
@@ -715,52 +718,10 @@ class PlaylistDetailPanel(Gtk.Overlay):
def on_playlist_edit_button_click(self, _):
assert self.playlist_id
playlist = AdapterManager.get_playlist_details(self.playlist_id).result()
dialog = EditPlaylistDialog(self.get_toplevel(), playlist)
playlist_deleted = False
window = EditPlaylistWindow(self.get_toplevel(), playlist)
result = dialog.run()
# Using ResponseType.NO as the delete event.
if result not in (Gtk.ResponseType.APPLY, Gtk.ResponseType.NO):
dialog.destroy()
return
if result == Gtk.ResponseType.APPLY:
AdapterManager.update_playlist(self.playlist_id, **dialog.get_data())
elif result == Gtk.ResponseType.NO:
# Delete the playlist.
confirm_dialog = Gtk.MessageDialog(
transient_for=self.get_toplevel(),
message_type=Gtk.MessageType.WARNING,
buttons=Gtk.ButtonsType.NONE,
text="Confirm deletion",
)
confirm_dialog.add_buttons(
Gtk.STOCK_DELETE,
Gtk.ResponseType.YES,
Gtk.STOCK_CANCEL,
Gtk.ResponseType.CANCEL,
)
confirm_dialog.format_secondary_markup(
f'Are you sure you want to delete the "{playlist.name}" playlist?'
)
result = confirm_dialog.run()
confirm_dialog.destroy()
if result == Gtk.ResponseType.YES:
AdapterManager.delete_playlist(self.playlist_id)
playlist_deleted = True
else:
# In this case, we don't want to do any invalidation of
# anything.
dialog.destroy()
return
# Force a re-fresh of the view
self.emit(
"refresh-window",
{"selected_playlist_id": None if playlist_deleted else self.playlist_id},
True,
)
dialog.destroy()
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):
@@ -777,39 +738,26 @@ class PlaylistDetailPanel(Gtk.Overlay):
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.emit(
"song-clicked",
0,
[m[-1] for m in self.playlist_song_store],
{"force_shuffle_state": False, "active_playlist_id": self.playlist_id},
)
self.play_song(0, {"force_shuffle_state": GLib.Variant('b', False)})
def on_shuffle_all_button(self, _):
self.emit(
"song-clicked",
self.play_song(
randint(0, len(self.playlist_song_store) - 1),
[m[-1] for m in self.playlist_song_store],
{"force_shuffle_state": True, "active_playlist_id": self.playlist_id},
)
{"force_shuffle_state": GLib.Variant('b', True)})
def on_expand_collapse_click(self, _):
self.emit(
"refresh-window",
{"playlist_details_expanded": not self.playlist_details_expanded},
False,
)
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.emit(
"song-clicked",
idx.get_indices()[0],
[m[-1] for m in self.playlist_song_store],
{"active_playlist_id": self.playlist_id},
)
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
@@ -881,7 +829,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
on_remove_songs_click,
)
],
on_playlist_state_change=lambda: self.emit("refresh-window", {}, True),
on_playlist_state_change=lambda: run_action(self, 'app.refresh'),
)
# If the click was on a selected row, don't deselect anything.

View File

@@ -0,0 +1,615 @@
import uuid
import time
from pathlib import Path
from typing import Optional, Callable, Dict, List, Any
import bleach
from gi.repository import Gdk, Gtk, Handy, GLib, Pango
from ..adapters import (
AdapterManager,
api_objects as API,
DownloadProgress,
Result,
ConfigurationStore,
ConfigParamDescriptor,
)
from ..config import AppConfiguration, ProviderConfiguration
from ..players import PlayerManager
from .actions import run_action, variant_type_from_python
from .common import IconButton
class ProvidersWindow(Handy.Window):
is_initial = True
def __init__(self, main_window):
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
# Don't die when closed
def hide_not_destroy(*_):
if self.is_initial:
run_action(self.main_window, 'app.quit')
return True
self.hide()
return True
self.connect('delete-event', hide_not_destroy)
self.stack = Gtk.Stack()
self.create_page = CreateProviderPage(self)
self.stack.add(self.create_page)
self.configure_page = ConfigureProviderPage(self)
self.stack.add(self.configure_page)
self.status_page = ProviderStatusPage(self)
self.stack.add(self.status_page)
self.add(self.stack)
def update(self, app_config: AppConfiguration, player_manager: PlayerManager):
self.is_initial = app_config.current_provider_id is None
self.status_page.update(app_config, player_manager)
def _set_transition(self, going_back: bool):
if not self.is_visible():
self.stack.set_transition_type(Gtk.StackTransitionType.NONE)
return
if going_back:
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_RIGHT)
else:
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT)
def open_create_page(self, go_back: Optional[Callable] = None, going_back=False):
self._set_transition(going_back)
self.create_page.setup(go_back)
self.create_page.show()
self.stack.set_visible_child(self.create_page)
def open_configure_page(self, id, adapter, config, go_back: Callable):
self._set_transition(False)
self.configure_page.setup(id, adapter, config, go_back)
self.configure_page.show()
self.stack.set_visible_child(self.configure_page)
def open_status_page(self):
self._set_transition(True)
self.status_page.show()
self.stack.set_visible_child(self.status_page)
class CreateProviderPage(Gtk.Box):
def __init__(self, window):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
self.window = window
header_bar = Handy.HeaderBar(title="Add Music Source")
self.cancel_button = Gtk.Button(label='Quit')
self.cancel_button.connect('clicked', self._on_cancel_clicked)
header_bar.pack_start(self.cancel_button)
self.add(header_bar)
scrolled_window = Gtk.ScrolledWindow()
scrolled_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
list_box = Gtk.ListBox(expand=True, selection_mode=Gtk.SelectionMode.NONE)
adapters = AdapterManager.available_adapters
adapters = sorted(
filter(lambda a: a.can_be_ground_truth, adapters),
key=lambda a: a.get_ui_info().name)
for adapter in adapters:
ui_info: UIInfo = adapter.get_ui_info()
row = Handy.ActionRow(
title=ui_info.name,
icon_name=ui_info.icon_name(),
subtitle=ui_info.description,
subtitle_lines=4,
use_underline=True,
activatable=True)
def activated(*_, adapter=adapter):
self.window.open_configure_page(
None,
adapter,
ConfigurationStore(),
lambda: self.window.open_create_page(self._go_back, going_back=True))
row.connect('activated', activated)
row.add(Gtk.Image(icon_name='go-next-symbolic'))
list_box.add(row)
scrolled_box.add(list_box)
scrolled_window.add(scrolled_box)
self.pack_start(scrolled_window, True, True, 0)
def setup(self, go_back: Optional[Callable]):
self._go_back = go_back
if go_back:
self.cancel_button.set_label("Cancel")
else:
self.cancel_button.set_label("Quit")
def _on_cancel_clicked(self, *_):
if self._go_back:
self._go_back()
else:
self.window.close()
class ConfigureProviderPage(Gtk.Box):
_id = None
_adapter = None
_go_back = None
_config_store = None
_config_widgets = {}
_config_updates = {}
_required_fields: Dict[str, Callable[[Any], Optional[str]]] = {}
_errors = {}
_validation_ratchet = 0
_had_all_required = False
def __init__(self, window):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
self.window = window
self.header_bar = Handy.HeaderBar(show_close_button=False, title="Configuration")
back_button = Gtk.Button(label="Back")
back_button.connect('clicked', lambda *_: self._go_back())
self.header_bar.pack_start(back_button)
self.create_button = Gtk.Button(label="Create", sensitive=False)
self.create_button.get_style_context().add_class('suggested-action')
self.create_button.connect('clicked', self._on_create_clicked)
self.header_bar.pack_end(self.create_button)
self.add(self.header_bar)
self.status_revealer = Gtk.Revealer(reveal_child=False)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10, margin=10)
clamp = Handy.Clamp()
self.status_stack = Gtk.Stack()
self.status_spinner = Gtk.Spinner()
self.status_stack.add(self.status_spinner)
self.status_ok_image = Gtk.Image(icon_name="config-ok-symbolic")
self.status_stack.add(self.status_ok_image)
self.status_error_image = Gtk.Image(icon_name="config-error-symbolic")
self.status_stack.add(self.status_error_image)
box.pack_start(self.status_stack, False, True, 0)
self.status_label = Gtk.Label(ellipsize=Pango.EllipsizeMode.END, lines=4, justify=Gtk.Justification.LEFT, wrap=True)
box.pack_start(self.status_label, False, True, 0)
clamp.add(box)
self.status_revealer.add(clamp)
self.pack_start(self.status_revealer, False, True, 0)
scrolled_window = Gtk.ScrolledWindow()
clamp = Handy.Clamp(margin=12)
self.settings_list = Gtk.ListBox()
self.settings_list.get_style_context().add_class('content')
clamp.add(self.settings_list)
scrolled_window.add(clamp)
self.pack_start(scrolled_window, True, True, 0)
def setup(self, id_, adapter, config_store, go_back):
self._id = id_
self._adapter = adapter
self._go_back = go_back
# Reset
self._config_store = config_store
self._config_updates = {}
self._required_fields = {}
self._errors = {}
self._validation_ratchet = 0
self._had_all_required = False
self.create_button.set_sensitive(False)
if self._id is not None:
self.create_button.set_label('Save')
else:
self.create_button.set_label('Create')
for child in self.settings_list.get_children():
self.settings_list.remove(child)
name = adapter.get_ui_info().name
self.header_bar.set_title(f'{name} Configuration')
# Reset status revealer
self.status_revealer.set_reveal_child(False)
# First row is always the name
name_row = self._create_entry_row('name', ConfigParamDescriptor(str, 'Name'))
if 'name' in self._config_store:
self._config_updates['name'](self._config_store['name'])
self.settings_list.add(name_row)
# Collected advanced settings in an expander row
advanced_row = Handy.ExpanderRow(title="Advanced", expanded=False)
self._params, self._validate = adapter.get_configuration_form()
for name, config in self._params.items():
row = {
str: self._create_entry_row,
bool: self._create_switch_row,
int: self._create_spin_button_row,
'password': lambda *a: self._create_entry_row(*a, is_password=True),
'option': self._create_option_row,
Path: self._create_path_row,
}[config.type](name, config)
# Set the initial value from the config store
if name in self._config_store:
self._config_updates[name](self._config_store[name])
elif config.default is not None:
self._config_store[name] = config.default
if config.advanced:
advanced_row.add(row)
else:
self.settings_list.add(row)
self.settings_list.add(advanced_row)
self.show_all()
def _create_entry_row(self, name, config, is_password=False):
row = Handy.ActionRow(title=config.description, subtitle=config.helptext, subtitle_lines=4, can_focus=False, selectable=False)
entry = Gtk.Entry(
valign=Gtk.Align.CENTER,
visibility=not is_password,
input_purpose=Gtk.InputPurpose.PASSWORD if is_password else Gtk.InputPurpose.FREE_FORM)
if config.default is not None:
if is_password:
entry.set_text(config.default[1])
else:
entry.set_text(config.default)
if config.required:
if is_password:
self._required_fields[name] = lambda v: len(v[1]) > 0
else:
self._required_fields[name] = lambda v: len(v) > 0
entry.connect('notify::text', lambda *_: self._update_config(name, entry.get_text(), is_password))
def set_value(value):
if is_password:
if value[0] != 'plaintext':
return
value = value[1]
entry.set_text(value)
self._config_updates[name] = set_value
self._config_widgets[name] = entry
row.add(entry)
return row
def _create_switch_row(self, name, config):
row = Handy.ActionRow(title=config.description, subtitle=config.helptext, subtitle_lines=4, can_focus=False, selectable=False)
switch = Gtk.Switch(valign=Gtk.Align.CENTER)
assert config.default is not None
switch.set_active(config.default)
switch.connect('notify::active', lambda *_: self._update_config(name, switch.get_active()))
self._config_updates[name] = switch.set_active
self._config_widgets[name] = switch
row.add(switch)
return row
def _create_spin_button_row(self, name, config):
pass
def _create_option_row(self, name, config):
pass
def _create_path_row(self, name, config):
pass
def _verify_required(self):
errors = {}
for name, verify in self._required_fields.items():
if name not in self._config_store or not verify(self._config_store[name]):
errors[name] = 'Missing field'
return errors
def _update_config(self, name, value, is_secret=False):
if is_secret:
self._config_store.set_secret(name, value)
else:
self._config_store[name] = value
# Reset errors
for name in self._errors.keys():
widget = self._config_widgets[name]
widget.get_style_context().remove_class('error')
widget.set_tooltip_markup(None)
self._errors = {}
self.create_button.set_sensitive(False)
if not self._had_all_required and self._verify_required():
return
self.status_revealer.set_reveal_child(True)
self._update_status(True, '')
self._had_all_required = True
self._validation_ratchet += 1
ratchet = self._validation_ratchet
def on_verify_result(errors: Optional[Dict[str, str]]):
if errors is None:
return
errors.update(self._verify_required())
# Update controls after validation, as fields may have changed
for name, update in self._config_updates.items():
if name in self._config_store:
update(self._config_store[name])
self._errors = errors
self.create_button.set_sensitive(len(errors) == 0)
if '__ping__' in self._errors:
ping_error = self._errors.pop('__ping__')
else:
ping_error = None
for name, error in self._errors.items():
widget = self._config_widgets[name]
widget.get_style_context().add_class('error')
widget.set_tooltip_markup(error)
self._update_status(False, ping_error)
def validate():
time.sleep(0.75)
if self._validation_ratchet != ratchet:
return None
return self._validate(self._config_store)
result = Result(validate)
result.add_done_callback(
lambda f: GLib.idle_add(on_verify_result, f.result())
)
def _update_status(self, verifying: bool, error: Optional[str]):
if verifying:
self.status_spinner.start()
self.status_stack.set_visible_child(self.status_spinner)
self.status_label.set_markup('<b>Verifying Connection...</b>')
elif error:
self.status_stack.set_visible_child(self.status_error_image)
self.status_label.set_markup(bleach.clean(error))
else:
self.status_stack.set_visible_child(self.status_ok_image)
self.status_label.set_markup('<b>Connected Successfully</b>')
if not verifying:
self.status_spinner.stop()
def _on_create_clicked(self, *_):
id = self._id or str(uuid.uuid4())
name = self._config_store.pop('name')
config = {}
for key, item in self._config_store.items():
type = self._params[key].type
if type == 'password':
type = List[str]
elif type == 'option':
type = str
config[key] = GLib.Variant(variant_type_from_python(type), item)
adapter_name = self._adapter.get_ui_info().name
run_action(
self.window.main_window,
'providers.set-config',
id,
name,
adapter_name,
config)
GLib.idle_add(lambda *_: self.window.close())
class ProviderStatusPage(Gtk.Box):
_current_provider = None
def __init__(self, window):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
self.window = window
header_bar = Handy.HeaderBar(show_close_button=True, title="Connection Status")
self.add(header_bar)
scrolled_window = Gtk.ScrolledWindow()
clamp = Handy.Clamp(margin=12)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
self.title = Gtk.Label(wrap=True, justify=Gtk.Justification.LEFT)
self.title.get_style_context().add_class('title')
self.title.get_style_context().add_class('large-title')
box.add(self.title)
self.status_stack = Gtk.Stack()
self.status_offline = self._create_status_box('offline', 'Offline')
self.status_stack.add(self.status_offline)
self.status_connected = self._create_status_box('connected', 'Connected')
self.status_stack.add(self.status_connected)
self.status_error = self._create_status_box('error', 'Error Connecting to Server')
self.status_stack.add(self.status_error)
box.add(self.status_stack)
list_box = Gtk.ListBox()
list_box.get_style_context().add_class('content')
row = Handy.ActionRow(title='Offline Mode', can_focus=False, selectable=False)
self.offline_switch = Gtk.Switch(valign=Gtk.Align.CENTER)
row.add(self.offline_switch)
list_box.add(row)
row = Handy.ActionRow(
title='Edit Configuration...',
use_underline=True,
activatable=True)
def activated(*_):
config = self._current_provider.ground_truth_adapter_config.clone()
config['name'] = self._current_provider.name
self.window.open_configure_page(
self._current_provider.id,
self._current_provider.ground_truth_adapter_type,
config,
lambda: self.window.open_status_page())
row.connect('activated', activated)
row.add(Gtk.Image(icon_name='go-next-symbolic'))
list_box.add(row)
self.provider_list = Handy.ExpanderRow(title="Other Providers", expanded=False)
add_button = IconButton(icon_name='list-add-symbolic', valign=Gtk.Align.CENTER, relief=True)
def add_clicked(*_):
self.window.open_create_page(lambda: self.window.open_status_page())
add_button.connect('clicked', add_clicked)
self.provider_list.add_action(add_button)
list_box.add(self.provider_list)
box.pack_start(list_box, False, True, 10)
clamp.add(box)
scrolled_window.add(clamp)
self.pack_start(scrolled_window, True, True, 0)
def _create_status_box(self, icon: str, label: str):
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10, halign=Gtk.Align.CENTER)
icon = Gtk.Image(icon_name=f"server-{icon}-symbolic")
box.add(icon)
label = Gtk.Label(label=label)
box.add(label)
return box
_other_providers_cache = None
_other_providers = []
def update(self, app_config: AppConfiguration, player_manager: PlayerManager):
assert AdapterManager.ground_truth_adapter_is_networked
self._current_provider = app_config.provider
self.title.set_label(app_config.provider.name)
if app_config.offline_mode:
self.status_stack.set_visible_child(self.status_offline)
elif AdapterManager.get_ping_status():
self.status_stack.set_visible_child(self.status_connected)
else:
self.status_stack.set_visible_child(self.status_error)
other_providers = [id for id in app_config.providers.keys() if id != app_config.current_provider_id]
if self._other_providers_cache is None or self._other_providers_cache != other_providers:
self._other_providers_cache = other_providers
for child in self._other_providers:
self.provider_list.remove(child)
self._other_providers = []
self.provider_list.set_enable_expansion(len(other_providers) > 0)
for id in other_providers:
provider = app_config.providers[id]
row = Handy.ActionRow(title=provider.name, can_focus=False, selectable=False)
button = Gtk.Button(label="Switch", valign=Gtk.Align.CENTER)
def on_clicked(*_, id=id):
run_action(self.window.main_window, 'providers.switch', id)
button.connect('clicked', on_clicked)
row.add(button)
button = IconButton(icon_name='user-trash-symbolic', valign=Gtk.Align.CENTER, relief=True)
button.get_style_context().add_class('destructive-action')
def on_clicked(*_, id=id):
run_action(self.window.main_window, 'providers.remove', id)
button.connect('clicked', on_clicked)
row.add(button)
self.provider_list.add(row)
self._other_providers.append(row)
self.provider_list.show_all()

262
sublime_music/ui/search.py Normal file
View File

@@ -0,0 +1,262 @@
from functools import partial
from typing import Optional, List, Any, Union, Tuple
import bleach
from gi.repository import Gio, GLib, GObject, Gtk, Pango, Handy, Gdk, GdkPixbuf
from ..adapters import (
AdapterManager,
api_objects as API,
Result,
)
from . import util
from ..config import AppConfiguration, ProviderConfiguration
from .actions import run_action
class SearchPanel(Gtk.ScrolledWindow):
_ratchet = 0
_query: str = ''
_search: Optional[Result] = None
def __init__(self):
super().__init__()
self._art_cache = {}
clamp = Handy.Clamp(margin=12)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.entry = Gtk.Entry(hexpand=True, placeholder_text="Search")
self.entry.connect("notify::text", lambda *_: run_action(self, 'search.set-query', self.entry.get_text()))
box.pack_start(self.entry, False, False, 10)
scrolled_window = ()
self.stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.NONE, homogeneous=True)
self.spinner = Gtk.Spinner(active=False, hexpand=True, vexpand=True)
self.stack.add(self.spinner)
self.store = Gtk.ListStore(
int, # type
str, # art
str, # title, subtitle
str, # id
)
self.art_cache = {}
self.list = Gtk.TreeView(
model=self.store,
reorderable=False,
headers_visible=False)
renderer = Gtk.CellRendererPixbuf(stock_size=Gtk.IconSize.LARGE_TOOLBAR)
renderer.set_fixed_size(40, 60)
column = Gtk.TreeViewColumn("", renderer)
column.set_cell_data_func(renderer, self._get_type_pixbuf)
column.set_resizable(True)
self.list.append_column(column)
renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(45, 60)
column = Gtk.TreeViewColumn("", renderer)
column.set_cell_data_func(renderer, self._get_result_pixbuf)
column.set_resizable(True)
self.list.append_column(column)
renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END)
column = Gtk.TreeViewColumn("", renderer, markup=2)
column.set_expand(True)
self.list.append_column(column)
renderer = Gtk.CellRendererPixbuf(icon_name="view-more-symbolic")
renderer.set_fixed_size(45, 60)
self.options_column = Gtk.TreeViewColumn("", renderer)
self.options_column.set_resizable(True)
self.list.append_column(self.options_column)
self.list.connect("button-press-event", self._on_list_button_press)
self.stack.add(self.list)
box.pack_start(self.stack, True, True, 10)
clamp.add(box)
self.add(clamp)
def _get_type_pixbuf(self,
column: Any,
cell: Gtk.CellRendererPixbuf,
model: Gtk.ListStore,
tree_iter: Gtk.TreeIter,
flags: Any):
kind = model.get_value(tree_iter, 0)
if kind == API.SearchResult.Kind.ARTIST.value:
cell.set_property("icon-name", "avatar-default-symbolic")
elif kind == API.SearchResult.Kind.ALBUM.value:
cell.set_property("icon-name", "media-optical-symbolic")
elif kind == API.SearchResult.Kind.SONG.value:
cell.set_property("icon-name", "folder-music-symbolic")
elif kind == API.SearchResult.Kind.PLAYLIST.value:
cell.set_property("icon-name", "open-menu-symbolic")
else:
assert False
def _get_pixbuf_from_path(self, path: Optional[str]):
if not path:
return None
if path in self._art_cache:
return self._art_cache[path]
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, 50, 50, True)
self._art_cache[path] = pixbuf
return pixbuf
def _get_result_pixbuf(self,
column: Any,
cell: Gtk.CellRendererPixbuf,
model: Gtk.ListStore,
tree_iter: Gtk.TreeIter,
flags: Any):
filename = model.get_value(tree_iter, 1)
pixbuf = self._get_pixbuf_from_path(filename)
if not pixbuf:
cell.set_property("icon-name", "")
else:
cell.set_property("pixbuf", pixbuf)
def _on_list_button_press(self, tree: Gtk.ListStore, event: Gdk.EventButton) -> bool:
if event.button != 1:
return False
path, column, cell_x, cell_y = tree.get_path_at_pos(event.x, event.y)
index = path.get_indices()[0]
row = self.store[index]
if column == self.options_column:
area = tree.get_cell_area(path, self.options_column)
x = area.x + area.width / 2
y = area.y + area.height / 2
# TODO: Show popup
return True
else:
if row[0] == API.SearchResult.Kind.ARTIST.value:
run_action(self, 'app.go-to-artist', row[3])
elif row[0] == API.SearchResult.Kind.ALBUM.value:
run_action(self, 'app.go-to-album', row[3])
elif row[0] == API.SearchResult.Kind.SONG.value:
run_action(self, 'app.play-song', 0, [row[3]], {"force_shuffle_state": GLib.Variant('b', False)})
elif row[0] == API.SearchResult.Kind.PLAYLIST.value:
run_action(self, 'app.go-to-playlist', row[3])
else:
assert False
return True
def update(self, app_config: AppConfiguration, force: bool = False):
query = app_config.state.search_query
if query != self._query:
self._query = query
self.entry.set_text(self._query)
if self._search:
self._search.cancel()
self._ratchet += 1
if self._query:
def search_callback(ratchet, result: API.SearchResult):
if ratchet != self._ratchet:
return
GLib.idle_add(self._update_search_results, result.get_results(), ratchet)
self._search = AdapterManager.search(
self._query,
search_callback=partial(search_callback, self._ratchet))
self._set_loading(True)
else:
self._update_search_results([], self._ratchet)
def _set_loading(self, loading: bool):
if loading:
self.spinner.start()
self.stack.set_visible_child(self.spinner)
else:
self.spinner.stop()
self.stack.set_visible_child(self.list)
def _set_art(self, index: int, path: str, ratchet: int):
if ratchet != self._ratchet:
return
self.store[index][1] = path
def _get_art_path(self, index: int, art_id: Optional[str], ratchet: int):
cover_art_result = AdapterManager.get_cover_art_uri(art_id, "file")
if not cover_art_result.data_is_available:
def on_done(result: Result):
if ratchet != self._ratchet:
return
GLib.idle_add(self._set_art, index, result.result(), ratchet)
cover_art_result.add_done_callback(on_done)
return None
# The cover art is already cached.
return cover_art_result.result()
def _update_search_results(
self,
results: List[Tuple[API.SearchResult.Kind, API.SearchResult.ValueType]],
ratchet: int,
):
if ratchet != self._ratchet:
return
self._set_loading(False)
self.store.clear()
self._art_cache = {}
for index, (kind, result) in enumerate(results):
id = result.id
if kind is API.SearchResult.Kind.ARTIST:
art_path = None
title = f"<b>{bleach.clean(result.name)}</b>"
elif kind is API.SearchResult.Kind.ALBUM:
art_path = self._get_art_path(index, result.cover_art, ratchet)
artist = bleach.clean(result.artist.name if result.artist else None)
song_count = f"{result.song_count} {util.pluralize('song', result.song_count)}"
title = f"<b>{bleach.clean(result.name)}</b>\n{util.dot_join(artist, song_count)}"
elif kind is API.SearchResult.Kind.SONG:
art_path = self._get_art_path(index, result.cover_art, ratchet)
name = bleach.clean(result.title)
album = bleach.clean(result.album.name if result.album else None)
artist = bleach.clean(result.artist.name if result.artist else None)
title = f"<b>{name}</b>\n{util.dot_join(album, artist)}"
elif kind is API.SearchResult.Kind.PLAYLIST:
art_path = None
title = f"<b>{bleach.clean(result.name)}</b>\n{result.song_count} {util.pluralize('song', result.song_count)}"
else:
assert False
self.store.append((kind.value, art_path, title, id))

View File

@@ -81,20 +81,18 @@ class UIState:
playlist_details_expanded: bool = True
artist_details_expanded: bool = True
loading_play_queue: bool = False
# State for Album sort.
class _DefaultGenre(Genre):
def __init__(self):
self.name = "Rock"
search_query: str = ''
current_album_search_query: AlbumSearchQuery = AlbumSearchQuery(
AlbumSearchQuery.Type.RANDOM,
genre=_DefaultGenre(),
year_range=this_decade(),
)
active_playlist_id: Optional[str] = None
# Pickle backwards compatibility
_DefaultGenre = AlbumSearchQuery.Genre
def __getstate__(self):
state = self.__dict__.copy()
del state["song_stream_cache_progress"]
@@ -109,6 +107,11 @@ class UIState:
self.current_notification = None
self.playing = False
# Ensure a song is selected if the play queue isn't empty
if self.play_queue and self.current_song_index < 0:
self.current_song_index = 0
self.song_progress = timedelta(0)
def __init_available_players__(self):
from sublime_music.players import PlayerManager

View File

@@ -213,9 +213,9 @@ def show_song_popover(
play_next_button = Gtk.ModelButton(text="Play next", sensitive=False)
add_to_queue_button = Gtk.ModelButton(text="Add to queue", sensitive=False)
if not offline_mode:
play_next_button.set_action_name("app.play-next")
play_next_button.set_action_name("app.queue-next-songs")
play_next_button.set_action_target_value(GLib.Variant("as", song_ids))
add_to_queue_button.set_action_name("app.add-to-queue")
add_to_queue_button.set_action_name("app.queue_songs")
add_to_queue_button.set_action_target_value(GLib.Variant("as", song_ids))
go_to_album_button = Gtk.ModelButton(text="Go to album", sensitive=False)
@@ -248,9 +248,9 @@ def show_song_popover(
):
remove_download_button.set_sensitive(True)
play_next_button.set_action_target_value(GLib.Variant("as", song_ids))
play_next_button.set_action_name("app.play-next")
play_next_button.set_action_name("app.queue-next-songs")
add_to_queue_button.set_action_target_value(GLib.Variant("as", song_ids))
add_to_queue_button.set_action_name("app.add-to-queue")
add_to_queue_button.set_action_name("app.queue-songs")
albums, artists, parents = set(), set(), set()
for song in songs:

19
tests/ui_actions_test.py Normal file
View File

@@ -0,0 +1,19 @@
from typing import Any, List, Tuple, Optional, Union, Dict
from sublime_music.ui import actions
def test_variant_type_from_python():
assert actions.variant_type_from_python(bool) == 'b'
assert actions.variant_type_from_python(int) == 'x'
assert actions.variant_type_from_python(float) == 'd'
assert actions.variant_type_from_python(str) == 's'
assert actions.variant_type_from_python(Any) == 'v'
assert actions.variant_type_from_python(List[int]) == 'ax'
assert actions.variant_type_from_python(Tuple[str, int, bool]) == '(sxb)'
assert actions.variant_type_from_python(Optional[str]) == 'ms'
assert actions.variant_type_from_python(Union[str, int]) == '[sx]'
assert actions.variant_type_from_python(Dict[str, int]) == 'a{sx}'
assert (actions.variant_type_from_python(
Tuple[Dict[Optional[str], List[bool]], List[List[Any]]])
== '(a{msab}aav)')