mpdevil/bin/mpdevil

3927 lines
140 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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", "4.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.2.0-gtk4" # sync with setup.py
COVER_REGEX=r"^\.?(album|cover|folder|front).*\.(gif|jpeg|jpg|png)$"
class WorkAroundListStore(Gtk.ListStore):
'''work around defect ".append"'''
def __init__(self, *args):
super().__init__(*args)
def append(self, *args):
treeiter=super(WorkAroundListStore, self).append()
row=list(sum(list(enumerate(*args)), ()))
self.set(treeiter, *row)
#########
# 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=-1):
loader=GdkPixbuf.PixbufLoader.new()
loader.write(binary)
loader.close()
if size > 0:
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)
else:
pixbuf=loader.get_pixbuf()
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="/usr/share/icons/Adwaita/scalable/devices/media-optical-symbolic.svg" #TODO
# 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):
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(self.fallback_cover)
else:
pixbuf=ClientHelper.binary_to_pixbuf(cover_binary)
else:
try:
pixbuf=GdkPixbuf.Pixbuf.new_from_file(cover_path)
except: # load fallback if cover can't be loaded (GLib: Couldnt recognize the image file format for file...)
pixbuf=GdkPixbuf.Pixbuf.new_from_file(self.fallback_cover)
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)
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_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, margin_top=18, margin_bottom=18, margin_start=18, margin_end=18)
self._settings=settings
# int settings
int_settings={}
int_settings_data=[
(_("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", lambda widget,key: self._settings.set_int(key, int(widget.get_value())), key)
# check buttons
check_buttons={}
check_buttons_data=[
(_("Use Client-side decoration"), "use-csd"),
(_("Show stop button"), "show-stop"),
(_("Show lyrics button"), "show-lyrics-button"),
(_("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"),
(_("Support “MPRIS”"), "mpris"),
]
for label, key in check_buttons_data:
check_buttons[key]=Gtk.CheckButton(label=label, margin_start=12)
check_buttons[key].set_active(self._settings.get_boolean(key))
check_buttons[key].connect("toggled", lambda widget,key: self._settings.set_boolean(key, widget.get_active()), 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, margin_start=12)
view_grid.attach(int_settings["album-cover"][0], 0, 0, 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["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)
# packing
csd_box=Gtk.Box(spacing=12)
csd_box.append(check_buttons["use-csd"])
csd_box.append(Gtk.Label(label=_("(restart required)"), sensitive=False))
self.append(view_heading)
self.append(csd_box)
self.append(check_buttons["show-stop"])
self.append(check_buttons["show-lyrics-button"])
self.append(check_buttons["playlist-right"])
self.append(view_grid)
self.append(behavior_heading)
mpris_box=Gtk.Box(spacing=12)
mpris_box.append(check_buttons["mpris"])
mpris_box.append(Gtk.Label(label=_("(restart required)"), sensitive=False))
self.append(mpris_box)
self.append(check_buttons["use-album-artist"])
self.append(check_buttons["sort-albums-by-year"])
self.append(check_buttons["send-notify"])
self.append(check_buttons["force-mode"])
self.append(check_buttons["stop-on-quit"])
class ProfileSettings(Gtk.Grid):
def __init__(self, parent, client, settings):
super().__init__(row_spacing=6, column_spacing=12, margin_top=18, margin_bottom=18, margin_start=18, margin_end=18)
self._client=client
self._settings=settings
# widgets
self._profiles_combo=ComboBoxEntry()
add_button=Gtk.Button(child=Gtk.Image.new_from_icon_name("list-add-symbolic"))
delete_button=Gtk.Button(child=Gtk.Image.new_from_icon_name("list-remove-symbolic"))
add_delete_buttons=Gtk.Box(css_classes=["linked"])
add_delete_buttons.append(add_button)
add_delete_buttons.append(delete_button)
connect_button=Gtk.Button.new_with_mnemonic(_("_Connect"))
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.append(self._host_entry)
address_entry.append(self._port_entry)
self._password_entry=Gtk.PasswordEntry(show_peek_icon=True, hexpand=True)
self._path_entry=Gtk.Entry(hexpand=True, placeholder_text=GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_MUSIC))
self._path_entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "folder-open-symbolic")
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)
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_entry.connect("icon-release", self._on_path_entry_icon_release, parent)
self._profiles_select=self._profiles_combo.connect("select", self._on_profiles_select)
self.entry_changed_handlers=[]
self.entry_changed_handlers.append((self._profiles_combo, self._profiles_combo.connect("text", 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)))
# packing
self.attach(profiles_label, 0, 0, 1, 1)
self.attach_next_to(host_label, profiles_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(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(self._path_entry, 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)
self._profiles_combo_reload()
self._profiles_combo.set_active(self._settings.get_int("active-profile"))
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_select)
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_select)
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_selected()
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):
selected=self._profiles_combo.get_selected()
if selected == self._settings.get_int("active-profile"):
self._client.reconnect()
else:
self._settings.set_int("active-profile", selected)
def _on_profile_entry_changed(self, *args):
self._settings.array_modify("as", "profiles", self._profiles_combo.get_selected(), self._profiles_combo.get_text())
def _on_host_entry_changed(self, *args):
self._settings.array_modify("as", "hosts", self._profiles_combo.get_selected(), self._host_entry.get_text())
def _on_port_entry_changed(self, *args):
self._settings.array_modify("ai", "ports", self._profiles_combo.get_selected(), int(self._port_entry.get_value()))
def _on_password_entry_changed(self, *args):
self._settings.array_modify("as", "passwords", self._profiles_combo.get_selected(), self._password_entry.get_text())
def _on_path_entry_changed(self, *args):
self._settings.array_modify("as", "paths", self._profiles_combo.get_selected(), self._path_entry.get_text())
def _on_regex_entry_changed(self, *args):
self._settings.array_modify("as", "regex", self._profiles_combo.get_selected(), self._regex_entry.get_text())
def _on_path_entry_icon_release(self, widget, icon_pos, event, 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_selected())
if folder is not None:
dialog.set_current_folder(folder)
response=dialog.response()
if response == Gtk.ResponseType.ACCEPT:
self._settings.array_modify("as", "paths", self._profiles_combo.get_selected(), dialog.get_filename())
self._path_entry.set_text(dialog.get_filename())
def _on_profiles_select(self, *args):
active=self._profiles_combo.get_active()
if active >= 0:
self._block_entry_changed_handlers()
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): # TODO
def __init__(self, settings):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin_top=18, margin_bottom=18, margin_start=18, margin_end=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=WorkAroundListStore(bool, str, int)
treeview=Gtk.TreeView(model=self._store, reorderable=True, headers_visible=False, search_column=-1, vexpand=True)
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(hexpand=True)
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll.set_child(treeview)
# column chooser
frame=Gtk.Frame()
frame.set_child(scroll)
# connect
self._row_deleted=self._store.connect("row-deleted", self._save_permutation)
renderer_toggle.connect("toggled", self._on_cell_toggled)
# packing
self.append(label)
self.append(frame)
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 _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])
class SettingsDialog(Gtk.Dialog):
def __init__(self, parent, client, settings, tab="general"):
use_csd=settings.get_boolean("use-csd")
super().__init__(title=_("Settings"), transient_for=parent, use_header_bar=use_csd)
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.append(stack)
header_bar=self.get_header_bar()
header_bar.set_title_widget(stack_switcher)
else:
tabs=Gtk.Notebook(show_border=False)
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.append(tabs)
self.show()
if use_csd:
stack.set_visible_child_name(tab)
else:
tabs.set_current_page({"general": 0, "profiles": 1, "playlist": 2}[tab])
#################
# 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)
self.set_resizable(False)
# grid
grid=Gtk.Grid(row_spacing=6, column_spacing=12, margin_top=6, margin_bottom=6, margin_start=6, margin_end=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.append(grid)
self.show()
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")
self.show()
###########################
# general purpose widgets #
###########################
class ComboBoxEntry(Gtk.ComboBoxText):
__gsignals__={"text": (GObject.SignalFlags.RUN_FIRST, None, ()), "select": (GObject.SignalFlags.RUN_FIRST, None, ())}
def __init__(self):
super().__init__(entry_text_column=0, has_entry=True)
self._store=self.get_property("model")
self._entry=self.get_child()
self._selected=-1
# connect
self.connect("changed", self._on_changed)
def get_text(self):
return self._entry.get_text()
def get_selected(self):
return self._selected
def _on_changed(self, *args):
active=self.get_active()
if active >= 0:
self._selected=active
self.emit("select")
else:
try:
self._store[self._selected][0]=self._entry.get_text()
self.emit("text")
except:
pass
class FocusFrame(Gtk.Overlay):
def __init__(self):
super().__init__()
self._frame=Gtk.Frame(visible=False)
# 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; border-radius: 0px;}"""
provider.load_from_data(css)
style_context.add_provider(provider, 600)
self.add_overlay(self._frame)
self._frame.set_property("can-target", False)
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
controller_focus=Gtk.EventControllerFocus()
widget.add_controller(controller_focus)
controller_focus.connect("enter", lambda *args: self._frame.show())
controller_focus.connect("leave", lambda *args: self._frame.hide())
class SongPopover(Gtk.Popover):
def __init__(self, client, parent, show_buttons=True):
super().__init__(vexpand=True)
self._client=client
self._rect=Gdk.Rectangle()
self._uri=None
self.set_parent(parent)
box=Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, vexpand=True)
# open-with button
open_button=Gtk.Button(child=Gtk.Image.new_from_icon_name("document-open-symbolic"), 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")
# open 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.set_child(open_button)
# buttons
if show_buttons:
button_box=Gtk.Box(css_classes=["linked"])
data=((_("Append"), "append"), (_("Play"), "play"), (_("Enqueue"), "enqueue"))
for label, mode in data:
button=Gtk.Button(label=label)
button.connect("clicked", self._on_button_clicked, mode)
button_box.append(button)
box.append(button_box)
# treeview
# (tag, display-value, tooltip)
self._store=WorkAroundListStore(str, str, str)
self._treeview=Gtk.TreeView(model=self._store, headers_visible=False, search_column=-1, tooltip_column=2, vexpand=True)
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(vexpand=True)
self._scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self._scroll.set_propagate_natural_height(True)
self._scroll.set_propagate_natural_width(True)
self._scroll.set_child(self._treeview)
# overlay
overlay=Gtk.Overlay()
overlay.set_child(self._scroll)
overlay.add_overlay(self._open_button_revealer)
# connect
open_button.connect("clicked", self._on_open_button_clicked)
# packing
box.prepend(overlay)
self.set_child(box)
def open(self, uri, x, y):
self._uri=uri
self._rect.x=x
self._rect.y=y
self.set_pointing_to(self._rect)
# window=self.get_root()
# self._scroll.set_max_content_height(window.get_size()[1]//2)
self._scroll.set_max_content_height(800) # TODO
self._scroll.set_min_content_height(500) # TODO
self._store.clear()
song=ClientHelper.song_to_str_dict(self._client.get_metadata(uri))
for tag, value in song.items():
tooltip=value.replace("&", "&amp;")
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()
def _on_open_button_clicked(self, *args): # TODO
self.popdown()
dialog=Gtk.AppChooserDialog(gfile=self._gfile, transient_for=self.get_root())
app_chooser=dialog.get_widget()
def callback(dialog, response):
if response == Gtk.ResponseType.OK:
print("a")
app=app_chooser.get_app_info()
app.launch([self._gfile], None)
dialog.destroy()
dialog.connect("response", callback)
dialog.show()
def _on_button_clicked(self, widget, mode):
self._client.files_to_playlist([self._uri], mode)
self.popdown()
class SongsView(Gtk.TreeView):
def __init__(self, client, store, file_column_id):
super().__init__(model=store, search_column=-1, activate_on_single_click=True, vexpand=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, self)
# event controllers
for i in (1,2,3):
controller=Gtk.GestureClick(button=i)
self.add_controller(controller)
controller.connect("pressed", self._on_gesture_click_pressed)
# connect
self.connect("row-activated", self._on_row_activated)
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_gesture_click_pressed(self, controller, n_press, raw_x, raw_y):
x,y=self.convert_widget_to_bin_window_coords(raw_x,raw_y)
path_re=self.get_path_at_pos(x, y)
if path_re is not None:
path=path_re[0]
if controller.get_property("button") == 1 and n_press == 2:
self._client.files_to_playlist([self._store[path][self._file_column_id]], "play")
elif controller.get_property("button") == 2 and n_press == 1:
self._client.files_to_playlist([self._store[path][self._file_column_id]], "append")
elif controller.get_property("button") == 3 and n_press == 1:
self._song_popover.open(self._store[path][self._file_column_id], raw_x, raw_y)
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], 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, 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.set_child(self._songs_view)
# buttons
button_box=Gtk.Box(css_classes=["linked"])
data=((_("_Append"), _("Add all titles to playlist"), "append"),
(_("_Play"), _("Directly play all titles"), "play"),
(_("_Enqueue"), _("Append all titles after the currently playing track and clear the playlist from all other songs"),
"enqueue")
)
for label, tooltip, mode in data:
button=Gtk.Button.new_with_mnemonic(label)
button.set_tooltip_text(tooltip)
button.connect("clicked", self._on_button_clicked, mode)
button_box.append(button)
# action bar
self._action_bar=Gtk.ActionBar()
# packing
if popover_mode:
self.append(button_box)
frame=Gtk.Frame()
else:
self._action_bar.pack_start(button_box)
self.append(self._action_bar)
frame=FocusFrame()
frame.set_widget(self._songs_view)
frame.set_child(self._scroll)
self.prepend(frame)
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=WorkAroundListStore(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.set_child(songs_window)
songs_window.show()
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_parent(widget)
self._scroll.set_min_content_height(4*widget.get_allocated_height()//7) # TODO
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("&", "&amp;")
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.Box(orientation=Gtk.Orientation.VERTICAL)
data=((_("Append"), "list-add-symbolic", "append"),
(_("Play"), "media-playback-start-symbolic", "play"),
(_("Enqueue"), "insert-object-symbolic", "enqueue")
)
for label, icon, mode in data:
button=Gtk.Button(label=label, has_frame=False)
button.get_child().set_property("xalign", 0)
button.connect("clicked", self._on_button_clicked, mode)
vbox.append(button)
self.set_child(vbox)
vbox.show()
def open(self, artist, genre, widget, x, y):
self._rect.x=x
self._rect.y=y
self.set_pointing_to(self._rect)
self.set_parent(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(hexpand=True)
# label
self._hits_label=Gtk.Label(xalign=1)
# store
# (track, title, artist, album, duration, file, sort track)
self._store=WorkAroundListStore(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)
# event controller
controller_focus=Gtk.EventControllerFocus()
self.search_entry.add_controller(controller_focus)
controller_focus.connect("enter", self._on_search_entry_focus_event, True)
controller_focus.connect("leave", self._on_search_entry_focus_event, False)
# 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, margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
hbox.append(self.search_entry)
hbox.append(self._tag_combo_box)
self._hits_label.set_margin_end(6)
self._action_bar.pack_end(self._hits_label)
self.append(hbox)
self.append(Gtk.Separator.new(orientation=Gtk.Orientation.HORIZONTAL))
self.append(self._songs_window)
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.set_cursor_from_name("progress")
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:
main=GLib.main_context_default()
while main.pending():
main.iteration()
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.set_cursor_from_name("normal")
self._stop_flag=False
self._done=True
pending=self._pending
self._pending=[]
for p in pending:
try:
p()
except:
pass
return False
def _on_search_entry_focus_event(self, controller, focus):
app=self.get_root().get_application()
if focus:
app.set_accels_for_action("mpd.toggle-play", [])
else:
app.set_accels_for_action("mpd.toggle-play", ["space"])
class GenreSelect(Gtk.DropDown):
__gsignals__={"genre_changed": (GObject.SignalFlags.RUN_FIRST, None, ())}
def __init__(self, client):
super().__init__(hexpand=True)
self._string_list=Gtk.StringList()
self.set_model(self._string_list)
self._client=client
# connect
self._changed=self.connect("notify::selected", self._on_selected_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_selected(0)
def _clear(self, *args):
self.handler_block(self._changed)
for i in range(len(self._string_list)):
self._string_list.remove(0)
self.handler_unblock(self._changed)
def get_selected_genre(self):
if self.get_property("selected") == 0:
return None
else:
return self._string_list.get_string(self.get_property("selected"))
def _refresh(self, *args):
self._clear()
self._string_list.append(_("all genres"))
for genre in self._client.comp_list("genre"):
self._string_list.append(genre)
self.set_selected(0)
def _on_selected_changed(self, obj, typestring):
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=WorkAroundListStore(str, Pango.Weight, str, Pango.Weight)
self._treeview=Gtk.TreeView(model=self._store, activate_on_single_click=True, search_column=0, headers_visible=False, vexpand=True)
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._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.set_child(self._treeview)
# artist popover
self._artist_popover=ArtistPopover(self._client)
# event controllers
for i in (1,2,3):
controller=Gtk.GestureClick(button=i)
self._treeview.add_controller(controller)
controller.connect("pressed", self._on_gesture_click_pressed)
# connect
self._treeview.connect("row-activated", self._on_row_activated)
self._settings.connect("changed::use-album-artist", self._refresh)
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.set_child(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)
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_gesture_click_pressed(self, controller, n_press, raw_x, raw_y):
if ((controller.get_property("button") in (2,3) and n_press == 1) or (controller.get_property("button") == 1 and n_press == 2)):
x,y=self._treeview.convert_widget_to_bin_window_coords(raw_x,raw_y)
path_re=self._treeview.get_path_at_pos(x, 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 controller.get_property("button") == 1:
self._client.artist_to_playlist(artist, genre, "play")
elif controller.get_property("button") == 2:
self._client.artist_to_playlist(artist, genre, "append")
elif controller.get_property("button") == 3:
self._artist_popover.open(artist, genre, self._treeview, raw_x, raw_y)
def _on_row_activated(self, widget, path, view_column):
if self._store[path][1] == Pango.Weight.BOOK:
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)
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=WorkAroundListStore(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, vexpand=True
)
# scroll
scroll=Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll.set_child(self._iconview)
self._scroll_vadj=scroll.get_vadjustment()
self._scroll_hadj=scroll.get_hadjustment()
# progress bar
self._progress_bar=Gtk.ProgressBar(visible=False)
# popover
self._album_popover=AlbumPopover(self._client, self._settings)
self._artist_popover=ArtistPopover(self._client)
# event controllers
for i in (1,2,3):
controller=Gtk.GestureClick(button=i)
self._iconview.add_controller(controller)
controller.connect("pressed", self._on_gesture_click_pressed)
# connect
self._iconview.connect("item-activated", self._on_item_activated)
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.append(scroll)
box.append(self._progress_bar)
self.set_child(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.set_cursor_from_name("progress")
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))
main=GLib.main_context_default()
while main.pending():
main.iteration()
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("&", "&amp;")
display_label_artist=display_label_artist.replace("&", "&amp;")
# 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.set_cursor_from_name("default")
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_gesture_click_pressed(self, controller, n_press, x, y):
v=self._scroll_vadj.get_value()
h=self._scroll_hadj.get_value()
path=self._iconview.get_path_at_pos(x+h, y+v)
if controller.get_property("button") == 1 and n_press == 2:
if path is not None:
self._path_to_playlist(path, "play")
elif controller.get_property("button") == 2 and n_press == 1:
if path is not None:
self._path_to_playlist(path, "append")
elif controller.get_property("button") == 3 and n_press == 1:
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]
self._album_popover.open(album, artist, year, genre, self._iconview, x, y)
else:
artist=self._artist_window.get_selected_artist()
self._artist_popover.open(artist, genre, self._iconview, x, y)
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):
def __init__(self, client, settings):
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, shrink_start_child=False, shrink_end_child=False, resize_start_child=False)
self._client=client
self._settings=settings
self._use_csd=self._settings.get_boolean("use-csd")
# widgets
self.back_button=Gtk.Button(icon_name="go-previous-symbolic",tooltip_text=_("Back to current album"),can_focus=False)
self.search_button=Gtk.ToggleButton(icon_name="system-search-symbolic", tooltip_text=_("Search"), can_focus=False)
if not self._use_csd:
self._settings.bind("icon-size-sec", self.back_button.get_child(), "pixel-size", Gio.SettingsBindFlags.GET)
self._settings.bind("icon-size-sec", self.search_button.get_child(), "pixel-size", Gio.SettingsBindFlags.GET)
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)
# event controllers TODO
gesture_click_button1=Gtk.GestureClick(button=1)
self.back_button.add_controller(gesture_click_button1)
gesture_click_button1.connect("pressed", self._on_gesture_click_pressed)
# connect
self.back_button.connect("clicked", self._on_back_button_clicked)
self.search_button.connect("toggled", self._on_search_toggled)
self._artist_window.connect("artists_changed", self._on_artists_changed)
self._client.emitter.connect("disconnected", self._on_disconnected)
self._client.emitter.connect("reconnected", self._on_reconnected)
self._settings.bind("mini-player", self, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
self._settings.bind("mini-player", self.back_button, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
self._settings.bind("mini-player", self.search_button, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
# 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, margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
if self._use_csd:
hbox.append(self._genre_select)
else:
hbox.append(self.back_button)
hbox.append(self._genre_select)
hbox.append(self.search_button)
box1=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
box1.append(hbox)
box1.append(Gtk.Separator.new(orientation=Gtk.Orientation.HORIZONTAL))
box1.append(self._artist_window)
self.set_start_child(box1)
self.set_end_child(self._stack)
def _back_to_current_album(self, force=False):
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() or force:
self._genre_select.deactivate()
# select artist
if self._artist_window.get_selected_artist() is None and not force: # 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_back_button_clicked(self, *args):
self._back_to_current_album()
def _on_gesture_click_pressed(self, controller, n_press, x, y):
if n_press == 2:
self._back_to_current_album(force=True)
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_button.set_sensitive(True)
self.search_button.set_sensitive(True)
def _on_disconnected(self, *args):
self.back_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)
######################
# 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.set_child(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.set_child(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)
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.Box):
def __init__(self, client, settings):
super().__init__()
self._client=client
self._settings=settings
# album popover
self._album_popover=AlbumPopover(self._client, self._settings)
# event controllers
for i in (1,2,3):
controller=Gtk.GestureClick(button=i)
self.add_controller(controller)
controller.connect("pressed", self._on_gesture_click_pressed)
# connect
self._client.emitter.connect("disconnected", self._on_disconnected)
def _on_gesture_click_pressed(self, controller, n_press, x, y):
if self._settings.get_property("mini-player"):
if controller.get_property("button") == 1 and n_press == 1:
window=self.get_root()
window.begin_move_drag(1, event.x_root, event.y_root, Gdk.CURRENT_TIME) #TODO
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 controller.get_property("button") == 1 and n_press == 1:
self._client.album_to_playlist(album, artist, year, None)
elif controller.get_property("button") == 1 and n_press == 2:
self._client.album_to_playlist(album, artist, year, None, "play")
elif controller.get_property("button") == 2 and n_press == 1:
self._client.album_to_playlist(album, artist, year, None, "append")
elif controller.get_property("button") == 3 and n_press == 1:
self._album_popover.open(album, artist, year, None, self, x, 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
# 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)
def _refresh(self, *args):
current_song=self._client.currentsong()
pixbuf=self._client.get_cover(current_song)
self.set_from_pixbuf(pixbuf)
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)
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(
icon_name="go-previous-symbolic",
tooltip_text=_("Scroll to current song"),
has_frame=False,
can_focus=False
)
style_context=self._back_to_current_song_button.get_style_context()
style_context.add_provider(provider, 600)
self._settings.bind("icon-size-sec", self._back_to_current_song_button.get_child(), "pixel-size", Gio.SettingsBindFlags.GET)
clear_button=Gtk.Button(
icon_name="edit-clear-symbolic",
tooltip_text=_("Clear playlist"),
has_frame=False,
action_name="mpd.clear",
can_focus=False
)
style_context=clear_button.get_style_context()
style_context.add_class("destructive-action")
style_context.add_provider(provider, 600)
self._settings.bind("icon-size-sec", clear_button.get_child(), "pixel-size", Gio.SettingsBindFlags.GET)
# treeview
# (track, disc, title, artist, album, duration, date, genre, file, weight)
self._store=WorkAroundListStore(str, str, str, str, str, str, str, str, str, Pango.Weight)
self._treeview=Gtk.TreeView(
model=self._store, activate_on_single_click=True, headers_clickable=False,
reorderable=True, search_column=2, fixed_height_mode=True, vexpand=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.set_child(self._treeview)
# frame
self._frame=FocusFrame()
self._frame.set_widget(self._treeview)
self._frame.set_child(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(clear_button)
action_bar.pack_end(audio)
# song popover
self._song_popover=SongPopover(self._client, self._treeview, show_buttons=False)
# event controllers
for i in (2,3):
controller=Gtk.GestureClick(button=i)
self._treeview.add_controller(controller)
controller.connect("pressed", self._on_gesture_click_pressed)
controller_key=Gtk.EventControllerKey()
self._treeview.add_controller(controller_key)
controller_key.connect("key-released", self._on_key_released)
# connect
self._treeview.connect("row-activated", self._on_row_activated)
# TODO
# 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._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("changed::column-visibilities", self._load_settings)
self._settings.connect("changed::column-permutation", self._load_settings)
self._settings.bind("mini-player", self, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
# packing
self.append(self._frame)
self.append(action_bar)
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_gesture_click_pressed(self, controller, n_press, raw_x, raw_y):
x,y=self._treeview.convert_widget_to_bin_window_coords(raw_x,raw_y)
path_re=self._treeview.get_path_at_pos(x, y)
if path_re is not None:
path=path_re[0]
if controller.get_property("button") == 2 and n_press == 1:
self._store.remove(self._store.get_iter(path))
elif controller.get_property("button") == 3 and n_press == 1:
self._song_popover.open(self._store[path][8], raw_x, raw_y)
def _on_key_released(self, controller, keyval, keycode, state):
if 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_disconnected(self, *args):
self._treeview.set_sensitive(False)
self._back_to_current_song_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._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)
x,y=self._treeview.convert_bin_window_to_widget_coords(cell.x, cell.y)
self._song_popover.open(self._store[path][8], x, y)
class CoverPlaylistWindow(Gtk.Paned):
def __init__(self, client, settings):
super().__init__(shrink_start_child=False, shrink_end_child=False, resize_start_child=False)
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(
child=Gtk.Image.new_from_icon_name("media-view-subtitles-symbolic"),
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.set_child(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.set_child(main_cover)
overlay.add_overlay(self._stack)
overlay.add_overlay(self._lyrics_button_revealer)
self.set_start_child(overlay)
self.set_end_child(self._playlist_window)
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.Box):
def __init__(self, client, settings):
super().__init__(css_classes=["linked"])
self._client=client
self._settings=settings
# buttons
self._buttons={}
data=(
("prev", "media-skip-backward-symbolic", "mpd.prev"),
("play", "media-playback-start-symbolic", "mpd.toggle-play"),
("stop", "media-playback-stop-symbolic", "mpd.stop"),
("next", "media-skip-forward-symbolic", "mpd.next"),
)
for name, icon, action in data:
button=Gtk.Button(icon_name=icon, action_name=action, can_focus=False)
self._settings.bind("icon-size", button.get_child(), "pixel-size", Gio.SettingsBindFlags.GET)
self.append(button)
self._buttons[name]=button
self._buttons["stop"].set_property("visible", self._settings.get_boolean("show-stop")) # TODO
# connect
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)
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._buttons["prev"].set_tooltip_text(" ".join((elapsed_titles, "({length})".format(length=elapsed))))
self._buttons["next"].set_tooltip_text(" ".join((rest_titles, "({length})".format(length=rest))))
except:
self._buttons["prev"].set_tooltip_text("")
self._buttons["next"].set_tooltip_text("")
def _on_state(self, emitter, state):
if state == "play":
self._buttons["play"].set_icon_name("media-playback-pause-symbolic")
else:
self._buttons["play"].set_icon_name("media-playback-start-symbolic")
def _on_disconnected(self, *args):
self._buttons["prev"].set_tooltip_text("")
self._buttons["next"].set_tooltip_text("")
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._buttons["stop"].set_property("visible", visibility)
class SeekBar(Gtk.Box):
def __init__(self, client):
super().__init__(hexpand=True)
self._client=client
# labels
self._elapsed=Gtk.Label(width_chars=5)
self._rest=Gtk.Label(width_chars=6)
# progress bar
self._scale=Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, draw_value=False, hexpand=True, can_focus=False)
self._scale.set_increments(10, 10)
self._adjustment=self._scale.get_adjustment()
# popover
self._popover=Gtk.Popover(autohide=False, has_arrow=False)
self._rect=Gdk.Rectangle()
self._time_label=Gtk.Label()
self._popover.set_child(self._time_label)
self._popover.set_parent(self)
self._popover.set_position(Gtk.PositionType.TOP)
# event controllers
for label, sign1, sign3 in ((self._elapsed, "-", "+"), (self._rest, "+", "-")):
con1=Gtk.GestureClick(button=1)
con3=Gtk.GestureClick(button=3)
label.add_controller(con1)
label.add_controller(con3)
con1.connect("released",
lambda con, n, x, y, sign: self._client.seekcur(sign+str(self._adjustment.get_property("step-increment"))), sign1
)
con3.connect("released",
lambda con, n, x, y, sign: self._client.seekcur(sign+str(self._adjustment.get_property("step-increment"))), sign3
)
controller_motion=Gtk.EventControllerMotion()
self._scale.add_controller(controller_motion)
controller_motion.connect("motion", self._on_pointer_motion)
controller_motion.connect("leave", self._on_pointer_leave)
# connect
self._scale.connect("change-value", self._on_change_value)
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.append(self._elapsed)
self.append(self._scale)
self.append(self._rest)
def _refresh(self, emitter, elapsed, duration):
self.set_sensitive(True)
if elapsed > duration: # fix display error
elapsed=duration
self._adjustment.set_upper(duration)
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_value(elapsed)
def _disable(self, *args):
self._popover.popdown()
self.set_sensitive(False)
self._scale.set_range(0, 0)
self._elapsed.set_text("")
self._rest.set_text("")
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()
current_pos=self._scale.get_value()
if value >= duration:
self._scale.set_sensitive(False)
pos=duration
self._scale.set_sensitive(True)
self._popover.popdown()
elif value <= 0:
pos=0
self._popover.popdown()
else:
pos=value
# if abs(current_pos-pos) > 0.1:
try:
self._client.seekcur(pos)
except:
pass
# else:
# print("a")
def _on_pointer_motion(self, controller, x, y):
rect=self._scale.get_range_rect()
duration=self._adjustment.get_upper()
if self._scale.get_direction() == Gtk.TextDirection.RTL:
elapsed=int(((rect.width-x)/rect.width*self._adjustment.get_upper()))
else:
elapsed=int((x/rect.width*self._adjustment.get_upper()))
if elapsed > duration: # fix display error
elapsed=int(duration)
elif elapsed < 0:
elapsed=0
self._rect.x=self._scale.translate_coordinates(self, x, y)[0] # fix position error
self._time_label.set_text(ClientHelper.seconds_to_display_time(elapsed))
self._popover.set_pointing_to(self._rect)
self._popover.popup()
def _on_pointer_leave(self, *args):
self._popover.popdown()
def _on_state(self, emitter, state):
if state == "stop":
self._disable()
class OutputPopover(Gtk.Popover):
def __init__(self, client, parent):
super().__init__()
self.set_parent(parent)
self._client=client
# widgets
box=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
for output in self._client.outputs():
button=Gtk.CheckButton(label="{} ({})".format(output["outputname"], output["plugin"]))
if output["outputenabled"] == "1":
button.set_property("active", True)
button.connect("toggled", self._on_button_clicked, output["outputid"])
box.append(button)
#connect
# self.connect("closed", lambda *args: self.destroy()) TODO
# packing
self.set_child(box)
box.show()
def _on_button_clicked(self, button, out_id):
if button.get_property("active"):
self._client.enableoutput(out_id)
else:
self._client.disableoutput(out_id)
class VolumeButton(Gtk.VolumeButton):
def __init__(self, client, settings):
super().__init__(use_symbolic=True, can_focus=False)
self._client=client
self._settings=settings
self._popover=None
self._adj=self.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)
# event controllers
gesture_click_button3=Gtk.GestureClick(button=3)
self.add_controller(gesture_click_button3)
gesture_click_button3.connect("pressed", self._on_gesture_click_pressed)
# connect
self._changed=self.connect("value-changed", self._set_volume)
self._client.emitter.connect("volume_changed", self._refresh)
self._client.emitter.connect("disconnected", self._on_disconnected)
self._client.emitter.connect("reconnected", self._on_reconnected)
self._settings.bind("icon-size", self.get_first_child().get_first_child(), "pixel-size", Gio.SettingsBindFlags.GET)
def _set_volume(self, widget, value):
self._client.setvol(str(int(value*100)))
def _refresh(self, emitter, volume):
self.handler_block(self._changed)
if volume < 0:
self.set_value(0)
self._adj.set_upper(0)
else:
self._adj.set_upper(1)
self.set_value(volume/100)
self.handler_unblock(self._changed)
def _on_gesture_click_pressed(self, controller, n_press, x, y):
self._popover=OutputPopover(self._client, self)
self._popover.popup()
def _on_reconnected(self, *args):
self.set_sensitive(True)
def _on_disconnected(self, *args):
self.set_sensitive(False)
self._refresh(None, -1)
if self._popover is not None:
self._popover.popdown()
self._popover=None
class PlaybackOptions(Gtk.Box):
def __init__(self, client, settings):
super().__init__(css_classes=["linked"])
self._client=client
self._settings=settings
# buttons
self._buttons={}
data=(
("repeat", "media-playlist-repeat-symbolic", _("Repeat mode")),
("random", "media-playlist-shuffle-symbolic", _("Random mode")),
("single", "org.mpdevil.mpdevil-single-symbolic", _("Single mode")),
("consume", "org.mpdevil.mpdevil-consume-symbolic", _("Consume mode")),
)
for name, icon, tooltip in data:
button=Gtk.ToggleButton(icon_name=icon, tooltip_text=tooltip, can_focus=False)
self._settings.bind("icon-size", button.get_child(), "pixel-size", Gio.SettingsBindFlags.GET)
handler=button.connect("toggled", self._set_option, name)
self.append(button)
self._buttons[name]=(button, handler)
# css
self._provider=Gtk.CssProvider()
self._provider.load_from_data(b"""image {color: @error_color;}""") # red icon
# event controllers
gesture_click_button3=Gtk.GestureClick(button=3)
self._buttons["single"][0].add_controller(gesture_click_button3)
gesture_click_button3.connect("pressed", self._on_gesture_click_pressed)
# connect
for name in ("repeat", "random", "consume"):
self._client.emitter.connect(name, self._button_refresh, name)
self._client.emitter.connect("single", self._single_refresh)
self._client.emitter.connect("disconnected", self._on_disconnected)
self._client.emitter.connect("reconnected", self._on_reconnected)
self._settings.bind("mini-player", self, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
def _set_option(self, widget, option):
func=getattr(self._client, option)
if widget.get_active():
func("1")
else:
func("0")
def _button_refresh(self, emitter, val, name):
self._buttons[name][0].handler_block(self._buttons[name][1])
self._buttons[name][0].set_active(val)
self._buttons[name][0].handler_unblock(self._buttons[name][1])
def _single_refresh(self, emitter, val):
self._buttons["single"][0].handler_block(self._buttons["single"][1])
self._buttons["single"][0].set_active((val in ("1", "oneshot")))
if val == "oneshot":
self._buttons["single"][0].get_child().get_style_context().add_provider(self._provider, 600)
else:
self._buttons["single"][0].get_child().get_style_context().remove_provider(self._provider)
self._buttons["single"][0].handler_unblock(self._buttons["single"][1])
def _on_gesture_click_pressed(self, controller, n_press, x, y):
state=self._client.status()["single"]
if state == "oneshot":
self._client.single("0")
else:
self._client.single("oneshot")
def _on_disconnected(self, *args):
self.set_sensitive(False)
for name in ("repeat", "random", "consume"):
self._button_refresh(None, False, name)
self._single_refresh(None, "0")
def _on_reconnected(self, *args):
self.set_sensitive(True)
###################
# 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): #TODO
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"), _("Double-click"), 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.append(shortcut)
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(child=Gtk.Image.new_from_icon_name("window-close-symbolic"), has_frame=False)
connect_button=Gtk.Button(label=_("Connect"))
settings_button=Gtk.Button(label=_("Settings"))
settings_button.set_action_name("win.profile-settings")
# 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.append(self._label)
box.append(settings_button)
box.append(connect_button)
box.append(close_button)
self.set_child(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 Title(Gtk.Box):
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL, homogeneous=True)
self._title=Gtk.Label()
self._subtitle=Gtk.Label()
self.append(self._title)
self.append(self._subtitle)
def set_title(self, text):
self._title.set_markup("<b>"+text.replace("&", "&amp;")+"</b>")
def set_subtitle(self, text):
self._subtitle.set_markup("<sub>"+text.replace("&", "&amp;")+"</sub>")
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
if self._settings.get_boolean("mpris"):
dbus_service=MPRISInterface(self, self._client, self._settings)
# actions
simple_actions_data=(
"settings","profile-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)
# shortcuts TODO
# shortcuts_window=ShortcutsWindow()
# self.set_help_overlay(shortcuts_window)
# shortcuts_window.set_modal(False)
# widgets
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)
volume_button=VolumeButton(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(menu_model=menu, tooltip_text=_("Menu"), icon_name="open-menu-symbolic", can_focus=False)
if not self._use_csd:
self._settings.bind("icon-size",self._menu_button.get_first_child().get_first_child(),"pixel-size",Gio.SettingsBindFlags.GET)
# action bar
action_bar=Gtk.Box(spacing=6, margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
action_bar.append(playback_control)
action_bar.append(seek_bar)
action_bar.append(playback_options)
action_bar.append(volume_button)
# 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("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)
# auto save window state and size
self._settings.bind("width", self, "default-width", Gio.SettingsBindFlags.SET)
self._settings.bind("height", self, "default-height", Gio.SettingsBindFlags.SET)
self._settings.bind("maximize", self, "maximized", Gio.SettingsBindFlags.SET)
# save and restore mini player
self._settings.bind("mini-player", self._settings, "mini-player", Gio.SettingsBindFlags.DEFAULT)
# packing
self._paned=Gtk.Paned(vexpand=True, shrink_start_child=False, shrink_end_child=False, resize_end_child=False)
self._on_playlist_pos_changed() # set paned orientation
self._paned.set_start_child(self._browser)
self._paned.set_end_child(self._cover_playlist_window)
vbox=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
vbox.append(self._paned)
vbox.append(Gtk.Separator.new(orientation=Gtk.Orientation.HORIZONTAL))
vbox.append(action_bar)
overlay=Gtk.Overlay()
overlay.set_child(vbox)
overlay.add_overlay(connection_notify)
if self._use_csd:
self._header_bar=Gtk.HeaderBar(title_widget=Title())
self.set_titlebar(self._header_bar)
self._header_bar.pack_start(self._browser.back_button)
self._header_bar.pack_end(self._menu_button)
self._header_bar.pack_end(self._browser.search_button)
else:
action_bar.append(self._menu_button)
self.set_child(overlay)
self._client.emitter.emit("disconnected") # bring player in defined state
# indicate connection process in window title
if self._use_csd:
self._header_bar.get_title_widget().set_subtitle(_("connecting…"))
else:
self.set_title("mpdevil "+_("connecting…"))
self.show()
# ensure window is visible
main=GLib.main_context_default()
while main.pending():
main.iteration()
# restore paned settings when window is visible (fixes a bug when window is maximized)
# auto save and restore paned positions
self._settings.bind("paned0", self._cover_playlist_window, "position", Gio.SettingsBindFlags.DEFAULT)
self._settings.bind("paned1", self._browser, "position", Gio.SettingsBindFlags.DEFAULT)
self._settings.bind("paned2", self._paned, "position", Gio.SettingsBindFlags.DEFAULT)
# 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_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)
def _on_profile_settings(self, action, param):
settings=SettingsDialog(self, self._client, self._settings, "profiles")
def _on_stats(self, action, param):
stats=ServerStats(self, self._client, self._settings)
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 == {}:
self.set_title("mpdevil")
if self._use_csd:
self._header_bar.get_title_widget().set_title("mpdevil")
self._header_bar.get_title_widget().set_subtitle("")
else:
song=ClientHelper.song_to_str_dict(ClientHelper.pepare_song_for_display(song))
if song["date"] == "":
date=""
else:
date=" ("+song["date"]+")"
self.set_title(song["title"]+" - "+song["artist"]+" - "+song["album"]+date)
if self._use_csd:
self._header_bar.get_title_widget().set_title(song["title"]+" - "+song["artist"])
self._header_bar.get_title_widget().set_subtitle(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)
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.get_title_widget().set_title("mpdevil")
self._header_bar.get_title_widget().set_subtitle("")
for action in ("stats","toggle-lyrics","back-to-current-album","toggle-search"):
self.lookup_action(action).set_enabled(False)
def _on_mini_player(self, obj, typestring):
if obj.get_property("mini-player"):
if self.is_maximized():
self.unmaximize()
# self.resize(1,1) TODO
else:
# self.resize(self._settings.get_int("width"), self._settings.get_int("height")) TODO
pass
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("destroy", self._quit)
self._window.insert_action_group("mpd", MPDActionGroup(self._client))
# 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)
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._quit)
self.add_action(action)
def _on_about(self, action, param):
dialog=AboutDialog(self._window)
def _quit(self, *args):
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)