mirror of
https://github.com/SoongNoonien/mpdevil.git
synced 2023-08-10 21:12:44 +03:00
4111 lines
151 KiB
Python
Executable File
4111 lines
151 KiB
Python
Executable File
#!/usr/bin/python3
|
||
# -*- coding: utf-8 -*-
|
||
#
|
||
# mpdevil - MPD Client.
|
||
# Copyright (C) 2020-2021 Martin Wagner <martin.wagner.dev@gmail.com>
|
||
#
|
||
# This program is free software: you can redistribute it and/or modify
|
||
# it under the terms of the GNU General Public License as published by
|
||
# the Free Software Foundation, either version 3 of the License, or
|
||
# (at your option) any later version.
|
||
#
|
||
# This program is distributed in the hope that it will be useful,
|
||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
# GNU General Public License for more details.
|
||
#
|
||
# You should have received a copy of the GNU General Public License
|
||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
|
||
import gi
|
||
gi.require_version("Gtk", "3.0")
|
||
gi.require_version("Notify", "0.7")
|
||
from gi.repository import Gtk, Gio, Gdk, GdkPixbuf, Pango, GObject, GLib, Notify
|
||
from mpd import MPDClient, base as MPDBase
|
||
import requests
|
||
from bs4 import BeautifulSoup
|
||
import threading
|
||
import datetime
|
||
import os
|
||
import sys
|
||
import re
|
||
from gettext import gettext as _, ngettext, textdomain, bindtextdomain
|
||
textdomain("mpdevil")
|
||
if os.path.isfile("/.flatpak-info"): # test for flatpak environment
|
||
bindtextdomain("mpdevil", "/app/share/locale")
|
||
else:
|
||
bindtextdomain("mpdevil", localedir=None) # replace "None" by a static path if needed (e.g when installing on a non-FHS distro)
|
||
|
||
VERSION="1.1.1-dev" # sync with setup.py
|
||
COVER_REGEX=r"^\.?(album|cover|folder|front).*\.(gif|jpeg|jpg|png)$"
|
||
|
||
|
||
#########
|
||
# MPRIS #
|
||
#########
|
||
|
||
class MPRISInterface: # TODO emit Seeked if needed
|
||
"""
|
||
based on 'Lollypop' (master 22.12.2020) by Cedric Bellegarde <cedric.bellegarde@adishatz.org>
|
||
and 'mpDris2' (master 19.03.2020) by Jean-Philippe Braun <eon@patapon.info>, Mantas Mikulėnas <grawity@gmail.com>
|
||
"""
|
||
_MPRIS_IFACE="org.mpris.MediaPlayer2"
|
||
_MPRIS_PLAYER_IFACE="org.mpris.MediaPlayer2.Player"
|
||
_MPRIS_NAME="org.mpris.MediaPlayer2.mpdevil"
|
||
_MPRIS_PATH="/org/mpris/MediaPlayer2"
|
||
_INTERFACES_XML="""
|
||
<!DOCTYPE node PUBLIC
|
||
"-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
|
||
"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
|
||
<node>
|
||
<interface name="org.freedesktop.DBus.Introspectable">
|
||
<method name="Introspect">
|
||
<arg name="data" direction="out" type="s"/>
|
||
</method>
|
||
</interface>
|
||
<interface name="org.freedesktop.DBus.Properties">
|
||
<method name="Get">
|
||
<arg name="interface" direction="in" type="s"/>
|
||
<arg name="property" direction="in" type="s"/>
|
||
<arg name="value" direction="out" type="v"/>
|
||
</method>
|
||
<method name="Set">
|
||
<arg name="interface_name" direction="in" type="s"/>
|
||
<arg name="property_name" direction="in" type="s"/>
|
||
<arg name="value" direction="in" type="v"/>
|
||
</method>
|
||
<method name="GetAll">
|
||
<arg name="interface" direction="in" type="s"/>
|
||
<arg name="properties" direction="out" type="a{sv}"/>
|
||
</method>
|
||
</interface>
|
||
<interface name="org.mpris.MediaPlayer2">
|
||
<method name="Raise">
|
||
</method>
|
||
<method name="Quit">
|
||
</method>
|
||
<property name="CanQuit" type="b" access="read" />
|
||
<property name="CanRaise" type="b" access="read" />
|
||
<property name="HasTrackList" type="b" access="read"/>
|
||
<property name="Identity" type="s" access="read"/>
|
||
<property name="DesktopEntry" type="s" access="read"/>
|
||
<property name="SupportedUriSchemes" type="as" access="read"/>
|
||
<property name="SupportedMimeTypes" type="as" access="read"/>
|
||
</interface>
|
||
<interface name="org.mpris.MediaPlayer2.Player">
|
||
<method name="Next"/>
|
||
<method name="Previous"/>
|
||
<method name="Pause"/>
|
||
<method name="PlayPause"/>
|
||
<method name="Stop"/>
|
||
<method name="Play"/>
|
||
<method name="Seek">
|
||
<arg direction="in" name="Offset" type="x"/>
|
||
</method>
|
||
<method name="SetPosition">
|
||
<arg direction="in" name="TrackId" type="o"/>
|
||
<arg direction="in" name="Position" type="x"/>
|
||
</method>
|
||
<method name="OpenUri">
|
||
<arg direction="in" name="Uri" type="s"/>
|
||
</method>
|
||
<signal name="Seeked">
|
||
<arg name="Position" type="x"/>
|
||
</signal>
|
||
<property name="PlaybackStatus" type="s" access="read"/>
|
||
<property name="LoopStatus" type="s" access="readwrite"/>
|
||
<property name="Rate" type="d" access="readwrite"/>
|
||
<property name="Shuffle" type="b" access="readwrite"/>
|
||
<property name="Metadata" type="a{sv}" access="read"/>
|
||
<property name="Volume" type="d" access="readwrite"/>
|
||
<property name="Position" type="x" access="read"/>
|
||
<property name="MinimumRate" type="d" access="read"/>
|
||
<property name="MaximumRate" type="d" access="read"/>
|
||
<property name="CanGoNext" type="b" access="read"/>
|
||
<property name="CanGoPrevious" type="b" access="read"/>
|
||
<property name="CanPlay" type="b" access="read"/>
|
||
<property name="CanPause" type="b" access="read"/>
|
||
<property name="CanSeek" type="b" access="read"/>
|
||
<property name="CanControl" type="b" access="read"/>
|
||
</interface>
|
||
</node>
|
||
"""
|
||
|
||
def __init__(self, window, client, settings):
|
||
self._window=window
|
||
self._client=client
|
||
self._settings=settings
|
||
self._metadata={}
|
||
|
||
# MPRIS property mappings
|
||
self._prop_mapping={
|
||
self._MPRIS_IFACE:
|
||
{"CanQuit": (GLib.Variant("b", False), None),
|
||
"CanRaise": (GLib.Variant("b", True), None),
|
||
"HasTrackList": (GLib.Variant("b", False), None),
|
||
"Identity": (GLib.Variant("s", "mpdevil"), None),
|
||
"DesktopEntry": (GLib.Variant("s", "org.mpdevil.mpdevil"), None),
|
||
"SupportedUriSchemes": (GLib.Variant("s", "None"), None),
|
||
"SupportedMimeTypes": (GLib.Variant("s", "None"), None)},
|
||
self._MPRIS_PLAYER_IFACE:
|
||
{"PlaybackStatus": (self._get_playback_status, None),
|
||
"LoopStatus": (self._get_loop_status, self._set_loop_status),
|
||
"Rate": (GLib.Variant("d", 1.0), None),
|
||
"Shuffle": (self._get_shuffle, self._set_shuffle),
|
||
"Metadata": (self._get_metadata, None),
|
||
"Volume": (self._get_volume, self._set_volume),
|
||
"Position": (self._get_position, None),
|
||
"MinimumRate": (GLib.Variant("d", 1.0), None),
|
||
"MaximumRate": (GLib.Variant("d", 1.0), None),
|
||
"CanGoNext": (self._get_can_next_prev, None),
|
||
"CanGoPrevious": (self._get_can_next_prev, None),
|
||
"CanPlay": (self._get_can_play_pause_seek, None),
|
||
"CanPause": (self._get_can_play_pause_seek, None),
|
||
"CanSeek": (self._get_can_play_pause_seek, None),
|
||
"CanControl": (GLib.Variant("b", True), None)},
|
||
}
|
||
|
||
# start
|
||
self._bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||
Gio.bus_own_name_on_connection(self._bus, self._MPRIS_NAME, Gio.BusNameOwnerFlags.NONE, None, None)
|
||
self._node_info=Gio.DBusNodeInfo.new_for_xml(self._INTERFACES_XML)
|
||
for interface in self._node_info.interfaces:
|
||
self._bus.register_object(self._MPRIS_PATH, interface, self._handle_method_call, None, None)
|
||
|
||
# connect
|
||
self._client.emitter.connect("state", self._on_state_changed)
|
||
self._client.emitter.connect("current_song_changed", self._on_song_changed)
|
||
self._client.emitter.connect("volume_changed", self._on_volume_changed)
|
||
self._client.emitter.connect("repeat", self._on_loop_changed)
|
||
self._client.emitter.connect("single", self._on_loop_changed)
|
||
self._client.emitter.connect("random", self._on_random_changed)
|
||
self._client.emitter.connect("connection_error", self._on_connection_error)
|
||
self._client.emitter.connect("reconnected", self._on_reconnected)
|
||
|
||
def _handle_method_call(self, connection, sender, object_path, interface_name, method_name, parameters, invocation):
|
||
args=list(parameters.unpack())
|
||
result=getattr(self, method_name)(*args)
|
||
out_args=self._node_info.lookup_interface(interface_name).lookup_method(method_name).out_args
|
||
if out_args == []:
|
||
invocation.return_value(None)
|
||
else:
|
||
signature="("+"".join([arg.signature for arg in out_args])+")"
|
||
variant=GLib.Variant(signature, (result,))
|
||
invocation.return_value(variant)
|
||
|
||
# setter and getter
|
||
def _get_playback_status(self):
|
||
if self._client.connected():
|
||
status=self._client.status()
|
||
return GLib.Variant("s", {"play": "Playing", "pause": "Paused", "stop": "Stopped"}[status["state"]])
|
||
return GLib.Variant("s", "Stopped")
|
||
|
||
def _set_loop_status(self, value):
|
||
if self._client.connected():
|
||
if value == "Playlist":
|
||
self._client.repeat(1)
|
||
self._client.single(0)
|
||
elif value == "Track":
|
||
self._client.repeat(1)
|
||
self._client.single(1)
|
||
elif value == "None":
|
||
self._client.repeat(0)
|
||
self._client.single(0)
|
||
|
||
def _get_loop_status(self):
|
||
if self._client.connected():
|
||
status=self._client.status()
|
||
if status["repeat"] == "1":
|
||
if status.get("single", "0") == "0":
|
||
return GLib.Variant("s", "Playlist")
|
||
else:
|
||
return GLib.Variant("s", "Track")
|
||
else:
|
||
return GLib.Variant("s", "None")
|
||
return GLib.Variant("s", "None")
|
||
|
||
def _set_shuffle(self, value):
|
||
if self._client.connected():
|
||
if value:
|
||
self._client.random("1")
|
||
else:
|
||
self._client.random("0")
|
||
|
||
def _get_shuffle(self):
|
||
if self._client.connected():
|
||
if self._client.status()["random"] == "1":
|
||
return GLib.Variant("b", True)
|
||
else:
|
||
return GLib.Variant("b", False)
|
||
return GLib.Variant("b", False)
|
||
|
||
def _get_metadata(self):
|
||
return GLib.Variant("a{sv}", self._metadata)
|
||
|
||
def _get_volume(self):
|
||
if self._client.connected():
|
||
return GLib.Variant("d", float(self._client.status().get("volume", 0))/100)
|
||
return GLib.Variant("d", 0)
|
||
|
||
def _set_volume(self, value):
|
||
if self._client.connected():
|
||
if value >= 0 and value <= 1:
|
||
self._client.setvol(int(value * 100))
|
||
|
||
def _get_position(self):
|
||
if self._client.connected():
|
||
status=self._client.status()
|
||
return GLib.Variant("x", float(status.get("elapsed", 0))*1000000)
|
||
return GLib.Variant("x", 0)
|
||
|
||
def _get_can_next_prev(self):
|
||
if self._client.connected():
|
||
status=self._client.status()
|
||
if status["state"] == "stop":
|
||
return GLib.Variant("b", False)
|
||
else:
|
||
return GLib.Variant("b", True)
|
||
return GLib.Variant("b", False)
|
||
|
||
def _get_can_play_pause_seek(self):
|
||
return GLib.Variant("b", self._client.connected())
|
||
|
||
# introspect methods
|
||
def Introspect(self):
|
||
return self._INTERFACES_XML
|
||
|
||
# property methods
|
||
def Get(self, interface_name, prop):
|
||
getter, setter=self._prop_mapping[interface_name][prop]
|
||
if callable(getter):
|
||
return getter()
|
||
return getter
|
||
|
||
def Set(self, interface_name, prop, value):
|
||
getter, setter=self._prop_mapping[interface_name][prop]
|
||
if setter is not None:
|
||
setter(value)
|
||
|
||
def GetAll(self, interface_name):
|
||
read_props={}
|
||
try:
|
||
props=self._prop_mapping[interface_name]
|
||
for key, (getter, setter) in props.items():
|
||
if callable(getter):
|
||
getter=getter()
|
||
read_props[key]=getter
|
||
except KeyError: # interface has no properties
|
||
pass
|
||
return read_props
|
||
|
||
def PropertiesChanged(self, interface_name, changed_properties, invalidated_properties):
|
||
self._bus.emit_signal(
|
||
None, self._MPRIS_PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged",
|
||
GLib.Variant.new_tuple(
|
||
GLib.Variant("s", interface_name),
|
||
GLib.Variant("a{sv}", changed_properties),
|
||
GLib.Variant("as", invalidated_properties)
|
||
)
|
||
)
|
||
|
||
# root methods
|
||
def Raise(self):
|
||
self._window.present()
|
||
|
||
def Quit(self):
|
||
app_action_group=self._window.get_action_group("app")
|
||
quit_action=app_action_group.lookup_action("quit")
|
||
quit_action.activate()
|
||
|
||
# player methods
|
||
def Next(self):
|
||
self._client.next()
|
||
|
||
def Previous(self):
|
||
self._client.previous()
|
||
|
||
def Pause(self):
|
||
self._client.pause(1)
|
||
|
||
def PlayPause(self):
|
||
self._client.toggle_play()
|
||
|
||
def Stop(self):
|
||
self._client.stop()
|
||
|
||
def Play(self):
|
||
self._client.play()
|
||
|
||
def Seek(self, offset):
|
||
if offset > 0:
|
||
offset="+"+str(offset/1000000)
|
||
else:
|
||
offset=str(offset/1000000)
|
||
self._client.seekcur(offset)
|
||
|
||
def SetPosition(self, trackid, position):
|
||
song=self._client.currentsong()
|
||
if str(trackid).split("/")[-1] != song["id"]:
|
||
return
|
||
mpd_pos=position/1000000
|
||
if mpd_pos >= 0 and mpd_pos <= float(song["duration"]):
|
||
self._client.seekcur(str(mpd_pos))
|
||
|
||
def OpenUri(self, uri):
|
||
pass
|
||
|
||
def Seeked(self, position):
|
||
self._bus.emit_signal(
|
||
None, self._MPRIS_PATH, self._MPRIS_PLAYER_IFACE, "Seeked",
|
||
GLib.Variant.new_tuple(GLib.Variant("x", position))
|
||
)
|
||
|
||
# other methods
|
||
def _update_metadata(self):
|
||
"""
|
||
Translate metadata returned by MPD to the MPRIS v2 syntax.
|
||
http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata
|
||
"""
|
||
mpd_meta=self._client.currentsong() # raw values needed for cover
|
||
song=ClientHelper.song_to_list_dict(mpd_meta)
|
||
self._metadata={}
|
||
for tag, xesam_tag in (("album","album"),("title","title"),("date","contentCreated")):
|
||
if tag in song:
|
||
self._metadata["xesam:{}".format(xesam_tag)]=GLib.Variant("s", song[tag][0])
|
||
for tag, xesam_tag in (("track","trackNumber"),("disc","discNumber")):
|
||
if tag in song:
|
||
self._metadata["xesam:{}".format(xesam_tag)]=GLib.Variant("i", int(song[tag][0]))
|
||
for tag, xesam_tag in (("albumartist","albumArtist"),("artist","artist"),("composer","composer"),("genre","genre")):
|
||
if tag in song:
|
||
self._metadata["xesam:{}".format(xesam_tag)]=GLib.Variant("as", song[tag])
|
||
if "id" in song:
|
||
self._metadata["mpris:trackid"]=GLib.Variant("o", self._MPRIS_PATH+"/Track/{}".format(song["id"][0]))
|
||
if "duration" in song:
|
||
self._metadata["mpris:length"]=GLib.Variant("x", float(song["duration"][0])*1000000)
|
||
if "file" in song:
|
||
song_file=song["file"][0]
|
||
if "://" in song_file: # remote file
|
||
self._metadata["xesam:url"]=GLib.Variant("s", song_file)
|
||
else:
|
||
lib_path=self._settings.get_value("paths")[self._settings.get_int("active-profile")]
|
||
self._metadata["xesam:url"]=GLib.Variant("s", "file://{}".format(os.path.join(lib_path, song_file)))
|
||
cover_path=self._client.get_cover_path(mpd_meta)
|
||
if cover_path is not None:
|
||
self._metadata["mpris:artUrl"]=GLib.Variant("s", "file://{}".format(cover_path))
|
||
|
||
def _update_property(self, interface_name, prop):
|
||
getter, setter=self._prop_mapping[interface_name][prop]
|
||
if callable(getter):
|
||
value=getter()
|
||
else:
|
||
value=getter
|
||
self.PropertiesChanged(interface_name, {prop: value}, [])
|
||
return value
|
||
|
||
def _on_state_changed(self, *args):
|
||
self._update_property(self._MPRIS_PLAYER_IFACE, "PlaybackStatus")
|
||
self._update_property(self._MPRIS_PLAYER_IFACE, "CanGoNext")
|
||
self._update_property(self._MPRIS_PLAYER_IFACE, "CanGoPrevious")
|
||
|
||
def _on_song_changed(self, *args):
|
||
self._update_metadata()
|
||
self._update_property(self._MPRIS_PLAYER_IFACE, "Metadata")
|
||
|
||
def _on_volume_changed(self, *args):
|
||
self._update_property(self._MPRIS_PLAYER_IFACE, "Volume")
|
||
|
||
def _on_loop_changed(self, *args):
|
||
self._update_property(self._MPRIS_PLAYER_IFACE, "LoopStatus")
|
||
|
||
def _on_random_changed(self, *args):
|
||
self._update_property(self._MPRIS_PLAYER_IFACE, "Shuffle")
|
||
|
||
def _on_reconnected(self, *args):
|
||
properties=("CanPlay","CanPause","CanSeek")
|
||
for p in properties:
|
||
self._update_property(self._MPRIS_PLAYER_IFACE, p)
|
||
|
||
def _on_connection_error(self, *args):
|
||
self._metadata={}
|
||
properties=("PlaybackStatus","CanGoNext","CanGoPrevious","Metadata","Volume","LoopStatus","Shuffle","CanPlay","CanPause","CanSeek")
|
||
for p in properties:
|
||
self._update_property(self._MPRIS_PLAYER_IFACE, p)
|
||
|
||
######################
|
||
# MPD client wrapper #
|
||
######################
|
||
|
||
class ClientHelper():
|
||
def seconds_to_display_time(seconds):
|
||
delta=datetime.timedelta(seconds=seconds)
|
||
if delta.days > 0:
|
||
days=ngettext("{days} day", "{days} days", delta.days).format(days=delta.days)
|
||
time_string=days+", "+str(datetime.timedelta(seconds=delta.seconds))
|
||
else:
|
||
time_string=str(delta).lstrip("0").lstrip(":")
|
||
return time_string.replace(":", "∶") # use 'ratio' as delimiter
|
||
|
||
def song_to_str_dict(song): # converts tags with multiple values to comma separated strings
|
||
return_song={}
|
||
for tag, value in song.items():
|
||
if type(value) == list:
|
||
return_song[tag]=(", ".join(value))
|
||
else:
|
||
return_song[tag]=value
|
||
return return_song
|
||
|
||
def song_to_first_str_dict(song): # extracts the first value of multiple value tags
|
||
return_song={}
|
||
for tag, value in song.items():
|
||
if type(value) == list:
|
||
return_song[tag]=value[0]
|
||
else:
|
||
return_song[tag]=value
|
||
return return_song
|
||
|
||
def song_to_list_dict(song): # converts all values to lists
|
||
return_song={}
|
||
for tag, value in song.items():
|
||
if type(value) != list:
|
||
return_song[tag]=[value]
|
||
else:
|
||
return_song[tag]=value
|
||
return return_song
|
||
|
||
def pepare_song_for_display(song):
|
||
base_song={
|
||
"title": _("Unknown Title"),
|
||
"track": "",
|
||
"disc": "",
|
||
"artist": "",
|
||
"album": "",
|
||
"duration": "0.0",
|
||
"date": "",
|
||
"genre": ""
|
||
}
|
||
if "range" in song: # translate .cue 'range' to 'duration' if needed
|
||
start, end=song["range"].split("-")
|
||
if start != "" and end != "":
|
||
base_song["duration"]=str((float(end)-float(start)))
|
||
base_song.update(song)
|
||
base_song["human_duration"]=ClientHelper.seconds_to_display_time(int(float(base_song["duration"])))
|
||
for tag in ("disc", "track"): # remove confusing multiple tags
|
||
if tag in song:
|
||
if type(song[tag]) == list:
|
||
base_song[tag]=song[tag][0]
|
||
return base_song
|
||
|
||
def calc_display_length(songs):
|
||
length=float(0)
|
||
for song in songs:
|
||
length=length+float(song.get("duration", 0.0))
|
||
return ClientHelper.seconds_to_display_time(int(length))
|
||
|
||
def binary_to_pixbuf(binary, size):
|
||
loader=GdkPixbuf.PixbufLoader.new()
|
||
loader.write(binary)
|
||
loader.close()
|
||
raw_pixbuf=loader.get_pixbuf()
|
||
ratio=raw_pixbuf.get_width()/raw_pixbuf.get_height()
|
||
if ratio > 1:
|
||
pixbuf=raw_pixbuf.scale_simple(size,size/ratio,GdkPixbuf.InterpType.BILINEAR)
|
||
else:
|
||
pixbuf=raw_pixbuf.scale_simple(size*ratio,size,GdkPixbuf.InterpType.BILINEAR)
|
||
return pixbuf
|
||
|
||
class EventEmitter(GObject.Object):
|
||
__gsignals__={
|
||
"update": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||
"disconnected": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||
"reconnected": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||
"connection_error": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||
"current_song_changed": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||
"state": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
|
||
"elapsed_changed": (GObject.SignalFlags.RUN_FIRST, None, (float,float,)),
|
||
"volume_changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
|
||
"playlist_changed": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
|
||
"repeat": (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
|
||
"random": (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
|
||
"single": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
|
||
"consume": (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
|
||
"audio": (GObject.SignalFlags.RUN_FIRST, None, (str,str,str,)),
|
||
"bitrate": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
|
||
"add_to_playlist": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
|
||
"show_info": (GObject.SignalFlags.RUN_FIRST, None, ())
|
||
}
|
||
def __init__(self):
|
||
super().__init__()
|
||
|
||
class Client(MPDClient):
|
||
def __init__(self, settings):
|
||
super().__init__()
|
||
self._settings=settings
|
||
self.emitter=EventEmitter()
|
||
self._last_status={}
|
||
self._refresh_interval=self._settings.get_int("refresh-interval")
|
||
self._main_timeout_id=None
|
||
self.fallback_cover=Gtk.IconTheme.get_default().lookup_icon("media-optical", 128, Gtk.IconLookupFlags.FORCE_SVG).get_filename()
|
||
|
||
# connect
|
||
self._settings.connect("changed::active-profile", self._on_active_profile_changed)
|
||
|
||
def start(self):
|
||
self.emitter.emit("disconnected") # bring player in defined state
|
||
active=self._settings.get_int("active-profile")
|
||
try:
|
||
self.connect(self._settings.get_value("hosts")[active], self._settings.get_value("ports")[active])
|
||
if self._settings.get_value("passwords")[active] != "":
|
||
self.password(self._settings.get_value("passwords")[active])
|
||
except:
|
||
self.emitter.emit("connection_error")
|
||
return False
|
||
# connect successful
|
||
if "status" in self.commands():
|
||
self._main_timeout_id=GLib.timeout_add(self._refresh_interval, self._main_loop)
|
||
self.emitter.emit("reconnected")
|
||
return True
|
||
else:
|
||
self.disconnect()
|
||
self.emitter.emit("connection_error")
|
||
print("No read permission, check your mpd config.")
|
||
return False
|
||
|
||
def reconnect(self):
|
||
if self._main_timeout_id is not None:
|
||
GLib.source_remove(self._main_timeout_id)
|
||
self._main_timeout_id=None
|
||
self._last_status={}
|
||
self.disconnect()
|
||
self.start()
|
||
|
||
def connected(self):
|
||
try:
|
||
self.ping()
|
||
return True
|
||
except:
|
||
return False
|
||
|
||
def files_to_playlist(self, files, mode="default"): # modes: default, play, append, enqueue
|
||
def append(files):
|
||
for f in files:
|
||
self.add(f)
|
||
def play(files):
|
||
if files != []:
|
||
self.clear()
|
||
for f in files:
|
||
self.add(f)
|
||
self.play()
|
||
def enqueue(files):
|
||
status=self.status()
|
||
if status["state"] == "stop":
|
||
play(files)
|
||
else:
|
||
self.moveid(status["songid"], 0)
|
||
current_song_file=self.playlistinfo()[0]["file"]
|
||
try:
|
||
self.delete((1,)) # delete all songs, but the first. bad song index possible
|
||
except:
|
||
pass
|
||
for f in files:
|
||
if f == current_song_file:
|
||
self.move(0, (len(self.playlistinfo())-1))
|
||
else:
|
||
self.add(f)
|
||
if mode == "append":
|
||
append(files)
|
||
elif mode == "enqueue":
|
||
enqueue(files)
|
||
elif mode == "play":
|
||
play(files)
|
||
elif mode == "default":
|
||
if self._settings.get_boolean("force-mode"):
|
||
play(files)
|
||
else:
|
||
enqueue(files)
|
||
|
||
def album_to_playlist(self, album, artist, year, genre, mode="default"):
|
||
if genre is None:
|
||
genre_filter=()
|
||
else:
|
||
genre_filter=("genre", genre)
|
||
songs=self.find("album", album, "date", year, self._settings.get_artist_type(), artist, *genre_filter)
|
||
self.files_to_playlist([song["file"] for song in songs], mode)
|
||
|
||
def artist_to_playlist(self, artist, genre, mode):
|
||
def append():
|
||
if self._settings.get_boolean("sort-albums-by-year"):
|
||
sort_tag="date"
|
||
else:
|
||
sort_tag="album"
|
||
if artist is None: # treat 'None' as 'all artists'
|
||
if genre is None:
|
||
self.searchadd("any", "", "sort", sort_tag)
|
||
else:
|
||
self.findadd("genre", genre, "sort", sort_tag)
|
||
else:
|
||
artist_type=self._settings.get_artist_type()
|
||
if genre is None:
|
||
self.findadd(artist_type, artist, "sort", sort_tag)
|
||
else:
|
||
self.findadd(artist_type, artist, "genre", genre, "sort", sort_tag)
|
||
if mode == "append":
|
||
append()
|
||
elif mode == "play":
|
||
self.clear()
|
||
append()
|
||
self.play()
|
||
elif mode == "enqueue":
|
||
status=self.status()
|
||
if status["state"] == "stop":
|
||
self.clear()
|
||
append()
|
||
self.play()
|
||
else:
|
||
self.moveid(status["songid"], 0)
|
||
current_song_file=self.currentsong()["file"]
|
||
try:
|
||
self.delete((1,)) # delete all songs, but the first. bad song index possible
|
||
except:
|
||
pass
|
||
append()
|
||
duplicates=self.playlistfind("file", current_song_file)
|
||
if len(duplicates) > 1:
|
||
self.move(0, duplicates[1]["pos"])
|
||
self.delete(int(duplicates[1]["pos"])-1)
|
||
|
||
def comp_list(self, *args): # simulates listing behavior of python-mpd2 1.0
|
||
native_list=self.list(*args)
|
||
if len(native_list) > 0:
|
||
if type(native_list[0]) == dict:
|
||
return ([l[args[0]] for l in native_list])
|
||
else:
|
||
return native_list
|
||
else:
|
||
return([])
|
||
|
||
def get_cover_path(self, raw_song):
|
||
path=None
|
||
song=ClientHelper.song_to_first_str_dict(raw_song)
|
||
song_file=song.get("file")
|
||
active_profile=self._settings.get_int("active-profile")
|
||
lib_path=self._settings.get_lib_path()
|
||
if lib_path is not None:
|
||
regex_str=self._settings.get_value("regex")[active_profile]
|
||
if regex_str == "":
|
||
regex=re.compile(COVER_REGEX, flags=re.IGNORECASE)
|
||
else:
|
||
regex_str=regex_str.replace("%AlbumArtist%", song.get("albumartist", ""))
|
||
regex_str=regex_str.replace("%Album%", song.get("album", ""))
|
||
try:
|
||
regex=re.compile(regex_str, flags=re.IGNORECASE)
|
||
except:
|
||
print("illegal regex:", regex_str)
|
||
return (None, None)
|
||
if song_file is not None:
|
||
song_dir=os.path.join(lib_path, os.path.dirname(song_file))
|
||
if song_dir.endswith(".cue"):
|
||
song_dir=os.path.dirname(song_dir) # get actual directory of .cue file
|
||
if os.path.exists(song_dir):
|
||
for f in os.listdir(song_dir):
|
||
if regex.match(f):
|
||
path=os.path.join(song_dir, f)
|
||
break
|
||
return path
|
||
|
||
def get_cover_binary(self, uri):
|
||
if uri is None:
|
||
binary=None
|
||
else:
|
||
try:
|
||
binary=self.albumart(uri)["binary"]
|
||
except:
|
||
try:
|
||
binary=self.readpicture(uri)["binary"]
|
||
except:
|
||
binary=None
|
||
return binary
|
||
|
||
def get_cover(self, song, size):
|
||
cover_path=self.get_cover_path(song)
|
||
if cover_path is None:
|
||
cover_binary=self.get_cover_binary(song.get("file"))
|
||
if cover_binary is None:
|
||
pixbuf=GdkPixbuf.Pixbuf.new_from_file_at_size(self.fallback_cover, size, size)
|
||
else:
|
||
pixbuf=ClientHelper.binary_to_pixbuf(cover_binary, size)
|
||
else:
|
||
try:
|
||
pixbuf=GdkPixbuf.Pixbuf.new_from_file_at_size(cover_path, size, size)
|
||
except: # load fallback if cover can't be loaded (GLib: Couldn’t recognize the image file format for file...)
|
||
pixbuf=GdkPixbuf.Pixbuf.new_from_file_at_size(self.fallback_cover, size, size)
|
||
return pixbuf
|
||
|
||
def get_metadata(self, uri):
|
||
meta_base=self.lsinfo(uri)[0]
|
||
try: # .cue files produce an error here
|
||
meta_extra=self.readcomments(uri) # contains comment tag
|
||
meta_base.update(meta_extra)
|
||
except:
|
||
pass
|
||
return meta_base
|
||
|
||
def get_absolute_path(self, uri):
|
||
lib_path=self._settings.get_lib_path()
|
||
if lib_path is not None:
|
||
path=os.path.join(lib_path, uri)
|
||
if os.path.isfile(path):
|
||
return path
|
||
else:
|
||
return None
|
||
else:
|
||
return None
|
||
|
||
def get_albums(self, artist, genre):
|
||
albums=[]
|
||
artist_type=self._settings.get_artist_type()
|
||
if genre is None:
|
||
genre_filter=()
|
||
else:
|
||
genre_filter=("genre", genre)
|
||
album_candidates=self.comp_list("album", artist_type, artist, *genre_filter)
|
||
for album in album_candidates:
|
||
years=self.comp_list("date", "album", album, artist_type, artist)
|
||
for year in years:
|
||
songs=self.find("album", album, "date", year, artist_type, artist, *genre_filter)
|
||
cover_path=self.get_cover_path(songs[0])
|
||
if cover_path is None:
|
||
cover_binary=self.get_cover_binary(songs[0].get("file"))
|
||
if cover_binary is None:
|
||
albums.append({"artist": artist, "album": album, "year": year, "songs": songs})
|
||
else:
|
||
albums.append({"artist":artist,"album":album,"year":year,"songs":songs,"cover_binary":cover_binary})
|
||
else:
|
||
albums.append({"artist": artist, "album": album, "year": year, "songs": songs, "cover_path": cover_path})
|
||
return albums
|
||
|
||
def toggle_play(self):
|
||
status=self.status()
|
||
if status["state"] == "play":
|
||
self.pause(1)
|
||
elif status["state"] == "pause":
|
||
self.pause(0)
|
||
else:
|
||
try:
|
||
self.play()
|
||
except:
|
||
pass
|
||
|
||
def toggle_option(self, option): # repeat, random, single, consume
|
||
state=self.status()[option]
|
||
if state != "1" and state != "0": # support single oneshot
|
||
state="1"
|
||
new_state=(int(state)+1)%2 # toggle 0,1
|
||
func=getattr(self, option)
|
||
func(new_state)
|
||
|
||
def _main_loop(self, *args):
|
||
try:
|
||
status=self.status()
|
||
diff=set(status.items())-set(self._last_status.items())
|
||
for key, val in diff:
|
||
if key == "elapsed":
|
||
if "duration" in status:
|
||
self.emitter.emit("elapsed_changed", float(val), float(status["duration"]))
|
||
else:
|
||
self.emitter.emit("elapsed_changed", 0.0, 0.0)
|
||
elif key == "bitrate":
|
||
self.emitter.emit("bitrate", float(val))
|
||
elif key == "songid":
|
||
self.emitter.emit("current_song_changed")
|
||
elif key in ("state", "single"):
|
||
self.emitter.emit(key, val)
|
||
elif key == "audio":
|
||
# see: https://www.musicpd.org/doc/html/user.html#audio-output-format
|
||
samplerate, bits, channels=val.split(":")
|
||
if bits == "f":
|
||
bits="32fp"
|
||
self.emitter.emit("audio", samplerate, bits, channels)
|
||
elif key == "volume":
|
||
self.emitter.emit("volume_changed", float(val))
|
||
elif key == "playlist":
|
||
self.emitter.emit("playlist_changed", int(val))
|
||
elif key in ("repeat", "random", "consume"):
|
||
if val == "1":
|
||
self.emitter.emit(key, True)
|
||
else:
|
||
self.emitter.emit(key, False)
|
||
diff=set(self._last_status)-set(status)
|
||
if "songid" in diff:
|
||
self.emitter.emit("current_song_changed")
|
||
if "volume" in diff:
|
||
self.emitter.emit("volume_changed", -1)
|
||
if "updating_db" in diff:
|
||
self.emitter.emit("update")
|
||
self._last_status=status
|
||
except (MPDBase.ConnectionError, ConnectionResetError) as e:
|
||
self.disconnect()
|
||
self._last_status={}
|
||
self.emitter.emit("disconnected")
|
||
self.emitter.emit("connection_error")
|
||
self._main_timeout_id=None
|
||
return False
|
||
return True
|
||
|
||
def _on_active_profile_changed(self, *args):
|
||
self.reconnect()
|
||
|
||
########################
|
||
# gio settings wrapper #
|
||
########################
|
||
|
||
class Settings(Gio.Settings):
|
||
BASE_KEY="org.mpdevil.mpdevil"
|
||
# temp settings
|
||
mini_player=GObject.Property(type=bool, default=False)
|
||
cursor_watch=GObject.Property(type=bool, default=False)
|
||
def __init__(self):
|
||
super().__init__(schema=self.BASE_KEY)
|
||
|
||
# fix profile settings
|
||
if len(self.get_value("profiles")) < (self.get_int("active-profile")+1):
|
||
self.set_int("active-profile", 0)
|
||
profile_keys=[
|
||
("as", "profiles", "new profile"),
|
||
("as", "hosts", "localhost"),
|
||
("ai", "ports", 6600),
|
||
("as", "passwords", ""),
|
||
("as", "paths", ""),
|
||
("as", "regex", "")
|
||
]
|
||
profile_arrays=[]
|
||
for vtype, key, default in profile_keys:
|
||
profile_arrays.append(self.get_value(key).unpack())
|
||
max_len=max(len(x) for x in profile_arrays)
|
||
for index, (vtype, key, default) in enumerate(profile_keys):
|
||
profile_arrays[index]=(profile_arrays[index]+max_len*[default])[:max_len]
|
||
self.set_value(key, GLib.Variant(vtype, profile_arrays[index]))
|
||
|
||
def array_append(self, vtype, key, value): # append to Gio.Settings (self._settings) array
|
||
array=self.get_value(key).unpack()
|
||
array.append(value)
|
||
self.set_value(key, GLib.Variant(vtype, array))
|
||
|
||
def array_delete(self, vtype, key, pos): # delete entry of Gio.Settings (self._settings) array
|
||
array=self.get_value(key).unpack()
|
||
array.pop(pos)
|
||
self.set_value(key, GLib.Variant(vtype, array))
|
||
|
||
def array_modify(self, vtype, key, pos, value): # modify entry of Gio.Settings (self._settings) array
|
||
array=self.get_value(key).unpack()
|
||
array[pos]=value
|
||
self.set_value(key, GLib.Variant(vtype, array))
|
||
|
||
def get_gtk_icon_size(self, key):
|
||
icon_size=self.get_int(key)
|
||
sizes=[(48, Gtk.IconSize.DIALOG), (32, Gtk.IconSize.DND), (24, Gtk.IconSize.LARGE_TOOLBAR), (16, Gtk.IconSize.BUTTON)]
|
||
for pixel_size, gtk_size in sizes:
|
||
if icon_size >= pixel_size:
|
||
return gtk_size
|
||
return Gtk.IconSize.INVALID
|
||
|
||
def get_artist_type(self):
|
||
if self.get_boolean("use-album-artist"):
|
||
return ("albumartist")
|
||
else:
|
||
return ("artist")
|
||
|
||
def get_lib_path(self, profile=None):
|
||
if profile is None: # use current profile if none is given
|
||
profile=self.get_int("active-profile")
|
||
lib_path=self.get_value("paths")[profile]
|
||
if lib_path == "":
|
||
lib_path=GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_MUSIC)
|
||
return lib_path
|
||
|
||
###################
|
||
# settings dialog #
|
||
###################
|
||
|
||
class GeneralSettings(Gtk.Box):
|
||
def __init__(self, settings):
|
||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6, border_width=18)
|
||
self._settings=settings
|
||
self._settings_handlers=[]
|
||
|
||
# int settings
|
||
int_settings={}
|
||
int_settings_data=[
|
||
(_("Main cover size:"), (100, 1200, 10), "track-cover"),
|
||
(_("Album view cover size:"), (50, 600, 10), "album-cover"),
|
||
(_("Action bar icon size:"), (16, 64, 2), "icon-size"),
|
||
(_("Secondary icon size:"), (16, 64, 2), "icon-size-sec")
|
||
]
|
||
for label, (vmin, vmax, step), key in int_settings_data:
|
||
int_settings[key]=(Gtk.Label(label=label, xalign=0), Gtk.SpinButton.new_with_range(vmin, vmax, step))
|
||
int_settings[key][1].set_value(self._settings.get_int(key))
|
||
int_settings[key][1].connect("value-changed", self._on_int_changed, key)
|
||
self._settings_handlers.append(
|
||
self._settings.connect("changed::{}".format(key), self._on_int_settings_changed, int_settings[key][1])
|
||
)
|
||
|
||
# check buttons
|
||
check_buttons={}
|
||
check_buttons_data=[
|
||
(_("Use Client-side decoration"), "use-csd"),
|
||
(_("Show stop button"), "show-stop"),
|
||
(_("Show lyrics button"), "show-lyrics-button"),
|
||
(_("Show initials in artist view"), "show-initials"),
|
||
(_("Place playlist at the side"), "playlist-right"),
|
||
(_("Use “Album Artist” tag"), "use-album-artist"),
|
||
(_("Send notification on title change"), "send-notify"),
|
||
(_("Stop playback on quit"), "stop-on-quit"),
|
||
(_("Play selected albums and titles immediately"), "force-mode"),
|
||
(_("Sort albums by year"), "sort-albums-by-year"),
|
||
]
|
||
for label, key in check_buttons_data:
|
||
check_buttons[key]=Gtk.CheckButton(label=label)
|
||
check_buttons[key].set_active(self._settings.get_boolean(key))
|
||
check_buttons[key].set_margin_start(12)
|
||
check_buttons[key].connect("toggled", self._on_toggled, key)
|
||
self._settings_handlers.append(
|
||
self._settings.connect("changed::{}".format(key), self._on_check_settings_changed, check_buttons[key])
|
||
)
|
||
|
||
# headings
|
||
view_heading=Gtk.Label(label=_("<b>View</b>"), use_markup=True, xalign=0)
|
||
behavior_heading=Gtk.Label(label=_("<b>Behavior</b>"), use_markup=True, xalign=0)
|
||
|
||
# view grid
|
||
view_grid=Gtk.Grid(row_spacing=6, column_spacing=12)
|
||
view_grid.set_margin_start(12)
|
||
view_grid.add(int_settings["track-cover"][0])
|
||
view_grid.attach_next_to(int_settings["album-cover"][0], int_settings["track-cover"][0], Gtk.PositionType.BOTTOM, 1, 1)
|
||
view_grid.attach_next_to(int_settings["icon-size"][0], int_settings["album-cover"][0], Gtk.PositionType.BOTTOM, 1, 1)
|
||
view_grid.attach_next_to(int_settings["icon-size-sec"][0], int_settings["icon-size"][0], Gtk.PositionType.BOTTOM, 1, 1)
|
||
view_grid.attach_next_to(int_settings["track-cover"][1], int_settings["track-cover"][0], Gtk.PositionType.RIGHT, 1, 1)
|
||
view_grid.attach_next_to(int_settings["album-cover"][1], int_settings["album-cover"][0], Gtk.PositionType.RIGHT, 1, 1)
|
||
view_grid.attach_next_to(int_settings["icon-size"][1], int_settings["icon-size"][0], Gtk.PositionType.RIGHT, 1, 1)
|
||
view_grid.attach_next_to(int_settings["icon-size-sec"][1], int_settings["icon-size-sec"][0], Gtk.PositionType.RIGHT, 1, 1)
|
||
|
||
# connect
|
||
self.connect("destroy", self._remove_handlers)
|
||
|
||
# packing
|
||
box=Gtk.Box(spacing=12)
|
||
box.pack_start(check_buttons["use-csd"], False, False, 0)
|
||
box.pack_start(Gtk.Label(label=_("(restart required)"), sensitive=False), False, False, 0)
|
||
self.pack_start(view_heading, False, False, 0)
|
||
self.pack_start(box, False, False, 0)
|
||
self.pack_start(check_buttons["show-stop"], False, False, 0)
|
||
self.pack_start(check_buttons["show-lyrics-button"], False, False, 0)
|
||
self.pack_start(check_buttons["show-initials"], False, False, 0)
|
||
self.pack_start(check_buttons["playlist-right"], False, False, 0)
|
||
self.pack_start(view_grid, False, False, 0)
|
||
self.pack_start(behavior_heading, False, False, 0)
|
||
self.pack_start(check_buttons["use-album-artist"], False, False, 0)
|
||
self.pack_start(check_buttons["sort-albums-by-year"], False, False, 0)
|
||
self.pack_start(check_buttons["send-notify"], False, False, 0)
|
||
self.pack_start(check_buttons["force-mode"], False, False, 0)
|
||
self.pack_start(check_buttons["stop-on-quit"], False, False, 0)
|
||
|
||
def _remove_handlers(self, *args):
|
||
for handler in self._settings_handlers:
|
||
self._settings.disconnect(handler)
|
||
|
||
def _on_int_settings_changed(self, settings, key, entry):
|
||
entry.set_value(settings.get_int(key))
|
||
|
||
def _on_check_settings_changed(self, settings, key, button):
|
||
button.set_active(settings.get_boolean(key))
|
||
|
||
def _on_int_changed(self, widget, key):
|
||
self._settings.set_int(key, int(widget.get_value()))
|
||
|
||
def _on_toggled(self, widget, key):
|
||
self._settings.set_boolean(key, widget.get_active())
|
||
|
||
class ProfileSettings(Gtk.Grid):
|
||
def __init__(self, parent, client, settings):
|
||
super().__init__(row_spacing=6, column_spacing=12, border_width=18)
|
||
self._client=client
|
||
self._settings=settings
|
||
self._gui_modification=False # indicates whether the settings were changed from the settings dialog
|
||
|
||
# widgets
|
||
self._profiles_combo=Gtk.ComboBoxText(entry_text_column=0, hexpand=True)
|
||
|
||
add_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("list-add-symbolic", Gtk.IconSize.BUTTON))
|
||
delete_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("list-remove-symbolic", Gtk.IconSize.BUTTON))
|
||
add_delete_buttons=Gtk.ButtonBox(layout_style=Gtk.ButtonBoxStyle.EXPAND)
|
||
add_delete_buttons.pack_start(add_button, True, True, 0)
|
||
add_delete_buttons.pack_start(delete_button, True, True, 0)
|
||
|
||
connect_button=Gtk.Button.new_with_mnemonic(_("_Connect"))
|
||
|
||
self._profile_entry=Gtk.Entry(hexpand=True)
|
||
self._host_entry=Gtk.Entry(hexpand=True)
|
||
self._port_entry=Gtk.SpinButton.new_with_range(0, 65535, 1)
|
||
address_entry=Gtk.Box(spacing=6)
|
||
address_entry.pack_start(self._host_entry, True, True, 0)
|
||
address_entry.pack_start(self._port_entry, False, False, 0)
|
||
self._password_entry=PasswordEntry(hexpand=True)
|
||
self._path_entry=Gtk.Entry(hexpand=True, placeholder_text=GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_MUSIC))
|
||
self._path_select_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("folder-open-symbolic", Gtk.IconSize.BUTTON))
|
||
path_box=Gtk.Box(spacing=6)
|
||
path_box.pack_start(self._path_entry, True, True, 0)
|
||
path_box.pack_start(self._path_select_button, False, False, 0)
|
||
self._regex_entry=Gtk.Entry(hexpand=True, placeholder_text=COVER_REGEX)
|
||
self._regex_entry.set_tooltip_text(
|
||
_("The first image in the same directory as the song file "\
|
||
"matching this regex will be displayed. %AlbumArtist% and "\
|
||
"%Album% will be replaced by the corresponding tags of the song.")
|
||
)
|
||
|
||
profiles_label=Gtk.Label(label=_("Profile:"), xalign=1)
|
||
profile_label=Gtk.Label(label=_("Name:"), xalign=1)
|
||
host_label=Gtk.Label(label=_("Host:"), xalign=1)
|
||
password_label=Gtk.Label(label=_("Password:"), xalign=1)
|
||
path_label=Gtk.Label(label=_("Music lib:"), xalign=1)
|
||
regex_label=Gtk.Label(label=_("Cover regex:"), xalign=1)
|
||
|
||
# connect
|
||
add_button.connect("clicked", self._on_add_button_clicked)
|
||
delete_button.connect("clicked", self._on_delete_button_clicked)
|
||
connect_button.connect("clicked", self._on_connect_button_clicked)
|
||
style_context=connect_button.get_style_context()
|
||
style_context.add_class("suggested-action")
|
||
self._path_select_button.connect("clicked", self._on_path_select_button_clicked, parent)
|
||
self._profiles_changed=self._profiles_combo.connect("changed", self._on_profiles_changed)
|
||
self.entry_changed_handlers=[]
|
||
self.entry_changed_handlers.append((self._profile_entry, self._profile_entry.connect("changed", self._on_profile_entry_changed)))
|
||
self.entry_changed_handlers.append((self._host_entry, self._host_entry.connect("changed", self._on_host_entry_changed)))
|
||
self.entry_changed_handlers.append((self._port_entry, self._port_entry.connect("value-changed", self._on_port_entry_changed)))
|
||
self.entry_changed_handlers.append((self._password_entry, self._password_entry.connect("changed", self._on_password_entry_changed)))
|
||
self.entry_changed_handlers.append((self._path_entry, self._path_entry.connect("changed", self._on_path_entry_changed)))
|
||
self.entry_changed_handlers.append((self._regex_entry, self._regex_entry.connect("changed", self._on_regex_entry_changed)))
|
||
self._settings_handlers=[]
|
||
self._settings_handlers.append(self._settings.connect("changed::profiles", self._on_settings_changed))
|
||
self._settings_handlers.append(self._settings.connect("changed::hosts", self._on_settings_changed))
|
||
self._settings_handlers.append(self._settings.connect("changed::ports", self._on_settings_changed))
|
||
self._settings_handlers.append(self._settings.connect("changed::passwords", self._on_settings_changed))
|
||
self._settings_handlers.append(self._settings.connect("changed::paths", self._on_settings_changed))
|
||
self._settings_handlers.append(self._settings.connect("changed::regex", self._on_settings_changed))
|
||
self.connect("destroy", self._remove_handlers)
|
||
|
||
self._profiles_combo_reload()
|
||
self._profiles_combo.set_active(0)
|
||
|
||
# packing
|
||
self.add(profiles_label)
|
||
self.attach_next_to(profile_label, profiles_label, Gtk.PositionType.BOTTOM, 1, 1)
|
||
self.attach_next_to(host_label, profile_label, Gtk.PositionType.BOTTOM, 1, 1)
|
||
self.attach_next_to(password_label, host_label, Gtk.PositionType.BOTTOM, 1, 1)
|
||
self.attach_next_to(path_label, password_label, Gtk.PositionType.BOTTOM, 1, 1)
|
||
self.attach_next_to(regex_label, path_label, Gtk.PositionType.BOTTOM, 1, 1)
|
||
self.attach_next_to(self._profiles_combo, profiles_label, Gtk.PositionType.RIGHT, 2, 1)
|
||
self.attach_next_to(add_delete_buttons, self._profiles_combo, Gtk.PositionType.RIGHT, 1, 1)
|
||
self.attach_next_to(self._profile_entry, profile_label, Gtk.PositionType.RIGHT, 2, 1)
|
||
self.attach_next_to(address_entry, host_label, Gtk.PositionType.RIGHT, 2, 1)
|
||
self.attach_next_to(self._password_entry, password_label, Gtk.PositionType.RIGHT, 2, 1)
|
||
self.attach_next_to(path_box, path_label, Gtk.PositionType.RIGHT, 2, 1)
|
||
self.attach_next_to(self._regex_entry, regex_label, Gtk.PositionType.RIGHT, 2, 1)
|
||
connect_button.set_margin_top(12)
|
||
self.attach_next_to(connect_button, self._regex_entry, Gtk.PositionType.BOTTOM, 2, 1)
|
||
|
||
def _block_entry_changed_handlers(self, *args):
|
||
for obj, handler in self.entry_changed_handlers:
|
||
obj.handler_block(handler)
|
||
|
||
def _unblock_entry_changed_handlers(self, *args):
|
||
for obj, handler in self.entry_changed_handlers:
|
||
obj.handler_unblock(handler)
|
||
|
||
def _profiles_combo_reload(self, *args):
|
||
self._profiles_combo.handler_block(self._profiles_changed)
|
||
|
||
self._profiles_combo.remove_all()
|
||
for profile in self._settings.get_value("profiles"):
|
||
self._profiles_combo.append_text(profile)
|
||
|
||
self._profiles_combo.handler_unblock(self._profiles_changed)
|
||
|
||
def _remove_handlers(self, *args):
|
||
for handler in self._settings_handlers:
|
||
self._settings.disconnect(handler)
|
||
|
||
def _on_settings_changed(self, *args):
|
||
if self._gui_modification:
|
||
self._gui_modification=False
|
||
else:
|
||
self._profiles_combo_reload()
|
||
self._profiles_combo.set_active(0)
|
||
|
||
def _on_add_button_clicked(self, *args):
|
||
model=self._profiles_combo.get_model()
|
||
self._settings.array_append("as", "profiles", "new profile ({})".format(len(model)))
|
||
self._settings.array_append("as", "hosts", "localhost")
|
||
self._settings.array_append("ai", "ports", 6600)
|
||
self._settings.array_append("as", "passwords", "")
|
||
self._settings.array_append("as", "paths", "")
|
||
self._settings.array_append("as", "regex", "")
|
||
self._profiles_combo_reload()
|
||
new_pos=len(model)-1
|
||
self._profiles_combo.set_active(new_pos)
|
||
|
||
def _on_delete_button_clicked(self, *args):
|
||
pos=self._profiles_combo.get_active()
|
||
self._settings.array_delete("as", "profiles", pos)
|
||
self._settings.array_delete("as", "hosts", pos)
|
||
self._settings.array_delete("ai", "ports", pos)
|
||
self._settings.array_delete("as", "passwords", pos)
|
||
self._settings.array_delete("as", "paths", pos)
|
||
self._settings.array_delete("as", "regex", pos)
|
||
if len(self._settings.get_value("profiles")) == 0:
|
||
self._on_add_button_clicked()
|
||
else:
|
||
self._profiles_combo_reload()
|
||
new_pos=max(pos-1,0)
|
||
self._profiles_combo.set_active(new_pos)
|
||
|
||
def _on_connect_button_clicked(self, *args):
|
||
self._settings.set_int("active-profile", self._profiles_combo.get_active())
|
||
self._client.reconnect()
|
||
|
||
def _on_profile_entry_changed(self, *args):
|
||
self._gui_modification=True
|
||
pos=self._profiles_combo.get_active()
|
||
self._settings.array_modify("as", "profiles", pos, self._profile_entry.get_text())
|
||
self._profiles_combo_reload()
|
||
self._profiles_combo.handler_block(self._profiles_changed) # do not reload all settings
|
||
self._profiles_combo.set_active(pos)
|
||
self._profiles_combo.handler_unblock(self._profiles_changed)
|
||
|
||
def _on_host_entry_changed(self, *args):
|
||
self._gui_modification=True
|
||
self._settings.array_modify("as", "hosts", self._profiles_combo.get_active(), self._host_entry.get_text())
|
||
|
||
def _on_port_entry_changed(self, *args):
|
||
self._gui_modification=True
|
||
self._settings.array_modify("ai", "ports", self._profiles_combo.get_active(), int(self._port_entry.get_value()))
|
||
|
||
def _on_password_entry_changed(self, *args):
|
||
self._gui_modification=True
|
||
self._settings.array_modify("as", "passwords", self._profiles_combo.get_active(), self._password_entry.get_text())
|
||
|
||
def _on_path_entry_changed(self, *args):
|
||
self._gui_modification=True
|
||
self._settings.array_modify("as", "paths", self._profiles_combo.get_active(), self._path_entry.get_text())
|
||
|
||
def _on_regex_entry_changed(self, *args):
|
||
self._gui_modification=True
|
||
self._settings.array_modify("as", "regex", self._profiles_combo.get_active(), self._regex_entry.get_text())
|
||
|
||
def _on_path_select_button_clicked(self, widget, parent):
|
||
dialog=Gtk.FileChooserNative(title=_("Choose directory"), transient_for=parent, action=Gtk.FileChooserAction.SELECT_FOLDER)
|
||
folder=self._settings.get_lib_path(self._profiles_combo.get_active())
|
||
if folder is not None:
|
||
dialog.set_current_folder(folder)
|
||
response=dialog.run()
|
||
if response == Gtk.ResponseType.ACCEPT:
|
||
self._gui_modification=True
|
||
self._settings.array_modify("as", "paths", self._profiles_combo.get_active(), dialog.get_filename())
|
||
self._path_entry.set_text(dialog.get_filename())
|
||
dialog.destroy()
|
||
|
||
def _on_profiles_changed(self, *args):
|
||
active=self._profiles_combo.get_active()
|
||
if active >= 0:
|
||
self._block_entry_changed_handlers()
|
||
self._profile_entry.set_text(self._settings.get_value("profiles")[active])
|
||
self._host_entry.set_text(self._settings.get_value("hosts")[active])
|
||
self._port_entry.set_value(self._settings.get_value("ports")[active])
|
||
self._password_entry.set_text(self._settings.get_value("passwords")[active])
|
||
self._path_entry.set_text(self._settings.get_value("paths")[active])
|
||
self._regex_entry.set_text(self._settings.get_value("regex")[active])
|
||
self._unblock_entry_changed_handlers()
|
||
|
||
class PlaylistSettings(Gtk.Box):
|
||
def __init__(self, settings):
|
||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6, border_width=18)
|
||
self._settings=settings
|
||
|
||
# label
|
||
label=Gtk.Label(label=_("Choose the order of information to appear in the playlist:"), wrap=True, xalign=0)
|
||
|
||
# treeview
|
||
# (toggle, header, actual_index)
|
||
self._store=Gtk.ListStore(bool, str, int)
|
||
treeview=Gtk.TreeView(model=self._store, reorderable=True, headers_visible=False, search_column=-1)
|
||
self._selection=treeview.get_selection()
|
||
|
||
# columns
|
||
renderer_text=Gtk.CellRendererText()
|
||
renderer_toggle=Gtk.CellRendererToggle()
|
||
column_toggle=Gtk.TreeViewColumn("", renderer_toggle, active=0)
|
||
treeview.append_column(column_toggle)
|
||
column_text=Gtk.TreeViewColumn("", renderer_text, text=1)
|
||
treeview.append_column(column_text)
|
||
|
||
# fill store
|
||
self._headers=[_("No"), _("Disc"), _("Title"), _("Artist"), _("Album"), _("Length"), _("Year"), _("Genre")]
|
||
self._fill()
|
||
|
||
# scroll
|
||
scroll=Gtk.ScrolledWindow()
|
||
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||
scroll.add(treeview)
|
||
|
||
# toolbar
|
||
toolbar=Gtk.Toolbar(icon_size=Gtk.IconSize.SMALL_TOOLBAR)
|
||
style_context=toolbar.get_style_context()
|
||
style_context.add_class("inline-toolbar")
|
||
self._up_button=Gtk.ToolButton(icon_name="go-up-symbolic")
|
||
self._up_button.set_sensitive(False)
|
||
self._down_button=Gtk.ToolButton(icon_name="go-down-symbolic")
|
||
self._down_button.set_sensitive(False)
|
||
toolbar.insert(self._up_button, 0)
|
||
toolbar.insert(self._down_button, 1)
|
||
|
||
# column chooser
|
||
frame=Gtk.Frame()
|
||
frame.add(scroll)
|
||
column_chooser=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||
column_chooser.pack_start(frame, True, True, 0)
|
||
column_chooser.pack_start(toolbar, False, False, 0)
|
||
|
||
# connect
|
||
self._row_deleted=self._store.connect("row-deleted", self._save_permutation)
|
||
renderer_toggle.connect("toggled", self._on_cell_toggled)
|
||
self._up_button.connect("clicked", self._on_up_button_clicked)
|
||
self._down_button.connect("clicked", self._on_down_button_clicked)
|
||
self._selection.connect("changed", self._set_button_sensitivity)
|
||
self._settings_handlers=[]
|
||
self._settings_handlers.append(self._settings.connect("changed::column-visibilities", self._on_visibilities_changed))
|
||
self._settings_handlers.append(self._settings.connect("changed::column-permutation", self._on_permutation_changed))
|
||
self.connect("destroy", self._remove_handlers)
|
||
|
||
# packing
|
||
self.pack_start(label, False, False, 0)
|
||
self.pack_start(column_chooser, True, True, 0)
|
||
|
||
def _fill(self, *args):
|
||
visibilities=self._settings.get_value("column-visibilities").unpack()
|
||
for actual_index in self._settings.get_value("column-permutation"):
|
||
self._store.append([visibilities[actual_index], self._headers[actual_index], actual_index])
|
||
|
||
def _save_permutation(self, *args):
|
||
permutation=[]
|
||
for row in self._store:
|
||
permutation.append(row[2])
|
||
self._settings.set_value("column-permutation", GLib.Variant("ai", permutation))
|
||
|
||
def _set_button_sensitivity(self, *args):
|
||
treeiter=self._selection.get_selected()[1]
|
||
if treeiter is None:
|
||
self._up_button.set_sensitive(False)
|
||
self._down_button.set_sensitive(False)
|
||
else:
|
||
path=self._store.get_path(treeiter)
|
||
if self._store.iter_next(treeiter) is None:
|
||
self._up_button.set_sensitive(True)
|
||
self._down_button.set_sensitive(False)
|
||
elif not path.prev():
|
||
self._up_button.set_sensitive(False)
|
||
self._down_button.set_sensitive(True)
|
||
else:
|
||
self._up_button.set_sensitive(True)
|
||
self._down_button.set_sensitive(True)
|
||
|
||
def _remove_handlers(self, *args):
|
||
for handler in self._settings_handlers:
|
||
self._settings.disconnect(handler)
|
||
|
||
def _on_cell_toggled(self, widget, path):
|
||
self._store[path][0]=not self._store[path][0]
|
||
self._settings.array_modify("ab", "column-visibilities", self._store[path][2], self._store[path][0])
|
||
|
||
def _on_up_button_clicked(self, *args):
|
||
treeiter=self._selection.get_selected()[1]
|
||
path=self._store.get_path(treeiter)
|
||
path.prev()
|
||
prev=self._store.get_iter(path)
|
||
self._store.move_before(treeiter, prev)
|
||
self._set_button_sensitivity()
|
||
self._save_permutation()
|
||
|
||
def _on_down_button_clicked(self, *args):
|
||
treeiter=self._selection.get_selected()[1]
|
||
path=self._store.get_path(treeiter)
|
||
next=self._store.iter_next(treeiter)
|
||
self._store.move_after(treeiter, next)
|
||
self._set_button_sensitivity()
|
||
self._save_permutation()
|
||
|
||
def _on_visibilities_changed(self, *args):
|
||
visibilities=self._settings.get_value("column-visibilities").unpack()
|
||
for i, actual_index in enumerate(self._settings.get_value("column-permutation")):
|
||
self._store[i][0]=visibilities[actual_index]
|
||
|
||
def _on_permutation_changed(self, *args):
|
||
equal=True
|
||
perm=self._settings.get_value("column-permutation")
|
||
for i, e in enumerate(self._store):
|
||
if e[2] != perm[i]:
|
||
equal=False
|
||
break
|
||
if not equal:
|
||
self._store.handler_block(self._row_deleted)
|
||
self._store.clear()
|
||
self._fill()
|
||
self._store.handler_unblock(self._row_deleted)
|
||
|
||
class SettingsDialog(Gtk.Dialog):
|
||
def __init__(self, parent, client, settings):
|
||
use_csd=settings.get_boolean("use-csd")
|
||
if use_csd:
|
||
super().__init__(title=_("Settings"), transient_for=parent, use_header_bar=True)
|
||
else:
|
||
super().__init__(title=_("Settings"), transient_for=parent)
|
||
self.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK)
|
||
self.set_default_size(500, 400)
|
||
|
||
# widgets
|
||
general=GeneralSettings(settings)
|
||
profiles=ProfileSettings(parent, client, settings)
|
||
playlist=PlaylistSettings(settings)
|
||
|
||
# packing
|
||
vbox=self.get_content_area()
|
||
if use_csd:
|
||
stack=Gtk.Stack()
|
||
stack.add_titled(general, "general", _("General"))
|
||
stack.add_titled(profiles, "profiles", _("Profiles"))
|
||
stack.add_titled(playlist, "playlist", _("Playlist"))
|
||
stack_switcher=Gtk.StackSwitcher(stack=stack)
|
||
vbox.pack_start(stack, True, True, 0)
|
||
header_bar=self.get_header_bar()
|
||
header_bar.set_custom_title(stack_switcher)
|
||
else:
|
||
tabs=Gtk.Notebook()
|
||
tabs.append_page(general, Gtk.Label(label=_("General")))
|
||
tabs.append_page(profiles, Gtk.Label(label=_("Profiles")))
|
||
tabs.append_page(playlist, Gtk.Label(label=_("Playlist")))
|
||
vbox.set_property("spacing", 6)
|
||
vbox.set_property("border-width", 6)
|
||
vbox.pack_start(tabs, True, True, 0)
|
||
self.show_all()
|
||
|
||
#################
|
||
# other dialogs #
|
||
#################
|
||
|
||
class ServerStats(Gtk.Dialog):
|
||
def __init__(self, parent, client, settings):
|
||
use_csd=settings.get_boolean("use-csd")
|
||
super().__init__(title=_("Stats"), transient_for=parent, use_header_bar=use_csd)
|
||
if not use_csd:
|
||
self.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK)
|
||
self.set_resizable(False)
|
||
|
||
# grid
|
||
grid=Gtk.Grid(row_spacing=6, column_spacing=12, border_width=6)
|
||
|
||
# populate
|
||
display_str={
|
||
"protocol": _("<b>Protocol:</b>"),
|
||
"uptime": _("<b>Uptime:</b>"),
|
||
"playtime": _("<b>Playtime:</b>"),
|
||
"artists": _("<b>Artists:</b>"),
|
||
"albums": _("<b>Albums:</b>"),
|
||
"songs": _("<b>Songs:</b>"),
|
||
"db_playtime": _("<b>Total Playtime:</b>"),
|
||
"db_update": _("<b>Database Update:</b>")
|
||
}
|
||
stats=client.stats()
|
||
stats["protocol"]=str(client.mpd_version)
|
||
for key in ("uptime","playtime","db_playtime"):
|
||
stats[key]=ClientHelper.seconds_to_display_time(int(stats[key]))
|
||
stats["db_update"]=str(datetime.datetime.fromtimestamp(int(stats["db_update"]))).replace(":", "∶")
|
||
|
||
for i, key in enumerate(("protocol","uptime","playtime","db_update","db_playtime","artists","albums","songs")):
|
||
grid.attach(Gtk.Label(label=display_str[key], use_markup=True, xalign=1), 0, i, 1, 1)
|
||
grid.attach(Gtk.Label(label=stats[key], xalign=0), 1, i, 1, 1)
|
||
|
||
# packing
|
||
vbox=self.get_content_area()
|
||
vbox.set_property("border-width", 6)
|
||
vbox.pack_start(grid, True, True, 0)
|
||
self.show_all()
|
||
self.run()
|
||
|
||
class AboutDialog(Gtk.AboutDialog):
|
||
def __init__(self, window):
|
||
super().__init__(transient_for=window, modal=True)
|
||
self.set_program_name("mpdevil")
|
||
self.set_version(VERSION)
|
||
self.set_comments(_("A simple music browser for MPD"))
|
||
self.set_authors(["Martin Wagner"])
|
||
self.set_translator_credits("Martin de Reuver\nMartin Wagner")
|
||
self.set_website("https://github.com/SoongNoonien/mpdevil")
|
||
self.set_copyright("Copyright \xa9 2020-2021 Martin Wagner")
|
||
self.set_license_type(Gtk.License.GPL_3_0)
|
||
self.set_logo_icon_name("org.mpdevil.mpdevil")
|
||
|
||
###########################
|
||
# general purpose widgets #
|
||
###########################
|
||
|
||
class AutoSizedIcon(Gtk.Image):
|
||
def __init__(self, icon_name, settings_key, settings):
|
||
super().__init__()
|
||
self.set_from_icon_name(icon_name, Gtk.IconSize.BUTTON)
|
||
pixel_size=settings.get_int(settings_key)
|
||
if pixel_size > 0:
|
||
self.set_pixel_size(pixel_size)
|
||
self._settings=settings
|
||
self._settings_key=settings_key
|
||
|
||
# connect
|
||
self._settings.connect("changed::"+self._settings_key, self._on_icon_size_changed)
|
||
|
||
def _on_icon_size_changed(self, *args):
|
||
self.set_pixel_size(self._settings.get_int(self._settings_key))
|
||
|
||
class PasswordEntry(Gtk.Entry):
|
||
def __init__(self, **kwargs):
|
||
super().__init__(visibility=False, caps_lock_warning=False, input_purpose=Gtk.InputPurpose.PASSWORD, **kwargs)
|
||
self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "view-conceal-symbolic")
|
||
|
||
# connect
|
||
self.connect("icon-release", self._on_icon_release)
|
||
|
||
def _on_icon_release(self, *args):
|
||
if self.get_icon_name(Gtk.EntryIconPosition.SECONDARY) == "view-conceal-symbolic":
|
||
self.set_visibility(True)
|
||
self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "view-reveal-symbolic")
|
||
else:
|
||
self.set_visibility(False)
|
||
self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "view-conceal-symbolic")
|
||
|
||
class FocusFrame(Gtk.Overlay):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self._frame=Gtk.Frame(no_show_all=True)
|
||
|
||
# css
|
||
style_context=self._frame.get_style_context()
|
||
provider=Gtk.CssProvider()
|
||
css=b"""* {border-color: @theme_selected_bg_color; border-width: 0px; border-top-width: 3px;}"""
|
||
provider.load_from_data(css)
|
||
style_context.add_provider(provider, 600)
|
||
|
||
self.add_overlay(self._frame)
|
||
self.set_overlay_pass_through(self._frame, True)
|
||
|
||
def disable(self):
|
||
self._frame.hide()
|
||
|
||
def enable(self):
|
||
if self._widget.has_focus():
|
||
self._frame.show()
|
||
|
||
def set_widget(self, widget):
|
||
self._widget=widget
|
||
self._widget.connect("focus-in-event", lambda *args: self._frame.show())
|
||
self._widget.connect("focus-out-event", lambda *args: self._frame.hide())
|
||
|
||
class SongPopover(Gtk.Popover):
|
||
def __init__(self, client):
|
||
super().__init__()
|
||
self._client=client
|
||
self._rect=Gdk.Rectangle()
|
||
|
||
# open-with button
|
||
open_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("document-open-symbolic",Gtk.IconSize.BUTTON),tooltip_text=_("Open with…"))
|
||
open_button.set_margin_bottom(6)
|
||
open_button.set_margin_end(6)
|
||
style_context=open_button.get_style_context()
|
||
style_context.add_class("circular")
|
||
|
||
# button revealer
|
||
self._open_button_revealer=Gtk.Revealer()
|
||
self._open_button_revealer.set_halign(Gtk.Align.END)
|
||
self._open_button_revealer.set_valign(Gtk.Align.END)
|
||
self._open_button_revealer.add(open_button)
|
||
|
||
# treeview
|
||
# (tag, display-value, tooltip)
|
||
self._store=Gtk.ListStore(str, str, str)
|
||
self._treeview=Gtk.TreeView(model=self._store, headers_visible=False, search_column=-1, tooltip_column=2)
|
||
self._treeview.set_can_focus(False)
|
||
self._treeview.get_selection().set_mode(Gtk.SelectionMode.NONE)
|
||
|
||
# columns
|
||
renderer_text=Gtk.CellRendererText(width_chars=50, ellipsize=Pango.EllipsizeMode.MIDDLE, ellipsize_set=True)
|
||
renderer_text_ralign=Gtk.CellRendererText(xalign=1.0, weight=Pango.Weight.BOLD)
|
||
column_tag=Gtk.TreeViewColumn(_("MPD-Tag"), renderer_text_ralign, text=0)
|
||
column_tag.set_property("resizable", False)
|
||
self._treeview.append_column(column_tag)
|
||
column_value=Gtk.TreeViewColumn(_("Value"), renderer_text, text=1)
|
||
column_value.set_property("resizable", False)
|
||
self._treeview.append_column(column_value)
|
||
|
||
# scroll
|
||
self._scroll=Gtk.ScrolledWindow()
|
||
self._scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||
self._scroll.set_propagate_natural_height(True)
|
||
self._scroll.add(self._treeview)
|
||
|
||
# overlay
|
||
overlay=Gtk.Overlay()
|
||
overlay.add(self._scroll)
|
||
overlay.add_overlay(self._open_button_revealer)
|
||
|
||
# connect
|
||
open_button.connect("clicked", self._on_open_button_clicked)
|
||
|
||
# packing
|
||
frame=Gtk.Frame(border_width=6)
|
||
frame.add(overlay)
|
||
self.add(frame)
|
||
frame.show_all()
|
||
|
||
def open(self, uri, widget, x, y, offset=26):
|
||
self._rect.x=x
|
||
# Gtk places popovers in treeviews 26px above the given position for no obvious reasons, so I move them 26px
|
||
# This seems to be related to the width/height of the headers in treeviews
|
||
self._rect.y=y+offset
|
||
self.set_pointing_to(self._rect)
|
||
self.set_relative_to(widget)
|
||
window=self.get_toplevel()
|
||
self._scroll.set_max_content_height(window.get_size()[1]//2)
|
||
self._store.clear()
|
||
song=ClientHelper.song_to_str_dict(self._client.get_metadata(uri))
|
||
for tag, value in song.items():
|
||
tooltip=value.replace("&", "&")
|
||
if tag == "time":
|
||
self._store.append([tag+":", ClientHelper.seconds_to_display_time(int(value)), tooltip])
|
||
elif tag == "last-modified":
|
||
time=datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
|
||
self._store.append([tag+":", time.strftime("%a %d %B %Y, %H∶%M UTC"), tooltip])
|
||
else:
|
||
self._store.append([tag+":", value, tooltip])
|
||
abs_path=self._client.get_absolute_path(uri)
|
||
if abs_path is None: # show open with button when song is on the same computer
|
||
self._open_button_revealer.set_reveal_child(False)
|
||
else:
|
||
self._gfile=Gio.File.new_for_path(abs_path)
|
||
self._open_button_revealer.set_reveal_child(True)
|
||
self.popup()
|
||
self._treeview.columns_autosize()
|
||
|
||
def _on_open_button_clicked(self, *args):
|
||
self.popdown()
|
||
dialog=Gtk.AppChooserDialog(gfile=self._gfile, transient_for=self.get_toplevel())
|
||
app_chooser=dialog.get_widget()
|
||
response=dialog.run()
|
||
if response == Gtk.ResponseType.OK:
|
||
app=app_chooser.get_app_info()
|
||
app.launch([self._gfile], None)
|
||
dialog.destroy()
|
||
|
||
class SongsView(Gtk.TreeView):
|
||
def __init__(self, client, store, file_column_id):
|
||
super().__init__(model=store, search_column=-1, activate_on_single_click=True)
|
||
self._client=client
|
||
self._store=store
|
||
self._file_column_id=file_column_id
|
||
|
||
# selection
|
||
self._selection=self.get_selection()
|
||
|
||
# song popover
|
||
self._song_popover=SongPopover(self._client)
|
||
|
||
# connect
|
||
self.connect("row-activated", self._on_row_activated)
|
||
self.connect("button-press-event", self._on_button_press_event)
|
||
self._client.emitter.connect("show-info", self._on_show_info)
|
||
self._client.emitter.connect("add-to-playlist", self._on_add_to_playlist)
|
||
|
||
def clear(self):
|
||
self._store.clear()
|
||
|
||
def count(self):
|
||
return len(self._store)
|
||
|
||
def get_files(self):
|
||
return_list=[]
|
||
for row in self._store:
|
||
return_list.append(row[self._file_column_id])
|
||
return return_list
|
||
|
||
def _on_row_activated(self, widget, path, view_column):
|
||
self._client.files_to_playlist([self._store[path][self._file_column_id]])
|
||
|
||
def _on_button_press_event(self, widget, event):
|
||
path_re=widget.get_path_at_pos(int(event.x), int(event.y))
|
||
if path_re is not None:
|
||
path=path_re[0]
|
||
if event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
self._client.files_to_playlist([self._store[path][self._file_column_id]], "append")
|
||
elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
uri=self._store[path][self._file_column_id]
|
||
if self.get_property("headers-visible"):
|
||
self._song_popover.open(uri, widget, int(event.x), int(event.y))
|
||
else:
|
||
self._song_popover.open(uri, widget, int(event.x), int(event.y), offset=0)
|
||
|
||
def _on_show_info(self, *args):
|
||
if self.has_focus():
|
||
treeview, treeiter=self._selection.get_selected()
|
||
if treeiter is not None:
|
||
path=self._store.get_path(treeiter)
|
||
cell=self.get_cell_area(path, None)
|
||
self._song_popover.open(self._store[path][self._file_column_id], self, cell.x, cell.y)
|
||
|
||
def _on_add_to_playlist(self, emitter, mode):
|
||
if self.has_focus():
|
||
treeview, treeiter=self._selection.get_selected()
|
||
if treeiter is not None:
|
||
self._client.files_to_playlist([self._store.get_value(treeiter, self._file_column_id)], mode)
|
||
|
||
class SongsWindow(Gtk.Box):
|
||
__gsignals__={"button-clicked": (GObject.SignalFlags.RUN_FIRST, None, ())}
|
||
def __init__(self, client, store, file_column_id, popover_mode=False):
|
||
if popover_mode:
|
||
super().__init__(orientation=Gtk.Orientation.VERTICAL, border_width=6, spacing=6)
|
||
else:
|
||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||
self._client=client
|
||
|
||
# treeview
|
||
self._songs_view=SongsView(client, store, file_column_id)
|
||
|
||
# scroll
|
||
self._scroll=Gtk.ScrolledWindow()
|
||
self._scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||
self._scroll.add(self._songs_view)
|
||
|
||
# buttons
|
||
button_box=Gtk.ButtonBox(layout_style=Gtk.ButtonBoxStyle.EXPAND)
|
||
data=((_("_Append"), _("Add all titles to playlist"), "list-add-symbolic", "append"),
|
||
(_("_Play"), _("Directly play all titles"), "media-playback-start-symbolic", "play"),
|
||
(_("_Enqueue"), _("Append all titles after the currently playing track and clear the playlist from all other songs"),
|
||
"insert-object-symbolic", "enqueue")
|
||
)
|
||
for label, tooltip, icon, mode in data:
|
||
button=Gtk.Button.new_with_mnemonic(label)
|
||
button.set_image(Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.BUTTON))
|
||
button.set_tooltip_text(tooltip)
|
||
button.connect("clicked", self._on_button_clicked, mode)
|
||
button_box.pack_start(button, True, True, 0)
|
||
|
||
# action bar
|
||
self._action_bar=Gtk.ActionBar()
|
||
|
||
# packing
|
||
if popover_mode:
|
||
self.pack_end(button_box, False, False, 0)
|
||
frame=Gtk.Frame()
|
||
else:
|
||
self._action_bar.pack_start(button_box)
|
||
self.pack_end(self._action_bar, False, False, 0)
|
||
frame=FocusFrame()
|
||
frame.set_widget(self._songs_view)
|
||
frame.add(self._scroll)
|
||
self.pack_start(frame, True, True, 0)
|
||
|
||
def get_treeview(self):
|
||
return self._songs_view
|
||
|
||
def get_action_bar(self):
|
||
return self._action_bar
|
||
|
||
def get_scroll(self):
|
||
return self._scroll
|
||
|
||
def _on_button_clicked(self, widget, mode):
|
||
self._client.files_to_playlist(self._songs_view.get_files(), mode)
|
||
self.emit("button-clicked")
|
||
|
||
class AlbumPopover(Gtk.Popover):
|
||
def __init__(self, client, settings):
|
||
super().__init__()
|
||
self._client=client
|
||
self._settings=settings
|
||
self._rect=Gdk.Rectangle()
|
||
|
||
# songs window
|
||
# (track, title (artist), duration, file, search text)
|
||
self._store=Gtk.ListStore(str, str, str, str, str)
|
||
songs_window=SongsWindow(self._client, self._store, 3, popover_mode=True)
|
||
|
||
# scroll
|
||
self._scroll=songs_window.get_scroll()
|
||
self._scroll.set_propagate_natural_height(True)
|
||
self._scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||
|
||
# songs view
|
||
self._songs_view=songs_window.get_treeview()
|
||
self._songs_view.set_property("headers-visible", False)
|
||
self._songs_view.set_property("search-column", 4)
|
||
|
||
# columns
|
||
renderer_text=Gtk.CellRendererText(width_chars=80, ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True)
|
||
renderer_text_ralign=Gtk.CellRendererText(xalign=1.0)
|
||
column_track=Gtk.TreeViewColumn(_("No"), renderer_text_ralign, text=0)
|
||
column_track.set_property("resizable", False)
|
||
self._songs_view.append_column(column_track)
|
||
column_title=Gtk.TreeViewColumn(_("Title"), renderer_text, markup=1)
|
||
column_title.set_property("resizable", False)
|
||
self._songs_view.append_column(column_title)
|
||
column_time=Gtk.TreeViewColumn(_("Length"), renderer_text_ralign, text=2)
|
||
column_time.set_property("resizable", False)
|
||
self._songs_view.append_column(column_time)
|
||
|
||
# connect
|
||
songs_window.connect("button-clicked", lambda *args: self.popdown())
|
||
|
||
# packing
|
||
self.add(songs_window)
|
||
songs_window.show_all()
|
||
|
||
def open(self, album, album_artist, date, genre, widget, x, y):
|
||
self._rect.x=x
|
||
self._rect.y=y
|
||
self.set_pointing_to(self._rect)
|
||
self.set_relative_to(widget)
|
||
self._scroll.set_max_content_height(4*widget.get_allocated_height()//7)
|
||
self._songs_view.set_model(None) # clear old scroll position
|
||
self._store.clear()
|
||
if genre is None:
|
||
genre_filter=()
|
||
else:
|
||
genre_filter=("genre", genre)
|
||
songs=self._client.find("album", album, "date", date, self._settings.get_artist_type(), album_artist, *genre_filter)
|
||
for s in songs:
|
||
song=ClientHelper.song_to_list_dict(ClientHelper.pepare_song_for_display(s))
|
||
track=song["track"][0]
|
||
title=(", ".join(song["title"]))
|
||
# only show artists =/= albumartist
|
||
try:
|
||
song["artist"].remove(album_artist)
|
||
except:
|
||
pass
|
||
artist=(", ".join(song["artist"]))
|
||
if artist == album_artist or artist == "":
|
||
title_artist="<b>{}</b>".format(title)
|
||
else:
|
||
title_artist="<b>{}</b> - {}".format(title, artist)
|
||
title_artist=title_artist.replace("&", "&")
|
||
self._store.append([track, title_artist, song["human_duration"][0], song["file"][0], title])
|
||
self._songs_view.set_model(self._store)
|
||
self.popup()
|
||
self._songs_view.columns_autosize()
|
||
|
||
class ArtistPopover(Gtk.Popover):
|
||
def __init__(self, client):
|
||
super().__init__()
|
||
self._client=client
|
||
self._rect=Gdk.Rectangle()
|
||
self._artist=None
|
||
self._genre=None
|
||
|
||
# buttons
|
||
vbox=Gtk.ButtonBox(orientation=Gtk.Orientation.VERTICAL, border_width=9)
|
||
data=((_("Append"), _("Add all titles to playlist"), "list-add-symbolic", "append"),
|
||
(_("Play"), _("Directly play all titles"), "media-playback-start-symbolic", "play"),
|
||
(_("Enqueue"), _("Append all titles after the currently playing track and clear the playlist from all other songs"),
|
||
"insert-object-symbolic", "enqueue")
|
||
)
|
||
for label, tooltip, icon, mode in data:
|
||
button=Gtk.ModelButton(label=label, tooltip_text=tooltip, image=Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.BUTTON))
|
||
button.get_child().set_property("xalign", 0)
|
||
button.connect("clicked", self._on_button_clicked, mode)
|
||
vbox.pack_start(button, True, True, 0)
|
||
|
||
self.add(vbox)
|
||
vbox.show_all()
|
||
|
||
def open(self, artist, genre, widget, x, y):
|
||
self._rect.x=x
|
||
self._rect.y=y
|
||
self.set_pointing_to(self._rect)
|
||
self.set_relative_to(widget)
|
||
self._artist=artist
|
||
self._genre=genre
|
||
self.popup()
|
||
|
||
def _on_button_clicked(self, widget, mode):
|
||
self._client.artist_to_playlist(self._artist, self._genre, mode)
|
||
self.popdown()
|
||
|
||
###########
|
||
# browser #
|
||
###########
|
||
|
||
class SearchWindow(Gtk.Box):
|
||
def __init__(self, client):
|
||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||
self._client=client
|
||
self._stop_flag=False
|
||
self._done=True
|
||
self._pending=[]
|
||
|
||
# tag switcher
|
||
self._tag_combo_box=Gtk.ComboBoxText()
|
||
|
||
# search entry
|
||
self.search_entry=Gtk.SearchEntry()
|
||
|
||
# label
|
||
self._hits_label=Gtk.Label(xalign=1)
|
||
|
||
# store
|
||
# (track, title, artist, album, duration, file, sort track)
|
||
self._store=Gtk.ListStore(str, str, str, str, str, str, int)
|
||
self._store.set_default_sort_func(lambda *args: 0)
|
||
|
||
# songs window
|
||
self._songs_window=SongsWindow(self._client, self._store, 5)
|
||
|
||
# action bar
|
||
self._action_bar=self._songs_window.get_action_bar()
|
||
self._action_bar.set_sensitive(False)
|
||
|
||
# songs view
|
||
self._songs_view=self._songs_window.get_treeview()
|
||
|
||
# columns
|
||
renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True)
|
||
renderer_text_ralign=Gtk.CellRendererText(xalign=1.0)
|
||
|
||
column_track=Gtk.TreeViewColumn(_("No"), renderer_text_ralign, text=0)
|
||
column_track.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
|
||
column_track.set_property("resizable", False)
|
||
self._songs_view.append_column(column_track)
|
||
|
||
column_title=Gtk.TreeViewColumn(_("Title"), renderer_text, text=1)
|
||
column_title.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
|
||
column_title.set_property("resizable", False)
|
||
column_title.set_property("expand", True)
|
||
self._songs_view.append_column(column_title)
|
||
|
||
column_artist=Gtk.TreeViewColumn(_("Artist"), renderer_text, text=2)
|
||
column_artist.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
|
||
column_artist.set_property("resizable", False)
|
||
column_artist.set_property("expand", True)
|
||
self._songs_view.append_column(column_artist)
|
||
|
||
column_album=Gtk.TreeViewColumn(_("Album"), renderer_text, text=3)
|
||
column_album.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
|
||
column_album.set_property("resizable", False)
|
||
column_album.set_property("expand", True)
|
||
self._songs_view.append_column(column_album)
|
||
|
||
column_time=Gtk.TreeViewColumn(_("Length"), renderer_text, text=4)
|
||
column_time.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
|
||
column_time.set_property("resizable", False)
|
||
self._songs_view.append_column(column_time)
|
||
|
||
column_track.set_sort_column_id(6)
|
||
column_title.set_sort_column_id(1)
|
||
column_artist.set_sort_column_id(2)
|
||
column_album.set_sort_column_id(3)
|
||
column_time.set_sort_column_id(4)
|
||
|
||
# connect
|
||
self._search_entry_changed=self.search_entry.connect("search-changed", self._search)
|
||
self._tag_combo_box_changed=self._tag_combo_box.connect("changed", self._search)
|
||
self._client.emitter.connect("reconnected", self._on_reconnected)
|
||
self._client.emitter.connect("disconnected", self._on_disconnected)
|
||
|
||
# packing
|
||
hbox=Gtk.Box(spacing=6, border_width=6)
|
||
hbox.pack_start(self.search_entry, True, True, 0)
|
||
hbox.pack_end(self._tag_combo_box, False, False, 0)
|
||
self._hits_label.set_margin_end(6)
|
||
self._action_bar.pack_end(self._hits_label)
|
||
self.pack_start(hbox, False, False, 0)
|
||
self.pack_start(Gtk.Separator.new(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0)
|
||
self.pack_start(self._songs_window, True, True, 0)
|
||
|
||
def _clear(self, *args):
|
||
if self._done:
|
||
self.search_entry.handler_block(self._search_entry_changed)
|
||
self._tag_combo_box.handler_block(self._tag_combo_box_changed)
|
||
self._songs_view.clear()
|
||
self.search_entry.set_text("")
|
||
self._tag_combo_box.remove_all()
|
||
self.search_entry.handler_unblock(self._search_entry_changed)
|
||
self._tag_combo_box.handler_unblock(self._tag_combo_box_changed)
|
||
elif not self._clear in self._pending:
|
||
self._stop_flag=True
|
||
self._pending.append(self._clear)
|
||
|
||
def _on_disconnected(self, *args):
|
||
self._tag_combo_box.set_sensitive(False)
|
||
self.search_entry.set_sensitive(False)
|
||
self._clear()
|
||
|
||
def _on_reconnected(self, *args):
|
||
if self._done:
|
||
self._tag_combo_box.handler_block(self._tag_combo_box_changed)
|
||
self._tag_combo_box.append_text(_("all tags"))
|
||
for tag in self._client.tagtypes():
|
||
if not tag.startswith("MUSICBRAINZ"):
|
||
self._tag_combo_box.append_text(tag)
|
||
self._tag_combo_box.set_active(0)
|
||
self._tag_combo_box.set_sensitive(True)
|
||
self.search_entry.set_sensitive(True)
|
||
self._tag_combo_box.handler_unblock(self._tag_combo_box_changed)
|
||
elif not self._on_reconnected in self._pending:
|
||
self._stop_flag=True
|
||
self._pending.append(self._on_reconnected)
|
||
|
||
def _search(self, *args):
|
||
if self._done:
|
||
self._done=False
|
||
self._songs_view.clear()
|
||
self._hits_label.set_text("")
|
||
self._action_bar.set_sensitive(False)
|
||
if len(self.search_entry.get_text()) > 0:
|
||
if self._tag_combo_box.get_active() == 0:
|
||
songs=self._client.search("any", self.search_entry.get_text())
|
||
else:
|
||
songs=self._client.search(self._tag_combo_box.get_active_text(), self.search_entry.get_text())
|
||
hits=len(songs)
|
||
self._hits_label.set_text(ngettext("{hits} hit", "{hits} hits", hits).format(hits=hits))
|
||
for i, s in enumerate(songs):
|
||
if self._stop_flag:
|
||
self._done_callback()
|
||
return
|
||
song=ClientHelper.song_to_str_dict(ClientHelper.pepare_song_for_display(s))
|
||
try:
|
||
int_track=int(song["track"])
|
||
except:
|
||
int_track=0
|
||
self._store.append([
|
||
song["track"], song["title"],
|
||
song["artist"], song["album"],
|
||
song["human_duration"], song["file"],
|
||
int_track
|
||
])
|
||
if i%100 == 0:
|
||
self.search_entry.set_progress_fraction((i+1)/hits)
|
||
while Gtk.events_pending():
|
||
Gtk.main_iteration_do(True)
|
||
if self._songs_view.count() > 0:
|
||
self._action_bar.set_sensitive(True)
|
||
self._done_callback()
|
||
elif not self._search in self._pending:
|
||
self._stop_flag=True
|
||
self._pending.append(self._search)
|
||
|
||
def _done_callback(self, *args):
|
||
self.search_entry.set_progress_fraction(0.0)
|
||
self._stop_flag=False
|
||
self._done=True
|
||
pending=self._pending
|
||
self._pending=[]
|
||
for p in pending:
|
||
try:
|
||
p()
|
||
except:
|
||
pass
|
||
return False
|
||
|
||
class GenreSelect(Gtk.ComboBoxText):
|
||
__gsignals__={"genre_changed": (GObject.SignalFlags.RUN_FIRST, None, ())}
|
||
def __init__(self, client):
|
||
super().__init__(wrap_width=3)
|
||
self._client=client
|
||
|
||
# connect
|
||
self._changed=self.connect("changed", self._on_changed)
|
||
self._client.emitter.connect("disconnected", self._on_disconnected)
|
||
self._client.emitter.connect("reconnected", self._on_reconnected)
|
||
self._client.emitter.connect("update", self._refresh)
|
||
|
||
def deactivate(self):
|
||
self.set_active(0)
|
||
|
||
def _clear(self, *args):
|
||
self.handler_block(self._changed)
|
||
self.remove_all()
|
||
self.handler_unblock(self._changed)
|
||
|
||
def get_selected_genre(self):
|
||
if self.get_active() == 0:
|
||
return None
|
||
else:
|
||
return self.get_active_text()
|
||
|
||
def _refresh(self, *args):
|
||
self.handler_block(self._changed)
|
||
self.remove_all()
|
||
self.append_text(_("all genres"))
|
||
for genre in self._client.comp_list("genre"):
|
||
self.append_text(genre)
|
||
self.set_active(0)
|
||
self.handler_unblock(self._changed)
|
||
|
||
def _on_changed(self, *args):
|
||
self.emit("genre_changed")
|
||
|
||
def _on_disconnected(self, *args):
|
||
self.set_sensitive(False)
|
||
self._clear()
|
||
|
||
def _on_reconnected(self, *args):
|
||
self._refresh()
|
||
self.set_sensitive(True)
|
||
|
||
class ArtistWindow(FocusFrame):
|
||
__gsignals__={"artists_changed": (GObject.SignalFlags.RUN_FIRST, None, ()), "clear": (GObject.SignalFlags.RUN_FIRST, None, ())}
|
||
def __init__(self, client, settings, genre_select):
|
||
super().__init__()
|
||
self._client=client
|
||
self._settings=settings
|
||
self.genre_select=genre_select
|
||
|
||
# treeview
|
||
# (name, weight, initial-letter, weight-initials)
|
||
self._store=Gtk.ListStore(str, Pango.Weight, str, Pango.Weight)
|
||
self._treeview=Gtk.TreeView(model=self._store, activate_on_single_click=True, search_column=0, headers_visible=False)
|
||
self._treeview.columns_autosize()
|
||
self._selection=self._treeview.get_selection()
|
||
|
||
# columns
|
||
renderer_text_malign=Gtk.CellRendererText(xalign=0.5)
|
||
self._column_initials=Gtk.TreeViewColumn("", renderer_text_malign, text=2, weight=3)
|
||
self._column_initials.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
|
||
self._column_initials.set_property("resizable", False)
|
||
self._column_initials.set_visible(self._settings.get_boolean("show-initials"))
|
||
self._treeview.append_column(self._column_initials)
|
||
|
||
renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True)
|
||
self._column_name=Gtk.TreeViewColumn("", renderer_text, text=0, weight=1)
|
||
self._column_name.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
|
||
self._column_name.set_property("resizable", False)
|
||
self._treeview.append_column(self._column_name)
|
||
|
||
# scroll
|
||
scroll=Gtk.ScrolledWindow()
|
||
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||
scroll.add(self._treeview)
|
||
|
||
# artist popover
|
||
self._artist_popover=ArtistPopover(self._client)
|
||
|
||
# connect
|
||
self._treeview.connect("button-press-event", self._on_button_press_event)
|
||
self._treeview.connect("row-activated", self._on_row_activated)
|
||
self._settings.connect("changed::use-album-artist", self._refresh)
|
||
self._settings.connect("changed::show-initials", self._on_show_initials_changed)
|
||
self._client.emitter.connect("disconnected", self._on_disconnected)
|
||
self._client.emitter.connect("reconnected", self._on_reconnected)
|
||
self._client.emitter.connect("update", self._refresh)
|
||
self._client.emitter.connect("add-to-playlist", self._on_add_to_playlist)
|
||
self._client.emitter.connect("show-info", self._on_show_info)
|
||
self.genre_select.connect("genre_changed", self._refresh)
|
||
|
||
self.set_widget(self._treeview)
|
||
self.add(scroll)
|
||
|
||
def _clear(self, *args):
|
||
self._store.clear()
|
||
self.emit("clear")
|
||
|
||
def select(self, artist):
|
||
row_num=len(self._store)
|
||
for i in range(0, row_num):
|
||
path=Gtk.TreePath(i)
|
||
if self._store[path][0] == artist:
|
||
self._treeview.set_cursor(path, None, False)
|
||
if self._store[path][1] != Pango.Weight.BOLD:
|
||
self._treeview.row_activated(path, self._column_name)
|
||
break
|
||
|
||
def get_selected_artist(self):
|
||
if self._store[Gtk.TreePath(0)][1] == Pango.Weight.BOLD:
|
||
return None
|
||
else:
|
||
for row in self._store:
|
||
if row[1] == Pango.Weight.BOLD:
|
||
return row[0]
|
||
|
||
def highlight_selected(self):
|
||
for path, row in enumerate(self._store):
|
||
if row[1] == Pango.Weight.BOLD:
|
||
self._treeview.set_cursor(path, None, False)
|
||
break
|
||
|
||
def _refresh(self, *args):
|
||
self._clear()
|
||
self._store.append([_("all artists"), Pango.Weight.BOOK, "", Pango.Weight.BOOK])
|
||
genre=self.genre_select.get_selected_genre()
|
||
if genre is None:
|
||
artists=self._client.comp_list(self._settings.get_artist_type())
|
||
else:
|
||
artists=self._client.comp_list(self._settings.get_artist_type(), "genre", genre)
|
||
current_char=""
|
||
for artist in artists:
|
||
try:
|
||
if current_char == artist[0]:
|
||
self._store.append([artist, Pango.Weight.BOOK, "", Pango.Weight.BOOK])
|
||
else:
|
||
self._store.append([artist, Pango.Weight.BOOK, artist[0], Pango.Weight.BOLD])
|
||
current_char=artist[0]
|
||
except:
|
||
self._store.append([artist, Pango.Weight.BOOK, "", Pango.Weight.BOOK])
|
||
if genre is not None:
|
||
self._treeview.set_cursor(Gtk.TreePath(0), None, False)
|
||
self._treeview.row_activated(Gtk.TreePath(0), self._column_name)
|
||
else:
|
||
song=ClientHelper.song_to_first_str_dict(self._client.currentsong())
|
||
if song != {}:
|
||
artist=song.get(self._settings.get_artist_type())
|
||
if artist is None:
|
||
artist=song.get("artist", "")
|
||
self.select(artist)
|
||
else:
|
||
if len(self._store) > 1:
|
||
path=Gtk.TreePath(1)
|
||
else:
|
||
path=Gtk.TreePath(0)
|
||
self._treeview.set_cursor(path, None, False)
|
||
self._treeview.row_activated(path, self._column_name)
|
||
|
||
def _on_button_press_event(self, widget, event):
|
||
if event.button in (2,3) and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
path_re=widget.get_path_at_pos(int(event.x), int(event.y))
|
||
if path_re is not None:
|
||
path=path_re[0]
|
||
genre=self.genre_select.get_selected_genre()
|
||
if path == Gtk.TreePath(0):
|
||
artist=None
|
||
else:
|
||
artist=self._store[path][0]
|
||
if event.button == 2:
|
||
self._client.artist_to_playlist(artist, genre, "append")
|
||
if event.button == 3:
|
||
self._artist_popover.open(artist, genre, self._treeview, event.x, event.y)
|
||
|
||
def _on_row_activated(self, widget, path, view_column):
|
||
for row in self._store: # reset bold text
|
||
row[1]=Pango.Weight.BOOK
|
||
self._store[path][1]=Pango.Weight.BOLD
|
||
self.emit("artists_changed")
|
||
|
||
def _on_add_to_playlist(self, emitter, mode):
|
||
if self._treeview.has_focus():
|
||
treeview, treeiter=self._selection.get_selected()
|
||
if treeiter is not None:
|
||
path=self._store.get_path(treeiter)
|
||
genre=self.genre_select.get_selected_genre()
|
||
if path == Gtk.TreePath(0):
|
||
self._client.artist_to_playlist(None, genre, mode)
|
||
else:
|
||
artist=self._store[path][0]
|
||
self._client.artist_to_playlist(artist, genre, mode)
|
||
|
||
def _on_show_info(self, *args):
|
||
if self._treeview.has_focus():
|
||
treeview, treeiter=self._selection.get_selected()
|
||
if treeiter is not None:
|
||
path=self._store.get_path(treeiter)
|
||
cell=self._treeview.get_cell_area(path, None)
|
||
genre=self.genre_select.get_selected_genre()
|
||
if path == Gtk.TreePath(0):
|
||
self._artist_popover.open(None, genre, self._treeview, cell.x, cell.y)
|
||
else:
|
||
self._artist_popover.open(self._store[path][0], genre, self._treeview, cell.x, cell.y)
|
||
|
||
def _on_disconnected(self, *args):
|
||
self.set_sensitive(False)
|
||
self._artist_popover.popdown()
|
||
self._clear()
|
||
|
||
def _on_reconnected(self, *args):
|
||
self._refresh()
|
||
self.set_sensitive(True)
|
||
|
||
def _on_show_initials_changed(self, *args):
|
||
self._column_initials.set_visible(self._settings.get_boolean("show-initials"))
|
||
|
||
class AlbumWindow(FocusFrame):
|
||
def __init__(self, client, settings, artist_window):
|
||
super().__init__()
|
||
self._settings=settings
|
||
self._client=client
|
||
self._artist_window=artist_window
|
||
self._stop_flag=False
|
||
self._done=True
|
||
self._pending=[]
|
||
|
||
# cover, display_label, display_label_artist, tooltip(titles), album, year, artist
|
||
self._store=Gtk.ListStore(GdkPixbuf.Pixbuf, str, str, str, str, str, str)
|
||
self._sort_settings()
|
||
|
||
# iconview
|
||
self._iconview=Gtk.IconView(
|
||
model=self._store, item_width=0, pixbuf_column=0, markup_column=1, tooltip_column=3, activate_on_single_click=True
|
||
)
|
||
|
||
# scroll
|
||
scroll=Gtk.ScrolledWindow()
|
||
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||
scroll.add(self._iconview)
|
||
self._scroll_vadj=scroll.get_vadjustment()
|
||
self._scroll_hadj=scroll.get_hadjustment()
|
||
|
||
# progress bar
|
||
self._progress_bar=Gtk.ProgressBar(no_show_all=True)
|
||
|
||
# popover
|
||
self._album_popover=AlbumPopover(self._client, self._settings)
|
||
self._artist_popover=ArtistPopover(self._client)
|
||
|
||
# connect
|
||
self._iconview.connect("item-activated", self._on_item_activated)
|
||
self._iconview.connect("button-press-event", self._on_button_press_event)
|
||
self._client.emitter.connect("disconnected", self._on_disconnected)
|
||
self._client.emitter.connect("reconnected", self._on_reconnected)
|
||
self._client.emitter.connect("show-info", self._on_show_info)
|
||
self._client.emitter.connect("add-to-playlist", self._on_add_to_playlist)
|
||
self._settings.connect("changed::sort-albums-by-year", self._sort_settings)
|
||
self._settings.connect("changed::album-cover", self._on_cover_size_changed)
|
||
self._artist_window.connect("artists_changed", self._refresh)
|
||
self._artist_window.connect("clear", self._clear)
|
||
|
||
# packing
|
||
self.set_widget(self._iconview)
|
||
box=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||
box.pack_start(scroll, True, True, 0)
|
||
box.pack_start(self._progress_bar, False, False, 0)
|
||
self.add(box)
|
||
|
||
def _workaround_clear(self):
|
||
self._store.clear()
|
||
# workaround (scrollbar still visible after clear)
|
||
self._iconview.set_model(None)
|
||
self._iconview.set_model(self._store)
|
||
|
||
def _clear(self, *args):
|
||
if self._done:
|
||
self._workaround_clear()
|
||
elif not self._clear in self._pending:
|
||
self._stop_flag=True
|
||
self._pending.append(self._clear)
|
||
|
||
def scroll_to_current_album(self):
|
||
def callback():
|
||
song=ClientHelper.song_to_first_str_dict(self._client.currentsong())
|
||
album=song.get("album", "")
|
||
self._iconview.unselect_all()
|
||
row_num=len(self._store)
|
||
for i in range(0, row_num):
|
||
path=Gtk.TreePath(i)
|
||
treeiter=self._store.get_iter(path)
|
||
if self._store.get_value(treeiter, 4) == album:
|
||
self._iconview.set_cursor(path, None, False)
|
||
self._iconview.select_path(path)
|
||
self._iconview.scroll_to_path(path, True, 0, 0)
|
||
break
|
||
if self._done:
|
||
callback()
|
||
elif not self.scroll_to_current_album in self._pending:
|
||
self._pending.append(self.scroll_to_current_album)
|
||
|
||
def _sort_settings(self, *args):
|
||
if self._settings.get_boolean("sort-albums-by-year"):
|
||
self._store.set_sort_column_id(5, Gtk.SortType.ASCENDING)
|
||
else:
|
||
self._store.set_sort_column_id(1, Gtk.SortType.ASCENDING)
|
||
|
||
def _refresh(self, *args):
|
||
if self._done:
|
||
self._done=False
|
||
self._settings.set_property("cursor-watch", True)
|
||
self._progress_bar.show()
|
||
self._store.clear()
|
||
self._iconview.set_model(None)
|
||
try: # self._artist_window can still be empty (e.g. when client is not connected and cover size gets changed)
|
||
artist=self._artist_window.get_selected_artist()
|
||
genre=self._artist_window.genre_select.get_selected_genre()
|
||
except:
|
||
self._done_callback()
|
||
return
|
||
if artist is None:
|
||
self._iconview.set_markup_column(2) # show artist names
|
||
if genre is None:
|
||
artists=self._client.comp_list(self._settings.get_artist_type())
|
||
else:
|
||
artists=self._client.comp_list(self._settings.get_artist_type(), "genre", genre)
|
||
else:
|
||
self._iconview.set_markup_column(1) # hide artist names
|
||
artists=[artist]
|
||
# prepare albmus list (run all mpd related commands)
|
||
albums=[]
|
||
for i, artist in enumerate(artists):
|
||
try: # client cloud meanwhile disconnect
|
||
if self._stop_flag:
|
||
self._done_callback()
|
||
return
|
||
else:
|
||
if i > 0: # more than one artist to show (all artists)
|
||
self._progress_bar.pulse()
|
||
albums.extend(self._client.get_albums(artist, genre))
|
||
while Gtk.events_pending():
|
||
Gtk.main_iteration_do(True)
|
||
except MPDBase.ConnectionError:
|
||
self._done_callback()
|
||
return
|
||
|
||
def display_albums():
|
||
for i, album in enumerate(albums):
|
||
# tooltip
|
||
length_human_readable=ClientHelper.calc_display_length(album["songs"])
|
||
titles=len(album["songs"])
|
||
discs=album["songs"][-1].get("disc", 1)
|
||
if type(discs) == list:
|
||
discs=int(discs[0])
|
||
else:
|
||
discs=int(discs)
|
||
tooltip=ngettext("{titles} title", "{titles} titles", titles).format(titles=titles)
|
||
if discs > 1:
|
||
tooltip=" ".join((tooltip, _("on {discs} discs").format(discs=discs)))
|
||
tooltip=" ".join((tooltip, "({length})".format(length=length_human_readable)))
|
||
# album label
|
||
if album["year"] == "":
|
||
display_label="<b>{}</b>".format(album["album"])
|
||
else:
|
||
display_label="<b>{}</b> ({})".format(album["album"], album["year"])
|
||
display_label_artist=display_label+"\n"+album["artist"]
|
||
display_label=display_label.replace("&", "&")
|
||
display_label_artist=display_label_artist.replace("&", "&")
|
||
# add album
|
||
self._store.append(
|
||
[album["cover"], display_label, display_label_artist,
|
||
tooltip, album["album"], album["year"], album["artist"]]
|
||
)
|
||
self._iconview.set_model(self._store)
|
||
self._done_callback()
|
||
return False
|
||
|
||
def render_covers():
|
||
size=self._settings.get_int("album-cover")
|
||
total_albums=len(albums)
|
||
for i, album in enumerate(albums):
|
||
if self._stop_flag:
|
||
break
|
||
if "cover_path" in album:
|
||
try:
|
||
album["cover"]=GdkPixbuf.Pixbuf.new_from_file_at_size(album["cover_path"], size, size)
|
||
except: # load fallback if cover can't be loaded
|
||
album["cover"]=GdkPixbuf.Pixbuf.new_from_file_at_size(self._client.fallback_cover, size, size)
|
||
else:
|
||
if "cover_binary" in album:
|
||
album["cover"]=ClientHelper.binary_to_pixbuf(album["cover_binary"], size)
|
||
else:
|
||
album["cover"]=GdkPixbuf.Pixbuf.new_from_file_at_size(self._client.fallback_cover, size, size)
|
||
GLib.idle_add(self._progress_bar.set_fraction, (i+1)/total_albums)
|
||
if self._stop_flag:
|
||
GLib.idle_add(self._done_callback)
|
||
else:
|
||
GLib.idle_add(display_albums)
|
||
|
||
cover_thread=threading.Thread(target=render_covers, daemon=True)
|
||
cover_thread.start()
|
||
elif not self._refresh in self._pending:
|
||
self._stop_flag=True
|
||
self._pending.append(self._refresh)
|
||
|
||
def _path_to_playlist(self, path, mode="default"):
|
||
album=self._store[path][4]
|
||
year=self._store[path][5]
|
||
artist=self._store[path][6]
|
||
genre=self._artist_window.genre_select.get_selected_genre()
|
||
self._client.album_to_playlist(album, artist, year, genre, mode)
|
||
|
||
def _done_callback(self, *args):
|
||
self._settings.set_property("cursor-watch", False)
|
||
self._progress_bar.hide()
|
||
self._progress_bar.set_fraction(0)
|
||
self._stop_flag=False
|
||
self._done=True
|
||
pending=self._pending
|
||
self._pending=[]
|
||
for p in pending:
|
||
try:
|
||
p()
|
||
except:
|
||
pass
|
||
return False
|
||
|
||
def _on_button_press_event(self, widget, event):
|
||
path=widget.get_path_at_pos(int(event.x), int(event.y))
|
||
if event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
if path is not None:
|
||
self._path_to_playlist(path, "append")
|
||
elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
v=self._scroll_vadj.get_value()
|
||
h=self._scroll_hadj.get_value()
|
||
genre=self._artist_window.genre_select.get_selected_genre()
|
||
if path is not None:
|
||
album=self._store[path][4]
|
||
year=self._store[path][5]
|
||
artist=self._store[path][6]
|
||
# when using "button-press-event" in iconview popovers only show up in combination with idle_add (bug in GTK?)
|
||
GLib.idle_add(self._album_popover.open, album, artist, year, genre, widget, event.x-h, event.y-v)
|
||
else:
|
||
artist=self._artist_window.get_selected_artist()
|
||
GLib.idle_add(self._artist_popover.open, artist, genre, widget, event.x-h, event.y-v)
|
||
|
||
def _on_item_activated(self, widget, path):
|
||
treeiter=self._store.get_iter(path)
|
||
album=self._store.get_value(treeiter, 4)
|
||
year=self._store.get_value(treeiter, 5)
|
||
artist=self._store.get_value(treeiter, 6)
|
||
genre=self._artist_window.genre_select.get_selected_genre()
|
||
self._client.album_to_playlist(album, artist, year, genre)
|
||
|
||
def _on_disconnected(self, *args):
|
||
self._iconview.set_sensitive(False)
|
||
self._album_popover.popdown()
|
||
self._artist_popover.popdown()
|
||
|
||
def _on_reconnected(self, *args):
|
||
self._iconview.set_sensitive(True)
|
||
|
||
def _on_show_info(self, *args):
|
||
if self._iconview.has_focus():
|
||
paths=self._iconview.get_selected_items()
|
||
if len(paths) > 0:
|
||
rect=self._iconview.get_cell_rect(paths[0], None)[1]
|
||
x=rect.x+rect.width//2
|
||
y=rect.y+rect.height//2
|
||
genre=self._artist_window.genre_select.get_selected_genre()
|
||
self._album_popover.open(
|
||
self._store[paths[0]][4], self._store[paths[0]][6], self._store[paths[0]][5], genre, self._iconview, x, y
|
||
)
|
||
|
||
def _on_add_to_playlist(self, emitter, mode):
|
||
if self._iconview.has_focus():
|
||
paths=self._iconview.get_selected_items()
|
||
if len(paths) != 0:
|
||
self._path_to_playlist(paths[0], mode)
|
||
|
||
def _on_cover_size_changed(self, *args):
|
||
def callback():
|
||
self._refresh()
|
||
return False
|
||
GLib.idle_add(callback)
|
||
|
||
class Browser(Gtk.Paned):
|
||
__gsignals__={"search-focus-changed": (GObject.SignalFlags.RUN_FIRST, None, (bool,))}
|
||
def __init__(self, client, settings):
|
||
super().__init__(orientation=Gtk.Orientation.HORIZONTAL)
|
||
self._client=client
|
||
self._settings=settings
|
||
self._use_csd=self._settings.get_boolean("use-csd")
|
||
|
||
# widgets
|
||
icons={}
|
||
icons_data=("go-previous-symbolic", "system-search-symbolic")
|
||
if self._use_csd:
|
||
for data in icons_data:
|
||
icons[data]=Gtk.Image.new_from_icon_name(data, Gtk.IconSize.BUTTON)
|
||
else:
|
||
for data in icons_data:
|
||
icons[data]=AutoSizedIcon(data, "icon-size-sec", self._settings)
|
||
|
||
self.back_to_current_album_button=Gtk.Button(image=icons["go-previous-symbolic"], tooltip_text=_("Back to current album"))
|
||
self.back_to_current_album_button.set_can_focus(False)
|
||
self.search_button=Gtk.ToggleButton(image=icons["system-search-symbolic"], tooltip_text=_("Search"))
|
||
self.search_button.set_can_focus(False)
|
||
self._genre_select=GenreSelect(self._client)
|
||
self._artist_window=ArtistWindow(self._client, self._settings, self._genre_select)
|
||
self._search_window=SearchWindow(self._client)
|
||
self._album_window=AlbumWindow(self._client, self._settings, self._artist_window)
|
||
|
||
# connect
|
||
self.back_to_current_album_button.connect("clicked", self._back_to_current_album)
|
||
self.search_button.connect("toggled", self._on_search_toggled)
|
||
self._search_window.search_entry.connect("focus_in_event", lambda *args: self.emit("search-focus-changed", True))
|
||
self._search_window.search_entry.connect("focus_out_event", lambda *args: self.emit("search-focus-changed", False))
|
||
self._artist_window.connect("artists_changed", self._on_artists_changed)
|
||
self._settings.connect("notify::mini-player", self._on_mini_player)
|
||
self._client.emitter.connect("disconnected", self._on_disconnected)
|
||
self._client.emitter.connect("reconnected", self._on_reconnected)
|
||
|
||
# packing
|
||
self._stack=Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE)
|
||
self._stack.add_named(self._album_window, "albums")
|
||
self._stack.add_named(self._search_window, "search")
|
||
hbox=Gtk.Box(spacing=6, border_width=6)
|
||
if self._use_csd:
|
||
hbox.pack_start(self._genre_select, True, True, 0)
|
||
else:
|
||
hbox.pack_start(self.back_to_current_album_button, False, False, 0)
|
||
hbox.pack_start(self._genre_select, True, True, 0)
|
||
hbox.pack_start(self.search_button, False, False, 0)
|
||
box1=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||
box1.pack_start(hbox, False, False, 0)
|
||
box1.pack_start(Gtk.Separator.new(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0)
|
||
box1.pack_start(self._artist_window, True, True, 0)
|
||
self.pack1(box1, False, False)
|
||
self.pack2(self._stack, True, False)
|
||
|
||
def _back_to_current_album(self, *args):
|
||
song=ClientHelper.song_to_first_str_dict(self._client.currentsong())
|
||
if song != {}:
|
||
self.search_button.set_active(False)
|
||
# get artist name
|
||
artist=song.get(self._settings.get_artist_type())
|
||
if artist is None:
|
||
artist=song.get("artist", "")
|
||
# deactivate genre filter to show all artists (if needed)
|
||
if song.get("genre", "") != self._genre_select.get_selected_genre():
|
||
self._genre_select.deactivate()
|
||
# select artist
|
||
if self._artist_window.get_selected_artist() is None: # all artists selected
|
||
self.search_button.set_active(False)
|
||
self._artist_window.highlight_selected()
|
||
else: # one artist selected
|
||
self._artist_window.select(artist)
|
||
self._album_window.scroll_to_current_album()
|
||
|
||
def _on_search_toggled(self, widget):
|
||
if widget.get_active():
|
||
self._stack.set_visible_child_name("search")
|
||
self._search_window.search_entry.grab_focus()
|
||
else:
|
||
self._stack.set_visible_child_name("albums")
|
||
|
||
def _on_reconnected(self, *args):
|
||
self.back_to_current_album_button.set_sensitive(True)
|
||
self.search_button.set_sensitive(True)
|
||
|
||
def _on_disconnected(self, *args):
|
||
self.back_to_current_album_button.set_sensitive(False)
|
||
self.search_button.set_active(False)
|
||
self.search_button.set_sensitive(False)
|
||
|
||
def _on_artists_changed(self, *args):
|
||
self.search_button.set_active(False)
|
||
|
||
def _on_mini_player(self, obj, typestring):
|
||
state=obj.get_property("mini-player")
|
||
self.set_property("no-show-all", state)
|
||
self.back_to_current_album_button.set_property("no-show-all", state)
|
||
self.search_button.set_property("no-show-all", state)
|
||
self.set_property("visible", not(state))
|
||
self.back_to_current_album_button.set_property("visible", not(state))
|
||
self.search_button.set_property("visible", not(state))
|
||
if not state:
|
||
self.show_all()
|
||
|
||
######################
|
||
# playlist and cover #
|
||
######################
|
||
|
||
class LyricsWindow(FocusFrame):
|
||
def __init__(self, client, settings):
|
||
super().__init__()
|
||
self._settings=settings
|
||
self._client=client
|
||
self._displayed_song_file=None
|
||
|
||
# text view
|
||
self._text_view=Gtk.TextView(
|
||
editable=False,
|
||
cursor_visible=False,
|
||
wrap_mode=Gtk.WrapMode.WORD,
|
||
justification=Gtk.Justification.CENTER,
|
||
opacity=0.9
|
||
)
|
||
self._text_view.set_left_margin(5)
|
||
self._text_view.set_right_margin(5)
|
||
self._text_view.set_bottom_margin(5)
|
||
self._text_view.set_top_margin(3)
|
||
self.set_widget(self._text_view)
|
||
|
||
# text buffer
|
||
self._text_buffer=self._text_view.get_buffer()
|
||
|
||
# scroll
|
||
scroll=Gtk.ScrolledWindow()
|
||
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||
scroll.add(self._text_view)
|
||
|
||
# connect
|
||
self._client.emitter.connect("disconnected", self._on_disconnected)
|
||
self._song_changed=self._client.emitter.connect("current_song_changed", self._refresh)
|
||
self._client.emitter.handler_block(self._song_changed)
|
||
|
||
# packing
|
||
self.add(scroll)
|
||
|
||
def enable(self, *args):
|
||
current_song=self._client.currentsong()
|
||
if current_song == {}:
|
||
if self._displayed_song_file is not None:
|
||
self._refresh()
|
||
else:
|
||
if current_song["file"] != self._displayed_song_file:
|
||
self._refresh()
|
||
self._client.emitter.handler_unblock(self._song_changed)
|
||
GLib.idle_add(self._text_view.grab_focus) # focus textview
|
||
|
||
def disable(self, *args):
|
||
self._client.emitter.handler_block(self._song_changed)
|
||
|
||
def _get_lyrics(self, title, artist):
|
||
replaces=((" ", "+"),(".", "_"),("@", "_"),(",", "_"),(";", "_"),("&", "_"),("\\", "_"),("/", "_"),('"', "_"),("(", "_"),(")", "_"))
|
||
for char1, char2 in replaces:
|
||
title=title.replace(char1, char2)
|
||
artist=artist.replace(char1, char2)
|
||
req=requests.get("https://www.letras.mus.br/winamp.php?musica={0}&artista={1}".format(title,artist))
|
||
soup=BeautifulSoup(req.text, "html.parser")
|
||
soup=soup.find(id="letra-cnt")
|
||
if soup is None:
|
||
raise ValueError("Not found")
|
||
paragraphs=[i for i in soup.children][1] # remove unneded paragraphs (NavigableString)
|
||
lyrics=""
|
||
for paragraph in paragraphs:
|
||
for line in paragraph.stripped_strings:
|
||
lyrics+=line+"\n"
|
||
lyrics+="\n"
|
||
output=lyrics[:-2] # omit last two newlines
|
||
if output == "": # assume song is instrumental when lyrics are empty
|
||
return "Instrumental"
|
||
else:
|
||
return output
|
||
|
||
def _display_lyrics(self, current_song):
|
||
GLib.idle_add(self._text_buffer.set_text, _("searching..."), -1)
|
||
try:
|
||
text=self._get_lyrics(current_song.get("title", ""), current_song.get("artist", ""))
|
||
except requests.exceptions.ConnectionError:
|
||
self._displayed_song_file=None
|
||
text=_("connection error")
|
||
except ValueError:
|
||
text=_("lyrics not found")
|
||
GLib.idle_add(self._text_buffer.set_text, text, -1)
|
||
|
||
def _refresh(self, *args):
|
||
current_song=self._client.currentsong()
|
||
if current_song == {}:
|
||
self._displayed_song_file=None
|
||
self._text_buffer.set_text("", -1)
|
||
else:
|
||
self._displayed_song_file=current_song["file"]
|
||
update_thread=threading.Thread(
|
||
target=self._display_lyrics,
|
||
kwargs={"current_song": ClientHelper.song_to_first_str_dict(current_song)},
|
||
daemon=True
|
||
)
|
||
update_thread.start()
|
||
|
||
def _on_disconnected(self, *args):
|
||
self._displayed_song_file=None
|
||
self._text_buffer.set_text("", -1)
|
||
|
||
class AudioType(Gtk.Label):
|
||
def __init__(self, client):
|
||
super().__init__()
|
||
self._client=client
|
||
self.freq, self.res, self.chan, self.brate, self.file_type=(0, 0, 0, 0, "")
|
||
|
||
# connect
|
||
self._client.emitter.connect("audio", self._on_audio)
|
||
self._client.emitter.connect("bitrate", self._on_bitrate)
|
||
self._client.emitter.connect("current_song_changed", self._on_song_changed)
|
||
self._client.emitter.connect("disconnected", self.clear)
|
||
self._client.emitter.connect("state", self._on_state)
|
||
|
||
def clear(self, *args):
|
||
self.set_text("")
|
||
self.freq, self.res, self.chan, self.brate, self.file_type=(0, 0, 0, 0, "")
|
||
|
||
def _refresh(self, *args):
|
||
try:
|
||
int_chan=int(self.chan)
|
||
except:
|
||
int_chan=0
|
||
channels=ngettext("{channels} channel", "{channels} channels", int_chan).format(channels=self.chan)
|
||
string="{} kb/s, {} kHz, {} bit, {}, {}".format(self.brate, self.freq, self.res, channels, self.file_type)
|
||
self.set_text(string)
|
||
|
||
def _on_audio(self, emitter, freq, res, chan):
|
||
try:
|
||
self.freq=str(int(freq)/1000)
|
||
except:
|
||
self.freq=freq
|
||
self.res=res
|
||
self.chan=chan
|
||
self._refresh()
|
||
|
||
def _on_bitrate(self, emitter, brate):
|
||
self.brate=brate
|
||
self._refresh()
|
||
|
||
def _on_song_changed(self, *args):
|
||
try:
|
||
self.file_type=self._client.currentsong()["file"].split(".")[-1].split("/")[0]
|
||
self._refresh()
|
||
except:
|
||
pass
|
||
|
||
def _on_state(self, emitter, state):
|
||
if state == "stop":
|
||
self.clear()
|
||
|
||
class CoverEventBox(Gtk.EventBox):
|
||
def __init__(self, client, settings):
|
||
super().__init__()
|
||
self._client=client
|
||
self._settings=settings
|
||
|
||
# album popover
|
||
self._album_popover=AlbumPopover(self._client, self._settings)
|
||
|
||
# connect
|
||
self._button_press_event=self.connect("button-press-event", self._on_button_press_event)
|
||
self._client.emitter.connect("disconnected", self._on_disconnected)
|
||
|
||
def _on_button_press_event(self, widget, event):
|
||
if self._settings.get_property("mini-player"):
|
||
if event.button == 1 and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
window=self.get_toplevel()
|
||
window.begin_move_drag(1, event.x_root, event.y_root, Gdk.CURRENT_TIME)
|
||
else:
|
||
if self._client.connected():
|
||
song=ClientHelper.song_to_first_str_dict(self._client.currentsong())
|
||
if song != {}:
|
||
try:
|
||
artist=song[self._settings.get_artist_type()]
|
||
except:
|
||
artist=song.get("artist", "")
|
||
album=song.get("album", "")
|
||
year=song.get("date", "")
|
||
if event.button == 1 and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
self._client.album_to_playlist(album, artist, year, None)
|
||
elif event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
self._client.album_to_playlist(album, artist, year, None, "append")
|
||
elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
self._album_popover.open(album, artist, year, None, widget, event.x, event.y)
|
||
|
||
def _on_disconnected(self, *args):
|
||
self._album_popover.popdown()
|
||
|
||
class MainCover(Gtk.Image):
|
||
def __init__(self, client, settings):
|
||
super().__init__()
|
||
self._client=client
|
||
self._settings=settings
|
||
# set default size
|
||
size=self._settings.get_int("track-cover")
|
||
self.set_size_request(size, size)
|
||
|
||
# connect
|
||
self._client.emitter.connect("current_song_changed", self._refresh)
|
||
self._client.emitter.connect("disconnected", self._on_disconnected)
|
||
self._client.emitter.connect("reconnected", self._on_reconnected)
|
||
self._settings.connect("changed::track-cover", self._on_settings_changed)
|
||
|
||
def _refresh(self, *args):
|
||
current_song=self._client.currentsong()
|
||
self.set_from_pixbuf(self._client.get_cover(current_song, self._settings.get_int("track-cover")))
|
||
|
||
def _on_disconnected(self, *args):
|
||
size=self._settings.get_int("track-cover")
|
||
self.set_from_pixbuf(GdkPixbuf.Pixbuf.new_from_file_at_size(self._client.fallback_cover, size, size))
|
||
self.set_sensitive(False)
|
||
|
||
def _on_reconnected(self, *args):
|
||
self.set_sensitive(True)
|
||
|
||
def _on_settings_changed(self, *args):
|
||
size=self._settings.get_int("track-cover")
|
||
self.set_size_request(size, size)
|
||
self._refresh()
|
||
|
||
class PlaylistWindow(Gtk.Box):
|
||
def __init__(self, client, settings):
|
||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||
self._client=client
|
||
self._settings=settings
|
||
self._playlist_version=None
|
||
self._icon_size=self._settings.get_int("icon-size-sec")
|
||
self._inserted_path=None # needed for drag and drop
|
||
|
||
# buttons
|
||
provider=Gtk.CssProvider()
|
||
css=b"""* {min-height: 8px;}""" # allow further shrinking
|
||
provider.load_from_data(css)
|
||
|
||
self._back_to_current_song_button=Gtk.Button(
|
||
image=AutoSizedIcon("go-previous-symbolic", "icon-size-sec", self._settings),
|
||
tooltip_text=_("Scroll to current song"),
|
||
relief=Gtk.ReliefStyle.NONE
|
||
)
|
||
self._back_to_current_song_button.set_can_focus(False)
|
||
style_context=self._back_to_current_song_button.get_style_context()
|
||
style_context.add_provider(provider, 600)
|
||
self._clear_button=Gtk.Button(
|
||
image=AutoSizedIcon("edit-clear-symbolic", "icon-size-sec", self._settings),
|
||
tooltip_text=_("Clear playlist"),
|
||
relief=Gtk.ReliefStyle.NONE
|
||
)
|
||
self._clear_button.set_can_focus(False)
|
||
style_context=self._clear_button.get_style_context()
|
||
style_context.add_class("destructive-action")
|
||
style_context.add_provider(provider, 600)
|
||
|
||
# treeview
|
||
# (track, disc, title, artist, album, duration, date, genre, file, weight)
|
||
self._store=Gtk.ListStore(str, str, str, str, str, str, str, str, str, Pango.Weight)
|
||
self._treeview=Gtk.TreeView(model=self._store,activate_on_single_click=True,reorderable=True,search_column=2,fixed_height_mode=True)
|
||
self._selection=self._treeview.get_selection()
|
||
|
||
# columns
|
||
renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True)
|
||
renderer_text_ralign=Gtk.CellRendererText(xalign=1.0)
|
||
self._columns=(
|
||
Gtk.TreeViewColumn(_("No"), renderer_text_ralign, text=0, weight=9),
|
||
Gtk.TreeViewColumn(_("Disc"), renderer_text_ralign, text=1, weight=9),
|
||
Gtk.TreeViewColumn(_("Title"), renderer_text, text=2, weight=9),
|
||
Gtk.TreeViewColumn(_("Artist"), renderer_text, text=3, weight=9),
|
||
Gtk.TreeViewColumn(_("Album"), renderer_text, text=4, weight=9),
|
||
Gtk.TreeViewColumn(_("Length"), renderer_text, text=5, weight=9),
|
||
Gtk.TreeViewColumn(_("Year"), renderer_text, text=6, weight=9),
|
||
Gtk.TreeViewColumn(_("Genre"), renderer_text, text=7, weight=9)
|
||
)
|
||
for i, column in enumerate(self._columns):
|
||
column.set_property("resizable", True)
|
||
column.set_property("sizing", Gtk.TreeViewColumnSizing.FIXED)
|
||
column.set_min_width(30)
|
||
column.connect("notify::fixed-width", self._on_column_width, i)
|
||
self._load_settings()
|
||
|
||
# scroll
|
||
scroll=Gtk.ScrolledWindow()
|
||
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||
scroll.add(self._treeview)
|
||
|
||
# frame
|
||
self._frame=FocusFrame()
|
||
self._frame.set_widget(self._treeview)
|
||
self._frame.add(scroll)
|
||
|
||
# audio infos
|
||
audio=AudioType(self._client)
|
||
audio.set_xalign(1)
|
||
audio.set_ellipsize(Pango.EllipsizeMode.END)
|
||
|
||
# playlist info
|
||
self._playlist_info=Gtk.Label(xalign=0, ellipsize=Pango.EllipsizeMode.END)
|
||
|
||
# action bar
|
||
action_bar=Gtk.ActionBar()
|
||
action_bar.pack_start(self._back_to_current_song_button)
|
||
self._playlist_info.set_margin_start(3)
|
||
action_bar.pack_start(self._playlist_info)
|
||
audio.set_margin_end(3)
|
||
audio.set_margin_start(12)
|
||
action_bar.pack_end(self._clear_button)
|
||
action_bar.pack_end(audio)
|
||
|
||
# song popover
|
||
self._song_popover=SongPopover(self._client)
|
||
|
||
# connect
|
||
self._treeview.connect("row-activated", self._on_row_activated)
|
||
self._treeview.connect("button-press-event", self._on_button_press_event)
|
||
self._treeview.connect("key-release-event", self._on_key_release_event)
|
||
self._treeview.connect("drag-begin", lambda *args: self._frame.disable())
|
||
self._treeview.connect("drag-end", lambda *args: self._frame.enable())
|
||
self._row_deleted=self._store.connect("row-deleted", self._on_row_deleted)
|
||
self._row_inserted=self._store.connect("row-inserted", self._on_row_inserted)
|
||
self._back_to_current_song_button.connect("clicked", self._on_back_to_current_song_button_clicked)
|
||
self._clear_button.connect("clicked", self._on_clear_button_clicked)
|
||
|
||
self._client.emitter.connect("playlist_changed", self._on_playlist_changed)
|
||
self._client.emitter.connect("current_song_changed", self._on_song_changed)
|
||
self._client.emitter.connect("disconnected", self._on_disconnected)
|
||
self._client.emitter.connect("reconnected", self._on_reconnected)
|
||
self._client.emitter.connect("show-info", self._on_show_info)
|
||
|
||
self._settings.connect("notify::mini-player", self._on_mini_player)
|
||
self._settings.connect("changed::column-visibilities", self._load_settings)
|
||
self._settings.connect("changed::column-permutation", self._load_settings)
|
||
|
||
# packing
|
||
self.pack_start(self._frame, True, True, 0)
|
||
self.pack_end(action_bar, False, False, 0)
|
||
|
||
def _on_column_width(self, obj, typestring, pos):
|
||
self._settings.array_modify("ai", "column-sizes", pos, obj.get_property("fixed-width"))
|
||
|
||
def _load_settings(self, *args):
|
||
columns=self._treeview.get_columns()
|
||
for column in columns:
|
||
self._treeview.remove_column(column)
|
||
sizes=self._settings.get_value("column-sizes").unpack()
|
||
visibilities=self._settings.get_value("column-visibilities").unpack()
|
||
for i in self._settings.get_value("column-permutation"):
|
||
if sizes[i] > 0:
|
||
self._columns[i].set_fixed_width(sizes[i])
|
||
self._columns[i].set_visible(visibilities[i])
|
||
self._treeview.append_column(self._columns[i])
|
||
|
||
def _clear(self, *args):
|
||
self._playlist_info.set_text("")
|
||
self._playlist_version=None
|
||
self._store.handler_block(self._row_inserted)
|
||
self._store.handler_block(self._row_deleted)
|
||
self._store.clear()
|
||
self._store.handler_unblock(self._row_inserted)
|
||
self._store.handler_unblock(self._row_deleted)
|
||
|
||
def _refresh_playlist_info(self):
|
||
songs=self._client.playlistinfo()
|
||
if songs == []:
|
||
self._playlist_info.set_text("")
|
||
else:
|
||
length_human_readable=ClientHelper.calc_display_length(songs)
|
||
titles=ngettext("{titles} title", "{titles} titles", len(songs)).format(titles=len(songs))
|
||
self._playlist_info.set_text(" ".join((titles, "({length})".format(length=length_human_readable))))
|
||
|
||
def _scroll_to_selected_title(self, *args):
|
||
treeview, treeiter=self._selection.get_selected()
|
||
if treeiter is not None:
|
||
path=treeview.get_path(treeiter)
|
||
self._treeview.scroll_to_cell(path, None, True, 0.25)
|
||
|
||
def _refresh_selection(self): # Gtk.TreePath(len(self._store) is used to generate an invalid TreePath (needed to unset cursor)
|
||
self._treeview.set_cursor(Gtk.TreePath(len(self._store)), None, False)
|
||
for row in self._store: # reset bold text
|
||
row[9]=Pango.Weight.BOOK
|
||
try:
|
||
song=self._client.status()["song"]
|
||
path=Gtk.TreePath(int(song))
|
||
self._selection.select_path(path)
|
||
self._store[path][9]=Pango.Weight.BOLD
|
||
except:
|
||
self._selection.unselect_all()
|
||
|
||
def _on_button_press_event(self, widget, event):
|
||
path_re=widget.get_path_at_pos(int(event.x), int(event.y))
|
||
if path_re is not None:
|
||
path=path_re[0]
|
||
if event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
self._store.remove(self._store.get_iter(path))
|
||
elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
self._song_popover.open(self._store[path][8], widget, int(event.x), int(event.y))
|
||
|
||
def _on_key_release_event(self, widget, event):
|
||
if event.keyval == Gdk.keyval_from_name("Delete"):
|
||
treeview, treeiter=self._selection.get_selected()
|
||
if treeiter is not None:
|
||
try:
|
||
self._store.remove(treeiter)
|
||
except:
|
||
pass
|
||
|
||
def _on_row_deleted(self, model, path): # sync treeview to mpd
|
||
try:
|
||
if self._inserted_path is not None: # move
|
||
path=int(path.to_string())
|
||
if path > self._inserted_path:
|
||
path=path-1
|
||
if path < self._inserted_path:
|
||
self._inserted_path=self._inserted_path-1
|
||
self._client.move(path, self._inserted_path)
|
||
self._inserted_path=None
|
||
else: # delete
|
||
self._client.delete(path) # bad song index possible
|
||
self._playlist_version=int(self._client.status()["playlist"])
|
||
except MPDBase.CommandError as e:
|
||
self._playlist_version=None
|
||
self._client.emitter.emit("playlist_changed", int(self._client.status()["playlist"]))
|
||
raise e # propagate exception
|
||
|
||
def _on_row_inserted(self, model, path, treeiter):
|
||
self._inserted_path=int(path.to_string())
|
||
|
||
def _on_row_activated(self, widget, path, view_column):
|
||
self._client.play(path)
|
||
|
||
def _on_playlist_changed(self, emitter, version):
|
||
self._store.handler_block(self._row_inserted)
|
||
self._store.handler_block(self._row_deleted)
|
||
songs=[]
|
||
if self._playlist_version is not None:
|
||
songs=self._client.plchanges(self._playlist_version)
|
||
else:
|
||
songs=self._client.playlistinfo()
|
||
if songs != []:
|
||
self._playlist_info.set_text("")
|
||
for s in songs:
|
||
song=ClientHelper.song_to_str_dict(ClientHelper.pepare_song_for_display(s))
|
||
try:
|
||
treeiter=self._store.get_iter(song["pos"])
|
||
self._store.set(treeiter,
|
||
0, song["track"],
|
||
1, song["disc"],
|
||
2, song["title"],
|
||
3, song["artist"],
|
||
4, song["album"],
|
||
5, song["human_duration"],
|
||
6, song["date"],
|
||
7, song["genre"],
|
||
8, song["file"],
|
||
9, Pango.Weight.BOOK
|
||
)
|
||
except:
|
||
self._store.append([
|
||
song["track"], song["disc"],
|
||
song["title"], song["artist"],
|
||
song["album"], song["human_duration"],
|
||
song["date"], song["genre"],
|
||
song["file"], Pango.Weight.BOOK
|
||
])
|
||
for i in reversed(range(int(self._client.status()["playlistlength"]), len(self._store))):
|
||
treeiter=self._store.get_iter(i)
|
||
self._store.remove(treeiter)
|
||
self._refresh_playlist_info()
|
||
if self._playlist_version is None or songs != []:
|
||
self._refresh_selection()
|
||
self._scroll_to_selected_title()
|
||
self._playlist_version=version
|
||
self._store.handler_unblock(self._row_inserted)
|
||
self._store.handler_unblock(self._row_deleted)
|
||
|
||
def _on_song_changed(self, *args):
|
||
self._refresh_selection()
|
||
if self._client.status()["state"] == "play":
|
||
self._scroll_to_selected_title()
|
||
|
||
def _on_back_to_current_song_button_clicked(self, *args):
|
||
self._treeview.set_cursor(Gtk.TreePath(len(self._store)), None, False) # set to invalid TreePath (needed to unset cursor)
|
||
for path, row in enumerate(self._store):
|
||
if row[9] == Pango.Weight.BOLD:
|
||
self._selection.select_path(path)
|
||
break
|
||
self._scroll_to_selected_title()
|
||
|
||
def _on_clear_button_clicked(self, *args):
|
||
self._client.clear()
|
||
|
||
def _on_disconnected(self, *args):
|
||
self._treeview.set_sensitive(False)
|
||
self._back_to_current_song_button.set_sensitive(False)
|
||
self._clear_button.set_sensitive(False)
|
||
self._song_popover.popdown()
|
||
self._clear()
|
||
|
||
def _on_reconnected(self, *args):
|
||
self._back_to_current_song_button.set_sensitive(True)
|
||
self._clear_button.set_sensitive(True)
|
||
self._treeview.set_sensitive(True)
|
||
|
||
def _on_show_info(self, *args):
|
||
if self._treeview.has_focus():
|
||
treeview, treeiter=self._selection.get_selected()
|
||
if treeiter is not None:
|
||
path=self._store.get_path(treeiter)
|
||
cell=self._treeview.get_cell_area(path, None)
|
||
self._song_popover.open(self._store[path][8], self._treeview, int(cell.x), int(cell.y))
|
||
|
||
def _on_mini_player(self, obj, typestring):
|
||
if obj.get_property("mini-player"):
|
||
self.set_property("no-show-all", True)
|
||
self.set_property("visible", False)
|
||
else:
|
||
self.set_property("no-show-all", False)
|
||
self.show_all()
|
||
|
||
class CoverPlaylistWindow(Gtk.Paned):
|
||
def __init__(self, client, settings):
|
||
super().__init__()
|
||
self._client=client
|
||
self._settings=settings
|
||
|
||
# cover
|
||
main_cover=MainCover(self._client, self._settings)
|
||
self._cover_event_box=CoverEventBox(self._client, self._settings)
|
||
|
||
# playlist
|
||
self._playlist_window=PlaylistWindow(self._client, self._settings)
|
||
|
||
# lyrics button
|
||
self.lyrics_button=Gtk.ToggleButton(
|
||
image=Gtk.Image.new_from_icon_name("media-view-subtitles-symbolic", Gtk.IconSize.BUTTON),
|
||
tooltip_text=_("Show lyrics")
|
||
)
|
||
self.lyrics_button.set_can_focus(False)
|
||
self.lyrics_button.set_margin_top(6)
|
||
self.lyrics_button.set_margin_end(6)
|
||
style_context=self.lyrics_button.get_style_context()
|
||
style_context.add_class("circular")
|
||
|
||
# lyrics window
|
||
self._lyrics_window=LyricsWindow(self._client, self._settings)
|
||
|
||
# revealer
|
||
self._lyrics_button_revealer=Gtk.Revealer()
|
||
self._lyrics_button_revealer.set_halign(Gtk.Align.END)
|
||
self._lyrics_button_revealer.set_valign(Gtk.Align.START)
|
||
self._lyrics_button_revealer.add(self.lyrics_button)
|
||
|
||
# stack
|
||
self._stack=Gtk.Stack(transition_type=Gtk.StackTransitionType.OVER_DOWN_UP)
|
||
self._stack.add_named(self._cover_event_box, "cover")
|
||
self._stack.add_named(self._lyrics_window, "lyrics")
|
||
self._stack.set_visible_child(self._cover_event_box)
|
||
|
||
# connect
|
||
self.lyrics_button.connect("toggled", self._on_lyrics_toggled)
|
||
self._client.emitter.connect("disconnected", self._on_disconnected)
|
||
self._client.emitter.connect("reconnected", self._on_reconnected)
|
||
self._settings.connect("changed::show-lyrics-button", self._on_settings_changed)
|
||
|
||
# packing
|
||
overlay=Gtk.Overlay()
|
||
overlay.add(main_cover)
|
||
overlay.add_overlay(self._stack)
|
||
overlay.add_overlay(self._lyrics_button_revealer)
|
||
self.pack1(overlay, False, False)
|
||
self.pack2(self._playlist_window, True, False)
|
||
self._on_settings_changed() # hide lyrics button
|
||
|
||
def _on_reconnected(self, *args):
|
||
self.lyrics_button.set_sensitive(True)
|
||
|
||
def _on_disconnected(self, *args):
|
||
self.lyrics_button.set_active(False)
|
||
self.lyrics_button.set_sensitive(False)
|
||
|
||
def _on_lyrics_toggled(self, widget):
|
||
if widget.get_active():
|
||
self._stack.set_visible_child(self._lyrics_window)
|
||
self._lyrics_window.enable()
|
||
else:
|
||
self._stack.set_visible_child(self._cover_event_box)
|
||
self._lyrics_window.disable()
|
||
|
||
def _on_settings_changed(self, *args):
|
||
if self._settings.get_boolean("show-lyrics-button"):
|
||
self._lyrics_button_revealer.set_reveal_child(True)
|
||
else:
|
||
self._lyrics_button_revealer.set_reveal_child(False)
|
||
|
||
###################
|
||
# control widgets #
|
||
###################
|
||
|
||
class PlaybackControl(Gtk.ButtonBox):
|
||
def __init__(self, client, settings):
|
||
super().__init__(layout_style=Gtk.ButtonBoxStyle.EXPAND)
|
||
self._client=client
|
||
self._settings=settings
|
||
|
||
# widgets
|
||
self._play_icon=AutoSizedIcon("media-playback-start-symbolic", "icon-size", self._settings)
|
||
self._pause_icon=AutoSizedIcon("media-playback-pause-symbolic", "icon-size", self._settings)
|
||
self._play_button=Gtk.Button(image=self._play_icon)
|
||
self._play_button.set_can_focus(False)
|
||
self._stop_button=Gtk.Button(image=AutoSizedIcon("media-playback-stop-symbolic", "icon-size", self._settings))
|
||
self._stop_button.set_can_focus(False)
|
||
self._prev_button=Gtk.Button(image=AutoSizedIcon("media-skip-backward-symbolic", "icon-size", self._settings))
|
||
self._prev_button.set_can_focus(False)
|
||
self._next_button=Gtk.Button(image=AutoSizedIcon("media-skip-forward-symbolic", "icon-size", self._settings))
|
||
self._next_button.set_can_focus(False)
|
||
|
||
# connect
|
||
self._play_button.connect("clicked", self._on_play_clicked)
|
||
self._stop_button.connect("clicked", self._on_stop_clicked)
|
||
self._stop_button.set_property("no-show-all", not(self._settings.get_boolean("show-stop")))
|
||
self._prev_button.connect("clicked", self._on_prev_clicked)
|
||
self._next_button.connect("clicked", self._on_next_clicked)
|
||
self._settings.connect("notify::mini-player", self._on_mini_player)
|
||
self._settings.connect("changed::show-stop", self._on_show_stop_changed)
|
||
self._client.emitter.connect("state", self._on_state)
|
||
self._client.emitter.connect("playlist_changed", self._refresh_tooltips)
|
||
self._client.emitter.connect("current_song_changed", self._refresh_tooltips)
|
||
self._client.emitter.connect("disconnected", self._on_disconnected)
|
||
self._client.emitter.connect("reconnected", self._on_reconnected)
|
||
|
||
# packing
|
||
self.pack_start(self._prev_button, True, True, 0)
|
||
self.pack_start(self._play_button, True, True, 0)
|
||
self.pack_start(self._stop_button, True, True, 0)
|
||
self.pack_start(self._next_button, True, True, 0)
|
||
|
||
def _refresh_tooltips(self, *args):
|
||
try:
|
||
songs=self._client.playlistinfo()
|
||
song=int(self._client.status()["song"])
|
||
elapsed=ClientHelper.calc_display_length(songs[:song])
|
||
rest=ClientHelper.calc_display_length(songs[song+1:])
|
||
elapsed_titles=ngettext("{titles} title", "{titles} titles", song).format(titles=song)
|
||
rest_titles=ngettext("{titles} title", "{titles} titles", (len(songs)-(song+1))).format(titles=(len(songs)-(song+1)))
|
||
self._prev_button.set_tooltip_text(" ".join((elapsed_titles, "({length})".format(length=elapsed))))
|
||
self._next_button.set_tooltip_text(" ".join((rest_titles, "({length})".format(length=rest))))
|
||
except:
|
||
self._prev_button.set_tooltip_text("")
|
||
self._next_button.set_tooltip_text("")
|
||
|
||
def _on_play_clicked(self, widget):
|
||
self._client.toggle_play()
|
||
|
||
def _on_stop_clicked(self, widget):
|
||
self._client.stop()
|
||
|
||
def _on_prev_clicked(self, widget):
|
||
self._client.previous()
|
||
|
||
def _on_next_clicked(self, widget):
|
||
self._client.next()
|
||
|
||
def _on_state(self, emitter, state):
|
||
if state == "play":
|
||
self._play_button.set_image(self._pause_icon)
|
||
self._prev_button.set_sensitive(True)
|
||
self._next_button.set_sensitive(True)
|
||
elif state == "pause":
|
||
self._play_button.set_image(self._play_icon)
|
||
self._prev_button.set_sensitive(True)
|
||
self._next_button.set_sensitive(True)
|
||
else:
|
||
self._play_button.set_image(self._play_icon)
|
||
self._prev_button.set_sensitive(False)
|
||
self._next_button.set_sensitive(False)
|
||
|
||
def _on_disconnected(self, *args):
|
||
self.set_sensitive(False)
|
||
self._prev_button.set_tooltip_text("")
|
||
self._next_button.set_tooltip_text("")
|
||
|
||
def _on_reconnected(self, *args):
|
||
self.set_sensitive(True)
|
||
|
||
def _on_mini_player(self, obj, typestring):
|
||
self._on_show_stop_changed()
|
||
|
||
def _on_show_stop_changed(self, *args):
|
||
visibility=(self._settings.get_boolean("show-stop") and not self._settings.get_property("mini-player"))
|
||
self._stop_button.set_property("visible", visibility)
|
||
self._stop_button.set_property("no-show-all", not(visibility))
|
||
|
||
class SeekBar(Gtk.Box):
|
||
def __init__(self, client):
|
||
super().__init__(hexpand=True)
|
||
self._client=client
|
||
self._update=True
|
||
self._jumped=False
|
||
|
||
# labels
|
||
self._elapsed=Gtk.Label(width_chars=5)
|
||
self._rest=Gtk.Label(width_chars=6)
|
||
|
||
# event boxes
|
||
self._elapsed_event_box=Gtk.EventBox()
|
||
self._rest_event_box=Gtk.EventBox()
|
||
|
||
# progress bar
|
||
self._scale=Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL)
|
||
self._scale.set_can_focus(False)
|
||
self._scale.set_show_fill_level(True)
|
||
self._scale.set_restrict_to_fill_level(False)
|
||
self._scale.set_draw_value(False)
|
||
self._scale.set_increments(10, 60)
|
||
self._adjustment=self._scale.get_adjustment()
|
||
|
||
# css (scale)
|
||
style_context=self._scale.get_style_context()
|
||
provider=Gtk.CssProvider()
|
||
css=b"""scale fill { background-color: @theme_selected_bg_color; }"""
|
||
provider.load_from_data(css)
|
||
style_context.add_provider(provider, 600)
|
||
|
||
# connect
|
||
self._elapsed_event_box.connect("button-release-event", self._on_elapsed_button_release_event)
|
||
self._rest_event_box.connect("button-release-event", self._on_rest_button_release_event)
|
||
self._scale.connect("change-value", self._on_change_value)
|
||
self._scale.connect("scroll-event", lambda *args: True) # disable mouse wheel
|
||
self._scale.connect("button-press-event", self._on_scale_button_press_event)
|
||
self._scale.connect("button-release-event", self._on_scale_button_release_event)
|
||
self._client.emitter.connect("disconnected", self._disable)
|
||
self._client.emitter.connect("state", self._on_state)
|
||
self._client.emitter.connect("elapsed_changed", self._refresh)
|
||
|
||
# packing
|
||
self._elapsed_event_box.add(self._elapsed)
|
||
self._rest_event_box.add(self._rest)
|
||
self.pack_start(self._elapsed_event_box, False, False, 0)
|
||
self.pack_start(self._scale, True, True, 0)
|
||
self.pack_end(self._rest_event_box, False, False, 0)
|
||
|
||
def _refresh(self, emitter, elapsed, duration):
|
||
self.set_sensitive(True)
|
||
if elapsed > duration: # fix display error
|
||
elapsed=duration
|
||
self._adjustment.set_upper(duration)
|
||
if self._update:
|
||
self._scale.set_value(elapsed)
|
||
self._elapsed.set_text(ClientHelper.seconds_to_display_time(int(elapsed)))
|
||
self._rest.set_text("-"+ClientHelper.seconds_to_display_time(int(duration-elapsed)))
|
||
self._scale.set_fill_level(elapsed)
|
||
|
||
def _disable(self, *args):
|
||
self.set_sensitive(False)
|
||
self._scale.set_fill_level(0)
|
||
self._scale.set_range(0, 0)
|
||
self._elapsed.set_text("––∶––")
|
||
self._rest.set_text("––∶––")
|
||
|
||
def _on_scale_button_press_event(self, widget, event):
|
||
if event.button == 1 and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
self._update=False
|
||
self._scale.set_has_origin(False)
|
||
elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
self._jumped=False
|
||
|
||
def _on_scale_button_release_event(self, widget, event):
|
||
if event.button == 1:
|
||
self._update=True
|
||
self._scale.set_has_origin(True)
|
||
if self._jumped: # actual seek
|
||
self._client.seekcur(self._scale.get_value())
|
||
self._jumped=False
|
||
else: # restore state
|
||
status=self._client.status()
|
||
self._refresh(None, float(status["elapsed"]), float(status["duration"]))
|
||
|
||
def _on_change_value(self, range, scroll, value): # value is inaccurate (can be above upper limit)
|
||
if (scroll == Gtk.ScrollType.STEP_BACKWARD or scroll == Gtk.ScrollType.STEP_FORWARD or
|
||
scroll == Gtk.ScrollType.PAGE_BACKWARD or scroll == Gtk.ScrollType.PAGE_FORWARD):
|
||
self._client.seekcur(value)
|
||
elif scroll == Gtk.ScrollType.JUMP:
|
||
duration=self._adjustment.get_upper()
|
||
if value > duration: # fix display error
|
||
elapsed=duration
|
||
else:
|
||
elapsed=value
|
||
self._elapsed.set_text(ClientHelper.seconds_to_display_time(int(elapsed)))
|
||
self._rest.set_text("-"+ClientHelper.seconds_to_display_time(int(duration-elapsed)))
|
||
self._jumped=True
|
||
|
||
def _on_elapsed_button_release_event(self, widget, event):
|
||
if event.button == 1:
|
||
self._client.seekcur("-"+str(self._adjustment.get_property("step-increment")))
|
||
elif event.button == 3:
|
||
self._client.seekcur("+"+str(self._adjustment.get_property("step-increment")))
|
||
|
||
def _on_rest_button_release_event(self, widget, event):
|
||
if event.button == 1:
|
||
self._client.seekcur("+"+str(self._adjustment.get_property("step-increment")))
|
||
elif event.button == 3:
|
||
self._client.seekcur("-"+str(self._adjustment.get_property("step-increment")))
|
||
|
||
def _on_state(self, emitter, state):
|
||
if state == "stop":
|
||
self._disable()
|
||
|
||
class OutputPopover(Gtk.Popover):
|
||
def __init__(self, client, relative):
|
||
super().__init__()
|
||
self.set_relative_to(relative)
|
||
self._client=client
|
||
|
||
# widgets
|
||
box=Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, border_width=6)
|
||
for output in self._client.outputs():
|
||
button=Gtk.CheckButton(label="{} ({})".format(output["outputname"], output["plugin"]))
|
||
if output["outputenabled"] == "1":
|
||
button.set_active(True)
|
||
button.connect("toggled", self._on_button_toggled, output["outputid"])
|
||
box.pack_start(button, False, False, 0)
|
||
|
||
# packing
|
||
self.add(box)
|
||
box.show_all()
|
||
|
||
def _on_button_toggled(self, button, out_id):
|
||
if button.get_active():
|
||
self._client.enableoutput(out_id)
|
||
else:
|
||
self._client.disableoutput(out_id)
|
||
|
||
class PlaybackOptions(Gtk.Box):
|
||
def __init__(self, client, settings):
|
||
super().__init__(spacing=6)
|
||
self._client=client
|
||
self._settings=settings
|
||
self._popover=None
|
||
|
||
# widgets
|
||
icons={}
|
||
for icon_name in ("media-playlist-shuffle-symbolic","media-playlist-repeat-symbolic",
|
||
"org.mpdevil.mpdevil-single-symbolic","org.mpdevil.mpdevil-consume-symbolic"):
|
||
icons[icon_name]=AutoSizedIcon(icon_name, "icon-size", self._settings)
|
||
self._random_button=Gtk.ToggleButton(image=icons["media-playlist-shuffle-symbolic"], tooltip_text=_("Random mode"))
|
||
self._random_button.set_can_focus(False)
|
||
self._repeat_button=Gtk.ToggleButton(image=icons["media-playlist-repeat-symbolic"], tooltip_text=_("Repeat mode"))
|
||
self._repeat_button.set_can_focus(False)
|
||
self._single_button=Gtk.ToggleButton(image=icons["org.mpdevil.mpdevil-single-symbolic"], tooltip_text=_("Single mode"))
|
||
self._single_button.set_can_focus(False)
|
||
self._consume_button=Gtk.ToggleButton(image=icons["org.mpdevil.mpdevil-consume-symbolic"], tooltip_text=_("Consume mode"))
|
||
self._consume_button.set_can_focus(False)
|
||
self._volume_button=Gtk.VolumeButton(use_symbolic=True, size=self._settings.get_gtk_icon_size("icon-size"))
|
||
self._volume_button.set_can_focus(False)
|
||
self._adj=self._volume_button.get_adjustment()
|
||
self._adj.set_step_increment(0.05)
|
||
self._adj.set_page_increment(0.1)
|
||
self._adj.set_upper(0) # do not allow volume change by user when MPD has not yet reported volume (no output enabled/avail)
|
||
|
||
# connect
|
||
self._random_button_toggled=self._random_button.connect("toggled", self._set_option, "random")
|
||
self._repeat_button_toggled=self._repeat_button.connect("toggled", self._set_option, "repeat")
|
||
self._single_button_toggled=self._single_button.connect("toggled", self._set_option, "single")
|
||
self._consume_button_toggled=self._consume_button.connect("toggled", self._set_option, "consume")
|
||
self._volume_button_changed=self._volume_button.connect("value-changed", self._set_volume)
|
||
self._repeat_changed=self._client.emitter.connect("repeat", self._repeat_refresh)
|
||
self._random_changed=self._client.emitter.connect("random", self._random_refresh)
|
||
self._single_changed=self._client.emitter.connect("single", self._single_refresh)
|
||
self._consume_changed=self._client.emitter.connect("consume", self._consume_refresh)
|
||
self._volume_changed=self._client.emitter.connect("volume_changed", self._volume_refresh)
|
||
self._single_button.connect("button-press-event", self._on_single_button_press_event)
|
||
self._volume_button.connect("button-press-event", self._on_volume_button_press_event)
|
||
self._client.emitter.connect("disconnected", self._on_disconnected)
|
||
self._client.emitter.connect("reconnected", self._on_reconnected)
|
||
self._settings.connect("notify::mini-player", self._on_mini_player)
|
||
self._settings.connect("changed::icon-size", self._on_icon_size_changed)
|
||
|
||
# packing
|
||
self._button_box=Gtk.ButtonBox(layout_style=Gtk.ButtonBoxStyle.EXPAND)
|
||
self._button_box.pack_start(self._repeat_button, True, True, 0)
|
||
self._button_box.pack_start(self._random_button, True, True, 0)
|
||
self._button_box.pack_start(self._single_button, True, True, 0)
|
||
self._button_box.pack_start(self._consume_button, True, True, 0)
|
||
self.pack_start(self._button_box, True, True, 0)
|
||
self.pack_start(self._volume_button, True, True, 0)
|
||
|
||
def _set_option(self, widget, option):
|
||
func=getattr(self._client, option)
|
||
if widget.get_active():
|
||
func("1")
|
||
else:
|
||
func("0")
|
||
|
||
def _set_volume(self, widget, value):
|
||
self._client.setvol(str(int(value*100)))
|
||
|
||
def _repeat_refresh(self, emitter, val):
|
||
self._repeat_button.handler_block(self._repeat_button_toggled)
|
||
self._repeat_button.set_active(val)
|
||
self._repeat_button.handler_unblock(self._repeat_button_toggled)
|
||
|
||
def _random_refresh(self, emitter, val):
|
||
self._random_button.handler_block(self._random_button_toggled)
|
||
self._random_button.set_active(val)
|
||
self._random_button.handler_unblock(self._random_button_toggled)
|
||
|
||
def _single_refresh(self, emitter, val):
|
||
self._single_button.handler_block(self._single_button_toggled)
|
||
if val == "1":
|
||
self._single_button.get_style_context().remove_class("destructive-action")
|
||
self._single_button.set_active(True)
|
||
elif val == "oneshot":
|
||
self._single_button.get_style_context().add_class("destructive-action")
|
||
self._single_button.set_active(False)
|
||
else:
|
||
self._single_button.get_style_context().remove_class("destructive-action")
|
||
self._single_button.set_active(False)
|
||
self._single_button.handler_unblock(self._single_button_toggled)
|
||
|
||
def _consume_refresh(self, emitter, val):
|
||
self._consume_button.handler_block(self._consume_button_toggled)
|
||
self._consume_button.set_active(val)
|
||
self._consume_button.handler_unblock(self._consume_button_toggled)
|
||
|
||
def _volume_refresh(self, emitter, volume):
|
||
self._volume_button.handler_block(self._volume_button_changed)
|
||
if volume < 0:
|
||
self._volume_button.set_value(0)
|
||
self._adj.set_upper(0)
|
||
else:
|
||
self._adj.set_upper(1)
|
||
self._volume_button.set_value(volume/100)
|
||
self._volume_button.handler_unblock(self._volume_button_changed)
|
||
|
||
def _on_volume_button_press_event(self, widget, event):
|
||
if event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
self._popover=OutputPopover(self._client, self._volume_button)
|
||
self._popover.popup()
|
||
|
||
def _on_single_button_press_event(self, widget, event):
|
||
if event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS:
|
||
state=self._client.status()["single"]
|
||
if state == "oneshot":
|
||
self._client.single("0")
|
||
else:
|
||
self._client.single("oneshot")
|
||
|
||
def _on_reconnected(self, *args):
|
||
self._repeat_button.set_sensitive(True)
|
||
self._random_button.set_sensitive(True)
|
||
self._single_button.set_sensitive(True)
|
||
self._consume_button.set_sensitive(True)
|
||
self._volume_button.set_sensitive(True)
|
||
|
||
def _on_disconnected(self, *args):
|
||
self._repeat_button.set_sensitive(False)
|
||
self._random_button.set_sensitive(False)
|
||
self._single_button.set_sensitive(False)
|
||
self._consume_button.set_sensitive(False)
|
||
self._volume_button.set_sensitive(False)
|
||
self._repeat_refresh(None, False)
|
||
self._random_refresh(None, False)
|
||
self._single_refresh(None, "0")
|
||
self._consume_refresh(None, False)
|
||
self._volume_refresh(None, -1)
|
||
if self._popover is not None:
|
||
self._popover.destroy()
|
||
self._popover=None
|
||
|
||
def _on_mini_player(self, obj, typestring):
|
||
if obj.get_property("mini-player"):
|
||
self._button_box.set_property("no-show-all", True)
|
||
self._button_box.set_property("visible", False)
|
||
else:
|
||
self._button_box.set_property("no-show-all", False)
|
||
self._button_box.show_all()
|
||
|
||
def _on_icon_size_changed(self, *args):
|
||
self._volume_button.set_property("size", self._settings.get_gtk_icon_size("icon-size"))
|
||
|
||
###################
|
||
# MPD gio actions #
|
||
###################
|
||
class MPDActionGroup(Gio.SimpleActionGroup):
|
||
def __init__(self, client):
|
||
super().__init__()
|
||
self._client=client
|
||
|
||
# actions
|
||
self._simple_actions_data=(
|
||
"toggle-play","stop","next","prev","seek-forward","seek-backward","clear","update",
|
||
"repeat","random","single","consume"
|
||
)
|
||
for name in self._simple_actions_data:
|
||
action=Gio.SimpleAction.new(name, None)
|
||
action.connect("activate", getattr(self, ("_on_"+name.replace("-","_"))))
|
||
self.add_action(action)
|
||
|
||
# connect
|
||
self._client.emitter.connect("state", self._on_state)
|
||
self._client.emitter.connect("disconnected", self._on_disconnected)
|
||
self._client.emitter.connect("reconnected", self._on_reconnected)
|
||
|
||
def _on_toggle_play(self, action, param):
|
||
self._client.toggle_play()
|
||
|
||
def _on_stop(self, action, param):
|
||
self._client.stop()
|
||
|
||
def _on_next(self, action, param):
|
||
self._client.next()
|
||
|
||
def _on_prev(self, action, param):
|
||
self._client.previous()
|
||
|
||
def _on_seek_forward(self, action, param):
|
||
self._client.seekcur("+10")
|
||
|
||
def _on_seek_backward(self, action, param):
|
||
self._client.seekcur("-10")
|
||
|
||
def _on_clear(self, action, param):
|
||
self._client.clear()
|
||
|
||
def _on_update(self, action, param):
|
||
self._client.update()
|
||
|
||
def _on_repeat(self, action, param):
|
||
self._client.toggle_option("repeat")
|
||
|
||
def _on_random(self, action, param):
|
||
self._client.toggle_option("random")
|
||
|
||
def _on_single(self, action, param):
|
||
self._client.toggle_option("single")
|
||
|
||
def _on_consume(self, action, param):
|
||
self._client.toggle_option("consume")
|
||
|
||
def _on_state(self, emitter, state):
|
||
state_dict={"play": True, "pause": True, "stop": False}
|
||
for action in ("next","prev","seek-forward","seek-backward"):
|
||
self.lookup_action(action).set_enabled(state_dict[state])
|
||
|
||
def _on_disconnected(self, *args):
|
||
for action in self._simple_actions_data:
|
||
self.lookup_action(action).set_enabled(False)
|
||
|
||
def _on_reconnected(self, *args):
|
||
for action in self._simple_actions_data:
|
||
self.lookup_action(action).set_enabled(True)
|
||
|
||
####################
|
||
# shortcuts window #
|
||
####################
|
||
class ShortcutsWindow(Gtk.ShortcutsWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
general_group=Gtk.ShortcutsGroup(title=_("General"), visible=True)
|
||
window_group=Gtk.ShortcutsGroup(title=_("Window"), visible=True)
|
||
playback_group=Gtk.ShortcutsGroup(title=_("Playback"), visible=True)
|
||
items_group=Gtk.ShortcutsGroup(title=_("Search, Album Dialog, Album List and Artist List"), visible=True)
|
||
playlist_group=Gtk.ShortcutsGroup(title=_("Playlist"), visible=True)
|
||
section=Gtk.ShortcutsSection(section_name="shortcuts", visible=True)
|
||
section.add(general_group)
|
||
section.add(window_group)
|
||
section.add(playback_group)
|
||
section.add(items_group)
|
||
section.add(playlist_group)
|
||
|
||
shortcut_data=(
|
||
("F1", _("Open online help"), None, general_group),
|
||
("<Control>question", _("Open shortcuts window"), None, general_group),
|
||
("F10", _("Open menu"), None, general_group),
|
||
("F5", _("Update database"), None, general_group),
|
||
("<Control>q", _("Quit"), None, general_group),
|
||
("<Control>p", _("Cycle through profiles"), None, window_group),
|
||
("<Shift><Control>p", _("Cycle through profiles in reversed order"), None, window_group),
|
||
("<Control>m", _("Toggle mini player"), None, window_group),
|
||
("<Control>l", _("Toggle lyrics"), None, window_group),
|
||
("<Control>f", _("Toggle search"), None, window_group),
|
||
("Escape", _("Back to current album"), None, window_group),
|
||
("space", _("Play/Pause"), None, playback_group),
|
||
("<Control>space", _("Stop"), None, playback_group),
|
||
("KP_Add", _("Next title"), None, playback_group),
|
||
("KP_Subtract", _("Previous title"), None, playback_group),
|
||
("KP_Multiply", _("Seek forward"), None, playback_group),
|
||
("KP_Divide", _("Seek backward"), None, playback_group),
|
||
("<Control>r", _("Toggle repeat mode"), None, playback_group),
|
||
("<Control>s", _("Toggle random mode"), None, playback_group),
|
||
("<Control>1", _("Toggle single mode"), None, playback_group),
|
||
("<Control>o", _("Toggle consume mode"), None, playback_group),
|
||
("<Control>e", _("Enqueue selected item"), None, items_group),
|
||
("<Control>plus", _("Append selected item"), _("Middle-click"), items_group),
|
||
("<Control>Return", _("Play selected item immediately"), None, items_group),
|
||
("<Control>i Menu", _("Show additional information"), _("Right-click"), items_group),
|
||
("Delete", _("Remove selected song"), _("Middle-click"), playlist_group),
|
||
("<Shift>Delete", _("Clear playlist"), None, playlist_group),
|
||
("<Control>i Menu", _("Show additional information"), _("Right-click"), playlist_group)
|
||
)
|
||
for accel, title, subtitle, group in shortcut_data:
|
||
shortcut=Gtk.ShortcutsShortcut(visible=True, accelerator=accel, title=title, subtitle=subtitle)
|
||
group.pack_start(shortcut, False, False, 0)
|
||
|
||
self.add(section)
|
||
|
||
###############
|
||
# main window #
|
||
###############
|
||
|
||
class ConnectionNotify(Gtk.Revealer):
|
||
def __init__(self, client, settings):
|
||
super().__init__(valign=Gtk.Align.START, halign=Gtk.Align.CENTER)
|
||
self._client=client
|
||
self._settings=settings
|
||
|
||
# widgets
|
||
self._label=Gtk.Label(wrap=True)
|
||
close_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("window-close-symbolic", Gtk.IconSize.BUTTON))
|
||
close_button.set_relief(Gtk.ReliefStyle.NONE)
|
||
connect_button=Gtk.Button(label=_("Connect"))
|
||
|
||
# connect
|
||
close_button.connect("clicked", self._on_close_button_clicked)
|
||
connect_button.connect("clicked", self._on_connect_button_clicked)
|
||
self._client.emitter.connect("connection_error", self._on_connection_error)
|
||
self._client.emitter.connect("reconnected", self._on_reconnected)
|
||
|
||
# packing
|
||
box=Gtk.Box(spacing=12)
|
||
box.get_style_context().add_class("app-notification")
|
||
box.pack_start(self._label, False, True, 6)
|
||
box.pack_end(close_button, False, True, 0)
|
||
box.pack_end(connect_button, False, True, 0)
|
||
self.add(box)
|
||
|
||
def _on_connection_error(self, *args):
|
||
active=self._settings.get_int("active-profile")
|
||
string=_("Connection to “{profile}” ({host}:{port}) failed").format(
|
||
profile=self._settings.get_value("profiles")[active],
|
||
host=self._settings.get_value("hosts")[active],
|
||
port=self._settings.get_value("ports")[active]
|
||
)
|
||
self._label.set_text(string)
|
||
self.set_reveal_child(True)
|
||
|
||
def _on_reconnected(self, *args):
|
||
self.set_reveal_child(False)
|
||
|
||
def _on_close_button_clicked(self, *args):
|
||
self.set_reveal_child(False)
|
||
|
||
def _on_connect_button_clicked(self, *args):
|
||
self._client.reconnect()
|
||
|
||
class MainWindow(Gtk.ApplicationWindow):
|
||
def __init__(self, app, client, settings):
|
||
super().__init__(title=("mpdevil"), icon_name="org.mpdevil.mpdevil", application=app)
|
||
self.set_default_icon_name("org.mpdevil.mpdevil")
|
||
self.set_default_size(settings.get_int("width"), settings.get_int("height"))
|
||
if settings.get_boolean("maximize"):
|
||
self.maximize() # request maximize
|
||
Notify.init("mpdevil")
|
||
self._client=client
|
||
self._settings=settings
|
||
self._use_csd=self._settings.get_boolean("use-csd")
|
||
self._size=None # needed for window size saving
|
||
|
||
# MPRIS
|
||
dbus_service=MPRISInterface(self, self._client, self._settings)
|
||
|
||
# actions
|
||
simple_actions_data=(
|
||
"settings","stats","help","menu",
|
||
"toggle-lyrics","back-to-current-album","toggle-search",
|
||
"profile-next","profile-prev","show-info","append","play","enqueue"
|
||
)
|
||
for name in simple_actions_data:
|
||
action=Gio.SimpleAction.new(name, None)
|
||
action.connect("activate", getattr(self, ("_on_"+name.replace("-","_"))))
|
||
self.add_action(action)
|
||
mini_player_action=Gio.PropertyAction.new("mini-player", self._settings, "mini-player")
|
||
self.add_action(mini_player_action)
|
||
self._profiles_action=Gio.SimpleAction.new_stateful("profiles", GLib.VariantType.new("i"), GLib.Variant("i", 0))
|
||
self._profiles_action.connect("change-state", self._on_profiles)
|
||
self.add_action(self._profiles_action)
|
||
self._mpd_action_group=MPDActionGroup(self._client)
|
||
self.insert_action_group("mpd", self._mpd_action_group)
|
||
|
||
# shortcuts
|
||
shortcuts_window=ShortcutsWindow()
|
||
self.set_help_overlay(shortcuts_window)
|
||
shortcuts_window.set_modal(False)
|
||
|
||
# widgets
|
||
if self._use_csd:
|
||
icons={"open-menu-symbolic": Gtk.Image.new_from_icon_name("open-menu-symbolic", Gtk.IconSize.BUTTON)}
|
||
else:
|
||
icons={"open-menu-symbolic": AutoSizedIcon("open-menu-symbolic", "icon-size", self._settings)}
|
||
|
||
self._paned=Gtk.Paned()
|
||
self._browser=Browser(self._client, self._settings)
|
||
self._cover_playlist_window=CoverPlaylistWindow(self._client, self._settings)
|
||
playback_control=PlaybackControl(self._client, self._settings)
|
||
seek_bar=SeekBar(self._client)
|
||
playback_options=PlaybackOptions(self._client, self._settings)
|
||
connection_notify=ConnectionNotify(self._client, self._settings)
|
||
|
||
# menu
|
||
subsection=Gio.Menu()
|
||
subsection.append(_("Settings"), "win.settings")
|
||
subsection.append(_("Keyboard shortcuts"), "win.show-help-overlay")
|
||
subsection.append(_("Help"), "win.help")
|
||
subsection.append(_("About"), "app.about")
|
||
subsection.append(_("Quit"), "app.quit")
|
||
mpd_subsection=Gio.Menu()
|
||
mpd_subsection.append(_("Update database"), "mpd.update")
|
||
mpd_subsection.append(_("Server stats"), "win.stats")
|
||
self._profiles_submenu=Gio.Menu()
|
||
self._refresh_profiles_menu()
|
||
menu=Gio.Menu()
|
||
menu.append_submenu(_("Profiles"), self._profiles_submenu)
|
||
menu.append(_("Mini player"), "win.mini-player")
|
||
menu.append_section(None, mpd_subsection)
|
||
menu.append_section(None, subsection)
|
||
|
||
# menu button / popover
|
||
self._menu_button=Gtk.MenuButton(image=icons["open-menu-symbolic"], tooltip_text=_("Menu"))
|
||
self._menu_button.set_can_focus(False)
|
||
menu_popover=Gtk.Popover.new_from_model(self._menu_button, menu)
|
||
self._menu_button.set_popover(menu_popover)
|
||
|
||
# action bar
|
||
action_bar=Gtk.ActionBar()
|
||
action_bar.pack_start(playback_control)
|
||
action_bar.pack_start(seek_bar)
|
||
action_bar.pack_start(playback_options)
|
||
|
||
# connect
|
||
self._settings.connect("changed::profiles", self._refresh_profiles_menu)
|
||
self._settings.connect("changed::active-profile", self._on_active_profile_changed)
|
||
self._settings.connect_after("notify::mini-player", self._on_mini_player)
|
||
self._settings.connect_after("notify::cursor-watch", self._on_cursor_watch)
|
||
self._settings.connect("changed::playlist-right", self._on_playlist_pos_changed)
|
||
self._client.emitter.connect("current_song_changed", self._on_song_changed)
|
||
self._client.emitter.connect("disconnected", self._on_disconnected)
|
||
self._client.emitter.connect("reconnected", self._on_reconnected)
|
||
self._browser.connect("search-focus-changed", self._on_search_focus_changed)
|
||
# auto save window state and size
|
||
self.connect("size-allocate", self._on_size_allocate)
|
||
self._settings.bind("maximize", self, "is-maximized", Gio.SettingsBindFlags.SET)
|
||
# save and restore mini player
|
||
self._settings.bind("mini-player", self._settings, "mini-player", Gio.SettingsBindFlags.DEFAULT)
|
||
|
||
# packing
|
||
self._on_playlist_pos_changed() # set orientation
|
||
self._paned.pack1(self._browser, True, False)
|
||
self._paned.pack2(self._cover_playlist_window, False, False)
|
||
vbox=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||
vbox.pack_start(self._paned, True, True, 0)
|
||
vbox.pack_start(action_bar, False, False, 0)
|
||
overlay=Gtk.Overlay()
|
||
overlay.add(vbox)
|
||
overlay.add_overlay(connection_notify)
|
||
if self._use_csd:
|
||
self._header_bar=Gtk.HeaderBar()
|
||
self._header_bar.set_show_close_button(True)
|
||
self.set_titlebar(self._header_bar)
|
||
self._header_bar.pack_start(self._browser.back_to_current_album_button)
|
||
self._header_bar.pack_end(self._menu_button)
|
||
self._header_bar.pack_end(self._browser.search_button)
|
||
else:
|
||
action_bar.pack_start(self._menu_button)
|
||
self.add(overlay)
|
||
self._client.emitter.emit("disconnected") # bring player in defined state
|
||
# indicate connection process in window title
|
||
if self._use_csd:
|
||
self._header_bar.set_subtitle(_("connecting…"))
|
||
else:
|
||
self.set_title("mpdevil "+_("connecting…"))
|
||
self.show_all()
|
||
while Gtk.events_pending(): # ensure window is visible
|
||
Gtk.main_iteration_do(True)
|
||
# restore paned settings when window is visible (fixes a bug when window is maximized)
|
||
self._cover_playlist_window.set_position(self._settings.get_int("paned0"))
|
||
self._browser.set_position(self._settings.get_int("paned1"))
|
||
self._paned.set_position(self._settings.get_int("paned2"))
|
||
|
||
# auto save paned positions
|
||
self._cover_playlist_window.connect("notify::position", self._on_paned_position, "paned0")
|
||
self._browser.connect("notify::position", self._on_paned_position, "paned1")
|
||
self._paned.connect("notify::position", self._on_paned_position, "paned2")
|
||
|
||
# start client
|
||
def callback(*args):
|
||
self._client.start() # connect client
|
||
return False
|
||
GLib.idle_add(callback)
|
||
|
||
def _on_toggle_lyrics(self, action, param):
|
||
self._cover_playlist_window.lyrics_button.set_active(not(self._cover_playlist_window.lyrics_button.get_active()))
|
||
|
||
def _on_back_to_current_album(self, action, param):
|
||
self._browser.back_to_current_album_button.emit("clicked")
|
||
|
||
def _on_toggle_search(self, action, param):
|
||
self._browser.search_button.set_active(not(self._browser.search_button.get_active()))
|
||
|
||
def _on_settings(self, action, param):
|
||
settings=SettingsDialog(self, self._client, self._settings)
|
||
settings.run()
|
||
settings.destroy()
|
||
|
||
def _on_stats(self, action, param):
|
||
stats=ServerStats(self, self._client, self._settings)
|
||
stats.destroy()
|
||
|
||
def _on_help(self, action, param):
|
||
Gtk.show_uri_on_window(self, "https://github.com/SoongNoonien/mpdevil/wiki/Usage", Gdk.CURRENT_TIME)
|
||
|
||
def _on_menu(self, action, param):
|
||
self._menu_button.emit("clicked")
|
||
|
||
def _on_profile_next(self, action, param):
|
||
total_profiles=len(self._settings.get_value("profiles"))
|
||
current_profile=self._settings.get_int("active-profile")
|
||
self._settings.set_int("active-profile", (current_profile+1)%total_profiles)
|
||
|
||
def _on_profile_prev(self, action, param):
|
||
total_profiles=len(self._settings.get_value("profiles"))
|
||
current_profile=self._settings.get_int("active-profile")
|
||
self._settings.set_int("active-profile", (current_profile-1)%total_profiles)
|
||
|
||
def _on_show_info(self, action, param):
|
||
self._client.emitter.emit("show-info")
|
||
|
||
def _on_append(self, action, param):
|
||
self._client.emitter.emit("add-to-playlist", "append")
|
||
|
||
def _on_play(self, action, param):
|
||
self._client.emitter.emit("add-to-playlist", "play")
|
||
|
||
def _on_enqueue(self, action, param):
|
||
self._client.emitter.emit("add-to-playlist", "enqueue")
|
||
|
||
def _on_profiles(self, action, param):
|
||
self._settings.set_int("active-profile", param.unpack())
|
||
action.set_state(param)
|
||
|
||
def _on_song_changed(self, *args):
|
||
song=self._client.currentsong()
|
||
if song == {}:
|
||
if self._use_csd:
|
||
self.set_title("mpdevil")
|
||
self._header_bar.set_subtitle("")
|
||
else:
|
||
self.set_title("mpdevil")
|
||
else:
|
||
song=ClientHelper.song_to_str_dict(ClientHelper.pepare_song_for_display(song))
|
||
if song["date"] == "":
|
||
date=""
|
||
else:
|
||
date=" ("+song["date"]+")"
|
||
if self._use_csd:
|
||
self.set_title(song["title"]+" - "+song["artist"])
|
||
self._header_bar.set_subtitle(song["album"]+date)
|
||
else:
|
||
self.set_title(song["title"]+" - "+song["artist"]+" - "+song["album"]+date)
|
||
if self._settings.get_boolean("send-notify"):
|
||
if not self.is_active() and self._client.status()["state"] == "play":
|
||
notify=Notify.Notification.new(song["title"], song["artist"]+"\n"+song["album"]+date)
|
||
pixbuf=self._client.get_cover(song, 400)
|
||
notify.set_image_from_pixbuf(pixbuf)
|
||
notify.show()
|
||
|
||
def _on_reconnected(self, *args):
|
||
for action in ("stats","toggle-lyrics","back-to-current-album","toggle-search"):
|
||
self.lookup_action(action).set_enabled(True)
|
||
|
||
def _on_disconnected(self, *args):
|
||
self.set_title("mpdevil")
|
||
if self._use_csd:
|
||
self._header_bar.set_subtitle("")
|
||
for action in ("stats","toggle-lyrics","back-to-current-album","toggle-search"):
|
||
self.lookup_action(action).set_enabled(False)
|
||
|
||
def _on_search_focus_changed(self, obj, focus):
|
||
self._mpd_action_group.lookup_action("toggle-play").set_enabled(not(focus))
|
||
|
||
def _on_size_allocate(self, widget, rect):
|
||
if not self.is_maximized() and not self._settings.get_property("mini-player"):
|
||
size=self.get_size()
|
||
if size != self._size: # prevent unneeded write operations
|
||
self._settings.set_int("width", size[0])
|
||
self._settings.set_int("height", size[1])
|
||
self._size=size
|
||
|
||
def _on_paned_position(self, obj, typestring, key):
|
||
self._settings.set_int(key, obj.get_position())
|
||
|
||
def _on_mini_player(self, obj, typestring):
|
||
if obj.get_property("mini-player"):
|
||
if self.is_maximized():
|
||
self.unmaximize()
|
||
self.resize(1,1)
|
||
else:
|
||
self.resize(self._settings.get_int("width"), self._settings.get_int("height"))
|
||
|
||
def _on_cursor_watch(self, obj, typestring):
|
||
if obj.get_property("cursor-watch"):
|
||
watch_cursor = Gdk.Cursor(Gdk.CursorType.WATCH)
|
||
self.get_window().set_cursor(watch_cursor)
|
||
else:
|
||
self.get_window().set_cursor(None)
|
||
|
||
def _on_playlist_pos_changed(self, *args):
|
||
if self._settings.get_boolean("playlist-right"):
|
||
self._cover_playlist_window.set_orientation(Gtk.Orientation.VERTICAL)
|
||
self._paned.set_orientation(Gtk.Orientation.HORIZONTAL)
|
||
else:
|
||
self._cover_playlist_window.set_orientation(Gtk.Orientation.HORIZONTAL)
|
||
self._paned.set_orientation(Gtk.Orientation.VERTICAL)
|
||
|
||
def _refresh_profiles_menu(self, *args):
|
||
self._profiles_submenu.remove_all()
|
||
for num, profile in enumerate(self._settings.get_value("profiles")):
|
||
item=Gio.MenuItem.new(profile, None)
|
||
item.set_action_and_target_value("win.profiles", GLib.Variant("i", num))
|
||
self._profiles_submenu.append_item(item)
|
||
self._profiles_action.set_state(GLib.Variant("i", self._settings.get_int("active-profile")))
|
||
|
||
def _on_active_profile_changed(self, *args):
|
||
self._profiles_action.set_state(GLib.Variant("i", self._settings.get_int("active-profile")))
|
||
|
||
###################
|
||
# Gtk application #
|
||
###################
|
||
|
||
class mpdevil(Gtk.Application):
|
||
def __init__(self, *args, **kwargs):
|
||
super().__init__(*args, application_id="org.mpdevil.mpdevil", flags=Gio.ApplicationFlags.FLAGS_NONE, **kwargs)
|
||
self._settings=Settings()
|
||
self._client=Client(self._settings)
|
||
self._window=None
|
||
|
||
def do_activate(self):
|
||
if not self._window: # allow just one instance
|
||
self._window=MainWindow(self, self._client, self._settings)
|
||
self._window.connect("delete-event", self._on_delete_event)
|
||
# accelerators
|
||
action_accels=(
|
||
("app.quit", ["<Control>q"]),("win.mini-player", ["<Control>m"]),("win.help", ["F1"]),("win.menu", ["F10"]),
|
||
("win.show-help-overlay", ["<Control>question"]),("win.toggle-lyrics", ["<Control>l"]),
|
||
("win.back-to-current-album", ["Escape"]),("win.toggle-search", ["<control>f"]),
|
||
("mpd.update", ["F5"]),("mpd.clear", ["<Shift>Delete"]),("mpd.toggle-play", ["space"]),
|
||
("mpd.stop", ["<Control>space"]),("mpd.next", ["KP_Add"]),("mpd.prev", ["KP_Subtract"]),
|
||
("mpd.repeat", ["<Control>r"]),("mpd.random", ["<Control>s"]),("mpd.single", ["<Control>1"]),
|
||
("mpd.consume", ["<Control>o"]),("mpd.seek-forward", ["KP_Multiply"]),("mpd.seek-backward", ["KP_Divide"]),
|
||
("win.profile-next", ["<Control>p"]),("win.profile-prev", ["<Shift><Control>p"]),
|
||
("win.show-info", ["<Control>i","Menu"]),("win.append", ["<Control>plus"]),
|
||
("win.play", ["<Control>Return"]),("win.enqueue", ["<Control>e"])
|
||
)
|
||
for action, accels in action_accels:
|
||
self.set_accels_for_action(action, accels)
|
||
# disable item activation on space key pressed in treeviews
|
||
Gtk.binding_entry_remove(Gtk.binding_set_find('GtkTreeView'), Gdk.keyval_from_name("space"), Gdk.ModifierType.MOD2_MASK)
|
||
self._window.present()
|
||
|
||
def do_startup(self):
|
||
Gtk.Application.do_startup(self)
|
||
|
||
action=Gio.SimpleAction.new("about", None)
|
||
action.connect("activate", self._on_about)
|
||
self.add_action(action)
|
||
|
||
action=Gio.SimpleAction.new("quit", None)
|
||
action.connect("activate", self._on_quit)
|
||
self.add_action(action)
|
||
|
||
def _on_delete_event(self, *args):
|
||
if self._settings.get_boolean("stop-on-quit") and self._client.connected():
|
||
self._client.stop()
|
||
self.quit()
|
||
|
||
def _on_about(self, action, param):
|
||
dialog=AboutDialog(self._window)
|
||
dialog.run()
|
||
dialog.destroy()
|
||
|
||
def _on_quit(self, action, param):
|
||
if self._settings.get_boolean("stop-on-quit") and self._client.connected():
|
||
self._client.stop()
|
||
self.quit()
|
||
|
||
if __name__ == "__main__":
|
||
app=mpdevil()
|
||
app.run(sys.argv)
|