mpdevil/bin/mpdevil

3791 lines
135 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 2020 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; version 3 of the License.
#
# 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, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
# USA
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Notify", "0.7")
from gi.repository import Gtk, Gio, Gdk, GdkPixbuf, Pango, GObject, GLib, Notify
from mpd import MPDClient, base as MPDBase
import requests
from bs4 import BeautifulSoup
import threading
import locale
import gettext
gettext.textdomain("mpdevil")
_=gettext.gettext
import datetime
import os
import sys
import re
# MPRIS modules
import dbus
import dbus.service
from dbus.mainloop.glib import DBusGMainLoop
DBusGMainLoop(set_as_default=True)
VERSION="0.9.3-dev" # sync with setup.py
COVER_REGEX=r"^\.?(album|cover|folder|front).*\.(gif|jpeg|jpg|png)$"
#########
# MPRIS #
#########
class MPRISInterface(dbus.service.Object): # TODO emit Seeked if needed
"""
based on 'mpDris2' (master 19.03.2020) by Jean-Philippe Braun <eon@patapon.info>, Mantas Mikulėnas <grawity@gmail.com>
"""
def __init__(self, window, client, settings):
super().__init__(dbus.SessionBus(), "/org/mpris/MediaPlayer2")
self._name="org.mpris.MediaPlayer2.mpdevil"
# adding vars
self._window=window
self._client=client
self._settings=settings
self._metadata={}
self._bus=dbus.SessionBus()
# start
self._bus_name=dbus.service.BusName(self._name, bus=self._bus, allow_replacement=True, replace_existing=True) # TODO
# 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)
# Interfaces
__prop_interface=dbus.PROPERTIES_IFACE
__root_interface="org.mpris.MediaPlayer2"
__root_props={
"CanQuit": (False, None),
"CanRaise": (True, None),
"DesktopEntry": ("mpdevil", None),
"HasTrackList": (False, None),
"Identity": ("mpdevil", None),
"SupportedUriSchemes": (dbus.Array(signature="s"), None),
"SupportedMimeTypes": (dbus.Array(signature="s"), None)
}
def __get_playback_status(self):
if self._client.connected():
status=self._client.wrapped_call("status")
return {"play": "Playing", "pause": "Paused", "stop": "Stopped"}[status["state"]]
return "Stopped"
def __set_loop_status(self, value):
if self._client.connected():
if value == "Playlist":
self._client.wrapped_call("repeat", 1)
self._client.wrapped_call("single", 0)
elif value == "Track":
self._client.wrapped_call("repeat", 1)
self._client.wrapped_call("single", 1)
elif value == "None":
self._client.wrapped_call("repeat", 0)
self._client.wrapped_call("single", 0)
else:
raise dbus.exceptions.DBusException("Loop mode '{}' not supported".format(value))
return
def __get_loop_status(self):
if self._client.connected():
status=self._client.wrapped_call("status")
if int(status["repeat"]) == 1:
if int(status.get("single", 0)) == 1:
return "Track"
else:
return "Playlist"
else:
return "None"
return "None"
def __set_shuffle(self, value):
if self._client.connected():
self._client.wrapped_call("random", value)
return
def __get_shuffle(self):
if self._client.connected():
if int(self._client.wrapped_call("status")["random"]) == 1:
return True
else:
return False
return False
def __get_metadata(self):
return dbus.Dictionary(self._metadata, signature="sv")
def __get_volume(self):
if self._client.connected():
return float(self._client.wrapped_call("status").get("volume", 0))/100
return 0.0
def __set_volume(self, value):
if self._client.connected():
if value >= 0 and value <= 1:
self._client.wrapped_call("setvol", int(value * 100))
return
def __get_position(self):
if self._client.connected():
status=self._client.wrapped_call("status")
return dbus.Int64(float(status.get("elapsed", 0))*1000000)
return dbus.Int64(0)
def __get_can_next_prev(self):
if self._client.connected():
status=self._client.wrapped_call("status")
if status["state"] == "stop":
return False
else:
return True
return False
def __get_can_play_pause_seek(self):
return self._client.connected()
__player_interface="org.mpris.MediaPlayer2.Player"
__player_props={
"PlaybackStatus": (__get_playback_status, None),
"LoopStatus": (__get_loop_status, __set_loop_status),
"Rate": (1.0, None),
"Shuffle": (__get_shuffle, __set_shuffle),
"Metadata": (__get_metadata, None),
"Volume": (__get_volume, __set_volume),
"Position": (__get_position, None),
"MinimumRate": (1.0, None),
"MaximumRate": (1.0, None),
"CanGoNext": (__get_can_next_prev, None),
"CanGoPrevious": (__get_can_next_prev, None),
"CanPlay": (__get_can_play_pause_seek, None),
"CanPause": (__get_can_play_pause_seek, None),
"CanSeek": (__get_can_play_pause_seek, None),
"CanControl": (True, None),
}
__prop_mapping={
__player_interface: __player_props,
__root_interface: __root_props,
}
# Prop methods
@dbus.service.signal(__prop_interface, signature="sa{sv}as")
def PropertiesChanged(self, interface, changed_properties, invalidated_properties):
pass
@dbus.service.method(__prop_interface, in_signature="ss", out_signature="v")
def Get(self, interface, prop):
getter, setter=self.__prop_mapping[interface][prop]
if callable(getter):
return getter(self)
return getter
@dbus.service.method(__prop_interface, in_signature="ssv", out_signature="")
def Set(self, interface, prop, value):
getter, setter=self.__prop_mapping[interface][prop]
if setter is not None:
setter(self, value)
@dbus.service.method(__prop_interface, in_signature="s", out_signature="a{sv}")
def GetAll(self, interface):
read_props={}
props=self.__prop_mapping[interface]
for key, (getter, setter) in props.items():
if callable(getter):
getter=getter(self)
read_props[key]=getter
return read_props
# Root methods
@dbus.service.method(__root_interface, in_signature="", out_signature="")
def Raise(self):
self._window.present()
return
@dbus.service.method(__root_interface, in_signature="", out_signature="")
def Quit(self):
return
# Player methods
@dbus.service.signal(__player_interface, signature="x")
def Seeked(self, position):
return float(position)
@dbus.service.method(__player_interface, in_signature="", out_signature="")
def Next(self):
self._client.wrapped_call("next")
return
@dbus.service.method(__player_interface, in_signature="", out_signature="")
def Previous(self):
self._client.wrapped_call("previous")
return
@dbus.service.method(__player_interface, in_signature="", out_signature="")
def Pause(self):
self._client.wrapped_call("pause", 1)
return
@dbus.service.method(__player_interface, in_signature="", out_signature="")
def PlayPause(self):
status=self._client.wrapped_call("status")
if status["state"] == "play":
self._client.wrapped_call("pause", 1)
else:
self._client.wrapped_call("play")
return
@dbus.service.method(__player_interface, in_signature="", out_signature="")
def Stop(self):
self._client.wrapped_call("stop")
return
@dbus.service.method(__player_interface, in_signature="", out_signature="")
def Play(self):
self._client.wrapped_call("play")
return
@dbus.service.method(__player_interface, in_signature="x", out_signature="")
def Seek(self, offset):
if offset > 0:
offset="+"+str(offset/1000000)
else:
offset=str(offset/1000000)
self._client.wrapped_call("seekcur", offset)
return
@dbus.service.method(__player_interface, in_signature="ox", out_signature="")
def SetPosition(self, trackid, position):
song=self._client.wrapped_call("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.wrapped_call("seekcur", str(mpd_pos))
return
@dbus.service.method(__player_interface, in_signature="s", out_signature="")
def OpenUri(self, uri):
return
# MPRIS implemented metadata tags (all: http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata)
implemented_tags={
"mpris:trackid": dbus.ObjectPath,
"mpris:length": dbus.Int64,
"mpris:artUrl": str,
"xesam:album": str,
"xesam:albumArtist": list,
"xesam:artist": list,
"xesam:composer": list,
"xesam:contentCreated": str,
"xesam:discNumber": int,
"xesam:genre": list,
"xesam:title": str,
"xesam:trackNumber": int,
"xesam:url": str,
}
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.wrapped_call("currentsong")
self._metadata={}
for tag, xesam_tag in (("album","album"),("title","title"),("track","trackNumber"),("disc","discNumber"),("date","contentCreated")):
if tag in mpd_meta:
self._metadata["xesam:{}".format(xesam_tag)]=mpd_meta[tag]
for tag, xesam_tag in (("albumartist","albumArtist"),("artist","artist"),("composer","composer"),("genre","genre")):
if tag in mpd_meta:
if type(mpd_meta[tag]) == list:
self._metadata["xesam:{}".format(xesam_tag)]=mpd_meta[tag]
else:
self._metadata["xesam:{}".format(xesam_tag)]=[mpd_meta[tag]]
if "id" in mpd_meta:
self._metadata["mpris:trackid"]="/org/mpris/MediaPlayer2/Track/{}".format(mpd_meta["id"])
if "time" in mpd_meta:
self._metadata["mpris:length"]=float(mpd_meta["duration"]) * 1000000
if "file" in mpd_meta:
song_file=mpd_meta["file"]
lib_path=self._settings.get_value("paths")[self._settings.get_int("active-profile")]
self._metadata["xesam:url"]="file://{}".format(os.path.join(lib_path, song_file))
cover=Cover(self._settings, mpd_meta)
if cover.path is not None:
self._metadata["mpris:artUrl"]="file://{}".format(cover.path)
# Cast self._metadata to the correct type, or discard it
for key, value in self._metadata.items():
try:
self._metadata[key]=self.implemented_tags[key](value)
except ValueError:
del self._metadata[key]
def _update_property(self, interface, prop):
getter, setter=self.__prop_mapping[interface][prop]
if callable(getter):
value=getter(self)
else:
value=getter
self.PropertiesChanged(interface, {prop: value}, [])
return value
def _on_state_changed(self, *args):
self._update_property("org.mpris.MediaPlayer2.Player", "PlaybackStatus")
self._update_property("org.mpris.MediaPlayer2.Player", "CanGoNext")
self._update_property("org.mpris.MediaPlayer2.Player", "CanGoPrevious")
def _on_song_changed(self, *args):
self._update_metadata()
self._update_property("org.mpris.MediaPlayer2.Player", "Metadata")
def _on_volume_changed(self, *args):
self._update_property("org.mpris.MediaPlayer2.Player", "Volume")
def _on_loop_changed(self, *args):
self._update_property("org.mpris.MediaPlayer2.Player", "LoopStatus")
def _on_random_changed(self, *args):
self._update_property("org.mpris.MediaPlayer2.Player", "Shuffle")
def _on_reconnected(self, *args):
properties=("CanPlay","CanPause","CanSeek")
for p in properties:
self._update_property("org.mpris.MediaPlayer2.Player", 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("org.mpris.MediaPlayer2.Player", p)
######################
# MPD client wrapper #
######################
class ClientHelper():
def seconds_to_display_time(seconds):
raw_time_string=str(datetime.timedelta(seconds=seconds))
stript_time_string=raw_time_string.lstrip("0").lstrip(":")
return stript_time_string.replace(":", "") # use 'ratio' as delimiter
def song_to_str_dict(song): # converts tags with multiple values to comma separated strings
return_song=song
for tag, value in return_song.items():
if type(value) == list:
return_song[tag]=(", ".join(value))
return return_song
def song_to_first_str_dict(song): # extracts the first value of multiple value tags
return_song=song
for tag, value in return_song.items():
if type(value) == list:
return_song[tag]=value[0]
return return_song
def extend_song_for_display(song):
base_song={
"title": _("Unknown Title"),
"track": "",
"disc": "",
"artist": "",
"album": "",
"duration": "0.0",
"date": "",
"genre": ""
}
base_song.update(song)
base_song["human_duration"]=ClientHelper.seconds_to_display_time(int(float(base_song["duration"])))
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))
class MpdEventEmitter(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, (bool,)),
"consume": (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
"audio": (GObject.SignalFlags.RUN_FIRST, None, (str,str,str,)),
"bitrate": (GObject.SignalFlags.RUN_FIRST, None, (float,))
}
def __init__(self):
super().__init__()
class Client(MPDClient):
def __init__(self, settings):
super().__init__()
# adding vars
self._settings=settings
self.emitter=MpdEventEmitter()
self._last_status={}
self._refresh_interval=self._settings.get_int("refresh-interval")
self._main_timeout_id=None
#connect
self._settings.connect("changed::active-profile", self._on_active_profile_changed)
def wrapped_call(self, name, *args):
try:
func=getattr(self, name)
except:
raise ValueError
return func(*args)
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
self._main_timeout_id=GLib.timeout_add(self._refresh_interval, self._main_loop)
self.emitter.emit("reconnected")
return True
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.wrapped_call("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, mode="default"):
songs=self.find("album", album, "date", year, self._settings.get_artist_type(), artist)
self.files_to_playlist([song["file"] for song in songs], mode)
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_metadata(self, uri):
meta_base=self.lsinfo(uri)[0]
meta_extra=self.readcomments(uri) # contains comment tag
meta_base.update(meta_extra)
return meta_base
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
new_state=(int(self.status()[option])+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":
self.emitter.emit("elapsed_changed", float(val), float(status["duration"]))
elif key == "bitrate":
self.emitter.emit("bitrate", float(val))
elif key == "songid":
self.emitter.emit("current_song_changed")
elif key == "state":
self.emitter.emit("state", 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", "single", "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"
# 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_gtk_icon_size(self, key):
icon_size=self.get_int(key)
sizes=[(48, Gtk.IconSize.DIALOG), (32, Gtk.IconSize.DND), (24, Gtk.IconSize.LARGE_TOOLBAR), (16, Gtk.IconSize.BUTTON)]
for pixel_size, gtk_size in sizes:
if icon_size >= pixel_size:
return gtk_size
return Gtk.IconSize.INVALID
def get_artist_type(self):
if self.get_boolean("use-album-artist"):
return ("albumartist")
else:
return ("artist")
###################
# settings dialog #
###################
class GeneralSettings(Gtk.Box):
def __init__(self, settings):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6, border_width=18)
# adding vars
self._settings=settings
self._settings_handlers=[]
# int_settings
int_settings={}
int_settings_data=[
(_("Main cover size:"), (100, 1200, 10), "track-cover"),
(_("Album view cover size:"), (50, 600, 10), "album-cover"),
(_("Action bar icon size:"), (16, 64, 2), "icon-size"),
(_("Secondary icon size:"), (16, 64, 2), "icon-size-sec")
]
for label, (vmin, vmax, step), key in int_settings_data:
int_settings[key]=(Gtk.Label(label=label, xalign=0), Gtk.SpinButton.new_with_range(vmin, vmax, step))
int_settings[key][1].set_value(self._settings.get_int(key))
int_settings[key][1].connect("value-changed", self._on_int_changed, key)
self._settings_handlers.append(
self._settings.connect("changed::{}".format(key), self._on_int_settings_changed, int_settings[key][1])
)
# combo_settings
combo_settings={}
combo_settings_data=[
(_("Sort albums by:"), _("name"), _("year"), "sort-albums-by-year"),
(_("Position of playlist:"), _("bottom"), _("right"), "playlist-right")
]
for label, vfalse, vtrue, key in combo_settings_data:
combo_settings[key]=(Gtk.Label(label=label, xalign=0), Gtk.ComboBoxText(entry_text_column=0))
combo_settings[key][1].append_text(vfalse)
combo_settings[key][1].append_text(vtrue)
if self._settings.get_boolean(key):
combo_settings[key][1].set_active(1)
else:
combo_settings[key][1].set_active(0)
combo_settings[key][1].connect("changed", self._on_combo_changed, key)
self._settings_handlers.append(
self._settings.connect("changed::{}".format(key), self._on_combo_settings_changed, combo_settings[key][1])
)
# check buttons
check_buttons={}
check_buttons_data=[
(_("Use Client-side decoration"), "use-csd"),
(_("Show stop button"), "show-stop"),
(_("Show lyrics button"), "show-lyrics-button"),
(_("Show initials in artist view"), "show-initials"),
(_("Show tooltips in album view"), "show-album-view-tooltips"),
(_("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")
]
for label, key in check_buttons_data:
check_buttons[key]=Gtk.CheckButton(label=label)
check_buttons[key].set_active(self._settings.get_boolean(key))
check_buttons[key].set_margin_start(12)
check_buttons[key].connect("toggled", self._on_toggled, key)
self._settings_handlers.append(
self._settings.connect("changed::{}".format(key), self._on_check_settings_changed, check_buttons[key])
)
# headings
view_heading=Gtk.Label(label=_("<b>View</b>"), use_markup=True, xalign=0)
behavior_heading=Gtk.Label(label=_("<b>Behavior</b>"), use_markup=True, xalign=0)
# view grid
view_grid=Gtk.Grid(row_spacing=6, column_spacing=12)
view_grid.set_margin_start(12)
view_grid.add(int_settings["track-cover"][0])
view_grid.attach_next_to(int_settings["album-cover"][0], int_settings["track-cover"][0], Gtk.PositionType.BOTTOM, 1, 1)
view_grid.attach_next_to(int_settings["icon-size"][0], int_settings["album-cover"][0], Gtk.PositionType.BOTTOM, 1, 1)
view_grid.attach_next_to(int_settings["icon-size-sec"][0], int_settings["icon-size"][0], Gtk.PositionType.BOTTOM, 1, 1)
view_grid.attach_next_to(combo_settings["playlist-right"][0], int_settings["icon-size-sec"][0], Gtk.PositionType.BOTTOM, 1, 1)
view_grid.attach_next_to(int_settings["track-cover"][1], int_settings["track-cover"][0], Gtk.PositionType.RIGHT, 1, 1)
view_grid.attach_next_to(int_settings["album-cover"][1], int_settings["album-cover"][0], Gtk.PositionType.RIGHT, 1, 1)
view_grid.attach_next_to(int_settings["icon-size"][1], int_settings["icon-size"][0], Gtk.PositionType.RIGHT, 1, 1)
view_grid.attach_next_to(int_settings["icon-size-sec"][1], int_settings["icon-size-sec"][0], Gtk.PositionType.RIGHT, 1, 1)
view_grid.attach_next_to(combo_settings["playlist-right"][1], combo_settings["playlist-right"][0], Gtk.PositionType.RIGHT, 1, 1)
# behavior grid
behavior_grid=Gtk.Grid(row_spacing=6, column_spacing=12)
behavior_grid.set_margin_start(12)
behavior_grid.add(combo_settings["sort-albums-by-year"][0])
behavior_grid.attach_next_to(
combo_settings["sort-albums-by-year"][1],
combo_settings["sort-albums-by-year"][0],
Gtk.PositionType.RIGHT, 1, 1
)
# connect
self.connect("destroy", self._remove_handlers)
# packing
box=Gtk.Box(spacing=12)
box.pack_start(check_buttons["use-csd"], False, False, 0)
box.pack_start(Gtk.Label(label=_("(restart required)"), sensitive=False), False, False, 0)
self.pack_start(view_heading, False, False, 0)
self.pack_start(box, False, False, 0)
self.pack_start(check_buttons["show-stop"], False, False, 0)
self.pack_start(check_buttons["show-lyrics-button"], False, False, 0)
self.pack_start(check_buttons["show-initials"], False, False, 0)
self.pack_start(check_buttons["show-album-view-tooltips"], False, False, 0)
self.pack_start(view_grid, False, False, 0)
self.pack_start(behavior_heading, False, False, 0)
self.pack_start(check_buttons["use-album-artist"], False, False, 0)
self.pack_start(check_buttons["send-notify"], False, False, 0)
self.pack_start(check_buttons["stop-on-quit"], False, False, 0)
self.pack_start(check_buttons["force-mode"], False, False, 0)
self.pack_start(behavior_grid, False, False, 0)
def _remove_handlers(self, *args):
for handler in self._settings_handlers:
self._settings.disconnect(handler)
def _on_int_settings_changed(self, settings, key, entry):
entry.set_value(settings.get_int(key))
def _on_combo_settings_changed(self, settings, key, combo):
if settings.get_boolean(key):
combo.set_active(1)
else:
combo.set_active(0)
def _on_check_settings_changed(self, settings, key, button):
button.set_active(settings.get_boolean(key))
def _on_int_changed(self, widget, key):
self._settings.set_int(key, int(widget.get_value()))
def _on_combo_changed(self, box, key):
active=box.get_active()
if active == 0:
self._settings.set_boolean(key, False)
else:
self._settings.set_boolean(key, True)
def _on_toggled(self, widget, key):
self._settings.set_boolean(key, widget.get_active())
class ProfileSettings(Gtk.Grid):
def __init__(self, parent, client, settings):
super().__init__(row_spacing=6, column_spacing=12, border_width=18)
# adding vars
self._client=client
self._settings=settings
self._gui_modification=False # indicates whether the settings were changed from the settings dialog
# widgets
self._profiles_combo=Gtk.ComboBoxText(entry_text_column=0, hexpand=True)
add_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("list-add", Gtk.IconSize.BUTTON))
delete_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("list-remove", Gtk.IconSize.BUTTON))
add_delete_buttons=Gtk.ButtonBox(layout_style=Gtk.ButtonBoxStyle.EXPAND)
add_delete_buttons.pack_start(add_button, True, True, 0)
add_delete_buttons.pack_start(delete_button, True, True, 0)
connect_button=Gtk.Button(label=_("Connect"), image=Gtk.Image.new_from_icon_name("system-run", Gtk.IconSize.BUTTON))
self._profile_entry=Gtk.Entry(hexpand=True)
self._host_entry=Gtk.Entry(hexpand=True)
self._port_entry=Gtk.SpinButton.new_with_range(0, 65535, 1)
address_entry=Gtk.Box(spacing=6)
address_entry.pack_start(self._host_entry, True, True, 0)
address_entry.pack_start(self._port_entry, False, False, 0)
self._password_entry=Gtk.Entry(hexpand=True, visibility=False)
self._path_entry=Gtk.Entry(hexpand=True)
self._path_select_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("folder-open", Gtk.IconSize.BUTTON))
path_box=Gtk.Box(spacing=6)
path_box.pack_start(self._path_entry, True, True, 0)
path_box.pack_start(self._path_select_button, False, False, 0)
self._regex_entry=Gtk.Entry(hexpand=True, placeholder_text=COVER_REGEX)
self._regex_entry.set_tooltip_text(
_("The first image in the same directory as the song file "\
"matching this regex will be displayed. %AlbumArtist% and "\
"%Album% will be replaced by the corresponding tags of the song.")
)
profiles_label=Gtk.Label(label=_("Profile:"), xalign=1)
profile_label=Gtk.Label(label=_("Name:"), xalign=1)
host_label=Gtk.Label(label=_("Host:"), xalign=1)
password_label=Gtk.Label(label=_("Password:"), xalign=1)
path_label=Gtk.Label(label=_("Music lib:"), xalign=1)
regex_label=Gtk.Label(label=_("Cover regex:"), xalign=1)
# connect
add_button.connect("clicked", self._on_add_button_clicked)
delete_button.connect("clicked", self._on_delete_button_clicked)
connect_button.connect("clicked", self._on_connect_button_clicked)
self._path_select_button.connect("clicked", self._on_path_select_button_clicked, parent)
self._profiles_combo.connect("changed", self._on_profiles_changed)
self.entry_changed_handlers=[]
self.entry_changed_handlers.append((self._profile_entry, self._profile_entry.connect("changed", self._on_profile_entry_changed)))
self.entry_changed_handlers.append((self._host_entry, self._host_entry.connect("changed", self._on_host_entry_changed)))
self.entry_changed_handlers.append((self._port_entry, self._port_entry.connect("value-changed", self._on_port_entry_changed)))
self.entry_changed_handlers.append((self._password_entry, self._password_entry.connect("changed", self._on_password_entry_changed)))
self.entry_changed_handlers.append((self._path_entry, self._path_entry.connect("changed", self._on_path_entry_changed)))
self.entry_changed_handlers.append((self._regex_entry, self._regex_entry.connect("changed", self._on_regex_entry_changed)))
self._settings_handlers=[]
self._settings_handlers.append(self._settings.connect("changed::profiles", self._on_settings_changed))
self._settings_handlers.append(self._settings.connect("changed::hosts", self._on_settings_changed))
self._settings_handlers.append(self._settings.connect("changed::ports", self._on_settings_changed))
self._settings_handlers.append(self._settings.connect("changed::passwords", self._on_settings_changed))
self._settings_handlers.append(self._settings.connect("changed::paths", self._on_settings_changed))
self._settings_handlers.append(self._settings.connect("changed::regex", self._on_settings_changed))
self.connect("destroy", self._remove_handlers)
self._profiles_combo_reload()
self._profiles_combo.set_active(0)
# packing
self.add(profiles_label)
self.attach_next_to(profile_label, profiles_label, Gtk.PositionType.BOTTOM, 1, 1)
self.attach_next_to(host_label, profile_label, Gtk.PositionType.BOTTOM, 1, 1)
self.attach_next_to(password_label, host_label, Gtk.PositionType.BOTTOM, 1, 1)
self.attach_next_to(path_label, password_label, Gtk.PositionType.BOTTOM, 1, 1)
self.attach_next_to(regex_label, path_label, Gtk.PositionType.BOTTOM, 1, 1)
self.attach_next_to(self._profiles_combo, profiles_label, Gtk.PositionType.RIGHT, 2, 1)
self.attach_next_to(add_delete_buttons, self._profiles_combo, Gtk.PositionType.RIGHT, 1, 1)
self.attach_next_to(self._profile_entry, profile_label, Gtk.PositionType.RIGHT, 2, 1)
self.attach_next_to(address_entry, host_label, Gtk.PositionType.RIGHT, 2, 1)
self.attach_next_to(self._password_entry, password_label, Gtk.PositionType.RIGHT, 2, 1)
self.attach_next_to(path_box, path_label, Gtk.PositionType.RIGHT, 2, 1)
self.attach_next_to(self._regex_entry, regex_label, Gtk.PositionType.RIGHT, 2, 1)
connect_button.set_margin_top(12)
self.attach_next_to(connect_button, self._regex_entry, Gtk.PositionType.BOTTOM, 2, 1)
def _block_entry_changed_handlers(self, *args):
for obj, handler in self.entry_changed_handlers:
obj.handler_block(handler)
def _unblock_entry_changed_handlers(self, *args):
for obj, handler in self.entry_changed_handlers:
obj.handler_unblock(handler)
def _profiles_combo_reload(self, *args):
self._block_entry_changed_handlers()
self._profiles_combo.remove_all()
for profile in self._settings.get_value("profiles"):
self._profiles_combo.append_text(profile)
self._unblock_entry_changed_handlers()
def _remove_handlers(self, *args):
for handler in self._settings_handlers:
self._settings.disconnect(handler)
def _on_settings_changed(self, *args):
if self._gui_modification:
self._gui_modification=False
else:
self._profiles_combo_reload()
self._profiles_combo.set_active(0)
def _on_add_button_clicked(self, *args):
model=self._profiles_combo.get_model()
self._settings.array_append("as", "profiles", "new profile ({})".format(len(model)))
self._settings.array_append("as", "hosts", "localhost")
self._settings.array_append("ai", "ports", 6600)
self._settings.array_append("as", "passwords", "")
self._settings.array_append("as", "paths", "")
self._settings.array_append("as", "regex", "")
self._profiles_combo_reload()
new_pos=len(model)-1
self._profiles_combo.set_active(new_pos)
def _on_delete_button_clicked(self, *args):
pos=self._profiles_combo.get_active()
self._settings.array_delete("as", "profiles", pos)
self._settings.array_delete("as", "hosts", pos)
self._settings.array_delete("ai", "ports", pos)
self._settings.array_delete("as", "passwords", pos)
self._settings.array_delete("as", "paths", pos)
self._settings.array_delete("as", "regex", pos)
if len(self._settings.get_value("profiles")) == 0:
self._on_add_button_clicked()
else:
self._profiles_combo_reload()
new_pos=max(pos-1,0)
self._profiles_combo.set_active(new_pos)
def _on_connect_button_clicked(self, *args):
self._settings.set_int("active-profile", self._profiles_combo.get_active())
self._client.reconnect()
def _on_profile_entry_changed(self, *args):
self._gui_modification=True
pos=self._profiles_combo.get_active()
self._settings.array_modify("as", "profiles", pos, self._profile_entry.get_text())
self._profiles_combo_reload()
self._profiles_combo.set_active(pos)
def _on_host_entry_changed(self, *args):
self._gui_modification=True
self._settings.array_modify("as", "hosts", self._profiles_combo.get_active(), self._host_entry.get_text())
def _on_port_entry_changed(self, *args):
self._gui_modification=True
self._settings.array_modify("ai", "ports", self._profiles_combo.get_active(), int(self._port_entry.get_value()))
def _on_password_entry_changed(self, *args):
self._gui_modification=True
self._settings.array_modify("as", "passwords", self._profiles_combo.get_active(), self._password_entry.get_text())
def _on_path_entry_changed(self, *args):
self._gui_modification=True
self._settings.array_modify("as", "paths", self._profiles_combo.get_active(), self._path_entry.get_text())
def _on_regex_entry_changed(self, *args):
self._gui_modification=True
self._settings.array_modify("as", "regex", self._profiles_combo.get_active(), self._regex_entry.get_text())
def _on_path_select_button_clicked(self, widget, parent):
dialog=Gtk.FileChooserDialog(title=_("Choose directory"), transient_for=parent, action=Gtk.FileChooserAction.SELECT_FOLDER)
dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
dialog.add_buttons(Gtk.STOCK_OK, Gtk.ResponseType.OK)
dialog.set_default_size(800, 400)
dialog.set_current_folder(self._settings.get_value("paths")[self._profiles_combo.get_active()])
response=dialog.run()
if response == Gtk.ResponseType.OK:
self._gui_modification=True
self._settings.array_modify("as", "paths", self._profiles_combo.get_active(), dialog.get_filename())
self._path_entry.set_text(dialog.get_filename())
dialog.destroy()
def _on_profiles_changed(self, *args):
active=self._profiles_combo.get_active()
self._block_entry_changed_handlers()
self._profile_entry.set_text(self._settings.get_value("profiles")[active])
self._host_entry.set_text(self._settings.get_value("hosts")[active])
self._port_entry.set_value(self._settings.get_value("ports")[active])
self._password_entry.set_text(self._settings.get_value("passwords")[active])
self._path_entry.set_text(self._settings.get_value("paths")[active])
self._regex_entry.set_text(self._settings.get_value("regex")[active])
self._unblock_entry_changed_handlers()
class PlaylistSettings(Gtk.Box):
def __init__(self, settings):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6, border_width=18)
# adding vars
self._settings=settings
# label
label=Gtk.Label(label=_("Choose the order of information to appear in the playlist:"), wrap=True, xalign=0)
# Store
# (toggle, header, actual_index)
self._store=Gtk.ListStore(bool, str, int)
# TreeView
treeview=Gtk.TreeView(model=self._store, reorderable=True, headers_visible=False)
treeview.set_search_column(-1)
# selection
self._selection=treeview.get_selection()
# Column
renderer_text=Gtk.CellRendererText()
renderer_toggle=Gtk.CellRendererToggle()
column_toggle=Gtk.TreeViewColumn("", renderer_toggle, active=0)
treeview.append_column(column_toggle)
column_text=Gtk.TreeViewColumn("", renderer_text, text=1)
treeview.append_column(column_text)
# fill store
self._headers=[_("No"), _("Disc"), _("Title"), _("Artist"), _("Album"), _("Length"), _("Year"), _("Genre")]
self._fill()
# scroll
scroll=Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll.add(treeview)
frame=Gtk.Frame()
frame.add(scroll)
# Toolbar
toolbar=Gtk.Toolbar()
style_context=toolbar.get_style_context()
style_context.add_class("inline-toolbar")
self._up_button=Gtk.ToolButton.new(Gtk.Image.new_from_icon_name("go-up-symbolic", Gtk.IconSize.SMALL_TOOLBAR))
self._up_button.set_sensitive(False)
self._down_button=Gtk.ToolButton.new(Gtk.Image.new_from_icon_name("go-down-symbolic", Gtk.IconSize.SMALL_TOOLBAR))
self._down_button.set_sensitive(False)
toolbar.insert(self._up_button, 0)
toolbar.insert(self._down_button, 1)
# column chooser
column_chooser=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
column_chooser.pack_start(frame, True, True, 0)
column_chooser.pack_start(toolbar, False, False, 0)
# connect
self._row_deleted=self._store.connect("row-deleted", self._save_permutation)
renderer_toggle.connect("toggled", self._on_cell_toggled)
self._up_button.connect("clicked", self._on_up_button_clicked)
self._down_button.connect("clicked", self._on_down_button_clicked)
self._selection.connect("changed", self._set_button_sensitivity)
self._settings_handlers=[]
self._settings_handlers.append(self._settings.connect("changed::column-visibilities", self._on_visibilities_changed))
self._settings_handlers.append(self._settings.connect("changed::column-permutation", self._on_permutation_changed))
self.connect("destroy", self._remove_handlers)
# packing
self.pack_start(label, False, False, 0)
self.pack_start(column_chooser, True, True, 0)
def _fill(self, *args):
visibilities=self._settings.get_value("column-visibilities").unpack()
for actual_index in self._settings.get_value("column-permutation"):
self._store.append([visibilities[actual_index], self._headers[actual_index], actual_index])
def _save_permutation(self, *args):
permutation=[]
for row in self._store:
permutation.append(row[2])
self._settings.set_value("column-permutation", GLib.Variant("ai", permutation))
def _set_button_sensitivity(self, *args):
treeiter=self._selection.get_selected()[1]
if treeiter is None:
self._up_button.set_sensitive(False)
self._down_button.set_sensitive(False)
else:
path=self._store.get_path(treeiter)
if self._store.iter_next(treeiter) is None:
self._up_button.set_sensitive(True)
self._down_button.set_sensitive(False)
elif not path.prev():
self._up_button.set_sensitive(False)
self._down_button.set_sensitive(True)
else:
self._up_button.set_sensitive(True)
self._down_button.set_sensitive(True)
def _remove_handlers(self, *args):
for handler in self._settings_handlers:
self._settings.disconnect(handler)
def _on_cell_toggled(self, widget, path):
self._store[path][0]=not self._store[path][0]
self._settings.array_modify("ab", "column-visibilities", self._store[path][2], self._store[path][0])
def _on_up_button_clicked(self, *args):
treeiter=self._selection.get_selected()[1]
path=self._store.get_path(treeiter)
path.prev()
prev=self._store.get_iter(path)
self._store.move_before(treeiter, prev)
self._set_button_sensitivity()
self._save_permutation()
def _on_down_button_clicked(self, *args):
treeiter=self._selection.get_selected()[1]
path=self._store.get_path(treeiter)
next=self._store.iter_next(treeiter)
self._store.move_after(treeiter, next)
self._set_button_sensitivity()
self._save_permutation()
def _on_visibilities_changed(self, *args):
visibilities=self._settings.get_value("column-visibilities").unpack()
for i, actual_index in enumerate(self._settings.get_value("column-permutation")):
self._store[i][0]=visibilities[actual_index]
def _on_permutation_changed(self, *args):
equal=True
perm=self._settings.get_value("column-permutation")
for i, e in enumerate(self._store):
if e[2] != perm[i]:
equal=False
break
if not equal:
self._store.handler_block(self._row_deleted)
self._store.clear()
self._fill()
self._store.handler_unblock(self._row_deleted)
class SettingsDialog(Gtk.Dialog):
def __init__(self, parent, client, settings):
use_csd=settings.get_boolean("use-csd")
if use_csd:
super().__init__(title=_("Settings"), transient_for=parent, use_header_bar=True)
# css
style_context=self.get_style_context()
provider=Gtk.CssProvider()
css=b"""* {-GtkDialog-content-area-border: 0px;}"""
provider.load_from_data(css)
style_context.add_provider(provider, 800)
else:
super().__init__(title=_("Settings"), transient_for=parent)
self.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK)
self.set_default_size(500, 400)
# widgets
general=GeneralSettings(settings)
profiles=ProfileSettings(parent, client, settings)
playlist=PlaylistSettings(settings)
# packing
tabs=Gtk.Notebook()
tabs.append_page(general, Gtk.Label(label=_("General")))
tabs.append_page(profiles, Gtk.Label(label=_("Profiles")))
tabs.append_page(playlist, Gtk.Label(label=_("Playlist")))
vbox=self.get_content_area()
vbox.set_spacing(6)
vbox.pack_start(tabs, True, True, 0)
self.show_all()
#################
# other dialogs #
#################
class ServerStats(Gtk.Dialog):
def __init__(self, parent, client, settings):
use_csd=settings.get_boolean("use-csd")
if use_csd:
super().__init__(title=_("Stats"), transient_for=parent, use_header_bar=True)
# css
style_context=self.get_style_context()
provider=Gtk.CssProvider()
css=b"""* {-GtkDialog-content-area-border: 0px;}"""
provider.load_from_data(css)
style_context.add_provider(provider, 800)
else:
super().__init__(title=_("Stats"), transient_for=parent)
self.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK)
self.set_resizable(False)
# Store
# (tag, value)
store=Gtk.ListStore(str, str)
# TreeView
treeview=Gtk.TreeView(model=store, headers_visible=False)
treeview.set_can_focus(False)
treeview.set_search_column(-1)
# selection
sel=treeview.get_selection()
sel.set_mode(Gtk.SelectionMode.NONE)
# Column
renderer_text=Gtk.CellRendererText()
renderer_text_ralign=Gtk.CellRendererText(xalign=1.0)
column_tag=Gtk.TreeViewColumn("", renderer_text_ralign, text=0)
treeview.append_column(column_tag)
column_value=Gtk.TreeViewColumn("", renderer_text, text=1)
treeview.append_column(column_value)
store.append(["protocol:", str(client.mpd_version)])
stats=client.wrapped_call("stats")
for key in stats:
print_key=key+":"
if key == "uptime" or key == "playtime" or key == "db_playtime":
store.append([print_key, ClientHelper.seconds_to_display_time(int(stats[key]))])
elif key == "db_update":
store.append([print_key, str(datetime.datetime.fromtimestamp(int(stats[key]))).replace(":", "")])
else:
store.append([print_key, stats[key]])
frame=Gtk.Frame()
frame.add(treeview)
self.vbox.pack_start(frame, True, True, 0)
self.vbox.set_spacing(6)
self.show_all()
self.run()
class AboutDialog(Gtk.AboutDialog):
def __init__(self, window):
super().__init__(transient_for=window, modal=True)
self.set_program_name("mpdevil")
self.set_version(VERSION)
self.set_comments(_("A simple music browser for MPD"))
self.set_authors(["Martin Wagner"])
self.set_website("https://github.com/SoongNoonien/mpdevil")
self.set_copyright("\xa9 2020 Martin Wagner")
self.set_logo_icon_name("mpdevil")
#################################
# small general purpose widgets #
#################################
class PixelSizedIcon(Gtk.Image):
def __init__(self, icon_name, pixel_size):
super().__init__()
self.set_from_icon_name(icon_name, Gtk.IconSize.BUTTON)
if pixel_size > 0:
self.set_pixel_size(pixel_size)
class FocusFrame(Gtk.Overlay):
def __init__(self):
super().__init__()
self._frame=Gtk.Frame()
self._frame.set_no_show_all(True)
# css
style_context=self._frame.get_style_context()
provider=Gtk.CssProvider()
css=b"""* {border-color: @theme_selected_bg_color; border-width: 2px;}"""
provider.load_from_data(css)
style_context.add_provider(provider, 800)
self.add_overlay(self._frame)
self.set_overlay_pass_through(self._frame, True)
def set_widget(self, widget):
widget.connect("focus-in-event", self._on_focus_in_event)
widget.connect("focus-out-event", self._on_focus_out_event)
def _on_focus_in_event(self, *args):
self._frame.show()
def _on_focus_out_event(self, *args):
self._frame.hide()
class SongPopover(Gtk.Popover):
def __init__(self, song, relative, x, y):
super().__init__()
rect=Gdk.Rectangle()
rect.x=x
# Gtk places popovers 26px above the given position for no obvious reasons, so I move them 26px
rect.y=y+26
rect.width = 1
rect.height = 1
self.set_pointing_to(rect)
self.set_relative_to(relative)
# Store
# (tag, display-value, tooltip)
store=Gtk.ListStore(str, str, str)
# TreeView
treeview=Gtk.TreeView(model=store, headers_visible=False, search_column=-1, tooltip_column=2)
treeview.set_can_focus(False)
sel=treeview.get_selection()
sel.set_mode(Gtk.SelectionMode.NONE)
frame=Gtk.Frame(border_width=3)
frame.add(treeview)
# Column
renderer_text=Gtk.CellRendererText(width_chars=50, ellipsize=Pango.EllipsizeMode.MIDDLE, ellipsize_set=True)
renderer_text_ralign=Gtk.CellRendererText(xalign=1.0)
column_tag=Gtk.TreeViewColumn(_("MPD-Tag"), renderer_text_ralign, text=0)
column_tag.set_property("resizable", False)
treeview.append_column(column_tag)
column_value=Gtk.TreeViewColumn(_("Value"), renderer_text, text=1)
column_value.set_property("resizable", False)
treeview.append_column(column_value)
# packing
self.add(frame)
song=ClientHelper.song_to_str_dict(song)
for tag, value in song.items():
tooltip=value.replace("&", "&amp;")
if tag == "time":
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")
store.append([tag+":", time.strftime("%a %d %B %Y, %H%M UTC"), tooltip])
else:
store.append([tag+":", value, tooltip])
frame.show_all()
class Cover(object):
def __init__(self, settings, song):
self.path=None
if song != {}:
song_file=song["file"]
active_profile=settings.get_int("active-profile")
lib_path=settings.get_value("paths")[active_profile]
regex_str=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
if song_file is not None:
head, tail=os.path.split(song_file)
song_dir=os.path.join(lib_path, head)
if os.path.exists(song_dir):
for f in os.listdir(song_dir):
if regex.match(f):
self.path=os.path.join(song_dir, f)
break
def get_pixbuf(self, size):
if self.path is None: # fallback needed
self.path=Gtk.IconTheme.get_default().lookup_icon("media-optical", size, Gtk.IconLookupFlags.FORCE_SVG).get_filename()
return GdkPixbuf.Pixbuf.new_from_file_at_size(self.path, size, size)
###########
# browser #
###########
class SongsView(Gtk.TreeView):
def __init__(self, client, store, file_column_id):
super().__init__(model=store)
self.set_search_column(-1)
self.columns_autosize()
# add vars
self._client=client
self._store=store
self._file_column_id=file_column_id
# selection
self._selection=self.get_selection()
self._selection.set_mode(Gtk.SelectionMode.SINGLE)
# connect
self.connect("row-activated", self._on_row_activated)
self.connect("button-press-event", self._on_button_press_event)
self._key_press_event=self.connect("key-press-event", self._on_key_press_event)
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.wrapped_call("files_to_playlist", [self._store[path][self._file_column_id]], "play")
def _on_button_press_event(self, widget, event):
if event.button == 1 and event.type == Gdk.EventType.BUTTON_PRESS:
try:
path=widget.get_path_at_pos(int(event.x), int(event.y))[0]
self._client.wrapped_call("files_to_playlist", [self._store[path][self._file_column_id]])
except:
pass
elif event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS:
try:
path=widget.get_path_at_pos(int(event.x), int(event.y))[0]
self._client.wrapped_call("files_to_playlist", [self._store[path][self._file_column_id]], "append")
except:
pass
elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS:
try:
path=widget.get_path_at_pos(int(event.x), int(event.y))[0]
file_name=self._store[path][self._file_column_id]
pop=SongPopover(self._client.wrapped_call("get_metadata", file_name), widget, int(event.x), int(event.y))
pop.popup()
pop.show_all()
except:
pass
def _on_key_press_event(self, widget, event):
self.handler_block(self._key_press_event)
if event.keyval == Gdk.keyval_from_name("p"):
treeview, treeiter=self._selection.get_selected()
if treeiter is not None:
self._client.wrapped_call("files_to_playlist", [self._store.get_value(treeiter, self._file_column_id)])
elif event.keyval == Gdk.keyval_from_name("a"):
treeview, treeiter=self._selection.get_selected()
if treeiter is not None:
self._client.wrapped_call("files_to_playlist", [self._store.get_value(treeiter, self._file_column_id)], "append")
elif event.keyval == Gdk.keyval_from_name("Menu"):
treeview, treeiter=self._selection.get_selected()
if treeiter is not None:
path=self._store.get_path(treeiter)
cell=self.get_cell_area(path, None)
file_name=self._store[path][self._file_column_id]
pop=SongPopover(self._client.wrapped_call("get_metadata", file_name), widget, int(cell.x), int(cell.y))
pop.popup()
pop.show_all()
self.handler_unblock(self._key_press_event)
class SongsWindow(Gtk.Box):
def __init__(self, client, store, file_column_id):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
# adding vars
self._client=client
# treeview
self._songs_view=SongsView(client, store, file_column_id)
# scroll
scroll=Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll.add(self._songs_view)
# buttons
append_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("list-add", Gtk.IconSize.BUTTON), label=_("Append"))
append_button.set_tooltip_text(_("Add all titles to playlist"))
play_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("media-playback-start", Gtk.IconSize.BUTTON), label=_("Play"))
play_button.set_tooltip_text(_("Directly play all titles"))
enqueue_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("insert-object", Gtk.IconSize.BUTTON), label=_("Enqueue"))
enqueue_button.set_tooltip_text(_("Append all titles after the currently playing track and clear the playlist from all other songs"))
# button box
button_box=Gtk.ButtonBox(layout_style=Gtk.ButtonBoxStyle.EXPAND)
# action bar
self._action_bar=Gtk.ActionBar()
# connect
append_button.connect("clicked", self._on_append_button_clicked)
play_button.connect("clicked", self._on_play_button_clicked)
enqueue_button.connect("clicked", self._on_enqueue_button_clicked)
# packing
frame=FocusFrame()
frame.set_widget(self._songs_view)
frame.add(scroll)
self.pack_start(frame, True, True, 0)
button_box.pack_start(append_button, True, True, 0)
button_box.pack_start(play_button, True, True, 0)
button_box.pack_start(enqueue_button, True, True, 0)
self._action_bar.pack_start(button_box)
self.pack_start(self._action_bar, False, False, 0)
def get_treeview(self):
return self._songs_view
def get_action_bar(self):
return self._action_bar
def _on_append_button_clicked(self, *args):
self._client.wrapped_call("files_to_playlist", self._songs_view.get_files(), "append")
def _on_play_button_clicked(self, *args):
self._client.wrapped_call("files_to_playlist", self._songs_view.get_files(), "play")
def _on_enqueue_button_clicked(self, *args):
self._client.wrapped_call("files_to_playlist", self._songs_view.get_files(), "enqueue")
class SearchWindow(Gtk.Box):
def __init__(self, client):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
# adding vars
self._client=client
# tag switcher
self._tags=Gtk.ComboBoxText()
# search entry
self.search_entry=Gtk.SearchEntry()
# label
self._hits_label=Gtk.Label(xalign=1)
# store
# (track, title, artist, album, duration, file, sort track)
self._store=Gtk.ListStore(str, str, str, str, str, str, int)
self._store.set_default_sort_func(lambda *args: 0)
# songs window
self._songs_window=SongsWindow(self._client, self._store, 5)
# action bar
self._action_bar=self._songs_window.get_action_bar()
self._action_bar.set_sensitive(False)
# songs view
self._songs_view=self._songs_window.get_treeview()
# columns
renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True)
renderer_text_ralign=Gtk.CellRendererText(xalign=1.0)
column_track=Gtk.TreeViewColumn(_("No"), renderer_text_ralign, text=0)
column_track.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
column_track.set_property("resizable", False)
self._songs_view.append_column(column_track)
column_title=Gtk.TreeViewColumn(_("Title"), renderer_text, text=1)
column_title.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
column_title.set_property("resizable", False)
column_title.set_property("expand", True)
self._songs_view.append_column(column_title)
column_artist=Gtk.TreeViewColumn(_("Artist"), renderer_text, text=2)
column_artist.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
column_artist.set_property("resizable", False)
column_artist.set_property("expand", True)
self._songs_view.append_column(column_artist)
column_album=Gtk.TreeViewColumn(_("Album"), renderer_text, text=3)
column_album.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
column_album.set_property("resizable", False)
column_album.set_property("expand", True)
self._songs_view.append_column(column_album)
column_time=Gtk.TreeViewColumn(_("Length"), renderer_text, text=4)
column_time.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
column_time.set_property("resizable", False)
self._songs_view.append_column(column_time)
column_track.set_sort_column_id(6)
column_title.set_sort_column_id(1)
column_artist.set_sort_column_id(2)
column_album.set_sort_column_id(3)
column_time.set_sort_column_id(4)
# connect
self.search_entry.connect("search-changed", self._on_search_changed)
self._tags.connect("changed", self._on_search_changed)
self._client.emitter.connect("reconnected", self._on_reconnected)
self._client.emitter.connect("disconnected", self._on_disconnected)
# packing
hbox=Gtk.Box(spacing=6, border_width=6)
hbox.pack_start(self.search_entry, True, True, 0)
hbox.pack_end(self._tags, False, False, 0)
self._hits_label.set_margin_end(6)
self._action_bar.pack_end(self._hits_label)
self.pack_start(hbox, False, False, 0)
self.pack_start(Gtk.Separator.new(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0)
self.pack_start(self._songs_window, True, True, 0)
def clear(self, *args):
self._songs_view.clear()
self.search_entry.set_text("")
self._tags.remove_all()
def _on_disconnected(self, *args):
self._tags.set_sensitive(False)
self.search_entry.set_sensitive(False)
self.clear()
def _on_reconnected(self, *args):
self._tags.append_text("any")
for tag in self._client.wrapped_call("tagtypes"):
if not tag.startswith("MUSICBRAINZ"):
self._tags.append_text(tag)
self._tags.set_active(0)
self._tags.set_sensitive(True)
self.search_entry.set_sensitive(True)
def _on_search_changed(self, widget):
self._songs_view.clear()
self._hits_label.set_text("")
if len(self.search_entry.get_text()) > 1:
songs=self._client.wrapped_call("search", self._tags.get_active_text(), self.search_entry.get_text())
for s in songs:
song=ClientHelper.extend_song_for_display(ClientHelper.song_to_str_dict(s))
self._store.append([
song["track"], song["title"],
song["artist"], song["album"],
song["human_duration"], song["file"],
int(song.get("track", 0))
])
self._hits_label.set_text(_("{num} hits").format(num=self._songs_view.count()))
if self._songs_view.count() == 0:
self._action_bar.set_sensitive(False)
else:
self._action_bar.set_sensitive(True)
class GenreSelect(Gtk.ComboBoxText):
__gsignals__={"genre_changed": (GObject.SignalFlags.RUN_FIRST, None, ())}
def __init__(self, client):
super().__init__()
# adding vars
self._client=client
# connect
self._changed=self.connect("changed", self._on_changed)
self._client.emitter.connect("disconnected", self._on_disconnected)
self._client.emitter.connect("reconnected", self._on_reconnected)
self._client.emitter.connect("update", self._refresh)
def deactivate(self):
self.set_active(0)
def _clear(self, *args):
self.handler_block(self._changed)
self.remove_all()
self.handler_unblock(self._changed)
def get_selected_genre(self):
if self.get_active() == 0:
return None
else:
return self.get_active_text()
def _refresh(self, *args):
self.handler_block(self._changed)
self.remove_all()
self.append_text(_("all genres"))
for genre in self._client.wrapped_call("comp_list", "genre"):
self.append_text(genre)
self.set_active(0)
self.handler_unblock(self._changed)
def _on_changed(self, *args):
self.emit("genre_changed")
def _on_disconnected(self, *args):
self.set_sensitive(False)
self._clear()
def _on_reconnected(self, *args):
self._refresh()
self.set_sensitive(True)
class ArtistWindow(FocusFrame):
__gsignals__={"artists_changed": (GObject.SignalFlags.RUN_FIRST, None, ())}
def __init__(self, client, settings, genre_select):
super().__init__()
# adding vars
self._client=client
self._settings=settings
self._genre_select=genre_select
# artistStore
# (name, weight, initial-letter, weight-initials)
self._store=Gtk.ListStore(str, Pango.Weight, str, Pango.Weight)
# TreeView
self._treeview=Gtk.TreeView(model=self._store, activate_on_single_click=True, search_column=0)
self._treeview.columns_autosize()
# Selection
self._selection=self._treeview.get_selection()
self._selection.set_mode(Gtk.SelectionMode.SINGLE)
# Columns
renderer_text_malign=Gtk.CellRendererText(xalign=0.5)
self._column_initials=Gtk.TreeViewColumn("", renderer_text_malign, text=2, weight=3)
self._column_initials.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
self._column_initials.set_property("resizable", False)
self._column_initials.set_visible(self._settings.get_boolean("show-initials"))
self._treeview.append_column(self._column_initials)
renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True)
self._column_name=Gtk.TreeViewColumn("", renderer_text, text=0, weight=1)
self._column_name.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
self._column_name.set_property("resizable", False)
self._treeview.append_column(self._column_name)
# scroll
scroll=Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll.add(self._treeview)
# connect
self._treeview.connect("row-activated", self._on_row_activated)
self._settings.connect("changed::use-album-artist", self._refresh)
self._settings.connect("changed::show-initials", self._on_show_initials_changed)
self._client.emitter.connect("disconnected", self._on_disconnected)
self._client.emitter.connect("reconnected", self._on_reconnected)
self._client.emitter.connect("update", self._refresh)
self._genre_select.connect("genre_changed", self._refresh)
self.set_widget(self._treeview)
self.add(scroll)
def _clear(self, *args):
self._store.clear()
def select(self, artist):
row_num=len(self._store)
for i in range(0, row_num):
path=Gtk.TreePath(i)
if self._store[path][0] == artist:
self._treeview.set_cursor(path, None, False)
if self.get_selected_artists()[1] != [artist]:
self._treeview.row_activated(path, self._column_name)
break
def get_selected_artists(self):
artists=[]
genre=self._genre_select.get_selected_genre()
if self._store[Gtk.TreePath(0)][1] == Pango.Weight.BOLD:
for row in self._store:
artists.append(row[0])
return (genre, artists[1:])
else:
for row in self._store:
if row[1] == Pango.Weight.BOLD:
artists.append(row[0])
break
return (genre, artists)
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._selection.set_mode(Gtk.SelectionMode.NONE)
self._clear()
if self._settings.get_artist_type() == "albumartist":
self._column_name.set_title(_("Album Artist"))
else:
self._column_name.set_title(_("Artist"))
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.wrapped_call("comp_list", self._settings.get_artist_type())
else:
artists=self._client.wrapped_call("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])
self._selection.set_mode(Gtk.SelectionMode.SINGLE)
self.emit("artists_changed")
def _on_row_activated(self, widget, path, view_column):
for row in self._store: # reset bold text
row[1]=Pango.Weight.BOOK
self._store[path][1]=Pango.Weight.BOLD
self.emit("artists_changed")
def _on_disconnected(self, *args):
self.set_sensitive(False)
self._clear()
def _on_reconnected(self, *args):
self._refresh()
self.set_sensitive(True)
def _on_show_initials_changed(self, *args):
self._column_initials.set_visible(self._settings.get_boolean("show-initials"))
class AlbumDialog(Gtk.Dialog): # also used by 'MainCover'
def __init__(self, parent, client, settings, album, album_artist, year):
use_csd=settings.get_boolean("use-csd")
if use_csd:
super().__init__(transient_for=parent, use_header_bar=True)
else:
super().__init__(transient_for=parent)
# css
style_context=self.get_style_context()
provider=Gtk.CssProvider()
if use_csd:
css=b"""* {-GtkDialog-content-area-border: 0px;}"""
else:
css=b"""* {-GtkDialog-action-area-border: 0px;}"""
provider.load_from_data(css)
style_context.add_provider(provider, 800)
# adding vars
self._client=client
self._settings=settings
songs=self._client.wrapped_call("find", "album", album, "date", year, self._settings.get_artist_type(), album_artist)
# determine size
size=parent.get_size()
diagonal=(size[0]**2+size[1]**2)**(0.5)
h=diagonal//4
w=h*5//4
self.set_default_size(w, h)
# title
duration=ClientHelper.calc_display_length(songs)
if year == "":
self.set_title("{} - {} ({})".format(album_artist, album, duration))
else:
self.set_title("{} - {} ({}) ({})".format(album_artist, album, year, duration))
# store
# (track, title (artist), duration, file)
store=Gtk.ListStore(str, str, str, str)
for s in songs:
song=ClientHelper.extend_song_for_display(s)
if type(song["title"]) == list: # could be impossible
title=(", ".join(song["title"]))
else:
title=song["title"]
if type(song["artist"]) == list:
try:
song["artist"].remove(album_artist)
except:
pass
artist=(", ".join(song["artist"]))
else:
artist=song["artist"]
if artist == album_artist:
title_artist="<b>{}</b>".format(title)
else:
title_artist="<b>{}</b> - {}".format(title, artist)
title_artist=title_artist.replace("&", "&amp;")
store.append([song["track"], title_artist, song["human_duration"], song["file"]])
# songs window
songs_window=SongsWindow(self._client, store, 3)
# songs view
songs_view=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)
songs_view.append_column(column_track)
column_title=Gtk.TreeViewColumn(_("Title"), renderer_text, markup=1)
column_title.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
column_title.set_property("resizable", False)
column_title.set_property("expand", True)
songs_view.append_column(column_title)
column_time=Gtk.TreeViewColumn(_("Length"), renderer_text, text=2)
column_time.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
column_time.set_property("resizable", False)
songs_view.append_column(column_time)
# close button
close_button=Gtk.ToggleButton(image=Gtk.Image.new_from_icon_name("window-close", Gtk.IconSize.BUTTON), label=_("Close"))
# action bar
action_bar=songs_window.get_action_bar()
action_bar.pack_end(close_button)
# connect
close_button.connect("clicked", self._on_close_button_clicked)
# packing
self.vbox.pack_start(songs_window, True, True, 0) # vbox default widget of dialogs
self.show_all()
def open(self):
response=self.run()
def _on_close_button_clicked(self, *args):
self.destroy()
class AlbumWindow(FocusFrame):
def __init__(self, client, settings, artist_window, window):
super().__init__()
# adding vars
self._settings=settings
self._client=client
self._artist_window=artist_window
self._window=window
self._button_event=(None, None)
self.stop_flag=False
self._done=True
self._pending=[]
# cover, display_label, display_label_artist, tooltip(titles), album, year, artist
self._store=Gtk.ListStore(GdkPixbuf.Pixbuf, str, str, str, str, str, str)
self._sort_settings()
# iconview
self._iconview=Gtk.IconView(model=self._store, item_width=0, pixbuf_column=0, markup_column=1)
self._tooltip_settings()
# scroll
scroll=Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll.add(self._iconview)
# connect
self._iconview.connect("item-activated", self._on_item_activated)
self._iconview.connect("button-release-event", self._on_button_release_event)
self._iconview.connect("button-press-event", self._on_button_press_event)
self._key_press_event=self.connect("key-press-event", self._on_key_press_event)
self._client.emitter.connect("update", self._clear)
self._client.emitter.connect("disconnected", self._on_disconnected)
self._client.emitter.connect("reconnected", self._on_reconnected)
self._settings.connect("changed::show-album-view-tooltips", self._tooltip_settings)
self._settings.connect("changed::sort-albums-by-year", self._sort_settings)
self._settings.connect("changed::album-cover", self._on_cover_size_changed)
self._settings.connect("changed::use-album-artist", self._clear)
self._artist_window.connect("artists_changed", self._refresh)
self.set_widget(self._iconview)
self.add(scroll)
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.wrapped_call("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 _tooltip_settings(self, *args):
if self._settings.get_boolean("show-album-view-tooltips"):
self._iconview.set_tooltip_column(3)
else:
self._iconview.set_tooltip_column(-1)
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 _add_row(self, row): # needed for GLib.idle
self._store.append(row)
return False # stop after one run
def _refresh(self, *args):
def callback():
GLib.idle_add(self._workaround_clear)
try: # self._artist_window could still be empty
genre, artists=self._artist_window.get_selected_artists()
except:
GLib.idle_add(self._done_callback)
# show artist names if all albums are shown
if len(artists) > 1:
self._iconview.set_markup_column(2)
else:
self._iconview.set_markup_column(1)
# prepare albmus list (run all mpd related commands)
albums=[]
artist_type=self._settings.get_artist_type()
for artist in artists:
try: # client cloud meanwhile disconnect
if self.stop_flag:
GLib.idle_add(self._done_callback)
return
else:
if genre is None:
album_candidates=self._client.wrapped_call("comp_list", "album", artist_type, artist)
else:
album_candidates=self._client.wrapped_call(
"comp_list", "album", artist_type, artist, "genre", genre
)
for album in album_candidates:
years=self._client.wrapped_call("comp_list", "date", "album", album, artist_type, artist)
for year in years:
songs=self._client.wrapped_call(
"find", "album", album, "date", year, artist_type, artist
)
albums.append({"artist": artist, "album": album, "year": year, "songs": songs})
while Gtk.events_pending():
Gtk.main_iteration_do(True)
except MPDBase.ConnectionError:
GLib.idle_add(self._done_callback)
return
# display albums
if self._settings.get_boolean("sort-albums-by-year"):
albums=sorted(albums, key=lambda k: k["year"])
else:
albums=sorted(albums, key=lambda k: k["album"])
size=self._settings.get_int("album-cover")
for i, album in enumerate(albums):
if self.stop_flag:
break
else:
cover=Cover(self._settings, album["songs"][0]).get_pixbuf(size)
# tooltip
length_human_readable=ClientHelper.calc_display_length(album["songs"])
discs=int(album["songs"][-1].get("disc", 1))
if discs > 1:
tooltip=_("{titles} titles on {discs} discs ({length})").format(
titles=len(album["songs"]), discs=discs, length=length_human_readable)
else:
tooltip=_("{titles} titles ({length})").format(
titles=len(album["songs"]), 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
GLib.idle_add(self._add_row,
[cover, display_label, display_label_artist, tooltip, album["album"], album["year"], album["artist"]]
)
# execute pending events
if i%16 == 0:
while Gtk.events_pending():
Gtk.main_iteration_do(True)
GLib.idle_add(self._done_callback)
if self._done:
self._done=False
callback()
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]
self._client.wrapped_call("album_to_playlist", album, artist, year, mode)
def _open_album_dialog(self, path):
if self._client.connected():
album=self._store[path][4]
year=self._store[path][5]
artist=self._store[path][6]
album_dialog=AlbumDialog(self._window, self._client, self._settings, album, artist, year)
album_dialog.open()
album_dialog.destroy()
def _done_callback(self, *args):
self.stop_flag=False
self._done=True
pending=self._pending
self._pending=[]
for p in pending:
try:
p()
except:
pass
return False
def _on_button_press_event(self, widget, event):
path=widget.get_path_at_pos(int(event.x), int(event.y))
if event.type == Gdk.EventType.BUTTON_PRESS:
self._button_event=(event.button, path)
def _on_button_release_event(self, widget, event):
path=widget.get_path_at_pos(int(event.x), int(event.y))
if path is not None:
if self._button_event == (event.button, path):
if event.button == 1 and event.type == Gdk.EventType.BUTTON_RELEASE:
self._path_to_playlist(path)
elif event.button == 2 and event.type == Gdk.EventType.BUTTON_RELEASE:
self._path_to_playlist(path, "append")
elif event.button == 3 and event.type == Gdk.EventType.BUTTON_RELEASE:
self._open_album_dialog(path)
def _on_key_press_event(self, widget, event):
self.handler_block(self._key_press_event)
if event.keyval == Gdk.keyval_from_name("p"):
paths=self._iconview.get_selected_items()
if len(paths) != 0:
self._path_to_playlist(paths[0])
elif event.keyval == Gdk.keyval_from_name("a"):
paths=self._iconview.get_selected_items()
if len(paths) != 0:
self._path_to_playlist(paths[0], "append")
elif event.keyval == Gdk.keyval_from_name("Menu"):
paths=self._iconview.get_selected_items()
if len(paths) != 0:
self._open_album_dialog(paths[0])
self.handler_unblock(self._key_press_event)
def _on_item_activated(self, widget, path):
treeiter=self._store.get_iter(path)
selected_album=self._store.get_value(treeiter, 4)
selected_album_year=self._store.get_value(treeiter, 5)
selected_artist=self._store.get_value(treeiter, 6)
self._client.wrapped_call("album_to_playlist", selected_album, selected_artist, selected_album_year, "play")
def _on_disconnected(self, *args):
self._iconview.set_sensitive(False)
self._clear()
def _on_reconnected(self, *args):
self._iconview.set_sensitive(True)
def _on_cover_size_changed(self, *args):
def callback():
self._refresh()
return False
GLib.idle_add(callback)
class Browser(Gtk.Paned):
__gsignals__={"search_focus_changed": (GObject.SignalFlags.RUN_FIRST, None, (bool,))}
def __init__(self, client, settings, window):
super().__init__(orientation=Gtk.Orientation.HORIZONTAL) # paned1
# adding vars
self._client=client
self._settings=settings
self._use_csd=self._settings.get_boolean("use-csd")
if self._use_csd:
self._icon_size=0
else:
self._icon_size=self._settings.get_int("icon-size-sec")
# widgets
self._icons={}
icons_data=("go-previous-symbolic", "system-search-symbolic")
for data in icons_data:
self._icons[data]=PixelSizedIcon(data, self._icon_size)
self.back_to_current_album_button=Gtk.Button(image=self._icons["go-previous-symbolic"], tooltip_text=_("Back to current album"))
self.back_to_current_album_button.set_can_focus(False)
self.search_button=Gtk.ToggleButton(image=self._icons["system-search-symbolic"], tooltip_text=_("Search"))
self.search_button.set_can_focus(False)
self.genre_select=GenreSelect(self._client)
self._artist_window=ArtistWindow(self._client, self._settings, self.genre_select)
self._search_window=SearchWindow(self._client)
self._album_window=AlbumWindow(self._client, self._settings, self._artist_window, window)
# connect
self.back_to_current_album_button.connect("clicked", self._back_to_current_album)
self.search_button.connect("toggled", self._on_search_toggled)
self._search_window.search_entry.connect("focus_in_event", lambda *args: self.emit("search_focus_changed", True))
self._search_window.search_entry.connect("focus_out_event", lambda *args: self.emit("search_focus_changed", False))
self._artist_window.connect("artists_changed", self._on_artists_changed)
self._settings.connect("notify::mini-player", self._on_mini_player)
if not self._use_csd:
self._settings.connect("changed::icon-size-sec", self._on_icon_size_changed)
self._client.emitter.connect("disconnected", self._on_disconnected)
self._client.emitter.connect("reconnected", self._on_reconnected)
# packing
self._stack=Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE)
self._stack.add_named(self._album_window, "albums")
self._stack.add_named(self._search_window, "search")
if self._use_csd:
self.pack1(self._artist_window, False, False)
else:
hbox=Gtk.Box(spacing=6, border_width=6)
hbox.pack_start(self.back_to_current_album_button, False, False, 0)
hbox.pack_start(self.genre_select, True, True, 0)
hbox.pack_start(self.search_button, False, False, 0)
box1=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
box1.pack_start(hbox, False, False, 0)
box1.pack_start(Gtk.Separator.new(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0)
box1.pack_start(self._artist_window, True, True, 0)
self.pack1(box1, False, False)
self.pack2(self._stack, True, False)
self.set_position(self._settings.get_int("paned1"))
def save_settings(self):
self._settings.set_int("paned1", self.get_position())
def _back_to_current_album(self, *args):
def callback():
try:
song=ClientHelper.song_to_first_str_dict(self._client.wrapped_call("currentsong"))
if song == {}:
return False
except MPDBase.ConnectionError:
return False
self.search_button.set_active(False)
# get artist name
try:
artist=song[self._settings.get_artist_type()]
except:
artist=song.get("artist", "")
# deactivate genre filter to show all artists (if needed)
try:
if song["genre"] != self.genre_select.get_selected_genre():
self.genre_select.deactivate()
except:
self.genre_select.deactivate()
# select artist
if len(self._artist_window.get_selected_artists()[1]) <= 1: # one artist selected
self._artist_window.select(artist)
else: # all artists selected
self.search_button.set_active(False)
self._artist_window.highlight_selected()
self._album_window.scroll_to_current_album()
return False
GLib.idle_add(callback) # ensure it will be executed even when albums are still loading
def _on_search_toggled(self, widget):
if widget.get_active():
self._stack.set_visible_child_name("search")
self._search_window.search_entry.grab_focus()
else:
self._stack.set_visible_child_name("albums")
def _on_reconnected(self, *args):
self._back_to_current_album()
self.back_to_current_album_button.set_sensitive(True)
self.search_button.set_sensitive(True)
def _on_disconnected(self, *args):
self.back_to_current_album_button.set_sensitive(False)
self.search_button.set_active(False)
self.search_button.set_sensitive(False)
def _on_artists_changed(self, *args):
self.search_button.set_active(False)
def _on_mini_player(self, obj, typestring):
visibility=not(obj.get_property("mini-player"))
self.set_property("visible", visibility)
self.back_to_current_album_button.set_property("visible", visibility)
self.search_button.set_property("visible", visibility)
self.genre_select.set_property("visible", visibility)
def _on_icon_size_changed(self, *args):
pixel_size=self._settings.get_int("icon-size-sec")
for icon in self._icons.values():
icon.set_pixel_size(pixel_size)
######################
# playlist and cover #
######################
class LyricsWindow(FocusFrame):
def __init__(self, client, settings):
super().__init__()
# adding vars
self._settings=settings
self._client=client
self._displayed_song_file=None
# text view
self._text_view=Gtk.TextView(
editable=False,
cursor_visible=False,
wrap_mode=Gtk.WrapMode.WORD,
justification=Gtk.Justification.CENTER,
opacity=0.9
)
self._text_view.set_left_margin(5)
self._text_view.set_right_margin(5)
self._text_view.set_bottom_margin(5)
self._text_view.set_top_margin(3)
self.set_widget(self._text_view)
# text buffer
self._text_buffer=self._text_view.get_buffer()
# scroll
scroll=Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll.add(self._text_view)
# connect
self._client.emitter.connect("disconnected", self._on_disconnected)
self._song_changed=self._client.emitter.connect("current_song_changed", self._refresh)
self._client.emitter.handler_block(self._song_changed)
# packing
self.add(scroll)
def enable(self, *args):
current_song=self._client.wrapped_call("currentsong")
if current_song == {}:
if self._displayed_song_file is not None:
self._refresh()
else:
if current_song["file"] != self._displayed_song_file:
self._refresh()
self._client.emitter.handler_unblock(self._song_changed)
GLib.idle_add(self._text_view.grab_focus) # focus textview
def disable(self, *args):
self._client.emitter.handler_block(self._song_changed)
def _get_lyrics(self, title, artist):
replaces=((" ", "+"),(".", "_"),("@", "_"),(",", "_"),(";", "_"),("&", "_"),("\\", "_"),("/", "_"),('"', "_"),("(", "_"),(")", "_"))
for char1, char2 in replaces:
title=title.replace(char1, char2)
artist=artist.replace(char1, char2)
req=requests.get("https://www.letras.mus.br/winamp.php?musica={0}&artista={1}".format(title,artist))
soup=BeautifulSoup(req.text, "html.parser")
soup=soup.find(id="letra-cnt")
if soup is None:
raise ValueError("Not found")
paragraphs=[i for i in soup.children][1] # remove unneded paragraphs (NavigableString)
lyrics=""
for paragraph in paragraphs:
for line in paragraph.stripped_strings:
lyrics+=line+"\n"
lyrics+="\n"
output=lyrics[:-2] # omit last two newlines
if output == "": # assume song is instrumental when lyrics are empty
return "Instrumental"
else:
return output
def _display_lyrics(self, current_song):
GLib.idle_add(self._text_buffer.set_text, _("searching..."), -1)
try:
text=self._get_lyrics(current_song.get("title", ""), current_song.get("artist", ""))
except requests.exceptions.ConnectionError:
self._displayed_song_file=None
text=_("connection error")
except ValueError:
text=_("lyrics not found")
GLib.idle_add(self._text_buffer.set_text, text, -1)
def _refresh(self, *args):
current_song=self._client.wrapped_call("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__()
# adding vars
self._client=client
self._init_vars()
# 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._init_vars()
def _init_vars(self):
self.freq=0
self.res=0
self.chan=0
self.brate=0
self.file_type=""
def _refresh(self, *args):
string=_("{bitrate} kb/s, {frequency} kHz, {resolution} bit, {channels} channels, {file_type}").format(
bitrate=self.brate, frequency=self.freq, resolution=self.res, channels=self.chan, file_type=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.wrapped_call("currentsong")["file"].split(".")[-1]
self._refresh()
except:
pass
def _on_state(self, emitter, state):
if state == "stop":
self.clear()
class CoverEventBox(Gtk.EventBox):
def __init__(self, client, settings, window):
super().__init__()
# adding vars
self._client=client
self._settings=settings
self._window=window
# connect
self._button_press_event=self.connect("button-press-event", self._on_button_press_event)
self._settings.connect("notify::mini-player", self._on_mini_player)
def _on_button_press_event(self, widget, event):
if self._client.connected():
song=ClientHelper.song_to_first_str_dict(self._client.wrapped_call("currentsong"))
if song != {}:
try:
artist=song[self._settings.get_artist_type()]
except:
artist=song.get("artist", "")
album=song.get("album", "")
album_year=song.get("date", "")
if event.button == 1 and event.type == Gdk.EventType.BUTTON_PRESS:
self._client.wrapped_call("album_to_playlist", album, artist, album_year)
elif event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS:
self._client.wrapped_call("album_to_playlist", album, artist, album_year, "append")
elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS:
album_dialog=AlbumDialog(self._window, self._client, self._settings, album, artist, album_year)
album_dialog.open()
album_dialog.destroy()
def _on_mini_player(self, obj, typestring):
if obj.get_property("mini-player"):
self.handler_block(self._button_press_event)
else:
self.handler_unblock(self._button_press_event)
class MainCover(Gtk.Frame):
def __init__(self, client, settings):
super().__init__()
# css
style_context=self.get_style_context()
provider=Gtk.CssProvider()
css=b"""* {background-color: @theme_base_color; border-width: 0px}"""
provider.load_from_data(css)
style_context.add_provider(provider, 800)
# adding vars
self._client=client
self._settings=settings
# cover
self._cover=Gtk.Image.new()
size=self._settings.get_int("track-cover")
self._cover.set_from_pixbuf(Cover(self._settings, {}).get_pixbuf(size)) # set to fallback cover
# set default size
self._cover.set_size_request(size, size)
# connect
self._client.emitter.connect("current_song_changed", self._refresh)
self._client.emitter.connect("disconnected", self._on_disconnected)
self._client.emitter.connect("reconnected", self._on_reconnected)
self._settings.connect("changed::track-cover", self._on_settings_changed)
self.add(self._cover)
def _refresh(self, *args):
current_song=self._client.wrapped_call("currentsong")
self._cover.set_from_pixbuf(Cover(self._settings, current_song).get_pixbuf(self._settings.get_int("track-cover")))
def _on_disconnected(self, *args):
size=self._settings.get_int("track-cover")
self._cover.set_from_pixbuf(Cover(self._settings, {}).get_pixbuf(size))
self._cover.set_sensitive(False)
def _on_reconnected(self, *args):
self._cover.set_sensitive(True)
def _on_settings_changed(self, *args):
size=self._settings.get_int("track-cover")
self._cover.set_size_request(size, size)
self._refresh()
class PlaylistWindow(Gtk.Box):
def __init__(self, client, settings):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
# adding vars
self._client=client
self._settings=settings
self._playlist_version=None
self._icon_size=self._settings.get_int("icon-size-sec")
# buttons
self._icons={}
icons_data=("go-previous-symbolic", "edit-clear-symbolic")
for data in icons_data:
self._icons[data]=PixelSizedIcon(data, self._icon_size)
provider=Gtk.CssProvider()
css=b"""* {min-height: 8px;}""" # allow further shrinking
provider.load_from_data(css)
self._back_to_current_song_button=Gtk.Button(
image=self._icons["go-previous-symbolic"],
tooltip_text=_("Scroll to current song"),
relief=Gtk.ReliefStyle.NONE
)
self._back_to_current_song_button.set_can_focus(False)
style_context=self._back_to_current_song_button.get_style_context()
style_context.add_provider(provider, 800)
self._clear_button=Gtk.Button(
image=self._icons["edit-clear-symbolic"],
tooltip_text=_("Clear playlist"),
relief=Gtk.ReliefStyle.NONE
)
self._clear_button.set_can_focus(False)
style_context=self._clear_button.get_style_context()
style_context.add_class("destructive-action")
style_context.add_provider(provider, 800)
# Store
# (track, disc, title, artist, album, duration, date, genre, file, weight)
self._store=Gtk.ListStore(str, str, str, str, str, str, str, str, str, Pango.Weight)
# TreeView
self._treeview=Gtk.TreeView(model=self._store, activate_on_single_click=True)
self._treeview.set_search_column(2)
# selection
self._selection=self._treeview.get_selection()
self._selection.set_mode(Gtk.SelectionMode.SINGLE)
# Columns
renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True)
renderer_text_ralign=Gtk.CellRendererText(xalign=1.0)
self._columns=[None, None, None, None, None, None, None, None]
self._columns[0]=Gtk.TreeViewColumn(_("No"), renderer_text_ralign, text=0, weight=9)
self._columns[1]=Gtk.TreeViewColumn(_("Disc"), renderer_text_ralign, text=1, weight=9)
self._columns[2]=Gtk.TreeViewColumn(_("Title"), renderer_text, text=2, weight=9)
self._columns[3]=Gtk.TreeViewColumn(_("Artist"), renderer_text, text=3, weight=9)
self._columns[4]=Gtk.TreeViewColumn(_("Album"), renderer_text, text=4, weight=9)
self._columns[5]=Gtk.TreeViewColumn(_("Length"), renderer_text, text=5, weight=9)
self._columns[6]=Gtk.TreeViewColumn(_("Year"), renderer_text, text=6, weight=9)
self._columns[7]=Gtk.TreeViewColumn(_("Genre"), renderer_text, text=7, weight=9)
for column in self._columns:
column.set_property("resizable", True)
column.set_min_width(30)
self._load_settings()
# scroll
scroll=Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll.add(self._treeview)
# frame
frame=FocusFrame()
frame.set_widget(self._treeview)
frame.add(scroll)
# audio infos
audio=AudioType(self._client)
audio.set_xalign(1)
audio.set_ellipsize(Pango.EllipsizeMode.END)
# playlist info
self._playlist_info=Gtk.Label(xalign=0, ellipsize=Pango.EllipsizeMode.END)
# action bar
action_bar=Gtk.ActionBar()
action_bar.pack_start(self._back_to_current_song_button)
self._playlist_info.set_margin_start(3)
action_bar.pack_start(self._playlist_info)
audio.set_margin_end(3)
audio.set_margin_start(12)
action_bar.pack_end(self._clear_button)
action_bar.pack_end(audio)
# connect
self._treeview.connect("row-activated", self._on_row_activated)
self._key_press_event=self._treeview.connect("key-press-event", self._on_key_press_event)
self._treeview.connect("button-press-event", self._on_button_press_event)
self._back_to_current_song_button.connect("clicked", self._on_back_to_current_song_button_clicked)
self._clear_button.connect("clicked", self._on_clear_button_clicked)
self._client.emitter.connect("playlist_changed", self._on_playlist_changed)
self._client.emitter.connect("current_song_changed", self._on_song_changed)
self._client.emitter.connect("disconnected", self._on_disconnected)
self._client.emitter.connect("reconnected", self._on_reconnected)
self._settings.connect("notify::mini-player", self._on_mini_player)
self._settings.connect("changed::column-visibilities", self._load_settings)
self._settings.connect("changed::column-permutation", self._load_settings)
self._settings.connect("changed::icon-size-sec", self._on_icon_size_changed)
# packing
self.pack_start(frame, True, True, 0)
self.pack_end(action_bar, False, False, 0)
def save_settings(self): # only saves the column sizes
columns=self._treeview.get_columns()
permutation=self._settings.get_value("column-permutation").unpack()
sizes=[0] * len(permutation)
for i in range(len(permutation)):
sizes[permutation[i]]=columns[i].get_width()
self._settings.set_value("column-sizes", GLib.Variant("ai", sizes))
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._store.clear()
self._playlist_version=None
def _refresh_playlist_info(self):
songs=self._client.wrapped_call("playlistinfo")
if songs == []:
self._playlist_info.set_text("")
else:
length_human_readable=ClientHelper.calc_display_length(songs)
self._playlist_info.set_text(_("{titles} titles ({length})").format(titles=len(songs), 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.wrapped_call("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 _remove_song(self, path):
self._client.wrapped_call("delete", path) # bad song index possible
self._store.remove(self._store.get_iter(path))
self._playlist_version=self._client.wrapped_call("status")["playlist"]
def _on_key_press_event(self, widget, event):
self._treeview.handler_block(self._key_press_event)
if event.keyval == Gdk.keyval_from_name("Delete"):
treeview, treeiter=self._selection.get_selected()
if treeiter is not None:
path=self._store.get_path(treeiter)
try:
self._remove_song(path)
except:
pass
elif event.keyval == Gdk.keyval_from_name("Menu"):
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)
file_name=self._store[path][8]
pop=SongPopover(self._client.wrapped_call("get_metadata", file_name), widget, int(cell.x), int(cell.y))
pop.popup()
pop.show_all()
self._treeview.handler_unblock(self._key_press_event)
def _on_button_press_event(self, widget, event):
if event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS:
try:
path=widget.get_path_at_pos(int(event.x), int(event.y))[0]
self._remove_song(path)
except:
pass
elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS:
try:
path=widget.get_path_at_pos(int(event.x), int(event.y))[0]
pop=SongPopover(self._client.wrapped_call("get_metadata", self._store[path][8]), widget, int(event.x), int(event.y))
pop.popup()
except:
pass
def _on_row_activated(self, widget, path, view_column):
self._client.wrapped_call("play", path)
def _on_playlist_changed(self, emitter, version):
songs=[]
if self._playlist_version is not None:
songs=self._client.wrapped_call("plchanges", self._playlist_version)
else:
songs=self._client.wrapped_call("playlistinfo")
if songs != []:
self._playlist_info.set_text("")
for s in songs:
song=ClientHelper.extend_song_for_display(ClientHelper.song_to_str_dict(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.wrapped_call("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
def _on_song_changed(self, *args):
self._refresh_selection()
if self._client.wrapped_call("status")["state"] == "play":
self._scroll_to_selected_title()
def _on_back_to_current_song_button_clicked(self, *args):
self._treeview.set_cursor(Gtk.TreePath(len(self._store)), None, False) # set to invalid TreePath (needed to unset cursor)
for path, row in enumerate(self._store):
if row[9] == Pango.Weight.BOLD:
self._selection.select_path(path)
break
self._scroll_to_selected_title()
def _on_clear_button_clicked(self, *args):
self._client.clear()
def _on_disconnected(self, *args):
self._treeview.set_sensitive(False)
self._clear()
self._back_to_current_song_button.set_sensitive(False)
self._clear_button.set_sensitive(False)
def _on_reconnected(self, *args):
self._back_to_current_song_button.set_sensitive(True)
self._clear_button.set_sensitive(True)
self._treeview.set_sensitive(True)
def _on_mini_player(self, obj, typestring):
visibility=not(obj.get_property("mini-player"))
self.set_property("visible", visibility)
def _on_icon_size_changed(self, *args):
pixel_size=self._settings.get_int("icon-size-sec")
for icon in self._icons.values():
icon.set_pixel_size(pixel_size)
class CoverPlaylistWindow(Gtk.Paned):
def __init__(self, client, settings, window):
super().__init__()
# adding vars
self._client=client
self._settings=settings
self._window=window
# cover
main_cover=MainCover(self._client, self._settings)
self._cover_event_box=CoverEventBox(self._client, self._settings, self._window)
# playlist
self._playlist_window=PlaylistWindow(self._client, self._settings)
# lyrics button
self.lyrics_button=Gtk.ToggleButton(
image=Gtk.Image.new_from_icon_name("media-view-subtitles-symbolic", Gtk.IconSize.BUTTON),
tooltip_text=_("Show lyrics")
)
self.lyrics_button.set_can_focus(False)
self.lyrics_button.set_margin_top(6)
self.lyrics_button.set_margin_end(6)
style_context=self.lyrics_button.get_style_context()
style_context.add_class("circular")
# lyrics window
self._lyrics_window=LyricsWindow(self._client, self._settings)
# revealer
self._lyrics_button_revealer=Gtk.Revealer()
self._lyrics_button_revealer.set_halign(Gtk.Align.END)
self._lyrics_button_revealer.set_valign(Gtk.Align.START)
self._lyrics_button_revealer.add(self.lyrics_button)
# stack
self._stack=Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE)
self._stack.add_named(self._cover_event_box, "cover")
self._stack.add_named(self._lyrics_window, "lyrics")
self._stack.set_visible_child(self._cover_event_box)
# connect
self.lyrics_button.connect("toggled", self._on_lyrics_toggled)
self._client.emitter.connect("disconnected", self._on_disconnected)
self._client.emitter.connect("reconnected", self._on_reconnected)
self._settings.connect("changed::show-lyrics-button", self._on_settings_changed)
# packing
overlay=Gtk.Overlay()
overlay.add(main_cover)
overlay.add_overlay(self._stack)
overlay.add_overlay(self._lyrics_button_revealer)
self.pack1(overlay, False, False)
self.pack2(self._playlist_window, True, False)
self.set_position(self._settings.get_int("paned0"))
self._on_settings_changed() # hide lyrics button
def save_settings(self):
self._settings.set_int("paned0", self.get_position())
self._playlist_window.save_settings()
def _on_reconnected(self, *args):
self.lyrics_button.set_sensitive(True)
def _on_disconnected(self, *args):
self.lyrics_button.set_active(False)
self.lyrics_button.set_sensitive(False)
def _on_lyrics_toggled(self, widget):
if widget.get_active():
self._stack.set_visible_child(self._lyrics_window)
self._lyrics_window.enable()
else:
self._stack.set_visible_child(self._cover_event_box)
self._lyrics_window.disable()
def _on_settings_changed(self, *args):
if self._settings.get_boolean("show-lyrics-button"):
self._lyrics_button_revealer.set_reveal_child(True)
else:
self._lyrics_button_revealer.set_reveal_child(False)
###################
# control widgets #
###################
class PlaybackControl(Gtk.ButtonBox):
def __init__(self, client, settings):
super().__init__(layout_style=Gtk.ButtonBoxStyle.EXPAND)
# adding vars
self._client=client
self._settings=settings
self._icon_size=self._settings.get_int("icon-size")
# widgets
self._icons={}
icons_data=(
"media-playback-start-symbolic", "media-playback-stop-symbolic",
"media-playback-pause-symbolic", "media-skip-backward-symbolic",
"media-skip-forward-symbolic"
)
for data in icons_data:
self._icons[data]=PixelSizedIcon(data, self._icon_size)
self._play_button=Gtk.Button(image=self._icons["media-playback-start-symbolic"])
self._play_button.set_can_focus(False)
self._stop_button=Gtk.Button(image=self._icons["media-playback-stop-symbolic"])
self._stop_button.set_can_focus(False)
self._prev_button=Gtk.Button(image=self._icons["media-skip-backward-symbolic"])
self._prev_button.set_can_focus(False)
self._next_button=Gtk.Button(image=self._icons["media-skip-forward-symbolic"])
self._next_button.set_can_focus(False)
# connect
self._play_button.connect("clicked", self._on_play_clicked)
self._stop_button.connect("clicked", self._on_stop_clicked)
self._stop_button.set_property("no-show-all", not(self._settings.get_boolean("show-stop")))
self._prev_button.connect("clicked", self._on_prev_clicked)
self._next_button.connect("clicked", self._on_next_clicked)
self._settings.connect("notify::mini-player", self._on_mini_player)
self._settings.connect("changed::show-stop", self._on_show_stop_changed)
self._settings.connect("changed::icon-size", self._on_icon_size_changed)
self._client.emitter.connect("state", self._on_state)
self._client.emitter.connect("disconnected", self._on_disconnected)
self._client.emitter.connect("reconnected", self._on_reconnected)
# packing
self.pack_start(self._prev_button, True, True, 0)
self.pack_start(self._play_button, True, True, 0)
self.pack_start(self._stop_button, True, True, 0)
self.pack_start(self._next_button, True, True, 0)
def _on_play_clicked(self, widget):
self._client.wrapped_call("toggle_play")
def _on_stop_clicked(self, widget):
self._client.wrapped_call("stop")
def _on_prev_clicked(self, widget):
self._client.wrapped_call("previous")
def _on_next_clicked(self, widget):
self._client.wrapped_call("next")
def _on_state(self, emitter, state):
if state == "play":
self._play_button.set_image(self._icons["media-playback-pause-symbolic"])
self._prev_button.set_sensitive(True)
self._next_button.set_sensitive(True)
elif state == "pause":
self._play_button.set_image(self._icons["media-playback-start-symbolic"])
self._prev_button.set_sensitive(True)
self._next_button.set_sensitive(True)
else:
self._play_button.set_image(self._icons["media-playback-start-symbolic"])
self._prev_button.set_sensitive(False)
self._next_button.set_sensitive(False)
def _on_disconnected(self, *args):
self.set_sensitive(False)
def _on_reconnected(self, *args):
self.set_sensitive(True)
def _on_mini_player(self, obj, typestring):
self._on_show_stop_changed()
def _on_show_stop_changed(self, *args):
visibility=(self._settings.get_boolean("show-stop") and not self._settings.get_property("mini-player"))
self._stop_button.set_property("visible", visibility)
self._stop_button.set_property("no-show-all", not(visibility))
def _on_icon_size_changed(self, *args):
pixel_size=self._settings.get_int("icon-size")
for icon in self._icons.values():
icon.set_pixel_size(pixel_size)
class SeekBar(Gtk.Box):
def __init__(self, client):
super().__init__(hexpand=True)
# adding vars
self._client=client
self._update=True
self._jumped=False
# labels
self._elapsed=Gtk.Label(width_chars=5)
self._rest=Gtk.Label(width_chars=6)
# event boxes
self._elapsed_event_box=Gtk.EventBox()
self._rest_event_box=Gtk.EventBox()
# progress bar
self._scale=Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL)
self._scale.set_can_focus(False)
self._scale.set_show_fill_level(True)
self._scale.set_restrict_to_fill_level(False)
self._scale.set_draw_value(False)
self._scale.set_increments(10, 60)
self._adjustment=self._scale.get_adjustment()
# css (scale)
style_context=self._scale.get_style_context()
provider=Gtk.CssProvider()
css=b"""scale fill { background-color: @theme_selected_bg_color; }"""
provider.load_from_data(css)
style_context.add_provider(provider, 800)
# connect
self._elapsed_event_box.connect("button-release-event", self._on_elapsed_button_release_event)
self._elapsed_event_box.connect("button-press-event", lambda *args: self._scale.grab_focus())
self._rest_event_box.connect("button-release-event", self._on_rest_button_release_event)
self._rest_event_box.connect("button-press-event", lambda *args: self._scale.grab_focus())
self._scale.connect("change-value", self._on_change_value)
self._scale.connect("scroll-event", lambda *args: True) # disable mouse wheel
self._scale.connect("button-press-event", self._on_scale_button_press_event)
self._scale.connect("button-release-event", self._on_scale_button_release_event)
self._client.emitter.connect("disconnected", self._disable)
self._client.emitter.connect("state", self._on_state)
self._client.emitter.connect("elapsed_changed", self._refresh)
# packing
self._elapsed_event_box.add(self._elapsed)
self._rest_event_box.add(self._rest)
self.pack_start(self._elapsed_event_box, False, False, 0)
self.pack_start(self._scale, True, True, 0)
self.pack_end(self._rest_event_box, False, False, 0)
def _refresh(self, emitter, elapsed, duration):
self.set_sensitive(True)
if elapsed > duration: # fix display error
elapsed=duration
self._adjustment.set_upper(duration)
if self._update:
self._scale.set_value(elapsed)
self._elapsed.set_text(ClientHelper.seconds_to_display_time(int(elapsed)))
self._rest.set_text("-"+ClientHelper.seconds_to_display_time(int(duration-elapsed)))
self._scale.set_fill_level(elapsed)
def _disable(self, *args):
self.set_sensitive(False)
self._scale.set_fill_level(0)
self._scale.set_range(0, 0)
self._elapsed.set_text("")
self._rest.set_text("")
def _on_scale_button_press_event(self, widget, event):
if event.button == 1 and event.type == Gdk.EventType.BUTTON_PRESS:
self._update=False
self._scale.set_has_origin(False)
if event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS:
self._jumped=False
def _on_scale_button_release_event(self, widget, event):
if event.button == 1:
self._update=True
self._scale.set_has_origin(True)
if self._jumped: # actual seek
self._client.wrapped_call("seekcur", self._scale.get_value())
self._jumped=False
else: # restore state
status=self._client.wrapped_call("status")
self._refresh(None, float(status["elapsed"]), float(status["duration"]))
def _on_change_value(self, range, scroll, value): # value is inaccurate (can be above upper limit)
if (scroll == Gtk.ScrollType.STEP_BACKWARD or scroll == Gtk.ScrollType.STEP_FORWARD or
scroll == Gtk.ScrollType.PAGE_BACKWARD or scroll == Gtk.ScrollType.PAGE_FORWARD):
self._client.wrapped_call("seekcur", value)
elif scroll == Gtk.ScrollType.JUMP:
duration=self._adjustment.get_upper()
if value > duration: # fix display error
elapsed=duration
else:
elapsed=value
self._elapsed.set_text(ClientHelper.seconds_to_display_time(int(elapsed)))
self._rest.set_text("-"+ClientHelper.seconds_to_display_time(int(duration-elapsed)))
self._jumped=True
def _on_elapsed_button_release_event(self, widget, event):
if event.button == 1:
self._client.wrapped_call("seekcur", "-"+str(self._adjustment.get_property("step-increment")))
elif event.button == 3:
self._client.wrapped_call("seekcur", "+"+str(self._adjustment.get_property("step-increment")))
def _on_rest_button_release_event(self, widget, event):
if event.button == 1:
self._client.wrapped_call("seekcur", "+"+str(self._adjustment.get_property("step-increment")))
elif event.button == 3:
self._client.wrapped_call("seekcur", "-"+str(self._adjustment.get_property("step-increment")))
def _on_state(self, emitter, state):
if state == "stop":
self._disable()
class PlaybackOptions(Gtk.Box):
def __init__(self, client, settings):
super().__init__(spacing=6)
# adding vars
self._client=client
self._settings=settings
self._icon_size=self._settings.get_int("icon-size")
# widgets
self._icons={}
icons_data=("media-playlist-shuffle-symbolic", "media-playlist-repeat-symbolic", "zoom-original-symbolic", "edit-cut-symbolic")
for data in icons_data:
self._icons[data]=PixelSizedIcon(data, self._icon_size)
self._random_button=Gtk.ToggleButton(image=self._icons["media-playlist-shuffle-symbolic"], tooltip_text=_("Random mode"))
self._random_button.set_can_focus(False)
self._repeat_button=Gtk.ToggleButton(image=self._icons["media-playlist-repeat-symbolic"], tooltip_text=_("Repeat mode"))
self._repeat_button.set_can_focus(False)
self._single_button=Gtk.ToggleButton(image=self._icons["zoom-original-symbolic"], tooltip_text=_("Single mode"))
self._single_button.set_can_focus(False)
self._consume_button=Gtk.ToggleButton(image=self._icons["edit-cut-symbolic"], tooltip_text=_("Consume mode"))
self._consume_button.set_can_focus(False)
self._volume_button=Gtk.VolumeButton(use_symbolic=True, size=self._settings.get_gtk_icon_size("icon-size"))
self._volume_button.set_can_focus(False)
self._volume_button.set_sensitive(False) # do not allow volume change by user when MPD has not yet reported volume
adj=self._volume_button.get_adjustment()
adj.set_step_increment(0.05)
adj.set_page_increment(0.1)
# connect
self._random_button_toggled=self._random_button.connect("toggled", self._set_option, "random")
self._repeat_button_toggled=self._repeat_button.connect("toggled", self._set_option, "repeat")
self._single_button_toggled=self._single_button.connect("toggled", self._set_option, "single")
self._consume_button_toggled=self._consume_button.connect("toggled", self._set_option, "consume")
self._volume_button_changed=self._volume_button.connect("value-changed", self._set_volume)
self._repeat_changed=self._client.emitter.connect("repeat", self._repeat_refresh)
self._random_changed=self._client.emitter.connect("random", self._random_refresh)
self._single_changed=self._client.emitter.connect("single", self._single_refresh)
self._consume_changed=self._client.emitter.connect("consume", self._consume_refresh)
self._volume_changed=self._client.emitter.connect("volume_changed", self._volume_refresh)
self._client.emitter.connect("disconnected", self._on_disconnected)
self._client.emitter.connect("reconnected", self._on_reconnected)
self._settings.connect("notify::mini-player", self._on_mini_player)
self._settings.connect("changed::icon-size", self._on_icon_size_changed)
# packing
self._button_box=Gtk.ButtonBox(layout_style=Gtk.ButtonBoxStyle.EXPAND)
self._button_box.pack_start(self._repeat_button, True, True, 0)
self._button_box.pack_start(self._random_button, True, True, 0)
self._button_box.pack_start(self._single_button, True, True, 0)
self._button_box.pack_start(self._consume_button, True, True, 0)
self.pack_start(self._button_box, True, True, 0)
self.pack_start(self._volume_button, True, True, 0)
def _set_option(self, widget, option):
if widget.get_active():
self._client.wrapped_call(option, "1")
else:
self._client.wrapped_call(option, "0")
def _set_volume(self, widget, value):
self._client.wrapped_call("setvol", str(int(value*100)))
def _repeat_refresh(self, emitter, val):
self._repeat_button.handler_block(self._repeat_button_toggled)
self._repeat_button.set_active(val)
self._repeat_button.handler_unblock(self._repeat_button_toggled)
def _random_refresh(self, emitter, val):
self._random_button.handler_block(self._random_button_toggled)
self._random_button.set_active(val)
self._random_button.handler_unblock(self._random_button_toggled)
def _single_refresh(self, emitter, val):
self._single_button.handler_block(self._single_button_toggled)
self._single_button.set_active(val)
self._single_button.handler_unblock(self._single_button_toggled)
def _consume_refresh(self, emitter, val):
self._consume_button.handler_block(self._consume_button_toggled)
self._consume_button.set_active(val)
self._consume_button.handler_unblock(self._consume_button_toggled)
def _volume_refresh(self, emitter, volume):
self._volume_button.handler_block(self._volume_button_changed)
if volume < 0:
self._volume_button.set_sensitive(False)
self._volume_button.set_value(0)
else:
self._volume_button.set_value(volume/100)
self._volume_button.set_sensitive(True)
self._volume_button.handler_unblock(self._volume_button_changed)
def _on_reconnected(self, *args):
self._repeat_button.set_sensitive(True)
self._random_button.set_sensitive(True)
self._single_button.set_sensitive(True)
self._consume_button.set_sensitive(True)
def _on_disconnected(self, *args):
self._repeat_button.set_sensitive(False)
self._random_button.set_sensitive(False)
self._single_button.set_sensitive(False)
self._consume_button.set_sensitive(False)
self._repeat_refresh(None, False)
self._random_refresh(None, False)
self._single_refresh(None, False)
self._consume_refresh(None, False)
self._volume_refresh(None, -1)
def _on_mini_player(self, obj, typestring):
visibility=not(obj.get_property("mini-player"))
self._button_box.set_property("visible", visibility)
def _on_icon_size_changed(self, *args):
pixel_size=self._settings.get_int("icon-size")
for icon in self._icons.values():
icon.set_pixel_size(pixel_size)
self._volume_button.set_property("size", self._settings.get_gtk_icon_size("icon-size"))
###################
# MPD gio actions #
###################
class MPDActionGroup(Gio.SimpleActionGroup):
def __init__(self, client):
super().__init__()
# adding vars
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.wrapped_call("toggle_play")
def _on_stop(self, action, param):
self._client.wrapped_call("stop")
def _on_next(self, action, param):
self._client.wrapped_call("next")
def _on_prev(self, action, param):
self._client.wrapped_call("previous")
def _on_seek_forward(self, action, param):
self._client.wrapped_call("seekcur", "+10")
def _on_seek_backward(self, action, param):
self._client.wrapped_call("seekcur", "-10")
def _on_clear(self, action, param):
self._client.wrapped_call("clear")
def _on_update(self, action, param):
self._client.wrapped_call("update")
def _on_repeat(self, action, param):
self._client.wrapped_call("toggle_option", "repeat")
def _on_random(self, action, param):
self._client.wrapped_call("toggle_option", "random")
def _on_single(self, action, param):
self._client.wrapped_call("toggle_option", "single")
def _on_consume(self, action, param):
self._client.wrapped_call("toggle_option", "consume")
def _on_state(self, emitter, state):
state_dict={"play": True, "pause": True, "stop": False}
for action in ("next","prev","seek-forward","seek-backward"):
self.lookup_action(action).set_enabled(state_dict[state])
def _on_disconnected(self, *args):
for action in self._simple_actions_data:
self.lookup_action(action).set_enabled(False)
def _on_reconnected(self, *args):
for action in self._simple_actions_data:
self.lookup_action(action).set_enabled(True)
####################
# shortcuts window #
####################
class ShortcutsWindow(Gtk.ShortcutsWindow):
def __init__(self):
super().__init__()
general_group=Gtk.ShortcutsGroup(title=_("General"), visible=True)
window_group=Gtk.ShortcutsGroup(title=_("Window"), visible=True)
playback_group=Gtk.ShortcutsGroup(title=_("Playback"), visible=True)
items_group=Gtk.ShortcutsGroup(title=_("Search, Album Dialog and Album 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),
("p", _("Play selected item (next)"), _("Left-click"), items_group),
("a", _("Append selected item"), _("Middle-click"), items_group),
("Return", _("Play selected item immediately"), _("Double-click"), items_group),
("Menu", _("Show additional information"), _("Right-click"), items_group),
("Delete", _("Remove selected song"), _("Middle-click"), playlist_group),
("<Shift>Delete", _("Clear playlist"), None, playlist_group),
("Menu", _("Show additional information"), _("Right-click"), playlist_group)
)
for accel, title, subtitle, group in shortcut_data:
shortcut=Gtk.ShortcutsShortcut(visible=True, accelerator=accel, title=title, subtitle=subtitle)
group.pack_start(shortcut, False, False, 0)
self.add(section)
###############
# main window #
###############
class ConnectionNotify(Gtk.Revealer):
def __init__(self, client, settings):
super().__init__(valign=Gtk.Align.START, halign=Gtk.Align.CENTER)
# adding vars
self._client=client
self._settings=settings
# widgets
self._label=Gtk.Label(wrap=True)
close_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("window-close-symbolic", Gtk.IconSize.BUTTON))
close_button.set_relief(Gtk.ReliefStyle.NONE)
connect_button=Gtk.Button(label=_("Connect"))
# connect
close_button.connect("clicked", self._on_close_button_clicked)
connect_button.connect("clicked", self._on_connect_button_clicked)
self._client.emitter.connect("connection_error", self._on_connection_error)
self._client.emitter.connect("reconnected", self._on_reconnected)
# packing
box=Gtk.Box(spacing=12)
box.get_style_context().add_class("app-notification")
box.pack_start(self._label, False, True, 6)
box.pack_end(close_button, False, True, 0)
box.pack_end(connect_button, False, True, 0)
self.add(box)
def _on_connection_error(self, *args):
active=self._settings.get_int("active-profile")
string=_("Connection to “{profile}” ({host}:{port}) failed").format(
profile=self._settings.get_value("profiles")[active],
host=self._settings.get_value("hosts")[active],
port=self._settings.get_value("ports")[active]
)
self._label.set_text(string)
self.set_reveal_child(True)
def _on_reconnected(self, *args):
self.set_reveal_child(False)
def _on_close_button_clicked(self, *args):
self.set_reveal_child(False)
def _on_connect_button_clicked(self, *args):
self._client.reconnect()
class MainWindow(Gtk.ApplicationWindow):
def __init__(self, app, client, settings):
super().__init__(title=("mpdevil"), icon_name="mpdevil", application=app)
Notify.init("mpdevil")
self.set_default_size(settings.get_int("width"), settings.get_int("height"))
shortcuts_window=ShortcutsWindow()
self.set_help_overlay(shortcuts_window)
shortcuts_window.set_modal(False)
# adding vars
self._client=client
self._settings=settings
self._use_csd=self._settings.get_boolean("use-csd")
if self._use_csd:
self._icon_size=0
else:
self._icon_size=self._settings.get_int("icon-size")
self._icons={"open-menu-symbolic": PixelSizedIcon("open-menu-symbolic", self._icon_size)}
self._tmp_saved_size=None # needed to restore size after leaving mini player mode
self._tmp_saved_maximized=None # needed to restore maximize state after leaving mini player mode
# MPRIS
dbus_service=MPRISInterface(self, self._client, self._settings)
# actions
simple_actions_data=(
"save","settings","stats","help","menu",
"toggle-lyrics","back-to-current-album","toggle-search",
"profile-next","profile-prev"
)
for name in simple_actions_data:
action=Gio.SimpleAction.new(name, None)
action.connect("activate", getattr(self, ("_on_"+name.replace("-","_"))))
self.add_action(action)
mini_player_action=Gio.PropertyAction.new("mini-player", self._settings, "mini-player")
self.add_action(mini_player_action)
self._profiles_action=Gio.SimpleAction.new_stateful("profiles", GLib.VariantType.new("i"), GLib.Variant("i", 0))
self._profiles_action.connect("change-state", self._on_profiles)
self.add_action(self._profiles_action)
self._mpd_action_group=MPDActionGroup(self._client)
self.insert_action_group("mpd", self._mpd_action_group)
# widgets
self._browser=Browser(self._client, self._settings, self)
self._cover_playlist_window=CoverPlaylistWindow(self._client, self._settings, self)
playback_control=PlaybackControl(self._client, self._settings)
seek_bar=SeekBar(self._client)
playback_options=PlaybackOptions(self._client, self._settings)
connection_notify=ConnectionNotify(self._client, self._settings)
# menu
subsection=Gio.Menu()
subsection.append(_("Settings"), "win.settings")
subsection.append(_("Keyboard shortcuts"), "win.show-help-overlay")
subsection.append(_("Help"), "win.help")
subsection.append(_("About"), "app.about")
subsection.append(_("Quit"), "app.quit")
mpd_subsection=Gio.Menu()
mpd_subsection.append(_("Update database"), "mpd.update")
mpd_subsection.append(_("Server stats"), "win.stats")
self._profiles_submenu=Gio.Menu()
self._refresh_profiles_menu()
menu=Gio.Menu()
menu.append_submenu(_("Profiles"), self._profiles_submenu)
menu.append(_("Mini player"), "win.mini-player")
menu.append(_("Save window layout"), "win.save")
menu.append_section(None, mpd_subsection)
menu.append_section(None, subsection)
# menu button / popover
self._menu_button=Gtk.MenuButton(image=self._icons["open-menu-symbolic"], tooltip_text=_("Menu"))
self._menu_button.set_can_focus(False)
menu_popover=Gtk.Popover.new_from_model(self._menu_button, menu)
self._menu_button.set_popover(menu_popover)
# action bar
action_bar=Gtk.ActionBar()
action_bar.pack_start(playback_control)
action_bar.pack_start(seek_bar)
action_bar.pack_start(playback_options)
# connect
self._settings.connect("changed::profiles", self._refresh_profiles_menu)
self._settings.connect("changed::active-profile", self._on_active_profile_changed)
self._settings.connect_after("notify::mini-player", self._on_mini_player)
self._settings.connect("changed::playlist-right", self._on_playlist_pos_changed)
if not self._use_csd:
self._settings.connect("changed::icon-size", self._on_icon_size_changed)
self._client.emitter.connect("current_song_changed", self._on_song_changed)
self._client.emitter.connect("disconnected", self._on_disconnected)
self._client.emitter.connect("reconnected", self._on_reconnected)
self._browser.connect("search_focus_changed", self._on_search_focus_changed)
# packing
self._paned2=Gtk.Paned()
self._paned2.set_position(self._settings.get_int("paned2"))
self._on_playlist_pos_changed() # set orientation
self._paned2.pack1(self._browser, True, False)
self._paned2.pack2(self._cover_playlist_window, False, False)
vbox=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
vbox.pack_start(self._paned2, True, True, 0)
vbox.pack_start(action_bar, False, False, 0)
overlay=Gtk.Overlay()
overlay.add(vbox)
overlay.add_overlay(connection_notify)
if self._use_csd:
self._header_bar=Gtk.HeaderBar()
self._header_bar.set_show_close_button(True)
self.set_titlebar(self._header_bar)
self._header_bar.pack_start(self._browser.back_to_current_album_button)
self._header_bar.pack_start(self._browser.genre_select)
self._header_bar.pack_end(self._menu_button)
self._header_bar.pack_end(self._browser.search_button)
else:
action_bar.pack_start(Gtk.Separator.new(orientation=Gtk.Orientation.VERTICAL))
action_bar.pack_start(self._menu_button)
self.add(overlay)
self.show_all()
if self._settings.get_boolean("maximize"):
self.maximize()
self._client.start() # connect client
def _on_toggle_lyrics(self, action, param):
self._cover_playlist_window.lyrics_button.set_active(not(self._cover_playlist_window.lyrics_button.get_active()))
def _on_back_to_current_album(self, action, param):
self._browser.back_to_current_album_button.emit("clicked")
def _on_toggle_search(self, action, param):
self._browser.search_button.set_active(not(self._browser.search_button.get_active()))
def _on_save(self, action, param):
size=self.get_size()
self._settings.set_int("width", size[0])
self._settings.set_int("height", size[1])
self._settings.set_boolean("maximize", self.is_maximized())
self._browser.save_settings()
self._cover_playlist_window.save_settings()
self._settings.set_int("paned2", self._paned2.get_position())
def _on_settings(self, action, param):
settings=SettingsDialog(self, self._client, self._settings)
settings.run()
settings.destroy()
def _on_stats(self, action, param):
stats=ServerStats(self, self._client, self._settings)
stats.destroy()
def _on_help(self, action, param):
Gtk.show_uri_on_window(self, "https://github.com/SoongNoonien/mpdevil/wiki/Usage", Gdk.CURRENT_TIME)
def _on_menu(self, action, param):
self._menu_button.emit("clicked")
def _on_profile_next(self, action, param):
total_profiles=len(self._settings.get_value("profiles"))
current_profile=self._settings.get_int("active-profile")
self._settings.set_int("active-profile", (current_profile+1)%total_profiles)
def _on_profile_prev(self, action, param):
total_profiles=len(self._settings.get_value("profiles"))
current_profile=self._settings.get_int("active-profile")
self._settings.set_int("active-profile", (current_profile-1)%total_profiles)
def _on_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.wrapped_call("currentsong")
if song == {}:
if self._use_csd:
self.set_title("mpdevil")
self._header_bar.set_subtitle("")
else:
self.set_title("mpdevil")
else:
song=ClientHelper.extend_song_for_display(ClientHelper.song_to_str_dict(song))
if song["date"] == "":
date=""
else:
date=" ("+song["date"]+")"
if self._use_csd:
self.set_title(song["title"]+" - "+song["artist"])
self._header_bar.set_subtitle(song["album"]+date)
else:
self.set_title(song["title"]+" - "+song["artist"]+" - "+song["album"]+date)
if self._settings.get_boolean("send-notify"):
if not self.is_active() and self._client.wrapped_call("status")["state"] == "play":
notify=Notify.Notification.new(song["title"], song["artist"]+"\n"+song["album"]+date)
pixbuf=Cover(self._settings, song).get_pixbuf(400)
notify.set_image_from_pixbuf(pixbuf)
notify.show()
def _on_reconnected(self, *args):
for action in ("save","stats","toggle-lyrics","back-to-current-album","toggle-search"):
self.lookup_action(action).set_enabled(True)
def _on_disconnected(self, *args):
self.set_title("mpdevil")
if self._use_csd:
self._header_bar.set_subtitle("")
for action in ("save","stats","toggle-lyrics","back-to-current-album","toggle-search"):
self.lookup_action(action).set_enabled(False)
def _on_search_focus_changed(self, obj, focus):
self._mpd_action_group.lookup_action("toggle-play").set_enabled(not(focus))
def _on_mini_player(self, obj, typestring):
if obj.get_property("mini-player"):
self.lookup_action("save").set_enabled(False)
self._tmp_saved_size=self.get_size()
self._tmp_saved_miximized=self.is_maximized()
if self._tmp_saved_miximized:
self.unmaximize()
self.resize(1,1)
else:
self.lookup_action("save").set_enabled(True)
self.resize(self._tmp_saved_size[0], self._tmp_saved_size[1])
if self._tmp_saved_miximized:
self.maximize()
self._tmp_saved_size=None
self._tmp_saved_maximized=None
def _on_playlist_pos_changed(self, *args):
if self._settings.get_boolean("playlist-right"):
self._cover_playlist_window.set_orientation(Gtk.Orientation.VERTICAL)
self._paned2.set_orientation(Gtk.Orientation.HORIZONTAL)
else:
self._cover_playlist_window.set_orientation(Gtk.Orientation.HORIZONTAL)
self._paned2.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")))
def _on_icon_size_changed(self, *args):
pixel_size=self._settings.get_int("icon-size")
for icon in self._icons.values():
icon.set_pixel_size(pixel_size)
###################
# Gtk application #
###################
class mpdevil(Gtk.Application):
def __init__(self, *args, **kwargs):
super().__init__(*args, application_id="org.mpdevil", flags=Gio.ApplicationFlags.FLAGS_NONE, **kwargs)
self._settings=Settings()
self._client=Client(self._settings)
self._window=None
def do_activate(self):
if not self._window: # allow just one instance
self._window=MainWindow(self, self._client, self._settings)
self._window.connect("delete-event", self._on_delete_event)
# accelerators
action_accels=(
("app.quit", ["<Control>q"]),("win.mini-player", ["<Control>m"]),("win.help", ["F1"]),("win.menu", ["F10"]),
("win.show-help-overlay", ["<Control>question"]),("win.toggle-lyrics", ["<Control>l"]),
("win.back-to-current-album", ["Escape"]),("win.toggle-search", ["<control>f"]),
("mpd.update", ["F5"]),("mpd.clear", ["<Shift>Delete"]),("mpd.toggle-play", ["space"]),
("mpd.stop", ["<Control>space"]),("mpd.next", ["KP_Add"]),("mpd.prev", ["KP_Subtract"]),
("mpd.repeat", ["<Control>r"]),("mpd.random", ["<Control>s"]),("mpd.single", ["<Control>1"]),
("mpd.consume", ["<Control>o"]),("mpd.seek-forward", ["KP_Multiply"]),("mpd.seek-backward", ["KP_Divide"]),
("win.profile-next", ["<Control>p"]),("win.profile-prev", ["<Shift><Control>p"])
)
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._on_quit)
self.add_action(action)
def _on_delete_event(self, *args):
if self._settings.get_boolean("stop-on-quit") and self._client.connected():
self._client.wrapped_call("stop")
self.quit()
def _on_about(self, action, param):
dialog=AboutDialog(self._window)
dialog.run()
dialog.destroy()
def _on_quit(self, action, param):
if self._settings.get_boolean("stop-on-quit") and self._client.connected():
self._client.wrapped_call("stop")
self.quit()
if __name__ == "__main__":
app=mpdevil()
app.run(sys.argv)