406 lines
14 KiB
Python
406 lines
14 KiB
Python
import functools
|
|
import os
|
|
import re
|
|
|
|
from collections import defaultdict
|
|
from typing import Any, Callable, DefaultDict, Dict, List, Optional, Tuple
|
|
|
|
from deepdiff import DeepDiff
|
|
from gi.repository import Gio, GLib
|
|
|
|
from sublime.cache_manager import CacheManager
|
|
from sublime.config import AppConfiguration
|
|
from sublime.players import Player
|
|
from sublime.ui.state import RepeatType
|
|
|
|
|
|
def dbus_propagate(param_self: Any = None) -> Callable:
|
|
"""
|
|
Wraps a function which causes changes to DBus properties.
|
|
"""
|
|
def decorator(function: Callable) -> Callable:
|
|
@functools.wraps(function)
|
|
def wrapper(*args):
|
|
function(*args)
|
|
if (param_self or args[0]).dbus_manager:
|
|
(param_self or args[0]).dbus_manager.property_diff()
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
class DBusManager:
|
|
second_microsecond_conversion = 1000000
|
|
|
|
current_state: Dict = {}
|
|
|
|
def __init__(
|
|
self,
|
|
connection: Gio.DBusConnection,
|
|
do_on_method_call: Callable[[
|
|
Gio.DBusConnection,
|
|
str,
|
|
str,
|
|
str,
|
|
str,
|
|
GLib.Variant,
|
|
Gio.DBusMethodInvocation,
|
|
], None],
|
|
on_set_property: Callable[
|
|
[Gio.DBusConnection, str, str, str, str, GLib.Variant], None],
|
|
get_config_and_player: Callable[[], Tuple[AppConfiguration,
|
|
Optional[Player]]],
|
|
):
|
|
self.get_config_and_player = get_config_and_player
|
|
self.do_on_method_call = do_on_method_call
|
|
self.on_set_property = on_set_property
|
|
self.connection = connection
|
|
|
|
def dbus_name_acquired(connection: Gio.DBusConnection, name: str):
|
|
specs = [
|
|
'org.mpris.MediaPlayer2.xml',
|
|
'org.mpris.MediaPlayer2.Player.xml',
|
|
'org.mpris.MediaPlayer2.Playlists.xml',
|
|
'org.mpris.MediaPlayer2.TrackList.xml',
|
|
]
|
|
for spec in specs:
|
|
spec_path = os.path.join(
|
|
os.path.dirname(__file__),
|
|
f'mpris_specs/{spec}',
|
|
)
|
|
with open(spec_path) as f:
|
|
node_info = Gio.DBusNodeInfo.new_for_xml(f.read())
|
|
|
|
connection.register_object(
|
|
'/org/mpris/MediaPlayer2',
|
|
node_info.interfaces[0],
|
|
self.on_method_call,
|
|
self.on_get_property,
|
|
self.on_set_property,
|
|
)
|
|
|
|
# TODO (#127): I have no idea what to do here.
|
|
def dbus_name_lost(*args):
|
|
pass
|
|
|
|
self.bus_number = Gio.bus_own_name_on_connection(
|
|
connection,
|
|
'org.mpris.MediaPlayer2.sublimemusic',
|
|
Gio.BusNameOwnerFlags.NONE,
|
|
dbus_name_acquired,
|
|
dbus_name_lost,
|
|
)
|
|
|
|
def shutdown(self):
|
|
Gio.bus_unown_name(self.bus_number)
|
|
|
|
def on_get_property(
|
|
self,
|
|
connection: Gio.DBusConnection,
|
|
sender: str,
|
|
path: str,
|
|
interface: str,
|
|
property_name: str,
|
|
) -> GLib.Variant:
|
|
value = self.property_dict().get(interface, {}).get(property_name)
|
|
return DBusManager.to_variant(value)
|
|
|
|
def on_method_call(
|
|
self,
|
|
connection: Gio.DBusConnection,
|
|
sender: str,
|
|
path: str,
|
|
interface: str,
|
|
method: str,
|
|
params: GLib.Variant,
|
|
invocation: Gio.DBusMethodInvocation,
|
|
):
|
|
if not CacheManager.ready():
|
|
return
|
|
|
|
# TODO (#127): I don't even know if this works.
|
|
if interface == 'org.freedesktop.DBus.Properties':
|
|
if method == 'Get':
|
|
invocation.return_value(
|
|
self.on_get_property(
|
|
connection, sender, path, interface, *params))
|
|
elif method == 'Set':
|
|
self.on_set_property(
|
|
connection, sender, path, interface, *params)
|
|
elif method == 'GetAll':
|
|
all_properties = {
|
|
k: DBusManager.to_variant(v)
|
|
for k, v in self.property_dict()[interface].items()
|
|
}
|
|
invocation.return_value(
|
|
GLib.Variant('(a{sv})', (all_properties, )))
|
|
|
|
return
|
|
self.do_on_method_call(
|
|
connection,
|
|
sender,
|
|
path,
|
|
interface,
|
|
method,
|
|
params,
|
|
invocation,
|
|
)
|
|
|
|
@staticmethod
|
|
def to_variant(value: Any) -> GLib.Variant:
|
|
if callable(value):
|
|
return DBusManager.to_variant(value())
|
|
|
|
if isinstance(value, GLib.Variant):
|
|
return value
|
|
|
|
if type(value) == tuple:
|
|
return GLib.Variant(*value)
|
|
|
|
if type(value) == dict:
|
|
return GLib.Variant(
|
|
'a{sv}',
|
|
{k: DBusManager.to_variant(v)
|
|
for k, v in value.items()},
|
|
)
|
|
|
|
variant_type = {
|
|
list: 'as',
|
|
str: 's',
|
|
int: 'i',
|
|
float: 'd',
|
|
bool: 'b',
|
|
}.get(type(value))
|
|
if not variant_type:
|
|
return value
|
|
return GLib.Variant(variant_type, value)
|
|
|
|
def property_dict(self) -> Dict[str, Any]:
|
|
if not CacheManager.ready():
|
|
return {}
|
|
config, player = self.get_config_and_player()
|
|
state = config.state
|
|
has_current_song = state.current_song is not None
|
|
has_next_song = False
|
|
if state.repeat_type in (RepeatType.REPEAT_QUEUE,
|
|
RepeatType.REPEAT_SONG):
|
|
has_next_song = True
|
|
elif has_current_song:
|
|
has_next_song = (
|
|
state.current_song_index < len(state.play_queue) - 1)
|
|
|
|
if state.active_playlist_id is None:
|
|
active_playlist = (False, GLib.Variant('(oss)', ('/', '', '')))
|
|
else:
|
|
playlist_result = CacheManager.get_playlist(
|
|
state.active_playlist_id)
|
|
|
|
if playlist_result.is_future:
|
|
# If we have to wait for the playlist result, just return
|
|
# no playlist.
|
|
active_playlist = (False, GLib.Variant('(oss)', ('/', '', '')))
|
|
else:
|
|
playlist = playlist_result.result()
|
|
|
|
active_playlist = (
|
|
True,
|
|
GLib.Variant(
|
|
'(oss)',
|
|
(
|
|
'/playlist/' + playlist.id,
|
|
playlist.name,
|
|
CacheManager.get_cover_art_url(playlist.coverArt),
|
|
),
|
|
),
|
|
)
|
|
|
|
get_playlists_result = CacheManager.get_playlists()
|
|
if get_playlists_result.is_future:
|
|
playlist_count = 0
|
|
else:
|
|
playlist_count = len(get_playlists_result.result())
|
|
|
|
return {
|
|
'org.mpris.MediaPlayer2': {
|
|
'CanQuit': True,
|
|
'CanRaise': True,
|
|
'HasTrackList': True,
|
|
'Identity': 'Sublime Music',
|
|
'DesktopEntry': 'sublime-music',
|
|
'SupportedUriSchemes': [],
|
|
'SupportedMimeTypes': [],
|
|
},
|
|
'org.mpris.MediaPlayer2.Player': {
|
|
'PlaybackStatus': {
|
|
(False, False): 'Stopped',
|
|
(False, True): 'Stopped',
|
|
(True, False): 'Paused',
|
|
(True, True): 'Playing',
|
|
}[player is not None and player.song_loaded, state.playing],
|
|
'LoopStatus':
|
|
state.repeat_type.as_mpris_loop_status(),
|
|
'Rate':
|
|
1.0,
|
|
'Shuffle':
|
|
state.shuffle_on,
|
|
'Metadata':
|
|
self.get_mpris_metadata(
|
|
state.current_song_index,
|
|
state.play_queue,
|
|
) if state.current_song else {},
|
|
'Volume':
|
|
0.0 if state.is_muted else state.volume / 100,
|
|
'Position': (
|
|
'x',
|
|
int(
|
|
max(state.song_progress or 0, 0)
|
|
* self.second_microsecond_conversion),
|
|
),
|
|
'MinimumRate':
|
|
1.0,
|
|
'MaximumRate':
|
|
1.0,
|
|
'CanGoNext':
|
|
has_current_song and has_next_song,
|
|
'CanGoPrevious':
|
|
has_current_song,
|
|
'CanPlay':
|
|
True,
|
|
'CanPause':
|
|
True,
|
|
'CanSeek':
|
|
True,
|
|
'CanControl':
|
|
True,
|
|
},
|
|
'org.mpris.MediaPlayer2.TrackList': {
|
|
'Tracks': self.get_dbus_playlist(state.play_queue),
|
|
'CanEditTracks': False,
|
|
},
|
|
'org.mpris.MediaPlayer2.Playlists': {
|
|
'PlaylistCount': playlist_count,
|
|
'Orderings': ['Alphabetical', 'Created', 'Modified'],
|
|
'ActivePlaylist': ('(b(oss))', active_playlist),
|
|
},
|
|
}
|
|
|
|
def get_mpris_metadata(
|
|
self,
|
|
idx: int,
|
|
play_queue: List[str],
|
|
) -> Dict[str, Any]:
|
|
song_result = CacheManager.get_song_details(play_queue[idx])
|
|
if song_result.is_future:
|
|
return {}
|
|
song = song_result.result()
|
|
|
|
trackid = self.get_dbus_playlist(play_queue)[idx]
|
|
duration = (
|
|
'x',
|
|
(song.duration or 0) * self.second_microsecond_conversion,
|
|
)
|
|
|
|
return {
|
|
'mpris:trackid': trackid,
|
|
'mpris:length': duration,
|
|
'mpris:artUrl': CacheManager.get_cover_art_url(song.coverArt),
|
|
'xesam:album': song.album or '',
|
|
'xesam:albumArtist': [song.artist or ''],
|
|
'xesam:artist': [song.artist or ''],
|
|
'xesam:title': song.title,
|
|
}
|
|
|
|
def get_dbus_playlist(self, play_queue: List[str]) -> List[str]:
|
|
seen_counts: DefaultDict[str, int] = defaultdict(int)
|
|
tracks = []
|
|
for song_id in play_queue:
|
|
id_ = seen_counts[song_id]
|
|
tracks.append(f'/song/{song_id}/{id_}')
|
|
seen_counts[song_id] += 1
|
|
|
|
return tracks
|
|
|
|
diff_parse_re = re.compile(r"root\['(.*?)'\]\['(.*?)'\](?:\[.*\])?")
|
|
|
|
def property_diff(self):
|
|
new_property_dict = self.property_dict()
|
|
diff = DeepDiff(self.current_state, new_property_dict)
|
|
|
|
changes = defaultdict(dict)
|
|
|
|
for path, change in diff.get('values_changed', {}).items():
|
|
interface, property_name = self.diff_parse_re.match(path).groups()
|
|
changes[interface][property_name] = change['new_value']
|
|
|
|
if diff.get('dictionary_item_added'):
|
|
changes = new_property_dict
|
|
|
|
for interface, changed_props in changes.items():
|
|
# If the metadata has changed, just make the entire Metadata object
|
|
# part of the update.
|
|
if 'Metadata' in changed_props.keys():
|
|
changed_props['Metadata'] = new_property_dict[interface][
|
|
'Metadata']
|
|
|
|
# Special handling for when the position changes (a seek).
|
|
# Technically, I'm sending this signal too often, but I don't think
|
|
# it really matters.
|
|
if (interface == 'org.mpris.MediaPlayer2.Player'
|
|
and 'Position' in changed_props):
|
|
self.connection.emit_signal(
|
|
None,
|
|
'/org/mpris/MediaPlayer2',
|
|
interface,
|
|
'Seeked',
|
|
GLib.Variant('(x)', (changed_props['Position'][1], )),
|
|
)
|
|
|
|
# Do not emit the property change.
|
|
del changed_props['Position']
|
|
|
|
# Special handling for when the track list changes.
|
|
# Technically, I'm supposed to use `TrackAdded` and `TrackRemoved`
|
|
# signals when minor changes occur, but the docs also say that:
|
|
#
|
|
# > It is left up to the implementation to decide when a change to
|
|
# > the track list is invasive enough that this signal should be
|
|
# > emitted instead of a series of TrackAdded and TrackRemoved
|
|
# > signals.
|
|
#
|
|
# So I think that any change is invasive enough that I should use
|
|
# this signal.
|
|
if (interface == 'org.mpris.MediaPlayer2.TrackList'
|
|
and 'Tracks' in changed_props):
|
|
track_list = changed_props['Tracks']
|
|
if len(track_list) > 0:
|
|
current_track = (
|
|
new_property_dict['org.mpris.MediaPlayer2.Player']
|
|
['Metadata'].get('mpris:trackid', track_list[0]))
|
|
self.connection.emit_signal(
|
|
None,
|
|
'/org/mpris/MediaPlayer2',
|
|
interface,
|
|
'TrackListReplaced',
|
|
GLib.Variant('(aoo)', (track_list, current_track)),
|
|
)
|
|
|
|
self.connection.emit_signal(
|
|
None,
|
|
'/org/mpris/MediaPlayer2',
|
|
'org.freedesktop.DBus.Properties',
|
|
'PropertiesChanged',
|
|
GLib.Variant(
|
|
'(sa{sv}as)', (
|
|
interface,
|
|
{
|
|
k: DBusManager.to_variant(v)
|
|
for k, v in changed_props.items()
|
|
},
|
|
[],
|
|
)),
|
|
)
|
|
|
|
# Update state for next diff.
|
|
self.current_state = new_property_dict
|