#!/usr/bin/python3 # -*- coding: utf-8 -*- # # mpdevil - MPD Client. # Copyright 2020 Martin Wagner # # 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, Comment 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.2' # sync with setup.py COVER_REGEX="^\.?(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 , Mantas Mikulėnas """ __prop_interface=dbus.PROPERTIES_IFACE # MPRIS allowed metadata tags allowed_tags={ 'mpris:trackid': dbus.ObjectPath, 'mpris:length': dbus.Int64, 'mpris:artUrl': str, 'xesam:album': str, 'xesam:albumArtist': list, 'xesam:artist': list, 'xesam:asText': str, 'xesam:audioBPM': int, 'xesam:comment': list, 'xesam:composer': list, 'xesam:contentCreated': str, 'xesam:discNumber': int, 'xesam:firstUsed': str, 'xesam:genre': list, 'xesam:lastUsed': str, 'xesam:lyricist': str, 'xesam:title': str, 'xesam:trackNumber': int, 'xesam:url': str, 'xesam:useCount': int, 'xesam:userRating': float, } def __init__(self, window, client, settings): super().__init__(dbus.SessionBus(), "/org/mpris/MediaPlayer2") self._name="org.mpris.MediaPlayer2.mpdevil" self._bus=dbus.SessionBus() self._uname=self._bus.get_unique_name() self._dbus_obj=self._bus.get_object("org.freedesktop.DBus", "/org/freedesktop/DBus") self._dbus_obj.connect_to_signal("NameOwnerChanged", self._name_owner_changed_callback, arg0=self._name) self._window=window self._client=client self._settings=settings self._metadata={} # 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("disconnected", self._on_disconnected) self._client.emitter.connect("reconnected", self._on_reconnected) def acquire_name(self): self._bus_name=dbus.service.BusName(self._name, bus=self._bus, allow_replacement=True, replace_existing=True) def release_name(self): if hasattr(self, "_bus_name"): del self._bus_name def update_metadata(self): # TODO """ 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 in ('album', 'title'): if tag in mpd_meta: self._metadata['xesam:%s' % tag]=mpd_meta[tag] if 'id' in mpd_meta: self._metadata['mpris:trackid']="/org/mpris/MediaPlayer2/Track/%s" % mpd_meta['id'] if 'time' in mpd_meta: self._metadata['mpris:length']=int(mpd_meta['time']) * 1000000 if 'date' in mpd_meta: self._metadata['xesam:contentCreated']=mpd_meta['date'][0:4] if 'track' in mpd_meta: # TODO: Is it even *possible* for mpd_meta['track'] to be a list? if type(mpd_meta['track']) == list and len(mpd_meta['track']) > 0: track=str(mpd_meta['track'][0]) else: track=str(mpd_meta['track']) m=re.match('^([0-9]+)', track) if m: self._metadata['xesam:trackNumber']=int(m.group(1)) # Ensure the integer is signed 32bit if self._metadata['xesam:trackNumber'] & 0x80000000: self._metadata['xesam:trackNumber'] += -0x100000000 else: self._metadata['xesam:trackNumber']=0 if 'disc' in mpd_meta: # TODO: Same as above. When is it a list? if type(mpd_meta['disc']) == list and len(mpd_meta['disc']) > 0: disc=str(mpd_meta['disc'][0]) else: disc=str(mpd_meta['disc']) m=re.match('^([0-9]+)', disc) if m: self._metadata['xesam:discNumber']=int(m.group(1)) if 'artist' in mpd_meta: if type(mpd_meta['artist']) == list: self._metadata['xesam:artist']=mpd_meta['artist'] else: self._metadata['xesam:artist']=[mpd_meta['artist']] if 'composer' in mpd_meta: if type(mpd_meta['composer']) == list: self._metadata['xesam:composer']=mpd_meta['composer'] else: self._metadata['xesam:composer']=[mpd_meta['composer']] # Stream: populate some missings tags with stream's name if 'name' in mpd_meta: if 'xesam:title' not in self._metadata: self._metadata['xesam:title']=mpd_meta['name'] elif 'xesam:album' not in self._metadata: self._metadata['xesam:album']=mpd_meta['name'] 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://"+os.path.join(lib_path, song_file) cover=Cover(self._settings, mpd_meta) if cover.path is None: self._metadata['mpris:artUrl']=None else: self._metadata['mpris:artUrl']="file://"+cover.path # Cast self._metadata to the correct type, or discard it for key, value in self._metadata.items(): try: self._metadata[key]=self.allowed_tags[key](value) except ValueError: del self._metadata[key] __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): status=self._client.wrapped_call("status") return {'play': 'Playing', 'pause': 'Paused', 'stop': 'Stopped'}[status['state']] def __set_loop_status(self, value): 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 %r not supported" % value) return def __get_loop_status(self): 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" def __set_shuffle(self, value): self._client.wrapped_call("random", value) return def __get_shuffle(self): if int(self._client.wrapped_call("status")['random']) == 1: return True else: return False def __get_metadata(self): return dbus.Dictionary(self._metadata, signature='sv') def __get_volume(self): vol=float(self._client.wrapped_call("status").get('volume', 0)) if vol > 0: return vol / 100.0 else: return 0.0 def __set_volume(self, value): if value >= 0 and value <= 1: self._client.wrapped_call("setvol", int(value * 100)) return def __get_position(self): status=self._client.wrapped_call("status") if 'time' in status: current, end=status['time'].split(':') return dbus.Int64((int(current) * 1000000)) else: return dbus.Int64(0) def __get_can_next_prev(self): status=self._client.wrapped_call("status") if status['state'] == "stop": return False else: return True __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": (True, None), "CanPause": (True, None), "CanSeek": (True, None), "CanControl": (True, None), } __prop_mapping={ __player_interface: __player_props, __root_interface: __root_props, } @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 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 # 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.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): # TODO status=self._client.wrapped_call("status") current, end=status['time'].split(':') current=int(current) end=int(end) offset=int(offset) / 1000000 if current + offset <= end: position=current + offset if position < 0: position=0 self._client.wrapped_call("seekid", int(status['songid']), position) self.Seeked(position * 1000000) return @dbus.service.method(__player_interface, in_signature='ox', out_signature='') def SetPosition(self, trackid, position): song=self._client.wrapped_call("currentsong") # FIXME: use real dbus objects if str(trackid) != '/org/mpris/MediaPlayer2/Track/%s' % song['id']: return # Convert position to seconds position=int(position) / 1000000 if position <= int(song['time']): self._client.wrapped_call("seekid", int(song['id']), position) self.Seeked(position * 1000000) return @dbus.service.signal(__player_interface, signature='x') def Seeked(self, position): return float(position) @dbus.service.method(__player_interface, in_signature='', out_signature='') def OpenUri(self): return 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): self.acquire_name() def _on_disconnected(self, *args): self.release_name() self._metadata={} def _name_owner_changed_callback(self, name, old_owner, new_owner): if name == self._name and old_owner == self._uname and new_owner != "": try: pid=self._dbus_obj.GetConnectionUnixProcessID(new_owner) except: pid=None loop.quit() ###################### # MPD client wrapper # ###################### class ClientHelper(): 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": "0", "disc": "", "artist": _("Unknown Artist"), "album": _("Unknown Album"), "duration": "0.0", "date": "", "genre": "" } base_song.update(song) base_song["human_duration"]=str(datetime.timedelta(seconds=int(float(base_song["duration"])))).lstrip("0").lstrip(":") return base_song def calc_display_length(songs): length=float(0) for song in songs: try: dura=float(song["duration"]) except: dura=0.0 length=length+dura return str(datetime.timedelta(seconds=int(length))).lstrip("0").lstrip(":") 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__() # gsignals def do_connection_error(self): print("Connection error!") 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 _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::"+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::"+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::"+key, self._on_check_settings_changed, check_buttons[key]) ) # headings view_heading=Gtk.Label(label=_("View"), use_markup=True, xalign=0) behavior_heading=Gtk.Label(label=_("Behavior"), 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 ("+str(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, 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, str(datetime.timedelta(seconds=int(stats[key])))]) elif key == "db_update": store.append([print_key, str(datetime.datetime.fromtimestamp(int(stats[key])))]) 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, can_focus=False, search_column=-1, tooltip_column=2) 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("&", "&") if tag == "time": store.append([tag+":", str(datetime.timedelta(seconds=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(r''+COVER_REGEX+'', flags=re.IGNORECASE) else: try: artist=song["albumartist"] except: artist="" try: album=song["album"] except: album="" regex_str=regex_str.replace("%AlbumArtist%", artist) regex_str=regex_str.replace("%Album%", album) try: regex=re.compile(r''+regex_str+'', flags=re.IGNORECASE) except: print("illegal regex:", regex_str) 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) self._store=Gtk.ListStore(int, str, str, str, str, str) # 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(0) 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([ int(song["track"]), song["title"], song["artist"], song["album"], song["human_duration"], song["file"] ]) self._hits_label.set_text(_("%i hits") % (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 album_duration=ClientHelper.calc_display_length(songs) if year == "": self.set_title(album_artist+" - "+album+" ("+album_duration+")") else: self.set_title(album_artist+" - "+album+" ("+year+") ("+album_duration+")") # store # (track, title (artist), duration, file) store=Gtk.ListStore(int, 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=""+title+"" else: title_artist=""+title+" - "+artist title_artist=title_artist.replace("&", "&") store.append([int(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")) try: album=song["album"] except: 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"]) try: discs=int(album["songs"][-1]["disc"]) except: discs=1 if discs > 1: tooltip=(_("%(total_tracks)i titles on %(discs)i discs (%(total_length)s)") %{"total_tracks": len(album["songs"]), "discs": discs, "total_length": length_human_readable}) else: tooltip=(_("%(total_tracks)i titles (%(total_length)s)") %{"total_tracks": len(album["songs"]), "total_length": length_human_readable}) # album label display_label=""+album["album"]+"" if album["year"] != "": display_label=display_label+" ("+album["year"]+")" display_label_artist=display_label+"\n"+album["artist"] display_label=display_label.replace("&", "&") display_label_artist=display_label_artist.replace("&", "&") # 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.search_button=Gtk.ToggleButton(image=self._icons["system-search-symbolic"], tooltip_text=_("Search")) 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: try: artist=song["artist"] except: 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_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 _display_lyrics(self, current_song): GLib.idle_add(self._text_buffer.set_text, _("searching..."), -1) try: text=self._get_lyrics(current_song["artist"], current_song["title"]) except: 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 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 _get_lyrics(self, singer, song): # partially copied from PyLyrics 1.1.0 # Replace spaces with _ singer=singer.replace(' ', '_') song=song.replace(' ', '_') r=requests.get('http://lyrics.wikia.com/{0}:{1}'.format(singer,song)) s=BeautifulSoup(r.text) # Get main lyrics holder lyrics=s.find("div",{'class':'lyricbox'}) if lyrics is None: raise ValueError("Song or Singer does not exist or the API does not have Lyrics") return None # Remove Scripts [s.extract() for s in lyrics('script')] # Remove Comments comments=lyrics.findAll(text=lambda text:isinstance(text, Comment)) [comment.extract() for comment in comments] # Remove span tag (Needed for instrumantal) if lyrics.span is not None: lyrics.span.extract() # Remove unecessary tags for tag in ['div','i','b','a']: for match in lyrics.findAll(tag): match.replaceWithChildren() # Get output as a string and remove non unicode characters and replace
with newlines output=str(lyrics).encode('utf-8', errors='replace')[22:-6:].decode("utf-8").replace('\n','').replace('
','\n') try: return output except: return output.encode('utf-8') 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)s kb/s, %(frequency)s kHz, %(resolution)s bit, %(channels)s channels, %(file_type)s") %{"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: try: artist=song["artist"] except: artist="" try: album=song["album"] except: album="" try: album_year=song["date"] except: album_year="" 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 ) 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 ) 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: whole_length_human_readable=ClientHelper.calc_display_length(songs) self._playlist_info.set_text(_("%(total_tracks)i titles (%(total_length)s)") %{"total_tracks": len(songs), "total_length": whole_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): 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 CoverLyricsOSD(Gtk.Overlay): 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) # 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_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) # packing self.add(main_cover) self.add_overlay(self._stack) self.add_overlay(self._lyrics_button_revealer) # 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) self._on_settings_changed() # hide lyrics button def toggle_lyrics(self, *args): self._lyrics_button.set_active(not(self._lyrics_button.get_active())) 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) class CoverPlaylistWindow(Gtk.Paned): def __init__(self, client, settings, window): super().__init__() # paned0 # adding vars self._client=client self._settings=settings # widgets self._cover_lyrics_osd=CoverLyricsOSD(self._client, self._settings, window) self._playlist_window=PlaylistWindow(self._client, self._settings) # packing self.pack1(self._cover_lyrics_osd, False, False) self.pack2(self._playlist_window, True, False) self.set_position(self._settings.get_int("paned0")) def toggle_lyrics(self, *args): self._cover_lyrics_osd.toggle_lyrics() def save_settings(self): self._settings.set_int("paned0", self.get_position()) self._playlist_window.save_settings() ################### # 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.stop_button=Gtk.Button(image=self._icons["media-playback-stop-symbolic"]) self.prev_button=Gtk.Button(image=self._icons["media-skip-backward-symbolic"]) self.next_button=Gtk.Button(image=self._icons["media-skip-forward-symbolic"]) # 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) # 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_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_play_clicked(self, widget): if self._client.connected(): status=self._client.wrapped_call("status") if status["state"] == "play": self._client.wrapped_call("pause", 1) elif status["state"] == "pause": self._client.wrapped_call("pause", 0) else: try: self._client.wrapped_call("play") except: pass def _on_stop_clicked(self, widget): if self._client.connected(): self._client.wrapped_call("stop") def _on_prev_clicked(self, widget): if self._client.connected(): self._client.wrapped_call("previous") def _on_next_clicked(self, widget): if self._client.connected(): self._client.wrapped_call("next") 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_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(str(datetime.timedelta(seconds=int(elapsed))).lstrip("0").lstrip(":")) self._rest.set_text("-"+str(datetime.timedelta(seconds=int(duration-elapsed))).lstrip("0").lstrip(":")) 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(str(datetime.timedelta(seconds=int(elapsed))).lstrip("0").lstrip(":")) self._rest.set_text("-"+str(datetime.timedelta(seconds=int(duration-elapsed))).lstrip("0").lstrip(":")) 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._repeat_button=Gtk.ToggleButton(image=self._icons["media-playlist-repeat-symbolic"], tooltip_text=_("Repeat mode")) self._single_button=Gtk.ToggleButton(image=self._icons["zoom-original-symbolic"], tooltip_text=_("Single mode")) self._consume_button=Gtk.ToggleButton(image=self._icons["edit-cut-symbolic"], tooltip_text=_("Consume mode")) self._volume_button=Gtk.VolumeButton(use_symbolic=True, size=self._settings.get_gtk_icon_size("icon-size")) 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")) #################### # shortcuts window # #################### class ShortcutsWindow(Gtk.ShortcutsWindow): def __init__(self): super().__init__(modal=True) 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=[ ("m", _("Toggle mini player"), None, window_group), ("l", _("Toggle lyrics"), None, window_group), ("f", _("Toggle search"), None, window_group), ("Escape", _("Back to current album"), None, window_group), ("F1", _("Open online help"), None, general_group), ("q", _("Quit"), None, general_group), ("space", _("Play/Pause"), 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), ("F5", _("Update database"), 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), ("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 ProfileSelect(Gtk.ComboBoxText): def __init__(self, settings): super().__init__(tooltip_text=_("Select profile")) self.set_property("no-show-all", not(len(settings.get_value("profiles")) > 1)) # adding vars self._settings=settings # connect self._changed=self.connect("changed", self._on_changed) self._settings.connect("changed::profiles", self._refresh) self._settings.connect("changed::hosts", self._refresh) self._settings.connect("changed::ports", self._refresh) self._settings.connect("changed::passwords", self._refresh) self._settings.connect("changed::paths", self._refresh) self._settings.connect("changed::active-profile", self._on_active_profile_changed) self._settings.connect("changed::profiles", self._on_profiles_changed) self._refresh() def _refresh(self, *args): self.handler_block(self._changed) self.remove_all() for profile in self._settings.get_value("profiles"): self.append_text(profile) self.set_active(self._settings.get_int("active-profile")) self.handler_unblock(self._changed) def _on_changed(self, *args): active=self.get_active() self._settings.set_int("active-profile", active) def _on_active_profile_changed(self, *args): self.handler_block(self._changed) self.set_active(self._settings.get_int("active-profile")) self.handler_unblock(self._changed) def _on_profiles_changed(self, *args): if len(self._settings.get_value("profiles")) > 1: self.set_property("no-show-all", False) self.set_property("visible", True) else: self.set_property("no-show-all", True) self.set_property("visible", False) 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") profile=self._settings.get_value("profiles")[active] host=self._settings.get_value("hosts")[active] port=self._settings.get_value("ports")[active] string=_('Connection to "%(profile)s" (%(host)s:%(port)s) failed') % {"profile": profile, "host": host, "port": port} 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")) self.set_help_overlay(ShortcutsWindow()) # 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._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","update","help", "toggle-lyrics","toggle-play","next","prev","back-to-current-album","seek-forward","seek-backward","toggle-search" ] 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) # widgets self._icons={} icons_data=["open-menu-symbolic"] for data in icons_data: self._icons[data]=PixelSizedIcon(data, self._icon_size) self._browser=Browser(self._client, self._settings, self) self._cover_playlist_window=CoverPlaylistWindow(self._client, self._settings, self) self._profile_select=ProfileSelect(self._settings) self._playback_control=PlaybackControl(self._client, self._settings) self._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"), "win.update") mpd_subsection.append(_("Server stats"), "win.stats") menu=Gio.Menu() menu.append(_("Save window layout"), "win.save") menu.append(_("Mini player"), "win.mini-player") menu.append_section(None, mpd_subsection) menu.append_section(None, subsection) menu_button=Gtk.MenuButton(image=self._icons["open-menu-symbolic"], tooltip_text=_("Menu")) menu_popover=Gtk.Popover.new_from_model(menu_button, menu) menu_button.set_popover(menu_popover) # action bar action_bar=Gtk.ActionBar() action_bar.pack_start(self._playback_control) action_bar.pack_start(self._seek_bar) action_bar.pack_start(playback_options) # connect 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(menu_button) self._header_bar.pack_end(self._profile_select) 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._profile_select) action_bar.pack_start(menu_button) self.add(overlay) self.show_all() if self._settings.get_boolean("maximize"): self.maximize() self._client.start() # connect client 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): self._playback_control.set_sensitive(True) self.action_enabled_changed self.lookup_action("update").set_enabled(True) self.lookup_action("stats").set_enabled(True) self.lookup_action("toggle-lyrics").set_enabled(True) def _on_disconnected(self, *args): self.set_title("mpdevil") if self._use_csd: self._header_bar.set_subtitle("") self._playback_control.set_sensitive(False) self.lookup_action("update").set_enabled(False) self.lookup_action("stats").set_enabled(False) self.lookup_action("toggle-lyrics").set_enabled(False) def _on_search_focus_changed(self, obj, focus): if focus: self.lookup_action("toggle-play").set_enabled(False) else: self.lookup_action("toggle-play").set_enabled(True) def _on_toggle_lyrics(self, action, param): self._cover_playlist_window.toggle_lyrics() def _on_toggle_play(self, action, param): self._playback_control.play_button.grab_focus() self._playback_control.play_button.emit("clicked") def _on_next(self, action, param): self._playback_control.next_button.grab_focus() self._playback_control.next_button.emit("clicked") def _on_prev(self, action, param): self._playback_control.prev_button.grab_focus() self._playback_control.prev_button.emit("clicked") def _on_back_to_current_album(self, action, param): self._browser.back_to_current_album_button.grab_focus() self._browser.back_to_current_album_button.emit("clicked") def _on_seek_forward(self, action, param): self._seek_bar.scale.grab_focus() self._client.wrapped_call("seekcur", "+10") def _on_seek_backward(self, action, param): self._seek_bar.scale.grab_focus() self._client.wrapped_call("seekcur", "-10") def _on_toggle_search(self, action, param): self._browser.search_button.grab_focus() 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_update(self, action, param): self._client.wrapped_call("update") def _on_help(self, action, param): Gtk.show_uri_on_window(self, "https://github.com/SoongNoonien/mpdevil/wiki/Usage", Gdk.CURRENT_TIME) def _on_mini_player(self, obj, typestring): if obj.get_property("mini-player"): self._tmp_saved_size=self.get_size() self._tmp_saved_miximized=self.is_maximized() self.lookup_action("save").set_enabled(False) self.lookup_action("back-to-current-album").set_enabled(False) if self._tmp_saved_miximized: self.unmaximize() self.resize(1,1) else: self.lookup_action("save").set_enabled(True) self.lookup_action("back-to-current-album").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 _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 self.set_accels_for_action("app.quit", ["q"]) self.set_accels_for_action("win.mini-player", ["m"]) self.set_accels_for_action("win.update", ["F5"]) self.set_accels_for_action("win.help", ["F1"]) self.set_accels_for_action("win.toggle-lyrics", ["l"]) self.set_accels_for_action("win.toggle-play", ["space"]) self.set_accels_for_action("win.next", ["KP_Add"]) self.set_accels_for_action("win.prev", ["KP_Subtract",]) self.set_accels_for_action("win.seek-forward", ["KP_Multiply"]) self.set_accels_for_action("win.seek-backward", ["KP_Divide"]) self.set_accels_for_action("win.back-to-current-album", ["Escape"]) self.set_accels_for_action("win.toggle-search", ["f"]) 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)