Compare commits
29 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
4ce2f222f1 | ||
![]() |
82a881ebfd | ||
![]() |
1f31e62340 | ||
![]() |
c7b0bc0bc8 | ||
![]() |
5ce4f1c944 | ||
![]() |
6a68cd1c99 | ||
![]() |
6c2dbf192e | ||
![]() |
d0861460fd | ||
![]() |
d35dd2c96d | ||
![]() |
827636ade6 | ||
![]() |
47850356b3 | ||
![]() |
4ced8e607b | ||
![]() |
0f3e04007f | ||
![]() |
5c4a29e6ae | ||
![]() |
87d32ddc7f | ||
![]() |
7c050555e3 | ||
![]() |
a8c5fc2f4d | ||
![]() |
cec0bf1285 | ||
![]() |
bef07bcdf1 | ||
![]() |
e511d471fc | ||
![]() |
8329cd3cfc | ||
![]() |
9fe6cb4519 | ||
![]() |
14a7b2bb77 | ||
![]() |
95c3e5a018 | ||
![]() |
4ba2e09cf1 | ||
![]() |
56ae24b479 | ||
![]() |
d7d774c579 | ||
![]() |
c612f31f42 | ||
![]() |
e1dcf8da4c |
@@ -65,3 +65,6 @@ def main():
|
|||||||
|
|
||||||
app = SublimeMusicApp(Path(config_file))
|
app = SublimeMusicApp(Path(config_file))
|
||||||
app.run(unknown_args)
|
app.run(unknown_args)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
@@ -6,8 +6,8 @@ from .adapter_base import (
|
|||||||
ConfigurationStore,
|
ConfigurationStore,
|
||||||
SongCacheStatus,
|
SongCacheStatus,
|
||||||
UIInfo,
|
UIInfo,
|
||||||
|
ConfigParamDescriptor,
|
||||||
)
|
)
|
||||||
from .configure_server_form import ConfigParamDescriptor, ConfigureServerForm
|
|
||||||
from .manager import AdapterManager, DownloadProgress, Result, SearchResult
|
from .manager import AdapterManager, DownloadProgress, Result, SearchResult
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@@ -18,7 +18,6 @@ __all__ = (
|
|||||||
"CachingAdapter",
|
"CachingAdapter",
|
||||||
"ConfigParamDescriptor",
|
"ConfigParamDescriptor",
|
||||||
"ConfigurationStore",
|
"ConfigurationStore",
|
||||||
"ConfigureServerForm",
|
|
||||||
"DownloadProgress",
|
"DownloadProgress",
|
||||||
"Result",
|
"Result",
|
||||||
"SearchResult",
|
"SearchResult",
|
||||||
|
@@ -16,13 +16,11 @@ from typing import (
|
|||||||
Sequence,
|
Sequence,
|
||||||
Set,
|
Set,
|
||||||
Tuple,
|
Tuple,
|
||||||
|
Union,
|
||||||
|
Type,
|
||||||
|
Callable,
|
||||||
)
|
)
|
||||||
|
|
||||||
import gi
|
|
||||||
|
|
||||||
gi.require_version("Gtk", "3.0")
|
|
||||||
from gi.repository import Gtk
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import keyring
|
import keyring
|
||||||
|
|
||||||
@@ -81,9 +79,12 @@ class AlbumSearchQuery:
|
|||||||
:class:`AlbumSearchQuery.Type.GENRE`) return albums of the given genre
|
:class:`AlbumSearchQuery.Type.GENRE`) return albums of the given genre
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class _Genre(Genre):
|
@dataclass
|
||||||
def __init__(self, name: str):
|
class Genre:
|
||||||
self.name = name
|
name: str
|
||||||
|
|
||||||
|
# Pickle backwards compatibility
|
||||||
|
_Genre = Genre
|
||||||
|
|
||||||
class Type(Enum):
|
class Type(Enum):
|
||||||
"""
|
"""
|
||||||
@@ -116,7 +117,7 @@ class AlbumSearchQuery:
|
|||||||
|
|
||||||
type: Type
|
type: Type
|
||||||
year_range: Tuple[int, int] = this_decade()
|
year_range: Tuple[int, int] = this_decade()
|
||||||
genre: Genre = _Genre("Rock")
|
genre: Genre = Genre("Rock")
|
||||||
|
|
||||||
_strhash: Optional[str] = None
|
_strhash: Optional[str] = None
|
||||||
|
|
||||||
@@ -255,6 +256,57 @@ class UIInfo:
|
|||||||
return f"{self.icon_basename}-{status.lower()}-symbolic"
|
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):
|
class Adapter(abc.ABC):
|
||||||
"""
|
"""
|
||||||
Defines the interface for a Sublime Music Adapter.
|
Defines the interface for a Sublime Music Adapter.
|
||||||
@@ -277,7 +329,7 @@ class Adapter(abc.ABC):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@abc.abstractmethod
|
@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
|
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
|
from the user and uses the given ``config_store`` to store the configuration
|
||||||
|
@@ -5,6 +5,7 @@ import abc
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from functools import lru_cache, partial
|
from functools import lru_cache, partial
|
||||||
|
from enum import Enum
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
@@ -145,18 +146,23 @@ class SearchResult:
|
|||||||
both server and local results.
|
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):
|
def __init__(self, query: str = None):
|
||||||
self.query = query
|
self.query = query
|
||||||
self.similiarity_partial = partial(
|
self.similiarity_partial = partial(
|
||||||
similarity_ratio, self.query.lower() if self.query else ""
|
similarity_ratio, self.query.lower() if self.query else ""
|
||||||
)
|
)
|
||||||
self._artists: Dict[str, Artist] = {}
|
self._results: Dict[Tuple[Kind, str], ValueType] = {}
|
||||||
self._albums: Dict[str, Album] = {}
|
|
||||||
self._songs: Dict[str, Song] = {}
|
|
||||||
self._playlists: Dict[str, Playlist] = {}
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
fields = ("query", "_artists", "_albums", "_songs", "_playlists")
|
fields = ("query", "_results")
|
||||||
formatted_fields = map(lambda f: f"{f}={getattr(self, f)}", fields)
|
formatted_fields = map(lambda f: f"{f}={getattr(self, f)}", fields)
|
||||||
return f"<SearchResult {' '.join(formatted_fields)}>"
|
return f"<SearchResult {' '.join(formatted_fields)}>"
|
||||||
|
|
||||||
@@ -165,76 +171,57 @@ class SearchResult:
|
|||||||
if results is None:
|
if results is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
member = f"_{result_type}"
|
for result in results:
|
||||||
cast(Dict[str, Any], getattr(self, member)).update({r.id: r for r 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"):
|
def update(self, other: "SearchResult"):
|
||||||
assert self.query == other.query
|
assert self.query == other.query
|
||||||
self._artists.update(other._artists)
|
self._results.update(other._results)
|
||||||
self._albums.update(other._albums)
|
|
||||||
self._songs.update(other._songs)
|
|
||||||
self._playlists.update(other._playlists)
|
|
||||||
|
|
||||||
_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(
|
def get_results(self) -> List[Tuple[Kind, ValueType]]:
|
||||||
self,
|
|
||||||
it: Dict[str, _S],
|
|
||||||
transform: Callable[[_S], Tuple[Optional[str], ...]],
|
|
||||||
) -> List[_S]:
|
|
||||||
assert self.query
|
assert self.query
|
||||||
|
|
||||||
all_results = []
|
all_results = []
|
||||||
for value in it.values():
|
for (kind, _), value in self._results.items():
|
||||||
transformed = transform(value)
|
try:
|
||||||
if any(t is None for t in transformed):
|
transformed = self._transform(kind, value)
|
||||||
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
max_similarity = max(
|
max_similarity = max(
|
||||||
self.similiarity_partial(t.lower())
|
(self.similiarity_partial(t.lower()) for t in transformed
|
||||||
for t in transformed
|
if t is not None),
|
||||||
if t is not None
|
default=0)
|
||||||
)
|
|
||||||
if max_similarity < 60:
|
if max_similarity < 60:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
all_results.append((max_similarity, value))
|
all_results.append((max_similarity, (kind, value)))
|
||||||
|
|
||||||
all_results.sort(key=lambda rx: rx[0], reverse=True)
|
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())
|
logging.debug(similarity_ratio.cache_info())
|
||||||
return result
|
return [r for _, r in all_results]
|
||||||
|
|
||||||
@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,))
|
|
||||||
|
@@ -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())
|
|
||||||
)
|
|
@@ -18,7 +18,6 @@ from .. import (
|
|||||||
CachingAdapter,
|
CachingAdapter,
|
||||||
ConfigParamDescriptor,
|
ConfigParamDescriptor,
|
||||||
ConfigurationStore,
|
ConfigurationStore,
|
||||||
ConfigureServerForm,
|
|
||||||
SongCacheStatus,
|
SongCacheStatus,
|
||||||
UIInfo,
|
UIInfo,
|
||||||
)
|
)
|
||||||
@@ -42,19 +41,16 @@ class FilesystemAdapter(CachingAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
|
def get_configuration_form() -> Gtk.Box:
|
||||||
def verify_config_store() -> Dict[str, Optional[str]]:
|
configs = {
|
||||||
|
"directory": ConfigParamDescriptor(
|
||||||
|
type=Path, description="Music Directory", pathtype="directory"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
def verify_config_store(config_store: ConfigurationStore) -> Dict[str, Optional[str]]:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
return ConfigureServerForm(
|
return configs, verify_config_store
|
||||||
config_store,
|
|
||||||
{
|
|
||||||
"directory": ConfigParamDescriptor(
|
|
||||||
type=Path, description="Music Directory", pathtype="directory"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
verify_config_store,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def migrate_configuration(config_store: ConfigurationStore):
|
def migrate_configuration(config_store: ConfigurationStore):
|
||||||
@@ -779,17 +775,15 @@ class FilesystemAdapter(CachingAdapter):
|
|||||||
|
|
||||||
elif data_key == KEYS.SEARCH_RESULTS:
|
elif data_key == KEYS.SEARCH_RESULTS:
|
||||||
data = cast(API.SearchResult, data)
|
data = cast(API.SearchResult, data)
|
||||||
for a in data._artists.values():
|
for (kind, id), v in data._results.items():
|
||||||
self._do_ingest_new_data(KEYS.ARTIST, a.id, a, partial=True)
|
if kind is API.SearchResult.Kind.ARTIST:
|
||||||
|
self._do_ingest_new_data(KEYS.ARTIST, id, v, partial=True)
|
||||||
for a in data._albums.values():
|
elif kind is API.SearchResult.Kind.ALBUM:
|
||||||
self._do_ingest_new_data(KEYS.ALBUM, a.id, a, partial=True)
|
self._do_ingest_new_data(KEYS.ALBUM, id, v, partial=True)
|
||||||
|
elif kind is API.SearchResult.Kind.SONG:
|
||||||
for s in data._songs.values():
|
self._do_ingest_new_data(KEYS.SONG, id, v, partial=True)
|
||||||
self._do_ingest_new_data(KEYS.SONG, s.id, s, partial=True)
|
elif kind is API.SearchResult.Kind.PLAYLIST:
|
||||||
|
self._do_ingest_new_data(KEYS.PLAYLIST_DETAILS, id, v, partial=True)
|
||||||
for p in data._playlists.values():
|
|
||||||
self._do_ingest_new_data(KEYS.PLAYLIST_DETAILS, p.id, p, partial=True)
|
|
||||||
|
|
||||||
elif data_key == KEYS.SONG:
|
elif data_key == KEYS.SONG:
|
||||||
api_song = cast(API.Song, data)
|
api_song = cast(API.Song, data)
|
||||||
|
@@ -458,7 +458,7 @@ class AdapterManager:
|
|||||||
f"expected size ({expected_size})."
|
f"expected size ({expected_size})."
|
||||||
)
|
)
|
||||||
|
|
||||||
block_size = 1024 # 1 KiB
|
block_size = 512 * 1024 # 512 KiB
|
||||||
total_consumed = 0
|
total_consumed = 0
|
||||||
|
|
||||||
with open(download_tmp_filename, "wb+") as f:
|
with open(download_tmp_filename, "wb+") as f:
|
||||||
@@ -473,21 +473,18 @@ class AdapterManager:
|
|||||||
)
|
)
|
||||||
raise Exception("Download Cancelled")
|
raise Exception("Download Cancelled")
|
||||||
|
|
||||||
if i % 100 == 0:
|
if DOWNLOAD_BLOCK_DELAY is not None:
|
||||||
# Only delay (if configured) and update the progress UI
|
sleep(DOWNLOAD_BLOCK_DELAY)
|
||||||
# every 100 KiB.
|
|
||||||
if DOWNLOAD_BLOCK_DELAY is not None:
|
|
||||||
sleep(DOWNLOAD_BLOCK_DELAY)
|
|
||||||
|
|
||||||
if expected_size_exists:
|
if expected_size_exists:
|
||||||
AdapterManager._instance.song_download_progress(
|
AdapterManager._instance.song_download_progress(
|
||||||
id,
|
id,
|
||||||
DownloadProgress(
|
DownloadProgress(
|
||||||
DownloadProgress.Type.PROGRESS,
|
DownloadProgress.Type.PROGRESS,
|
||||||
total_bytes=total_size,
|
total_bytes=total_size,
|
||||||
current_bytes=total_consumed,
|
current_bytes=total_consumed,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Everything succeeded.
|
# Everything succeeded.
|
||||||
if expected_size_exists:
|
if expected_size_exists:
|
||||||
|
@@ -38,7 +38,6 @@ from .. import (
|
|||||||
api_objects as API,
|
api_objects as API,
|
||||||
ConfigParamDescriptor,
|
ConfigParamDescriptor,
|
||||||
ConfigurationStore,
|
ConfigurationStore,
|
||||||
ConfigureServerForm,
|
|
||||||
UIInfo,
|
UIInfo,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,7 +89,7 @@ class SubsonicAdapter(Adapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
|
def get_configuration_form():
|
||||||
configs = {
|
configs = {
|
||||||
"server_address": ConfigParamDescriptor(str, "Server Address"),
|
"server_address": ConfigParamDescriptor(str, "Server Address"),
|
||||||
"username": ConfigParamDescriptor(str, "Username"),
|
"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]] = {}
|
errors: Dict[str, Optional[str]] = {}
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir_name:
|
with tempfile.TemporaryDirectory() as tmp_dir_name:
|
||||||
@@ -168,10 +167,11 @@ class SubsonicAdapter(Adapter):
|
|||||||
"Double check the server address."
|
"Double check the server address."
|
||||||
)
|
)
|
||||||
except ServerError as e:
|
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
|
# status code 10: if salt auth is not enabled, server will
|
||||||
# return error server error with status_code 10 since it'll
|
# return error server error with status_code 10 since it'll
|
||||||
# interpret it as a missing (password) parameter
|
# 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: as per subsonic api docs, description of
|
||||||
# status_code 41 is "Token authentication not supported for
|
# status_code 41 is "Token authentication not supported for
|
||||||
# LDAP users." so fall back to password auth
|
# LDAP users." so fall back to password auth
|
||||||
@@ -205,7 +205,7 @@ class SubsonicAdapter(Adapter):
|
|||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
return ConfigureServerForm(config_store, configs, verify_configuration)
|
return configs, verify_configuration
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def migrate_configuration(config_store: ConfigurationStore):
|
def migrate_configuration(config_store: ConfigurationStore):
|
||||||
|
@@ -6,7 +6,7 @@ import sys
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
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
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import bleach
|
import bleach
|
||||||
@@ -18,11 +18,19 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
tap_imported = False
|
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:
|
try:
|
||||||
import gi
|
|
||||||
|
|
||||||
gi.require_version("Notify", "0.7")
|
gi.require_version("Notify", "0.7")
|
||||||
from gi.repository import Notify
|
from gi.repository import Notify
|
||||||
|
|
||||||
@@ -42,14 +50,16 @@ from .adapters import (
|
|||||||
DownloadProgress,
|
DownloadProgress,
|
||||||
Result,
|
Result,
|
||||||
SongCacheStatus,
|
SongCacheStatus,
|
||||||
|
ConfigurationStore,
|
||||||
)
|
)
|
||||||
|
from .adapters.filesystem import FilesystemAdapter
|
||||||
from .adapters.api_objects import Playlist, PlayQueue, Song
|
from .adapters.api_objects import Playlist, PlayQueue, Song
|
||||||
from .config import AppConfiguration, ProviderConfiguration
|
from .config import AppConfiguration, ProviderConfiguration
|
||||||
from .dbus import dbus_propagate, DBusManager
|
from .dbus import dbus_propagate, DBusManager
|
||||||
from .players import PlayerDeviceEvent, PlayerEvent, PlayerManager
|
from .players import PlayerDeviceEvent, PlayerEvent, PlayerManager
|
||||||
from .ui.configure_provider import ConfigureProviderDialog
|
|
||||||
from .ui.main import MainWindow
|
from .ui.main import MainWindow
|
||||||
from .ui.state import RepeatType, UIState
|
from .ui.state import RepeatType, UIState
|
||||||
|
from .ui.actions import register_action, register_dataclass_actions
|
||||||
from .util import resolve_path
|
from .util import resolve_path
|
||||||
|
|
||||||
|
|
||||||
@@ -59,12 +69,16 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
if glib_notify_exists:
|
if glib_notify_exists:
|
||||||
Notify.init("Sublime Music")
|
Notify.init("Sublime Music")
|
||||||
|
|
||||||
|
Handy.init()
|
||||||
|
|
||||||
self.window: Optional[Gtk.Window] = None
|
self.window: Optional[Gtk.Window] = None
|
||||||
self.app_config = AppConfiguration.load_from_file(config_file)
|
self.app_config = AppConfiguration.load_from_file(config_file)
|
||||||
self.dbus_manager: Optional[DBusManager] = None
|
self.dbus_manager: Optional[DBusManager] = None
|
||||||
|
|
||||||
self.connect("shutdown", self.on_app_shutdown)
|
self.connect("shutdown", self.on_app_shutdown)
|
||||||
|
|
||||||
|
self._download_progress = {}
|
||||||
|
|
||||||
player_manager: Optional[PlayerManager] = None
|
player_manager: Optional[PlayerManager] = None
|
||||||
exiting: bool = False
|
exiting: bool = False
|
||||||
|
|
||||||
@@ -79,38 +93,40 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
action.connect("activate", fn)
|
action.connect("activate", fn)
|
||||||
self.add_action(action)
|
self.add_action(action)
|
||||||
|
|
||||||
# Add action for menu items.
|
register_action(self, self.quit, types=tuple())
|
||||||
add_action("add-new-music-provider", self.on_add_new_music_provider)
|
|
||||||
add_action("edit-current-music-provider", self.on_edit_current_music_provider)
|
register_action(self, self.change_tab)
|
||||||
add_action(
|
|
||||||
"switch-music-provider", self.on_switch_music_provider, parameter_type="s"
|
# Connect after we know there's a server configured.
|
||||||
)
|
# self.window.connect("notification-closed", self.on_notification_closed)
|
||||||
add_action(
|
# self.window.connect("key-press-event", self.on_window_key_press)
|
||||||
"remove-music-provider", self.on_remove_music_provider, parameter_type="s"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add actions for player controls
|
# Add actions for player controls
|
||||||
add_action("play-pause", self.on_play_pause)
|
register_action(self, self.seek)
|
||||||
add_action("next-track", self.on_next_track)
|
register_action(self, self.play_pause)
|
||||||
add_action("prev-track", self.on_prev_track)
|
register_action(self, self.next_track)
|
||||||
add_action("repeat-press", self.on_repeat_press)
|
register_action(self, self.prev_track)
|
||||||
add_action("shuffle-press", self.on_shuffle_press)
|
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.
|
# Navigation actions.
|
||||||
add_action("play-next", self.on_play_next, parameter_type="as")
|
register_action(self, self.queue_next_songs)
|
||||||
add_action("add-to-queue", self.on_add_to_queue, parameter_type="as")
|
register_action(self, self.queue_songs)
|
||||||
add_action("go-to-album", self.on_go_to_album, parameter_type="s")
|
register_action(self, self.go_to_album)
|
||||||
add_action("go-to-artist", self.on_go_to_artist, parameter_type="s")
|
register_action(self, self.go_to_artist)
|
||||||
add_action("browse-to", self.browse_to, parameter_type="s")
|
register_action(self, self.browse_to)
|
||||||
add_action("go-to-playlist", self.on_go_to_playlist, parameter_type="s")
|
register_action(self, self.go_to_playlist)
|
||||||
|
|
||||||
add_action("go-online", self.on_go_online)
|
add_action("go-online", self.on_go_online)
|
||||||
add_action("refresh-devices", self.on_refresh_devices)
|
add_action("refresh-devices", self.on_refresh_devices)
|
||||||
add_action(
|
register_action(self, self.refresh)
|
||||||
"refresh-window",
|
register_action(self, self.force_refresh)
|
||||||
lambda *a: self.on_refresh_window(None, {}, True),
|
|
||||||
)
|
|
||||||
add_action("mute-toggle", self.on_mute_toggle)
|
|
||||||
add_action(
|
add_action(
|
||||||
"update-play-queue-from-server",
|
"update-play-queue-from-server",
|
||||||
lambda a, p: self.update_play_state_from_server(),
|
lambda a, p: self.update_play_state_from_server(),
|
||||||
@@ -118,11 +134,19 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
|
|
||||||
if tap_imported:
|
if tap_imported:
|
||||||
self.tap = osxmmkeys.Tap()
|
self.tap = osxmmkeys.Tap()
|
||||||
self.tap.on("play_pause", self.on_play_pause)
|
self.tap.on("play_pause", self.play_pause)
|
||||||
self.tap.on("next_track", self.on_next_track)
|
self.tap.on("next_track", self.next_track)
|
||||||
self.tap.on("prev_track", self.on_prev_track)
|
self.tap.on("prev_track", self.prev_track)
|
||||||
self.tap.start()
|
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):
|
def do_activate(self):
|
||||||
# We only allow a single window and raise any existing ones
|
# We only allow a single window and raise any existing ones
|
||||||
if self.window:
|
if self.window:
|
||||||
@@ -143,6 +167,36 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
# closed the application shuts down.
|
# closed the application shuts down.
|
||||||
self.window = MainWindow(application=self, title="Sublime Music")
|
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
|
# Configure the CSS provider so that we can style elements on the
|
||||||
# window.
|
# window.
|
||||||
css_provider = Gtk.CssProvider()
|
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
|
# configured, and if none are configured, then show the dialog to create a new
|
||||||
# one.
|
# one.
|
||||||
if self.app_config.provider is None:
|
if self.app_config.provider is None:
|
||||||
if len(self.app_config.providers) == 0:
|
self.window.show_providers_window()
|
||||||
self.show_configure_servers_dialog()
|
else:
|
||||||
|
AdapterManager.reset(self.app_config, self.on_song_download_progress)
|
||||||
# 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)
|
|
||||||
|
|
||||||
# Configure the players
|
# Configure the players
|
||||||
self.last_play_queue_update = timedelta(0)
|
self.last_play_queue_update = timedelta(0)
|
||||||
@@ -204,7 +240,7 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
|
|
||||||
self.app_config.state.song_progress = timedelta(seconds=value)
|
self.app_config.state.song_progress = timedelta(seconds=value)
|
||||||
GLib.idle_add(
|
GLib.idle_add(
|
||||||
self.window.player_controls.update_scrubber,
|
self.window.update_song_progress,
|
||||||
self.app_config.state.song_progress,
|
self.app_config.state.song_progress,
|
||||||
self.app_config.state.current_song.duration,
|
self.app_config.state.current_song.duration,
|
||||||
self.app_config.state.song_stream_cache_progress,
|
self.app_config.state.song_stream_cache_progress,
|
||||||
@@ -233,7 +269,7 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
self.update_window()
|
self.update_window()
|
||||||
return
|
return
|
||||||
|
|
||||||
GLib.idle_add(self.on_next_track)
|
GLib.idle_add(self.next_track)
|
||||||
|
|
||||||
def on_player_event(event: PlayerEvent):
|
def on_player_event(event: PlayerEvent):
|
||||||
if event.type == PlayerEvent.EventType.PLAY_STATE_CHANGE:
|
if event.type == PlayerEvent.EventType.PLAY_STATE_CHANGE:
|
||||||
@@ -262,7 +298,7 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
seconds=event.stream_cache_duration
|
seconds=event.stream_cache_duration
|
||||||
)
|
)
|
||||||
GLib.idle_add(
|
GLib.idle_add(
|
||||||
self.window.player_controls.update_scrubber,
|
self.window.update_song_progress,
|
||||||
self.app_config.state.song_progress,
|
self.app_config.state.song_progress,
|
||||||
self.app_config.state.current_song.duration,
|
self.app_config.state.current_song.duration,
|
||||||
self.app_config.state.song_stream_cache_progress,
|
self.app_config.state.song_stream_cache_progress,
|
||||||
@@ -326,20 +362,21 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
GLib.timeout_add(10000, check_if_connected)
|
GLib.timeout_add(10000, check_if_connected)
|
||||||
|
|
||||||
# Update after Adapter Initial Sync
|
# Update after Adapter Initial Sync
|
||||||
def after_initial_sync(_):
|
if self.app_config.provider:
|
||||||
self.update_window()
|
def after_initial_sync(_):
|
||||||
|
self.update_window()
|
||||||
|
|
||||||
# Prompt to load the play queue from the server.
|
# Prompt to load the play queue from the server.
|
||||||
if AdapterManager.can_get_play_queue():
|
if AdapterManager.can_get_play_queue():
|
||||||
self.update_play_state_from_server(prompt_confirm=True)
|
self.update_play_state_from_server(prompt_confirm=True)
|
||||||
|
|
||||||
# Get the playlists, just so that we don't have tons of cache misses from
|
# Get the playlists, just so that we don't have tons of cache misses from
|
||||||
# DBus trying to get the playlists.
|
# DBus trying to get the playlists.
|
||||||
if AdapterManager.can_get_playlists():
|
if AdapterManager.can_get_playlists():
|
||||||
AdapterManager.get_playlists()
|
AdapterManager.get_playlists()
|
||||||
|
|
||||||
inital_sync_result = AdapterManager.initial_sync()
|
inital_sync_result = AdapterManager.initial_sync()
|
||||||
inital_sync_result.add_done_callback(after_initial_sync)
|
inital_sync_result.add_done_callback(after_initial_sync)
|
||||||
|
|
||||||
# Send out to the bus that we exist.
|
# Send out to the bus that we exist.
|
||||||
if self.dbus_manager:
|
if self.dbus_manager:
|
||||||
@@ -376,18 +413,11 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
# a duration, but the Child object has `duration` optional because
|
# a duration, but the Child object has `duration` optional because
|
||||||
# it could be a directory.
|
# it could be a directory.
|
||||||
assert self.app_config.state.current_song.duration is not None
|
assert self.app_config.state.current_song.duration is not None
|
||||||
self.on_song_scrub(
|
self.window.player_manager.scrubber = new_seconds.total_seconds()
|
||||||
None,
|
|
||||||
(
|
|
||||||
new_seconds.total_seconds()
|
|
||||||
/ self.app_config.state.current_song.duration.total_seconds()
|
|
||||||
)
|
|
||||||
* 100,
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_pos_fn(track_id: str, position: float = 0):
|
def set_pos_fn(track_id: str, position: float = 0):
|
||||||
if self.app_config.state.playing:
|
if self.app_config.state.playing:
|
||||||
self.on_play_pause()
|
self.play_pause()
|
||||||
pos_seconds = timedelta(microseconds=position)
|
pos_seconds = timedelta(microseconds=position)
|
||||||
self.app_config.state.song_progress = pos_seconds
|
self.app_config.state.song_progress = pos_seconds
|
||||||
track_id, occurrence = track_id.split("/")[-2:]
|
track_id, occurrence = track_id.split("/")[-2:]
|
||||||
@@ -451,7 +481,7 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
if self.app_config.state.shuffle_on:
|
if self.app_config.state.shuffle_on:
|
||||||
song_idx = random.randint(0, len(playlist.songs) - 1)
|
song_idx = random.randint(0, len(playlist.songs) - 1)
|
||||||
|
|
||||||
self.on_song_clicked(
|
self.play_song(
|
||||||
None,
|
None,
|
||||||
song_idx,
|
song_idx,
|
||||||
tuple(s.id for s in playlist.songs),
|
tuple(s.id for s in playlist.songs),
|
||||||
@@ -502,11 +532,11 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
|
|
||||||
def play():
|
def play():
|
||||||
if not self.app_config.state.playing:
|
if not self.app_config.state.playing:
|
||||||
self.on_play_pause()
|
self.play_pause()
|
||||||
|
|
||||||
def pause():
|
def pause():
|
||||||
if self.app_config.state.playing:
|
if self.app_config.state.playing:
|
||||||
self.on_play_pause()
|
self.play_pause()
|
||||||
|
|
||||||
method_call_map: Dict[str, Dict[str, Any]] = {
|
method_call_map: Dict[str, Dict[str, Any]] = {
|
||||||
"org.mpris.MediaPlayer2": {
|
"org.mpris.MediaPlayer2": {
|
||||||
@@ -514,10 +544,10 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
"Quit": self.window and self.window.destroy,
|
"Quit": self.window and self.window.destroy,
|
||||||
},
|
},
|
||||||
"org.mpris.MediaPlayer2.Player": {
|
"org.mpris.MediaPlayer2.Player": {
|
||||||
"Next": self.on_next_track,
|
"Next": self.next_track,
|
||||||
"Previous": self.on_prev_track,
|
"Previous": self.prev_track,
|
||||||
"Pause": pause,
|
"Pause": pause,
|
||||||
"PlayPause": self.on_play_pause,
|
"PlayPause": self.play_pause,
|
||||||
"Stop": pause,
|
"Stop": pause,
|
||||||
"Play": play,
|
"Play": play,
|
||||||
"Seek": seek_fn,
|
"Seek": seek_fn,
|
||||||
@@ -555,10 +585,10 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
|
|
||||||
def set_shuffle(new_val: GLib.Variant):
|
def set_shuffle(new_val: GLib.Variant):
|
||||||
if new_val.get_boolean() != self.app_config.state.shuffle_on:
|
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):
|
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]] = {
|
setter_map: Dict[str, Dict[str, Any]] = {
|
||||||
"org.mpris.MediaPlayer2.Player": {
|
"org.mpris.MediaPlayer2.Player": {
|
||||||
@@ -577,81 +607,49 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
setter(value)
|
setter(value)
|
||||||
|
|
||||||
# ########## ACTION HANDLERS ########## #
|
# ########## 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()
|
@dbus_propagate()
|
||||||
def on_refresh_window(self, _, state_updates: Dict[str, Any], force: bool = False):
|
def force_refresh(self):
|
||||||
if settings := state_updates.get("__settings__"):
|
self.update_window(force=True)
|
||||||
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__"]
|
def _save_and_refresh(self):
|
||||||
self.app_config.save()
|
self.app_config.save()
|
||||||
|
self.refresh()
|
||||||
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 on_notification_closed(self, _):
|
def on_notification_closed(self, _):
|
||||||
self.app_config.state.current_notification = None
|
self.app_config.state.current_notification = None
|
||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
def on_add_new_music_provider(self, *args):
|
_inhibit_cookie = None
|
||||||
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))
|
|
||||||
|
|
||||||
@dbus_propagate()
|
@dbus_propagate()
|
||||||
def on_play_pause(self, *args):
|
def play_pause(self):
|
||||||
if self.app_config.state.current_song_index < 0:
|
if self.app_config.state.current_song_index < 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -666,9 +664,17 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
# This is from a restart, start playing the file.
|
# This is from a restart, start playing the file.
|
||||||
self.play_song(self.app_config.state.current_song_index)
|
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()
|
self.update_window()
|
||||||
|
|
||||||
def on_next_track(self, *args):
|
def next_track(self):
|
||||||
if self.app_config.state.current_song is None:
|
if self.app_config.state.current_song is None:
|
||||||
# This may happen due to DBUS, ignore.
|
# This may happen due to DBUS, ignore.
|
||||||
return
|
return
|
||||||
@@ -694,7 +700,7 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
else:
|
else:
|
||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
def on_prev_track(self, *args):
|
def prev_track(self):
|
||||||
if self.app_config.state.current_song is None:
|
if self.app_config.state.current_song is None:
|
||||||
# This may happen due to DBUS, ignore.
|
# This may happen due to DBUS, ignore.
|
||||||
return
|
return
|
||||||
@@ -727,39 +733,39 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
@dbus_propagate()
|
@dbus_propagate()
|
||||||
def on_repeat_press(self, *args):
|
def repeat(self):
|
||||||
# Cycle through the repeat types.
|
# Cycle through the repeat types.
|
||||||
new_repeat_type = RepeatType((self.app_config.state.repeat_type.value + 1) % 3)
|
new_repeat_type = RepeatType((self.app_config.state.repeat_type.value + 1) % 3)
|
||||||
self.app_config.state.repeat_type = new_repeat_type
|
self.app_config.state.repeat_type = new_repeat_type
|
||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
@dbus_propagate()
|
@dbus_propagate()
|
||||||
def on_shuffle_press(self, *args):
|
def shuffle(self):
|
||||||
if self.app_config.state.shuffle_on:
|
if self.app_config.state.current_song:
|
||||||
# Revert to the old play queue.
|
if self.app_config.state.shuffle_on:
|
||||||
old_play_queue_copy = self.app_config.state.old_play_queue
|
# Revert to the old play queue.
|
||||||
self.app_config.state.current_song_index = old_play_queue_copy.index(
|
old_play_queue_copy = self.app_config.state.old_play_queue
|
||||||
self.app_config.state.current_song.id
|
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.play_queue = old_play_queue_copy
|
||||||
self.app_config.state.old_play_queue = self.app_config.state.play_queue
|
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.
|
# Remove the current song, then shuffle and put the song back.
|
||||||
song_id = self.app_config.state.current_song.id
|
song_id = self.app_config.state.current_song.id
|
||||||
del mutable_play_queue[self.app_config.state.current_song_index]
|
del mutable_play_queue[self.app_config.state.current_song_index]
|
||||||
random.shuffle(mutable_play_queue)
|
random.shuffle(mutable_play_queue)
|
||||||
self.app_config.state.play_queue = (song_id,) + tuple(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.current_song_index = 0
|
||||||
|
|
||||||
self.app_config.state.shuffle_on = not self.app_config.state.shuffle_on
|
self.app_config.state.shuffle_on = not self.app_config.state.shuffle_on
|
||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
@dbus_propagate()
|
@dbus_propagate()
|
||||||
def on_play_next(self, action: Any, song_ids: GLib.Variant):
|
def queue_next_songs(self, song_ids: List[str]):
|
||||||
song_ids = tuple(song_ids)
|
|
||||||
if self.app_config.state.current_song is None:
|
if self.app_config.state.current_song is None:
|
||||||
insert_at = 0
|
insert_at = 0
|
||||||
else:
|
else:
|
||||||
@@ -774,13 +780,13 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
@dbus_propagate()
|
@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)
|
song_ids = tuple(song_ids)
|
||||||
self.app_config.state.play_queue += tuple(song_ids)
|
self.app_config.state.play_queue += tuple(song_ids)
|
||||||
self.app_config.state.old_play_queue += tuple(song_ids)
|
self.app_config.state.old_play_queue += tuple(song_ids)
|
||||||
self.update_window()
|
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.
|
# Switch to the Alphabetical by Name view to guarantee that the album is there.
|
||||||
self.app_config.state.current_album_search_query = AlbumSearchQuery(
|
self.app_config.state.current_album_search_query = AlbumSearchQuery(
|
||||||
AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME,
|
AlbumSearchQuery.Type.ALPHABETICAL_BY_NAME,
|
||||||
@@ -789,22 +795,22 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.app_config.state.current_tab = "albums"
|
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()
|
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.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()
|
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.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()
|
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.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()
|
self.update_window()
|
||||||
|
|
||||||
def on_go_online(self, *args):
|
def on_go_online(self, *args):
|
||||||
@@ -815,7 +821,7 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
|
|
||||||
def reset_state(self):
|
def reset_state(self):
|
||||||
if self.app_config.state.playing:
|
if self.app_config.state.playing:
|
||||||
self.on_play_pause()
|
self.play_pause()
|
||||||
self.loading_state = True
|
self.loading_state = True
|
||||||
self.player_manager.reset()
|
self.player_manager.reset()
|
||||||
AdapterManager.reset(self.app_config, self.on_song_download_progress)
|
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.
|
# Update the window according to the new server configuration.
|
||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
def on_stack_change(self, stack: Gtk.Stack, _):
|
def change_tab(self, tab_id: str):
|
||||||
self.app_config.state.current_tab = stack.get_visible_child_name()
|
self.app_config.state.current_tab = tab_id
|
||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
def on_song_clicked(
|
def play_song_action(self, song_index: int, song_queue: List[str], metadata: Dict[str, Any]):
|
||||||
self,
|
if not song_queue:
|
||||||
win: Any,
|
song_queue = self.app_config.state.play_queue
|
||||||
song_index: int,
|
|
||||||
song_queue: Tuple[str, ...],
|
|
||||||
metadata: Dict[str, Any],
|
|
||||||
):
|
|
||||||
song_queue = tuple(song_queue)
|
song_queue = tuple(song_queue)
|
||||||
# Reset the play queue so that we don't ever revert back to the
|
# Reset the play queue so that we don't ever revert back to the
|
||||||
# previous one.
|
# previous one.
|
||||||
@@ -879,7 +882,7 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
|
|
||||||
if self.app_config.state.current_song_index in song_indexes_to_remove:
|
if self.app_config.state.current_song_index in song_indexes_to_remove:
|
||||||
if len(self.app_config.state.play_queue) == 0:
|
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.app_config.state.current_song_index = -1
|
||||||
self.update_window()
|
self.update_window()
|
||||||
return
|
return
|
||||||
@@ -892,7 +895,7 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
self.save_play_queue()
|
self.save_play_queue()
|
||||||
|
|
||||||
@dbus_propagate()
|
@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:
|
if not self.app_config.state.current_song or not self.window:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -900,14 +903,9 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
# a duration, but the Child object has `duration` optional because
|
# a duration, but the Child object has `duration` optional because
|
||||||
# it could be a directory.
|
# it could be a directory.
|
||||||
assert self.app_config.state.current_song.duration is not None
|
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.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 already playing, then make the player itself seek.
|
||||||
if self.player_manager and self.player_manager.song_loaded:
|
if self.player_manager and self.player_manager.song_loaded:
|
||||||
@@ -915,14 +913,14 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
|
|
||||||
self.save_play_queue()
|
self.save_play_queue()
|
||||||
|
|
||||||
def on_device_update(self, _, device_id: str):
|
def select_device(self, device_id: str):
|
||||||
assert self.player_manager
|
assert self.player_manager
|
||||||
if device_id == self.app_config.state.current_device:
|
if device_id == self.app_config.state.current_device:
|
||||||
return
|
return
|
||||||
self.app_config.state.current_device = device_id
|
self.app_config.state.current_device = device_id
|
||||||
|
|
||||||
if was_playing := self.app_config.state.playing:
|
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)
|
self.player_manager.set_current_device_id(self.app_config.state.current_device)
|
||||||
|
|
||||||
@@ -932,55 +930,38 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
if was_playing:
|
if was_playing:
|
||||||
self.on_play_pause()
|
self.play_pause()
|
||||||
if self.dbus_manager:
|
if self.dbus_manager:
|
||||||
self.dbus_manager.property_diff()
|
self.dbus_manager.property_diff()
|
||||||
|
|
||||||
@dbus_propagate()
|
@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.app_config.state.is_muted = not self.app_config.state.is_muted
|
||||||
self.player_manager.set_muted(self.app_config.state.is_muted)
|
self.player_manager.set_muted(self.app_config.state.is_muted)
|
||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
@dbus_propagate()
|
@dbus_propagate()
|
||||||
def on_volume_change(self, _, value: float):
|
def set_volume(self, value: float):
|
||||||
assert self.player_manager
|
|
||||||
self.app_config.state.volume = value
|
self.app_config.state.volume = value
|
||||||
self.player_manager.set_volume(self.app_config.state.volume)
|
self.player_manager.set_volume(self.app_config.state.volume)
|
||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey) -> bool:
|
def on_song_download_progress(self, song_id: str, progress: DownloadProgress):
|
||||||
# Need to use bitwise & here to see if CTRL is pressed.
|
if len(self._download_progress) == 0:
|
||||||
if event.keyval == 102 and event.state & Gdk.ModifierType.CONTROL_MASK:
|
GLib.timeout_add(100, self._propagate_download_progress)
|
||||||
# Ctrl + F
|
|
||||||
window.search_entry.grab_focus()
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Allow spaces to work in the text entry boxes.
|
# Amortize progress updates
|
||||||
if (
|
self._download_progress[song_id] = progress
|
||||||
window.search_entry.has_focus()
|
|
||||||
or window.playlists_panel.playlist_list.new_playlist_entry.has_focus()
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Spacebar, home/prev
|
def _propagate_download_progress(self):
|
||||||
keymap = {
|
items = list(self._download_progress.items())
|
||||||
32: self.on_play_pause,
|
self._download_progress = {}
|
||||||
65360: self.on_prev_track,
|
|
||||||
65367: self.on_next_track,
|
|
||||||
}
|
|
||||||
|
|
||||||
action = keymap.get(event.keyval)
|
for song_id, progress in items:
|
||||||
if action:
|
self.window.update_song_download_progress(song_id, progress)
|
||||||
action()
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
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"):
|
def on_app_shutdown(self, app: "SublimeMusicApp"):
|
||||||
self.exiting = True
|
self.exiting = True
|
||||||
if glib_notify_exists:
|
if glib_notify_exists:
|
||||||
@@ -1003,33 +984,126 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
self.dbus_manager.shutdown()
|
self.dbus_manager.shutdown()
|
||||||
AdapterManager.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 ########## #
|
# ########## HELPER METHODS ########## #
|
||||||
def show_configure_servers_dialog(
|
# def show_configure_servers_dialog(
|
||||||
self,
|
# self,
|
||||||
provider_config: Optional[ProviderConfiguration] = None,
|
# provider_config: Optional[ProviderConfiguration] = None,
|
||||||
):
|
# ):
|
||||||
"""Show the Connect to Server dialog."""
|
# """Show the Connect to Server dialog."""
|
||||||
dialog = ConfigureProviderDialog(self.window, provider_config)
|
# dialog = ConfigureProviderDialog(self.window, provider_config)
|
||||||
result = dialog.run()
|
# result = dialog.run()
|
||||||
if result == Gtk.ResponseType.APPLY:
|
# if result == Gtk.ResponseType.APPLY:
|
||||||
assert dialog.provider_config is not None
|
# assert dialog.provider_config is not None
|
||||||
provider_id = dialog.provider_config.id
|
# provider_id = dialog.provider_config.id
|
||||||
dialog.provider_config.persist_secrets()
|
# dialog.provider_config.persist_secrets()
|
||||||
self.app_config.providers[provider_id] = dialog.provider_config
|
# self.app_config.providers[provider_id] = dialog.provider_config
|
||||||
self.app_config.save()
|
# self.app_config.save()
|
||||||
|
|
||||||
if provider_id == self.app_config.current_provider_id:
|
# if provider_id == self.app_config.current_provider_id:
|
||||||
# Just update the window.
|
# # Just update the window.
|
||||||
self.update_window()
|
# self.update_window()
|
||||||
else:
|
# else:
|
||||||
# Switch to the new provider.
|
# # Switch to the new provider.
|
||||||
if self.app_config.state.playing:
|
# if self.app_config.state.playing:
|
||||||
self.on_play_pause()
|
# self.play_pause()
|
||||||
self.app_config.current_provider_id = provider_id
|
# self.app_config.current_provider_id = provider_id
|
||||||
self.app_config.save()
|
# self.app_config.save()
|
||||||
self.update_window(force=True)
|
# self.update_window(force=True)
|
||||||
|
|
||||||
dialog.destroy()
|
# dialog.destroy()
|
||||||
|
|
||||||
def update_window(self, force: bool = False):
|
def update_window(self, force: bool = False):
|
||||||
if not self.window:
|
if not self.window:
|
||||||
@@ -1064,7 +1138,7 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
def do_resume(clear_notification: bool):
|
def do_resume(clear_notification: bool):
|
||||||
assert self.player_manager
|
assert self.player_manager
|
||||||
if was_playing := self.app_config.state.playing:
|
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.play_queue = new_play_queue
|
||||||
self.app_config.state.song_progress = play_queue.position
|
self.app_config.state.song_progress = play_queue.position
|
||||||
@@ -1076,7 +1150,7 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
if was_playing:
|
if was_playing:
|
||||||
self.on_play_pause()
|
self.play_pause()
|
||||||
|
|
||||||
if prompt_confirm:
|
if prompt_confirm:
|
||||||
# If there's not a significant enough difference in the song state,
|
# 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
|
# 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.
|
# have to go online to play anything and then don't go further.
|
||||||
if was_playing := self.app_config.state.playing:
|
if was_playing := self.app_config.state.playing:
|
||||||
self.on_play_pause()
|
self.play_pause()
|
||||||
|
|
||||||
def go_online_clicked():
|
def go_online_clicked():
|
||||||
self.app_config.state.current_notification = None
|
self.app_config.state.current_notification = None
|
||||||
self.on_go_online()
|
self.on_go_online()
|
||||||
if was_playing:
|
if was_playing:
|
||||||
self.on_play_pause()
|
self.play_pause()
|
||||||
|
|
||||||
if all(s == SongCacheStatus.NOT_CACHED for s in statuses):
|
if all(s == SongCacheStatus.NOT_CACHED for s in statuses):
|
||||||
markup = (
|
markup = (
|
||||||
|
@@ -191,7 +191,7 @@ class AppConfiguration(DataClassJsonMixin):
|
|||||||
|
|
||||||
if self.version < 6:
|
if self.version < 6:
|
||||||
self.player_config = {
|
self.player_config = {
|
||||||
"Local Playback": {"Replay Gain": ["no", "track", "album"][self._rg]},
|
"Local Playback": {"Replay Gain": ["Disabled", "Track", "Album"][self._rg]},
|
||||||
"Chromecast": {
|
"Chromecast": {
|
||||||
"Serve Local Files to Chromecasts on the LAN": self._sol,
|
"Serve Local Files to Chromecasts on the LAN": self._sol,
|
||||||
"LAN Server Port Number": self._pn,
|
"LAN Server Port Number": self._pn,
|
||||||
|
288
sublime_music/ui/actions.py
Normal file
288
sublime_music/ui/actions.py
Normal 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()
|
@@ -4,7 +4,7 @@ import logging
|
|||||||
import math
|
import math
|
||||||
from typing import Any, Callable, cast, Iterable, List, Optional, Tuple
|
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 (
|
from ..adapters import (
|
||||||
AdapterManager,
|
AdapterManager,
|
||||||
@@ -14,8 +14,12 @@ from ..adapters import (
|
|||||||
Result,
|
Result,
|
||||||
)
|
)
|
||||||
from ..config import AppConfiguration
|
from ..config import AppConfiguration
|
||||||
from ..ui import util
|
from . import util
|
||||||
from ..ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage
|
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:
|
def _to_type(query_type: AlbumSearchQuery.Type) -> str:
|
||||||
@@ -46,30 +50,23 @@ def _from_str(type_str: str) -> AlbumSearchQuery.Type:
|
|||||||
}[type_str]
|
}[type_str]
|
||||||
|
|
||||||
|
|
||||||
class AlbumsPanel(Gtk.Box):
|
class AlbumsPanel(Handy.Leaflet):
|
||||||
__gsignals__ = {
|
current_query: AlbumSearchQuery = AlbumSearchQuery(AlbumSearchQuery.Type.RANDOM)
|
||||||
"song-clicked": (
|
|
||||||
GObject.SignalFlags.RUN_FIRST,
|
|
||||||
GObject.TYPE_NONE,
|
|
||||||
(int, object, object),
|
|
||||||
),
|
|
||||||
"refresh-window": (
|
|
||||||
GObject.SignalFlags.RUN_FIRST,
|
|
||||||
GObject.TYPE_NONE,
|
|
||||||
(object, bool),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
offline_mode = False
|
offline_mode = False
|
||||||
|
provider_id: Optional[str] = None
|
||||||
|
|
||||||
populating_genre_combo = False
|
populating_genre_combo = False
|
||||||
grid_order_token: int = 0
|
|
||||||
album_sort_direction: str = "ascending"
|
album_sort_direction: str = "ascending"
|
||||||
album_page_size: int = 30
|
|
||||||
album_page: int = 0
|
current_albums_result: Result = None
|
||||||
grid_pages_count: int = 0
|
|
||||||
|
albums = []
|
||||||
|
albums_by_id = {}
|
||||||
|
|
||||||
def __init__(self):
|
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()
|
actionbar = Gtk.ActionBar()
|
||||||
|
|
||||||
@@ -90,30 +87,37 @@ class AlbumsPanel(Gtk.Box):
|
|||||||
)
|
)
|
||||||
actionbar.pack_start(self.sort_type_combo)
|
actionbar.pack_start(self.sort_type_combo)
|
||||||
|
|
||||||
|
self.filter_stack = Gtk.Stack(no_show_all=True)
|
||||||
|
|
||||||
self.alphabetical_type_combo, _ = self.make_combobox(
|
self.alphabetical_type_combo, _ = self.make_combobox(
|
||||||
(("by_name", "by album name", True), ("by_artist", "by artist name", True)),
|
(("by_name", "by album name", True), ("by_artist", "by artist name", True)),
|
||||||
self.on_alphabetical_type_change,
|
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.genre_combo, self.genre_combo_store = self.make_combobox(
|
||||||
(), self.on_genre_change
|
(), 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
|
next_decade = (datetime.datetime.now().year // 10) * 10 + 10
|
||||||
|
|
||||||
self.from_year_label = Gtk.Label(label="from")
|
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 = Gtk.SpinButton.new_with_range(0, next_decade, 1)
|
||||||
self.from_year_spin_button.connect("value-changed", self.on_year_changed)
|
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")
|
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 = Gtk.SpinButton.new_with_range(0, next_decade, 1)
|
||||||
self.to_year_spin_button.connect("value-changed", self.on_year_changed)
|
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(
|
self.sort_toggle = IconButton(
|
||||||
"view-sort-descending-symbolic", "Sort descending", relief=True
|
"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)
|
self.sort_toggle.connect("clicked", self.on_sort_toggle_clicked)
|
||||||
actionbar.pack_start(self.sort_toggle)
|
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(
|
self.refresh_button = IconButton(
|
||||||
"view-refresh-symbolic", "Refresh list of albums", relief=True
|
"view-refresh-symbolic", "Refresh list of albums", relief=True
|
||||||
)
|
)
|
||||||
self.refresh_button.connect("clicked", self.on_refresh_clicked)
|
self.refresh_button.connect("clicked", self.on_refresh_clicked)
|
||||||
actionbar.pack_end(self.refresh_button)
|
actionbar.pack_end(self.refresh_button)
|
||||||
|
|
||||||
actionbar.pack_end(Gtk.Label(label="albums per page"))
|
self.grid_box.pack_start(actionbar, False, False, 0)
|
||||||
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.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(
|
def make_combobox(
|
||||||
self,
|
self,
|
||||||
@@ -231,7 +237,7 @@ class AlbumsPanel(Gtk.Box):
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.updating_query = False
|
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
|
self.updating_query = True
|
||||||
|
|
||||||
supported_type_strings = {
|
supported_type_strings = {
|
||||||
@@ -243,9 +249,36 @@ class AlbumsPanel(Gtk.Box):
|
|||||||
# (En|Dis)able getting genres.
|
# (En|Dis)able getting genres.
|
||||||
self.sort_type_combo_store[1][2] = AdapterManager.can_get_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.current_query = app_config.state.current_album_search_query
|
||||||
self.offline_mode = app_config.offline_mode
|
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(
|
self.alphabetical_type_combo.set_active_id(
|
||||||
{
|
{
|
||||||
@@ -262,13 +295,8 @@ class AlbumsPanel(Gtk.Box):
|
|||||||
|
|
||||||
# Update the page display
|
# Update the page display
|
||||||
if app_config:
|
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.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.
|
# Show/hide the combo boxes.
|
||||||
def show_if(sort_type: Iterable[AlbumSearchQuery.Type], *elements):
|
def show_if(sort_type: Iterable[AlbumSearchQuery.Type], *elements):
|
||||||
for element in elements:
|
for element in elements:
|
||||||
@@ -306,16 +334,100 @@ class AlbumsPanel(Gtk.Box):
|
|||||||
+ self._get_opposite_sort_dir(self.album_sort_direction)
|
+ 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
|
# Has to be last because it resets self.updating_query
|
||||||
self.populate_genre_combo(app_config, force=force)
|
self.populate_genre_combo(app_config, force=force)
|
||||||
|
|
||||||
# At this point, the current query should be totally updated.
|
selected_album = self.albums_by_id.get(app_config.state.selected_album_id, None) or (self.albums and self.albums[0])
|
||||||
self.grid_order_token = self.grid.update_params(app_config)
|
if selected_album is not None:
|
||||||
self.grid.update(self.grid_order_token, app_config, force=force)
|
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:
|
def _get_opposite_sort_dir(self, sort_dir: str) -> str:
|
||||||
return ("ascending", "descending")[0 if sort_dir == "descending" else 1]
|
return ("ascending", "descending")[0 if sort_dir == "descending" else 1]
|
||||||
@@ -327,85 +439,50 @@ class AlbumsPanel(Gtk.Box):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def on_sort_toggle_clicked(self, _):
|
def on_sort_toggle_clicked(self, _):
|
||||||
self.emit(
|
run_action(self, 'albums.set-search-query', self.current_query, self._get_opposite_sort_dir(self.album_sort_direction))
|
||||||
"refresh-window",
|
|
||||||
{
|
|
||||||
"album_sort_direction": self._get_opposite_sort_dir(
|
|
||||||
self.album_sort_direction
|
|
||||||
),
|
|
||||||
"album_page": 0,
|
|
||||||
"selected_album_id": None,
|
|
||||||
},
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_refresh_clicked(self, _):
|
def on_refresh_clicked(self, _):
|
||||||
self.emit("refresh-window", {}, True)
|
run_action(self, "app.force-refresh")
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
def on_type_combo_changed(self, combo: Gtk.ComboBox):
|
def on_type_combo_changed(self, combo: Gtk.ComboBox):
|
||||||
id = self.get_id(combo)
|
id = self.get_id(combo)
|
||||||
assert id
|
assert id
|
||||||
if id == "alphabetical":
|
if id == "alphabetical":
|
||||||
id += "_" + cast(str, self.get_id(self.alphabetical_type_combo))
|
id += "_" + cast(str, self.get_id(self.alphabetical_type_combo))
|
||||||
self.emit_if_not_updating(
|
run_action(
|
||||||
"refresh-window",
|
self,
|
||||||
{
|
'albums.set-search-query',
|
||||||
"current_album_search_query": AlbumSearchQuery(
|
AlbumSearchQuery(
|
||||||
_from_str(id),
|
_from_str(id),
|
||||||
self.current_query.year_range,
|
self.current_query.year_range,
|
||||||
self.current_query.genre,
|
self.current_query.genre,
|
||||||
),
|
),
|
||||||
"album_page": 0,
|
self.album_sort_direction)
|
||||||
"selected_album_id": None,
|
|
||||||
},
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_alphabetical_type_change(self, combo: Gtk.ComboBox):
|
def on_alphabetical_type_change(self, combo: Gtk.ComboBox):
|
||||||
id = "alphabetical_" + cast(str, self.get_id(combo))
|
id = "alphabetical_" + cast(str, self.get_id(combo))
|
||||||
self.emit_if_not_updating(
|
run_action(
|
||||||
"refresh-window",
|
self,
|
||||||
{
|
'albums.set-search-query',
|
||||||
"current_album_search_query": AlbumSearchQuery(
|
AlbumSearchQuery(
|
||||||
_from_str(id),
|
_from_str(id),
|
||||||
self.current_query.year_range,
|
self.current_query.year_range,
|
||||||
self.current_query.genre,
|
self.current_query.genre,
|
||||||
),
|
),
|
||||||
"album_page": 0,
|
self.album_sort_direction)
|
||||||
"selected_album_id": None,
|
|
||||||
},
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_genre_change(self, combo: Gtk.ComboBox):
|
def on_genre_change(self, combo: Gtk.ComboBox):
|
||||||
genre = self.get_id(combo)
|
genre = self.get_id(combo)
|
||||||
assert genre
|
assert genre
|
||||||
self.emit_if_not_updating(
|
run_action(
|
||||||
"refresh-window",
|
self,
|
||||||
{
|
'albums.set-search-query',
|
||||||
"current_album_search_query": AlbumSearchQuery(
|
AlbumSearchQuery(
|
||||||
self.current_query.type,
|
self.current_query.type,
|
||||||
self.current_query.year_range,
|
self.current_query.year_range,
|
||||||
AlbumsPanel._Genre(genre),
|
AlbumSearchQuery.Genre(genre),
|
||||||
),
|
),
|
||||||
"album_page": 0,
|
self.album_sort_direction)
|
||||||
"selected_album_id": None,
|
|
||||||
},
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_year_changed(self, entry: Gtk.SpinButton) -> bool:
|
def on_year_changed(self, entry: Gtk.SpinButton) -> bool:
|
||||||
year = int(entry.get_value())
|
year = int(entry.get_value())
|
||||||
@@ -415,27 +492,19 @@ class AlbumsPanel(Gtk.Box):
|
|||||||
else:
|
else:
|
||||||
new_year_tuple = (year, self.current_query.year_range[1])
|
new_year_tuple = (year, self.current_query.year_range[1])
|
||||||
|
|
||||||
self.emit_if_not_updating(
|
run_action(
|
||||||
"refresh-window",
|
self,
|
||||||
{
|
'albums.set-search-query',
|
||||||
"current_album_search_query": AlbumSearchQuery(
|
AlbumSearchQuery(
|
||||||
self.current_query.type, new_year_tuple, self.current_query.genre
|
self.current_query.type, new_year_tuple, self.current_query.genre
|
||||||
),
|
),
|
||||||
"album_page": 0,
|
self.album_sort_direction)
|
||||||
"selected_album_id": None,
|
|
||||||
},
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def on_page_entry_changed(self, entry: Gtk.Entry) -> bool:
|
def on_page_entry_changed(self, entry: Gtk.Entry) -> bool:
|
||||||
if len(text := entry.get_text()) > 0:
|
if len(text := entry.get_text()) > 0:
|
||||||
self.emit_if_not_updating(
|
run_action(self, 'albums.set-page', int(text) - 1)
|
||||||
"refresh-window",
|
|
||||||
{"album_page": int(text) - 1, "selected_album_id": None},
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def on_page_entry_insert_text(
|
def on_page_entry_insert_text(
|
||||||
@@ -452,43 +521,18 @@ class AlbumsPanel(Gtk.Box):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def on_prev_page_clicked(self, _):
|
def on_album_clicked(self, _:Any, child: Gtk.FlowBoxChild):
|
||||||
self.emit_if_not_updating(
|
album = self.albums[child.get_index()]
|
||||||
"refresh-window",
|
|
||||||
{"album_page": self.album_page - 1, "selected_album_id": None},
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_next_page_clicked(self, _):
|
if self.get_folded() and self.get_visible_child() == self.grid_box:
|
||||||
self.emit_if_not_updating(
|
self.set_visible_child(self.album_container)
|
||||||
"refresh-window",
|
|
||||||
{"album_page": self.album_page + 1, "selected_album_id": None},
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_grid_cover_clicked(self, grid: Any, id: str):
|
run_action(self, 'albums.select-album', album.id)
|
||||||
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)
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: REMOVE
|
||||||
class AlbumsGrid(Gtk.Overlay):
|
class AlbumsGrid(Gtk.Overlay):
|
||||||
"""Defines the albums panel."""
|
|
||||||
|
|
||||||
__gsignals__ = {
|
__gsignals__ = {
|
||||||
"cover-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,)),
|
"cover-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,)),
|
||||||
@@ -824,7 +868,7 @@ class AlbumsGrid(Gtk.Overlay):
|
|||||||
loading=False,
|
loading=False,
|
||||||
image_name="grid-artwork",
|
image_name="grid-artwork",
|
||||||
spinner_name="grid-artwork-spinner",
|
spinner_name="grid-artwork-spinner",
|
||||||
image_size=200,
|
image_size=150,
|
||||||
)
|
)
|
||||||
widget_box.pack_start(artwork, False, False, 0)
|
widget_box.pack_start(artwork, False, False, 0)
|
||||||
|
|
||||||
@@ -967,3 +1011,4 @@ class AlbumsGrid(Gtk.Overlay):
|
|||||||
self.grid_bottom.unselect_all()
|
self.grid_bottom.unselect_all()
|
||||||
|
|
||||||
self.currently_selected_index = selected_index
|
self.currently_selected_index = selected_index
|
||||||
|
"""
|
||||||
|
@@ -49,12 +49,12 @@
|
|||||||
min-width: 230px;
|
min-width: 230px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#icon-button-box image {
|
.icon-button-box image {
|
||||||
margin: 5px 2px;
|
margin: 5px 2px;
|
||||||
min-width: 15px;
|
min-width: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#icon-button-box label {
|
.icon-button-box label {
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
margin-right: 3px;
|
margin-right: 3px;
|
||||||
}
|
}
|
||||||
@@ -161,12 +161,12 @@ entry.invalid {
|
|||||||
|
|
||||||
/* ********** Playback Controls ********** */
|
/* ********** Playback Controls ********** */
|
||||||
#player-controls-album-artwork {
|
#player-controls-album-artwork {
|
||||||
min-height: 70px;
|
/*min-height: 70px;
|
||||||
min-width: 70px;
|
min-width: 70px;*/
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#player-controls-bar #play-button {
|
.play-button-large {
|
||||||
min-height: 45px;
|
min-height: 45px;
|
||||||
min-width: 35px;
|
min-width: 35px;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
@@ -174,7 +174,7 @@ entry.invalid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Make the play icon look centered. */
|
/* Make the play icon look centered. */
|
||||||
#player-controls-bar #play-button image {
|
.play-button-large image {
|
||||||
margin-left: 5px;
|
margin-left: 5px;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
@@ -182,7 +182,7 @@ entry.invalid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#player-controls-bar #song-scrubber {
|
#player-controls-bar #song-scrubber {
|
||||||
min-width: 400px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#player-controls-bar #volume-slider {
|
#player-controls-bar #volume-slider {
|
||||||
@@ -194,6 +194,7 @@ entry.invalid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#player-controls-bar #song-title {
|
#player-controls-bar #song-title {
|
||||||
|
min-width: 150px;
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
@@ -235,11 +236,6 @@ entry.invalid {
|
|||||||
min-width: 35px;
|
min-width: 35px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ********** General ********** */
|
|
||||||
.menu-button {
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ********** Search ********** */
|
/* ********** Search ********** */
|
||||||
#search-results {
|
#search-results {
|
||||||
min-width: 400px;
|
min-width: 400px;
|
||||||
@@ -276,34 +272,12 @@ entry.invalid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ********** Artists & Albums ********** */
|
/* ********** 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 {
|
#grid-header-label {
|
||||||
margin-left: 10px;
|
margin-top: 5px;
|
||||||
margin-right: 10px;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
#grid-info-label {
|
#grid-info-label {
|
||||||
margin-left: 10px;
|
|
||||||
margin-right: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#artist-album-artwork {
|
#artist-album-artwork {
|
||||||
@@ -338,3 +312,7 @@ entry.invalid {
|
|||||||
inset 0 -5px 5px @box_shadow_color;
|
inset 0 -5px 5px @box_shadow_color;
|
||||||
background-color: @box_shadow_color;
|
background-color: @box_shadow_color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.details-panel {
|
||||||
|
box-shadow: inset 5px 0 5px @box_shadow_color;
|
||||||
|
}
|
||||||
|
@@ -5,7 +5,7 @@ from typing import cast, List, Sequence
|
|||||||
|
|
||||||
import bleach
|
import bleach
|
||||||
|
|
||||||
from gi.repository import Gio, GLib, GObject, Gtk, Pango
|
from gi.repository import Gio, GLib, GObject, Gtk, Pango, Handy
|
||||||
|
|
||||||
from ..adapters import (
|
from ..adapters import (
|
||||||
AdapterManager,
|
AdapterManager,
|
||||||
@@ -15,41 +15,44 @@ from ..adapters import (
|
|||||||
)
|
)
|
||||||
from ..config import AppConfiguration
|
from ..config import AppConfiguration
|
||||||
from ..ui import util
|
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."""
|
"""Defines the arist panel."""
|
||||||
|
|
||||||
__gsignals__ = {
|
def __init__(self):
|
||||||
"song-clicked": (
|
super().__init__(
|
||||||
GObject.SignalFlags.RUN_FIRST,
|
transition_type=Handy.LeafletTransitionType.SLIDE,
|
||||||
GObject.TYPE_NONE,
|
can_swipe_forward=False,
|
||||||
(int, object, object),
|
interpolate_size=False)
|
||||||
),
|
|
||||||
"refresh-window": (
|
|
||||||
GObject.SignalFlags.RUN_FIRST,
|
|
||||||
GObject.TYPE_NONE,
|
|
||||||
(object, bool),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
|
|
||||||
|
|
||||||
|
list_sizer = Sizer(natural_width=400)
|
||||||
self.artist_list = ArtistList()
|
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 = ArtistDetailPanel()
|
||||||
self.artist_detail_panel.connect(
|
details_sizer.add(self.artist_detail_panel)
|
||||||
"song-clicked",
|
self.add(details_sizer)
|
||||||
lambda _, *args: self.emit("song-clicked", *args),
|
|
||||||
)
|
def artist_clicked(_):
|
||||||
self.artist_detail_panel.connect(
|
if self.get_folded():
|
||||||
"refresh-window",
|
self.set_visible_child(details_sizer)
|
||||||
lambda _, *args: self.emit("refresh-window", *args),
|
self.artist_list.connect("artist-clicked", artist_clicked)
|
||||||
)
|
|
||||||
self.pack2(self.artist_detail_panel, True, False)
|
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):
|
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||||
self.artist_list.update(app_config=app_config)
|
self.artist_list.update(app_config=app_config)
|
||||||
@@ -69,6 +72,10 @@ class _ArtistModel(GObject.GObject):
|
|||||||
|
|
||||||
|
|
||||||
class ArtistList(Gtk.Box):
|
class ArtistList(Gtk.Box):
|
||||||
|
__gsignals__ = {
|
||||||
|
"artist-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
|
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
|
||||||
@@ -119,8 +126,9 @@ class ArtistList(Gtk.Box):
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
self.artists_store = Gio.ListStore()
|
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.bind_model(self.artists_store, create_artist_row)
|
||||||
|
self.list.connect("row-selected", lambda *_: self.emit("artist-clicked"))
|
||||||
list_scroll_window.add(self.list)
|
list_scroll_window.add(self.list)
|
||||||
|
|
||||||
self.pack_start(list_scroll_window, True, True, 0)
|
self.pack_start(list_scroll_window, True, True, 0)
|
||||||
@@ -180,24 +188,20 @@ class ArtistList(Gtk.Box):
|
|||||||
self.loading_indicator.hide()
|
self.loading_indicator.hide()
|
||||||
|
|
||||||
|
|
||||||
|
ARTIST_ARTWORK_SIZE_DESKTOP=200
|
||||||
|
ARTIST_ARTWORK_SIZE_MOBILE=80
|
||||||
|
|
||||||
|
|
||||||
class ArtistDetailPanel(Gtk.Box):
|
class ArtistDetailPanel(Gtk.Box):
|
||||||
"""Defines the artists list."""
|
"""Defines the artists list."""
|
||||||
|
|
||||||
__gsignals__ = {
|
__gsignals__ = {
|
||||||
"song-clicked": (
|
"back-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
||||||
GObject.SignalFlags.RUN_FIRST,
|
|
||||||
GObject.TYPE_NONE,
|
|
||||||
(int, object, object),
|
|
||||||
),
|
|
||||||
"refresh-window": (
|
|
||||||
GObject.SignalFlags.RUN_FIRST,
|
|
||||||
GObject.TYPE_NONE,
|
|
||||||
(object, bool),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
show_mobile = GObject.Property(type=bool, default=False)
|
||||||
|
|
||||||
update_order_token = 0
|
update_order_token = 0
|
||||||
artist_details_expanded = False
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -206,134 +210,136 @@ class ArtistDetailPanel(Gtk.Box):
|
|||||||
orientation=Gtk.Orientation.VERTICAL,
|
orientation=Gtk.Orientation.VERTICAL,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.connect("notify::show-mobile", self.on_show_mobile_changed)
|
||||||
|
|
||||||
self.albums: Sequence[API.Album] = []
|
self.albums: Sequence[API.Album] = []
|
||||||
self.artist_id = None
|
self.artist_id = None
|
||||||
|
|
||||||
# Artist info panel
|
action_bar = Gtk.ActionBar()
|
||||||
self.big_info_panel = Gtk.Box(
|
|
||||||
orientation=Gtk.Orientation.HORIZONTAL, name="artist-info-panel"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.artist_artwork = SpinnerImage(
|
back_button_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.CROSSFADE)
|
||||||
loading=False,
|
self.bind_property("show-mobile", back_button_revealer, "reveal-child", GObject.BindingFlags.SYNC_CREATE)
|
||||||
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)
|
|
||||||
|
|
||||||
# Action buttons, name, comment, number of songs, etc.
|
back_button = IconButton("go-previous-symbolic")
|
||||||
artist_details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
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")
|
self.refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info")
|
||||||
artist_details_box.add(self.artist_indicator)
|
self.refresh_button.connect("clicked", self.on_view_refresh_click)
|
||||||
|
action_bar.pack_end(self.refresh_button)
|
||||||
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.download_all_button = IconButton(
|
self.download_all_button = IconButton(
|
||||||
"folder-download-symbolic", "Download all songs by this artist"
|
"folder-download-symbolic", "Download all songs by this artist"
|
||||||
)
|
)
|
||||||
self.download_all_button.connect("clicked", self.on_download_all_click)
|
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.shuffle_button = IconButton("media-playlist-shuffle-symbolic")
|
||||||
self.refresh_button.connect("clicked", self.on_view_refresh_click)
|
self.shuffle_button.connect("clicked", self.on_shuffle_all_button)
|
||||||
self.artist_action_buttons.add(self.refresh_button)
|
action_bar.pack_end(self.shuffle_button)
|
||||||
|
|
||||||
action_buttons_container.pack_start(
|
self.play_button = IconButton("media-playback-start-symbolic")
|
||||||
self.artist_action_buttons, False, False, 10
|
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)
|
self.artist_artwork = SpinnerImage(
|
||||||
|
loading=False,
|
||||||
expand_button_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
image_size=ARTIST_ARTWORK_SIZE_DESKTOP,
|
||||||
self.expand_collapse_button = IconButton(
|
valign=Gtk.Align.START,
|
||||||
"pan-up-symbolic", "Expand playlist details"
|
|
||||||
)
|
)
|
||||||
self.expand_collapse_button.connect("clicked", self.on_expand_collapse_click)
|
info_panel.pack_start(self.artist_artwork, False, False, 10)
|
||||||
expand_button_container.pack_end(self.expand_collapse_button, False, False, 0)
|
|
||||||
action_buttons_container.add(expand_button_container)
|
|
||||||
|
|
||||||
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.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 = AlbumsListWithSongs()
|
||||||
self.albums_list.connect(
|
box.pack_start(self.albums_list, True, True, 0)
|
||||||
"song-clicked",
|
|
||||||
lambda _, *args: self.emit("song-clicked", *args),
|
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):
|
def update(self, app_config: AppConfiguration):
|
||||||
self.artist_id = app_config.state.selected_artist_id
|
self.artist_id = app_config.state.selected_artist_id
|
||||||
self.offline_mode = app_config.offline_mode
|
self.offline_mode = app_config.offline_mode
|
||||||
if app_config.state.selected_artist_id is None:
|
if app_config.state.selected_artist_id is None:
|
||||||
self.big_info_panel.hide()
|
self.shuffle_button.set_sensitive(False)
|
||||||
self.album_list_scrolledwindow.hide()
|
self.play_button.set_sensitive(False)
|
||||||
self.play_shuffle_buttons.hide()
|
|
||||||
else:
|
else:
|
||||||
self.update_order_token += 1
|
self.update_order_token += 1
|
||||||
self.album_list_scrolledwindow.show()
|
|
||||||
self.update_artist_view(
|
self.update_artist_view(
|
||||||
app_config.state.selected_artist_id,
|
app_config.state.selected_artist_id,
|
||||||
app_config=app_config,
|
app_config=app_config,
|
||||||
@@ -358,58 +364,28 @@ class ArtistDetailPanel(Gtk.Box):
|
|||||||
if order_token != self.update_order_token:
|
if order_token != self.update_order_token:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.big_info_panel.show_all()
|
# Scroll to top
|
||||||
|
self.scrolled_window.get_vadjustment().set_value(0)
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.artist_name.set_markup(bleach.clean(f"<b>{artist.name}</b>"))
|
self.artist_name.set_markup(bleach.clean(f"<b>{artist.name}</b>"))
|
||||||
self.artist_name.set_tooltip_text(artist.name)
|
self.artist_name.set_tooltip_text(artist.name)
|
||||||
|
|
||||||
if self.artist_details_expanded:
|
self.artist_stats_long.set_markup(self.format_stats(artist, short_time=False))
|
||||||
self.artist_artwork.get_style_context().remove_class("collapsed")
|
self.artist_stats_medium.set_markup(self.format_stats(artist, short_time=True))
|
||||||
self.artist_name.get_style_context().remove_class("collapsed")
|
self.artist_stats_short.set_markup(self.format_stats(artist, short_time=True, short_count=True))
|
||||||
self.artist_indicator.set_text("ARTIST")
|
|
||||||
self.artist_stats.set_markup(self.format_stats(artist))
|
|
||||||
|
|
||||||
if artist.biography:
|
biography = ""
|
||||||
self.artist_bio.set_markup(bleach.clean(artist.biography))
|
if artist.biography:
|
||||||
self.artist_bio.show()
|
biography += bleach.clean(artist.biography)
|
||||||
else:
|
|
||||||
self.artist_bio.hide()
|
|
||||||
|
|
||||||
if len(artist.similar_artists or []) > 0:
|
if artist.similar_artists:
|
||||||
self.similar_artists_label.set_markup("<b>Similar Artists:</b> ")
|
biography += "\n\n<b>Similar Artists:</b> "
|
||||||
for c in self.similar_artists_button_box.get_children():
|
|
||||||
self.similar_artists_button_box.remove(c)
|
|
||||||
|
|
||||||
for similar_artist in (artist.similar_artists or [])[:5]:
|
# TODO: Make links work
|
||||||
self.similar_artists_button_box.add(
|
biography += ", ".join(f"<a href=\"{a.id}\">{bleach.clean(a.name)}</a>" for a in artist.similar_artists[:6])
|
||||||
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()
|
|
||||||
|
|
||||||
self.play_shuffle_buttons.show_all()
|
self.artist_bio.set_markup(biography)
|
||||||
|
self.expand_button.set_sensitive(bool(biography))
|
||||||
|
|
||||||
self.update_artist_artwork(
|
self.update_artist_artwork(
|
||||||
artist.artist_image_url,
|
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.pack_start(load_error, True, True, 0)
|
||||||
self.error_container.show_all()
|
self.error_container.show_all()
|
||||||
if not has_data:
|
|
||||||
self.album_list_scrolledwindow.hide()
|
|
||||||
else:
|
else:
|
||||||
self.error_container.hide()
|
self.error_container.hide()
|
||||||
self.album_list_scrolledwindow.show()
|
|
||||||
|
|
||||||
self.albums = artist.albums or []
|
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_from_file(cover_art_filename)
|
||||||
self.artist_artwork.set_loading(False)
|
self.artist_artwork.set_loading(False)
|
||||||
|
|
||||||
if self.artist_details_expanded:
|
# if self.artist_details_expanded:
|
||||||
self.artist_artwork.set_image_size(300)
|
# self.artist_artwork.set_image_size(300)
|
||||||
else:
|
# else:
|
||||||
self.artist_artwork.set_image_size(70)
|
# self.artist_artwork.set_image_size(70)
|
||||||
|
|
||||||
# Event Handlers
|
# Event Handlers
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -503,40 +476,28 @@ class ArtistDetailPanel(Gtk.Box):
|
|||||||
def on_download_all_click(self, _):
|
def on_download_all_click(self, _):
|
||||||
AdapterManager.batch_download_songs(
|
AdapterManager.batch_download_songs(
|
||||||
self.get_artist_song_ids(),
|
self.get_artist_song_ids(),
|
||||||
before_download=lambda _: self.update_artist_view(
|
before_download=lambda _: GLib.idle_add(
|
||||||
self.artist_id,
|
lambda: self.update_artist_view(
|
||||||
order_token=self.update_order_token,
|
self.artist_id,
|
||||||
|
order_token=self.update_order_token,
|
||||||
|
)
|
||||||
),
|
),
|
||||||
on_song_download_complete=lambda _: self.update_artist_view(
|
on_song_download_complete=lambda _: GLib.idle_add(
|
||||||
self.artist_id,
|
lambda: self.update_artist_view(
|
||||||
order_token=self.update_order_token,
|
self.artist_id,
|
||||||
|
order_token=self.update_order_token,
|
||||||
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_play_all_clicked(self, _):
|
def on_play_all_clicked(self, _):
|
||||||
songs = self.get_artist_song_ids()
|
songs = self.get_artist_song_ids()
|
||||||
self.emit(
|
run_action(self, 'app.play-song', 0, songs, {"force_shuffle_state": GLib.Variant('b', False)})
|
||||||
"song-clicked",
|
|
||||||
0,
|
|
||||||
songs,
|
|
||||||
{"force_shuffle_state": False},
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_shuffle_all_button(self, _):
|
def on_shuffle_all_button(self, _):
|
||||||
songs = self.get_artist_song_ids()
|
songs = self.get_artist_song_ids()
|
||||||
self.emit(
|
song_idx = randint(0, len(songs) - 1)
|
||||||
"song-clicked",
|
run_action(self, 'app.play-song', song_idx, songs, {"force_shuffle_state": GLib.Variant('b', True)})
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Helper Methods
|
# Helper Methods
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -554,18 +515,27 @@ class ArtistDetailPanel(Gtk.Box):
|
|||||||
label=text, name=name, halign=Gtk.Align.START, xalign=0, **params
|
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 [])
|
album_count = artist.album_count or len(artist.albums or [])
|
||||||
song_count, duration = 0, timedelta(0)
|
song_count, duration = 0, timedelta(0)
|
||||||
for album in artist.albums or []:
|
for album in artist.albums or []:
|
||||||
song_count += album.song_count or 0
|
song_count += album.song_count or 0
|
||||||
duration += album.duration or timedelta(0)
|
duration += album.duration or timedelta(0)
|
||||||
|
|
||||||
return util.dot_join(
|
parts = []
|
||||||
"{} {}".format(album_count, util.pluralize("album", album_count)),
|
|
||||||
"{} {}".format(song_count, util.pluralize("song", song_count)),
|
if short_count:
|
||||||
util.format_sequence_duration(duration),
|
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]:
|
def get_artist_song_ids(self) -> List[str]:
|
||||||
try:
|
try:
|
||||||
@@ -592,14 +562,6 @@ class ArtistDetailPanel(Gtk.Box):
|
|||||||
|
|
||||||
|
|
||||||
class AlbumsListWithSongs(Gtk.Overlay):
|
class AlbumsListWithSongs(Gtk.Overlay):
|
||||||
__gsignals__ = {
|
|
||||||
"song-clicked": (
|
|
||||||
GObject.SignalFlags.RUN_FIRST,
|
|
||||||
GObject.TYPE_NONE,
|
|
||||||
(int, object, object),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
Gtk.Overlay.__init__(self)
|
Gtk.Overlay.__init__(self)
|
||||||
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
@@ -633,8 +595,8 @@ class AlbumsListWithSongs(Gtk.Overlay):
|
|||||||
|
|
||||||
if self.albums == new_albums:
|
if self.albums == new_albums:
|
||||||
# Just go through all of the colidren and update them.
|
# Just go through all of the colidren and update them.
|
||||||
for c in self.box.get_children():
|
for c, album in zip(self.box.get_children(), self.albums):
|
||||||
c.update(app_config=app_config, force=force)
|
c.update(album, app_config=app_config, force=force)
|
||||||
|
|
||||||
self.spinner.hide()
|
self.spinner.hide()
|
||||||
return
|
return
|
||||||
@@ -644,19 +606,16 @@ class AlbumsListWithSongs(Gtk.Overlay):
|
|||||||
remove_all()
|
remove_all()
|
||||||
|
|
||||||
for album in self.albums:
|
for album in self.albums:
|
||||||
album_with_songs = AlbumWithSongs(album, show_artist_name=False)
|
album_with_songs = AlbumWithSongs(show_artist_name=False)
|
||||||
album_with_songs.connect(
|
album_with_songs.update(album, app_config, force=force)
|
||||||
"song-clicked",
|
# album_with_songs.connect("song-selected", self.on_song_selected)
|
||||||
lambda _, *args: self.emit("song-clicked", *args),
|
|
||||||
)
|
|
||||||
album_with_songs.connect("song-selected", self.on_song_selected)
|
|
||||||
album_with_songs.show_all()
|
album_with_songs.show_all()
|
||||||
self.box.add(album_with_songs)
|
self.box.add(album_with_songs)
|
||||||
|
|
||||||
# Update everything (no force to ensure that if we are online, then everything
|
# Update everything (no force to ensure that if we are online, then everything
|
||||||
# is clickable)
|
# is clickable)
|
||||||
for c in self.box.get_children():
|
# for c in self.box.get_children():
|
||||||
c.update(app_config=app_config)
|
# c.update(app_config=app_config)
|
||||||
|
|
||||||
self.spinner.hide()
|
self.spinner.hide()
|
||||||
|
|
||||||
|
@@ -440,7 +440,7 @@ class MusicDirectoryList(Gtk.Box):
|
|||||||
self.loading_indicator.hide()
|
self.loading_indicator.hide()
|
||||||
|
|
||||||
def on_download_state_change(self, _):
|
def on_download_state_change(self, _):
|
||||||
self.update()
|
GLib.idle_add(self.update)
|
||||||
|
|
||||||
# Create Element Helper Functions
|
# Create Element Helper Functions
|
||||||
# ==================================================================================
|
# ==================================================================================
|
||||||
|
@@ -1,8 +1,10 @@
|
|||||||
from .album_with_songs import AlbumWithSongs
|
from .album_with_songs import AlbumWithSongs
|
||||||
from .icon_button import IconButton, IconMenuButton, IconToggleButton
|
from .icon_button import IconButton, IconMenuButton, IconToggleButton
|
||||||
from .load_error import LoadError
|
from .load_error import LoadError
|
||||||
|
from .sizer import Sizer
|
||||||
from .song_list_column import SongListColumn
|
from .song_list_column import SongListColumn
|
||||||
from .spinner_image import SpinnerImage
|
from .spinner_image import SpinnerImage
|
||||||
|
from .spinner_picture import SpinnerPicture
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
"AlbumWithSongs",
|
"AlbumWithSongs",
|
||||||
@@ -10,6 +12,8 @@ __all__ = (
|
|||||||
"IconMenuButton",
|
"IconMenuButton",
|
||||||
"IconToggleButton",
|
"IconToggleButton",
|
||||||
"LoadError",
|
"LoadError",
|
||||||
|
"Sizer",
|
||||||
"SongListColumn",
|
"SongListColumn",
|
||||||
"SpinnerImage",
|
"SpinnerImage",
|
||||||
|
"SpinnerPicture",
|
||||||
)
|
)
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
from random import randint
|
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.adapters import AdapterManager, api_objects as API, Result
|
||||||
from sublime_music.config import AppConfiguration
|
from sublime_music.config import AppConfiguration
|
||||||
@@ -11,97 +11,36 @@ from .icon_button import IconButton
|
|||||||
from .load_error import LoadError
|
from .load_error import LoadError
|
||||||
from .song_list_column import SongListColumn
|
from .song_list_column import SongListColumn
|
||||||
from .spinner_image import SpinnerImage
|
from .spinner_image import SpinnerImage
|
||||||
|
from ..actions import run_action
|
||||||
|
|
||||||
|
|
||||||
class AlbumWithSongs(Gtk.Box):
|
class AlbumWithSongs(Gtk.Box):
|
||||||
__gsignals__ = {
|
__gsignals__ = {
|
||||||
"song-selected": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
"back-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
||||||
"song-clicked": (
|
|
||||||
GObject.SignalFlags.RUN_FIRST,
|
|
||||||
GObject.TYPE_NONE,
|
|
||||||
(int, object, object),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
show_back_button = GObject.Property(type=bool, default=False)
|
||||||
|
|
||||||
|
album = None
|
||||||
offline_mode = True
|
offline_mode = True
|
||||||
|
|
||||||
def __init__(
|
cover_art_result = None
|
||||||
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
|
|
||||||
|
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
def __init__(self, show_artist_name: bool = True, scroll_contents: bool = False, **kwargs):
|
||||||
artist_artwork = SpinnerImage(
|
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
|
||||||
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 cover_art_future_done(f: Result):
|
self.show_artist_name = show_artist_name
|
||||||
artist_artwork.set_from_file(f.result())
|
|
||||||
artist_artwork.set_loading(False)
|
|
||||||
|
|
||||||
cover_art_filename_future = AdapterManager.get_cover_art_uri(
|
action_bar = Gtk.ActionBar()
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
back_button_revealer = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.CROSSFADE)
|
||||||
album_title_and_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
self.bind_property("show-back-button", back_button_revealer, "reveal-child", GObject.BindingFlags.SYNC_CREATE)
|
||||||
|
|
||||||
# TODO (#43): deal with super long-ass titles
|
back_button = IconButton("go-previous-symbolic")
|
||||||
album_title_and_buttons.add(
|
back_button.connect("clicked", lambda *_: self.emit("back-clicked"))
|
||||||
Gtk.Label(
|
back_button_revealer.add(back_button)
|
||||||
label=album.name,
|
|
||||||
name="artist-album-list-album-name",
|
|
||||||
halign=Gtk.Align.START,
|
|
||||||
ellipsize=Pango.EllipsizeMode.END,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.play_btn = IconButton(
|
action_bar.pack_start(back_button_revealer)
|
||||||
"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)
|
|
||||||
|
|
||||||
self.download_all_btn = IconButton(
|
self.download_all_btn = IconButton(
|
||||||
"folder-download-symbolic",
|
"folder-download-symbolic",
|
||||||
@@ -109,30 +48,112 @@ class AlbumWithSongs(Gtk.Box):
|
|||||||
sensitive=False,
|
sensitive=False,
|
||||||
)
|
)
|
||||||
self.download_all_btn.connect("clicked", self.on_download_all_click)
|
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)
|
self.add_to_queue_btn = IconButton(
|
||||||
|
"queue-back-symbolic",
|
||||||
stats: List[Any] = [
|
"Add all the songs in this album to the end of the play queue",
|
||||||
album.artist.name if show_artist_name and album.artist else None,
|
sensitive=False,
|
||||||
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,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
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()
|
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()
|
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
|
# clickable, cache status, title, duration, song ID
|
||||||
self.album_song_store = Gtk.ListStore(bool, str, str, str, str)
|
self.album_song_store = Gtk.ListStore(bool, str, str, str, str)
|
||||||
@@ -147,8 +168,7 @@ class AlbumWithSongs(Gtk.Box):
|
|||||||
margin_bottom=10,
|
margin_bottom=10,
|
||||||
)
|
)
|
||||||
selection = self.album_songs.get_selection()
|
selection = self.album_songs.get_selection()
|
||||||
selection.set_mode(Gtk.SelectionMode.MULTIPLE)
|
selection.set_mode(Gtk.SelectionMode.SINGLE)
|
||||||
selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
|
|
||||||
|
|
||||||
# Song status column.
|
# Song status column.
|
||||||
renderer = Gtk.CellRendererPixbuf()
|
renderer = Gtk.CellRendererPixbuf()
|
||||||
@@ -165,28 +185,23 @@ class AlbumWithSongs(Gtk.Box):
|
|||||||
self.album_songs.get_selection().connect(
|
self.album_songs.get_selection().connect(
|
||||||
"changed", self.on_song_selection_change
|
"changed", self.on_song_selection_change
|
||||||
)
|
)
|
||||||
album_details.add(self.album_songs)
|
contents_box.pack_start(self.album_songs, True, True, 0)
|
||||||
|
|
||||||
self.pack_end(album_details, True, True, 0)
|
|
||||||
|
|
||||||
self.update_album_songs(album.id)
|
|
||||||
|
|
||||||
# Event Handlers
|
# Event Handlers
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
def on_song_selection_change(self, event: Any):
|
def on_song_selection_change(self, selection: Gtk.TreeSelection):
|
||||||
if not self.album_songs.has_focus():
|
paths = selection.get_selected_rows()[1]
|
||||||
self.emit("song-selected")
|
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):
|
def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
|
||||||
if not self.album_song_store[idx[0]][0]:
|
if not self.album_song_store[idx[0]][0]:
|
||||||
return
|
return
|
||||||
# The song ID is in the last column of the model.
|
|
||||||
self.emit(
|
self.play_song(idx.get_indices()[0], {})
|
||||||
"song-clicked",
|
|
||||||
idx.get_indices()[0],
|
|
||||||
[m[-1] for m in self.album_song_store],
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_song_button_press(self, tree: Any, event: Gdk.EventButton) -> bool:
|
def on_song_button_press(self, tree: Any, event: Gdk.EventButton) -> bool:
|
||||||
if event.button == 3: # Right click
|
if event.button == 3: # Right click
|
||||||
@@ -198,7 +213,7 @@ class AlbumWithSongs(Gtk.Box):
|
|||||||
allow_deselect = False
|
allow_deselect = False
|
||||||
|
|
||||||
def on_download_state_change(song_id: str):
|
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
|
# Use the new selection instead of the old one for calculating what
|
||||||
# to do the right click on.
|
# to do the right click on.
|
||||||
@@ -230,34 +245,28 @@ class AlbumWithSongs(Gtk.Box):
|
|||||||
def on_download_all_click(self, btn: Any):
|
def on_download_all_click(self, btn: Any):
|
||||||
AdapterManager.batch_download_songs(
|
AdapterManager.batch_download_songs(
|
||||||
[x[-1] for x in self.album_song_store],
|
[x[-1] for x in self.album_song_store],
|
||||||
before_download=lambda _: self.update(),
|
before_download=lambda _: GLib.idle_add(self.update),
|
||||||
on_song_download_complete=lambda _: self.update(),
|
on_song_download_complete=lambda _: GLib.idle_add(self.update),
|
||||||
)
|
)
|
||||||
|
|
||||||
def play_btn_clicked(self, btn: Any):
|
def play_btn_clicked(self, btn: Any):
|
||||||
song_ids = [x[-1] for x in self.album_song_store]
|
self.play_song(0, {"force_shuffle_state": GLib.Variant('b', False)})
|
||||||
self.emit(
|
|
||||||
"song-clicked",
|
|
||||||
0,
|
|
||||||
song_ids,
|
|
||||||
{"force_shuffle_state": False},
|
|
||||||
)
|
|
||||||
|
|
||||||
def shuffle_btn_clicked(self, btn: Any):
|
def shuffle_btn_clicked(self, btn: Any):
|
||||||
song_ids = [x[-1] for x in self.album_song_store]
|
self.play_song(randint(0, len(self.album_song_store) - 1),
|
||||||
self.emit(
|
{"force_shuffle_state": GLib.Variant('b', True)})
|
||||||
"song-clicked",
|
|
||||||
randint(0, len(self.album_song_store) - 1),
|
def play_song(self, index: int, metadata: Dict[str, GLib.Variant]):
|
||||||
song_ids,
|
run_action(self, 'app.play-song', index, [m[-1] for m in self.album_song_store], metadata)
|
||||||
{"force_shuffle_state": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Helper Methods
|
# Helper Methods
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
def deselect_all(self):
|
def deselect_all(self):
|
||||||
self.album_songs.get_selection().unselect_all()
|
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:
|
if app_config:
|
||||||
# Deselect everything and reset the error container if switching between
|
# Deselect everything and reset the error container if switching between
|
||||||
# online and offline.
|
# online and offline.
|
||||||
@@ -266,9 +275,53 @@ class AlbumWithSongs(Gtk.Box):
|
|||||||
for c in self.error_container.get_children():
|
for c in self.error_container.get_children():
|
||||||
self.error_container.remove(c)
|
self.error_container.remove(c)
|
||||||
|
|
||||||
|
update_songs = True
|
||||||
|
|
||||||
self.offline_mode = app_config.offline_mode
|
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):
|
def set_loading(self, loading: bool):
|
||||||
if loading:
|
if loading:
|
||||||
@@ -346,8 +399,8 @@ class AlbumWithSongs(Gtk.Box):
|
|||||||
if any_song_playable:
|
if any_song_playable:
|
||||||
self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids))
|
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.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids))
|
||||||
self.play_next_btn.set_action_name("app.play-next")
|
self.play_next_btn.set_action_name("app.queue-next-songs")
|
||||||
self.add_to_queue_btn.set_action_name("app.add-to-queue")
|
self.add_to_queue_btn.set_action_name("app.queue-songs")
|
||||||
else:
|
else:
|
||||||
self.play_next_btn.set_action_name("")
|
self.play_next_btn.set_action_name("")
|
||||||
self.add_to_queue_btn.set_action_name("")
|
self.add_to_queue_btn.set_action_name("")
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from gi.repository import Gtk
|
from gi.repository import Gtk, GObject
|
||||||
|
|
||||||
|
|
||||||
class IconButton(Gtk.Button):
|
class IconButton(Gtk.Button):
|
||||||
@@ -16,8 +16,10 @@ class IconButton(Gtk.Button):
|
|||||||
Gtk.Button.__init__(self, **kwargs)
|
Gtk.Button.__init__(self, **kwargs)
|
||||||
|
|
||||||
self.icon_size = icon_size
|
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)
|
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
|
||||||
box.pack_start(self.image, False, False, 0)
|
box.pack_start(self.image, False, False, 0)
|
||||||
|
|
||||||
@@ -30,7 +32,21 @@ class IconButton(Gtk.Button):
|
|||||||
self.add(box)
|
self.add(box)
|
||||||
self.set_tooltip_text(tooltip_text)
|
self.set_tooltip_text(tooltip_text)
|
||||||
|
|
||||||
|
# TODO: Remove
|
||||||
def set_icon(self, icon_name: Optional[str]):
|
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)
|
self.image.set_from_icon_name(icon_name, self.icon_size)
|
||||||
|
|
||||||
|
|
||||||
@@ -46,8 +62,10 @@ class IconToggleButton(Gtk.ToggleButton):
|
|||||||
):
|
):
|
||||||
Gtk.ToggleButton.__init__(self, **kwargs)
|
Gtk.ToggleButton.__init__(self, **kwargs)
|
||||||
self.icon_size = icon_size
|
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)
|
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
|
||||||
box.add(self.image)
|
box.add(self.image)
|
||||||
|
|
||||||
@@ -60,15 +78,22 @@ class IconToggleButton(Gtk.ToggleButton):
|
|||||||
self.add(box)
|
self.add(box)
|
||||||
self.set_tooltip_text(tooltip_text)
|
self.set_tooltip_text(tooltip_text)
|
||||||
|
|
||||||
|
# TODO: Remove
|
||||||
def set_icon(self, icon_name: Optional[str]):
|
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)
|
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):
|
class IconMenuButton(Gtk.MenuButton):
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -88,7 +113,8 @@ class IconMenuButton(Gtk.MenuButton):
|
|||||||
self.set_popover(popover)
|
self.set_popover(popover)
|
||||||
|
|
||||||
self.icon_size = icon_size
|
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)
|
self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
|
||||||
box.add(self.image)
|
box.add(self.image)
|
||||||
|
44
sublime_music/ui/common/sizer.py
Normal file
44
sublime_music/ui/common/sizer.py
Normal 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)
|
||||||
|
|
||||||
|
|
81
sublime_music/ui/common/spinner_picture.py
Normal file
81
sublime_music/ui/common/spinner_picture.py
Normal 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,
|
||||||
|
)
|
@@ -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
@@ -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
|
|
3
sublime_music/ui/player_controls/__init__.py
Normal file
3
sublime_music/ui/player_controls/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .desktop import Desktop
|
||||||
|
from .mobile import MobileHandle, MobileFlap
|
||||||
|
from .manager import Manager
|
191
sublime_music/ui/player_controls/common.py
Normal file
191
sublime_music/ui/player_controls/common.py
Normal 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
|
402
sublime_music/ui/player_controls/desktop.py
Normal file
402
sublime_music/ui/player_controls/desktop.py
Normal 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
|
444
sublime_music/ui/player_controls/manager.py
Normal file
444
sublime_music/ui/player_controls/manager.py
Normal 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))
|
||||||
|
|
289
sublime_music/ui/player_controls/mobile.py
Normal file
289
sublime_music/ui/player_controls/mobile.py
Normal 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)
|
@@ -4,114 +4,161 @@ from random import randint
|
|||||||
from typing import Any, cast, Dict, List, Tuple
|
from typing import Any, cast, Dict, List, Tuple
|
||||||
|
|
||||||
from fuzzywuzzy import fuzz
|
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 ..adapters import AdapterManager, api_objects as API
|
||||||
from ..config import AppConfiguration
|
from ..config import AppConfiguration
|
||||||
from ..ui import util
|
from . import util
|
||||||
from ..ui.common import (
|
from .common import (
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadError,
|
LoadError,
|
||||||
SongListColumn,
|
SongListColumn,
|
||||||
SpinnerImage,
|
SpinnerImage,
|
||||||
|
Sizer,
|
||||||
)
|
)
|
||||||
|
from .actions import run_action
|
||||||
|
|
||||||
|
|
||||||
class EditPlaylistDialog(Gtk.Dialog):
|
class EditPlaylistWindow(Handy.Window):
|
||||||
def __init__(self, parent: Any, playlist: API.Playlist):
|
def __init__(self, main_window: Any, playlist: API.Playlist):
|
||||||
Gtk.Dialog.__init__(self, transient_for=parent, flags=Gtk.DialogFlags.MODAL)
|
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
|
# HEADER
|
||||||
self.header = Gtk.HeaderBar()
|
self.header_bar = Handy.HeaderBar()
|
||||||
self._set_title(playlist.name)
|
self._set_title(playlist.name)
|
||||||
|
|
||||||
cancel_button = Gtk.Button(label="Cancel")
|
cancel_button = Gtk.Button(label="Cancel")
|
||||||
cancel_button.connect("clicked", lambda _: self.close())
|
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.save_button = Gtk.Button(label="Save")
|
||||||
self.edit_button.get_style_context().add_class("suggested-action")
|
self.save_button.get_style_context().add_class("suggested-action")
|
||||||
self.edit_button.connect(
|
self.save_button.connect("clicked", self._on_save_clicked)
|
||||||
"clicked", lambda *a: self.response(Gtk.ResponseType.APPLY)
|
self.header_bar.pack_end(self.save_button)
|
||||||
)
|
|
||||||
self.header.pack_end(self.edit_button)
|
|
||||||
|
|
||||||
self.set_titlebar(self.header)
|
box.add(self.header_bar)
|
||||||
|
|
||||||
content_area = self.get_content_area()
|
clamp = Handy.Clamp(margin=12)
|
||||||
content_grid = Gtk.Grid(column_spacing=10, row_spacing=10, margin=10)
|
|
||||||
|
|
||||||
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)
|
list_box = Gtk.ListBox()
|
||||||
self.name_entry = Gtk.Entry(text=playlist.name, hexpand=True)
|
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)
|
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)
|
row = Handy.ActionRow(title="Comment")
|
||||||
self.comment_entry = Gtk.Entry(text=playlist.comment, hexpand=True)
|
self.comment_entry = Gtk.Entry(valign=Gtk.Align.CENTER, text=(playlist.comment or ''))
|
||||||
content_grid.attach(self.comment_entry, 1, 1, 1, 1)
|
row.add(self.comment_entry)
|
||||||
|
list_box.add(row)
|
||||||
|
|
||||||
content_grid.attach(make_label("Public"), 0, 2, 1, 1)
|
row = Handy.ActionRow(title="Public")
|
||||||
self.public_switch = Gtk.Switch(active=playlist.public, halign=Gtk.Align.START)
|
self.public_switch = Gtk.Switch(valign=Gtk.Align.CENTER, active=playlist.public)
|
||||||
content_grid.attach(self.public_switch, 1, 2, 1, 1)
|
row.add(self.public_switch)
|
||||||
|
list_box.add(row)
|
||||||
|
|
||||||
delete_button = Gtk.Button(label="Delete")
|
inner_box.add(list_box)
|
||||||
delete_button.connect("clicked", lambda *a: self.response(Gtk.ResponseType.NO))
|
|
||||||
content_grid.attach(delete_button, 0, 3, 1, 2)
|
|
||||||
|
|
||||||
content_area.add(content_grid)
|
delete_button = IconButton(label="Delete", icon_name="user-trash-symbolic", relief=True, halign=Gtk.Align.END)
|
||||||
self.show_all()
|
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):
|
def _on_name_change(self, entry: Gtk.Entry):
|
||||||
text = entry.get_text()
|
text = entry.get_text()
|
||||||
if len(text) > 0:
|
if len(text) > 0:
|
||||||
self._set_title(text)
|
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):
|
def _set_title(self, playlist_name: str):
|
||||||
self.header.props.title = f"Edit {playlist_name}"
|
self.header_bar.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(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistsPanel(Gtk.Paned):
|
class PlaylistsPanel(Handy.Leaflet):
|
||||||
"""Defines the playlists panel."""
|
"""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):
|
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.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 = PlaylistDetailPanel()
|
||||||
self.playlist_detail_panel.connect(
|
details_sizer.add(self.playlist_detail_panel)
|
||||||
"song-clicked",
|
self.add(details_sizer)
|
||||||
lambda _, *args: self.emit("song-clicked", *args),
|
|
||||||
)
|
def playlist_clicked(_):
|
||||||
self.playlist_detail_panel.connect(
|
if self.get_folded():
|
||||||
"refresh-window",
|
self.set_visible_child(details_sizer)
|
||||||
lambda _, *args: self.emit("refresh-window", *args),
|
self.playlist_list.connect("playlist-clicked", playlist_clicked)
|
||||||
)
|
|
||||||
self.pack2(self.playlist_detail_panel, True, False)
|
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):
|
def update(self, app_config: AppConfiguration = None, force: bool = False):
|
||||||
self.playlist_list.update(app_config=app_config, force=force)
|
self.playlist_list.update(app_config=app_config, force=force)
|
||||||
@@ -125,6 +172,7 @@ class PlaylistList(Gtk.Box):
|
|||||||
GObject.TYPE_NONE,
|
GObject.TYPE_NONE,
|
||||||
(object, bool),
|
(object, bool),
|
||||||
),
|
),
|
||||||
|
"playlist-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
||||||
}
|
}
|
||||||
|
|
||||||
offline_mode = False
|
offline_mode = False
|
||||||
@@ -220,6 +268,7 @@ class PlaylistList(Gtk.Box):
|
|||||||
self.playlists_store = Gio.ListStore()
|
self.playlists_store = Gio.ListStore()
|
||||||
self.list = Gtk.ListBox(name="playlist-list-listbox")
|
self.list = Gtk.ListBox(name="playlist-list-listbox")
|
||||||
self.list.bind_model(self.playlists_store, create_playlist_row)
|
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)
|
list_scroll_window.add(self.list)
|
||||||
self.pack_start(list_scroll_window, True, True, 0)
|
self.pack_start(list_scroll_window, True, True, 0)
|
||||||
|
|
||||||
@@ -311,20 +360,12 @@ class PlaylistList(Gtk.Box):
|
|||||||
|
|
||||||
class PlaylistDetailPanel(Gtk.Overlay):
|
class PlaylistDetailPanel(Gtk.Overlay):
|
||||||
__gsignals__ = {
|
__gsignals__ = {
|
||||||
"song-clicked": (
|
"back-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
|
||||||
GObject.SignalFlags.RUN_FIRST,
|
|
||||||
GObject.TYPE_NONE,
|
|
||||||
(int, object, object),
|
|
||||||
),
|
|
||||||
"refresh-window": (
|
|
||||||
GObject.SignalFlags.RUN_FIRST,
|
|
||||||
GObject.TYPE_NONE,
|
|
||||||
(object, bool),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
show_mobile = GObject.Property(type=bool, default=False)
|
||||||
|
|
||||||
playlist_id = None
|
playlist_id = None
|
||||||
playlist_details_expanded = False
|
|
||||||
offline_mode = False
|
offline_mode = False
|
||||||
|
|
||||||
editing_playlist_song_list: bool = False
|
editing_playlist_song_list: bool = False
|
||||||
@@ -332,8 +373,61 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
Gtk.Overlay.__init__(self, name="playlist-view-overlay")
|
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)
|
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)
|
playlist_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||||
|
|
||||||
self.playlist_artwork = SpinnerImage(
|
self.playlist_artwork = SpinnerImage(
|
||||||
@@ -360,79 +454,11 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
|||||||
self.playlist_stats = self.make_label(name="playlist-stats")
|
self.playlist_stats = self.make_label(name="playlist-stats")
|
||||||
playlist_details_box.add(self.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)
|
playlist_info_box.pack_start(playlist_details_box, True, True, 0)
|
||||||
|
|
||||||
# Action buttons & expand/collapse button
|
scrolled_box.add(playlist_info_box)
|
||||||
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)
|
|
||||||
|
|
||||||
# Playlist songs list
|
# Playlist songs list
|
||||||
self.playlist_song_scroll_window = Gtk.ScrolledWindow()
|
|
||||||
|
|
||||||
self.playlist_song_store = Gtk.ListStore(
|
self.playlist_song_store = Gtk.ListStore(
|
||||||
bool, # clickable
|
bool, # clickable
|
||||||
str, # cache status
|
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_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)
|
self.add(self.playlist_box)
|
||||||
|
|
||||||
playlist_view_spinner = Gtk.Spinner(active=True)
|
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.playlist_view_loading_box.add(playlist_view_spinner)
|
||||||
self.add_overlay(self.playlist_view_loading_box)
|
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
|
update_playlist_view_order_token = 0
|
||||||
|
|
||||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||||
@@ -555,40 +586,18 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
|||||||
|
|
||||||
self.playlist_id = playlist.id
|
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.
|
# Update the info display.
|
||||||
self.playlist_name.set_markup(f"<b>{playlist.name}</b>")
|
self.playlist_name.set_markup(f"<b>{playlist.name}</b>")
|
||||||
self.playlist_name.set_tooltip_text(playlist.name)
|
self.playlist_name.set_tooltip_text(playlist.name)
|
||||||
|
|
||||||
if self.playlist_details_expanded:
|
if playlist.comment:
|
||||||
self.playlist_artwork.get_style_context().remove_class("collapsed")
|
self.playlist_comment.set_text(playlist.comment)
|
||||||
self.playlist_name.get_style_context().remove_class("collapsed")
|
self.playlist_comment.set_tooltip_text(playlist.comment)
|
||||||
self.playlist_box.show_all()
|
self.playlist_comment.show()
|
||||||
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))
|
|
||||||
else:
|
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_comment.hide()
|
||||||
self.playlist_stats.hide()
|
|
||||||
|
self.playlist_stats.set_markup(self._format_stats(playlist))
|
||||||
|
|
||||||
# Update the artwork.
|
# Update the artwork.
|
||||||
self.update_playlist_artwork(playlist.cover_art, order_token=order_token)
|
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.pack_start(load_error, True, True, 0)
|
||||||
self.error_container.show_all()
|
self.error_container.show_all()
|
||||||
if not has_data:
|
if not has_data:
|
||||||
self.playlist_song_scroll_window.hide()
|
self.scrolled_window.hide()
|
||||||
else:
|
else:
|
||||||
self.error_container.hide()
|
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 song list model. This requires some fancy diffing to
|
||||||
# update the list.
|
# update the list.
|
||||||
@@ -677,7 +686,6 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
|||||||
self.editing_playlist_song_list = False
|
self.editing_playlist_song_list = False
|
||||||
|
|
||||||
self.playlist_view_loading_box.hide()
|
self.playlist_view_loading_box.hide()
|
||||||
self.playlist_action_buttons.show_all()
|
|
||||||
|
|
||||||
@util.async_callback(
|
@util.async_callback(
|
||||||
partial(AdapterManager.get_cover_art_uri, scheme="file"),
|
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_from_file(cover_art_filename)
|
||||||
self.playlist_artwork.set_loading(False)
|
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
|
# Event Handlers
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
def on_view_refresh_click(self, _):
|
def on_view_refresh_click(self, _):
|
||||||
@@ -715,52 +718,10 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
|||||||
def on_playlist_edit_button_click(self, _):
|
def on_playlist_edit_button_click(self, _):
|
||||||
assert self.playlist_id
|
assert self.playlist_id
|
||||||
playlist = AdapterManager.get_playlist_details(self.playlist_id).result()
|
playlist = AdapterManager.get_playlist_details(self.playlist_id).result()
|
||||||
dialog = EditPlaylistDialog(self.get_toplevel(), playlist)
|
window = EditPlaylistWindow(self.get_toplevel(), playlist)
|
||||||
playlist_deleted = False
|
|
||||||
|
|
||||||
result = dialog.run()
|
window.set_transient_for(self.get_toplevel())
|
||||||
# Using ResponseType.NO as the delete event.
|
window.show_all()
|
||||||
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()
|
|
||||||
|
|
||||||
def on_playlist_list_download_all_button_click(self, _):
|
def on_playlist_list_download_all_button_click(self, _):
|
||||||
def download_state_change(song_id: str):
|
def download_state_change(song_id: str):
|
||||||
@@ -777,39 +738,26 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
|||||||
on_song_download_complete=download_state_change,
|
on_song_download_complete=download_state_change,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def play_song(self, index: int, metadata: Dict[str, Any]):
|
||||||
|
metadata["active_playlist_id"] = GLib.Variant('s', self.playlist_id)
|
||||||
|
run_action(self, 'app.play-song', index, [m[-1] for m in self.playlist_song_store], metadata)
|
||||||
|
|
||||||
def on_play_all_clicked(self, _):
|
def on_play_all_clicked(self, _):
|
||||||
self.emit(
|
self.play_song(0, {"force_shuffle_state": GLib.Variant('b', False)})
|
||||||
"song-clicked",
|
|
||||||
0,
|
|
||||||
[m[-1] for m in self.playlist_song_store],
|
|
||||||
{"force_shuffle_state": False, "active_playlist_id": self.playlist_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_shuffle_all_button(self, _):
|
def on_shuffle_all_button(self, _):
|
||||||
self.emit(
|
self.play_song(
|
||||||
"song-clicked",
|
|
||||||
randint(0, len(self.playlist_song_store) - 1),
|
randint(0, len(self.playlist_song_store) - 1),
|
||||||
[m[-1] for m in self.playlist_song_store],
|
{"force_shuffle_state": GLib.Variant('b', True)})
|
||||||
{"force_shuffle_state": True, "active_playlist_id": self.playlist_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_expand_collapse_click(self, _):
|
def on_expand_collapse_click(self, _):
|
||||||
self.emit(
|
run_action(self, 'playlists.set-details-expanded', not self.playlist_details_expanded)
|
||||||
"refresh-window",
|
|
||||||
{"playlist_details_expanded": not self.playlist_details_expanded},
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_song_activated(self, _, idx: Gtk.TreePath, col: Any):
|
def on_song_activated(self, _, idx: Gtk.TreePath, col: Any):
|
||||||
if not self.playlist_song_store[idx[0]][0]:
|
if not self.playlist_song_store[idx[0]][0]:
|
||||||
return
|
return
|
||||||
# The song ID is in the last column of the model.
|
# The song ID is in the last column of the model.
|
||||||
self.emit(
|
self.play_song(idx.get_indices()[0], {})
|
||||||
"song-clicked",
|
|
||||||
idx.get_indices()[0],
|
|
||||||
[m[-1] for m in self.playlist_song_store],
|
|
||||||
{"active_playlist_id": self.playlist_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton) -> bool:
|
def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton) -> bool:
|
||||||
if event.button == 3: # Right click
|
if event.button == 3: # Right click
|
||||||
@@ -881,7 +829,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
|
|||||||
on_remove_songs_click,
|
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.
|
# If the click was on a selected row, don't deselect anything.
|
||||||
|
615
sublime_music/ui/providers.py
Normal file
615
sublime_music/ui/providers.py
Normal 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
262
sublime_music/ui/search.py
Normal 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))
|
@@ -81,20 +81,18 @@ class UIState:
|
|||||||
playlist_details_expanded: bool = True
|
playlist_details_expanded: bool = True
|
||||||
artist_details_expanded: bool = True
|
artist_details_expanded: bool = True
|
||||||
loading_play_queue: bool = False
|
loading_play_queue: bool = False
|
||||||
|
search_query: str = ''
|
||||||
# State for Album sort.
|
|
||||||
class _DefaultGenre(Genre):
|
|
||||||
def __init__(self):
|
|
||||||
self.name = "Rock"
|
|
||||||
|
|
||||||
current_album_search_query: AlbumSearchQuery = AlbumSearchQuery(
|
current_album_search_query: AlbumSearchQuery = AlbumSearchQuery(
|
||||||
AlbumSearchQuery.Type.RANDOM,
|
AlbumSearchQuery.Type.RANDOM,
|
||||||
genre=_DefaultGenre(),
|
|
||||||
year_range=this_decade(),
|
year_range=this_decade(),
|
||||||
)
|
)
|
||||||
|
|
||||||
active_playlist_id: Optional[str] = None
|
active_playlist_id: Optional[str] = None
|
||||||
|
|
||||||
|
# Pickle backwards compatibility
|
||||||
|
_DefaultGenre = AlbumSearchQuery.Genre
|
||||||
|
|
||||||
def __getstate__(self):
|
def __getstate__(self):
|
||||||
state = self.__dict__.copy()
|
state = self.__dict__.copy()
|
||||||
del state["song_stream_cache_progress"]
|
del state["song_stream_cache_progress"]
|
||||||
@@ -109,6 +107,11 @@ class UIState:
|
|||||||
self.current_notification = None
|
self.current_notification = None
|
||||||
self.playing = False
|
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):
|
def __init_available_players__(self):
|
||||||
from sublime_music.players import PlayerManager
|
from sublime_music.players import PlayerManager
|
||||||
|
|
||||||
|
@@ -213,9 +213,9 @@ def show_song_popover(
|
|||||||
play_next_button = Gtk.ModelButton(text="Play next", sensitive=False)
|
play_next_button = Gtk.ModelButton(text="Play next", sensitive=False)
|
||||||
add_to_queue_button = Gtk.ModelButton(text="Add to queue", sensitive=False)
|
add_to_queue_button = Gtk.ModelButton(text="Add to queue", sensitive=False)
|
||||||
if not offline_mode:
|
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))
|
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))
|
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)
|
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)
|
remove_download_button.set_sensitive(True)
|
||||||
play_next_button.set_action_target_value(GLib.Variant("as", song_ids))
|
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_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()
|
albums, artists, parents = set(), set(), set()
|
||||||
for song in songs:
|
for song in songs:
|
||||||
|
19
tests/ui_actions_test.py
Normal file
19
tests/ui_actions_test.py
Normal 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)')
|
Reference in New Issue
Block a user