#!/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 threading import locale import gettext gettext.textdomain('mpdevil') _=gettext.gettext import datetime import os import sys import re from mpdevil.lyrics import LyricsHelper # MPRIS modules import dbus import dbus.service from dbus.mainloop.glib import DBusGMainLoop DBusGMainLoop(set_as_default=True) VERSION='0.9.1-dev' # 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": "", "disc": "", "artist": "", "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: length=length+float(song.get("duration", 0.0)) 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: regex_str=regex_str.replace("%AlbumArtist%", song.get("albumartist", "")) regex_str=regex_str.replace("%Album%", song.get("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, sort track) self._store=Gtk.ListStore(str, str, str, str, str, str, int) self._store.set_default_sort_func(lambda *args: 0) # songs window self._songs_window=SongsWindow(self._client, self._store, 5) # action bar self._action_bar=self._songs_window.get_action_bar() self._action_bar.set_sensitive(False) # songs view self._songs_view=self._songs_window.get_treeview() # columns renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True) renderer_text_ralign=Gtk.CellRendererText(xalign=1.0) column_track=Gtk.TreeViewColumn(_("No"), renderer_text_ralign, text=0) column_track.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) column_track.set_property("resizable", False) self._songs_view.append_column(column_track) column_title=Gtk.TreeViewColumn(_("Title"), renderer_text, text=1) column_title.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) column_title.set_property("resizable", False) column_title.set_property("expand", True) self._songs_view.append_column(column_title) column_artist=Gtk.TreeViewColumn(_("Artist"), renderer_text, text=2) column_artist.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) column_artist.set_property("resizable", False) column_artist.set_property("expand", True) self._songs_view.append_column(column_artist) column_album=Gtk.TreeViewColumn(_("Album"), renderer_text, text=3) column_album.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) column_album.set_property("resizable", False) column_album.set_property("expand", True) self._songs_view.append_column(column_album) column_time=Gtk.TreeViewColumn(_("Length"), renderer_text, text=4) column_time.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) column_time.set_property("resizable", False) self._songs_view.append_column(column_time) column_track.set_sort_column_id(6) column_title.set_sort_column_id(1) column_artist.set_sort_column_id(2) column_album.set_sort_column_id(3) column_time.set_sort_column_id(4) # connect self.search_entry.connect("search-changed", self._on_search_changed) self._tags.connect("changed", self._on_search_changed) self._client.emitter.connect("reconnected", self._on_reconnected) self._client.emitter.connect("disconnected", self._on_disconnected) # packing hbox=Gtk.Box(spacing=6, border_width=6) hbox.pack_start(self.search_entry, True, True, 0) hbox.pack_end(self._tags, False, False, 0) self._hits_label.set_margin_end(6) self._action_bar.pack_end(self._hits_label) self.pack_start(hbox, False, False, 0) self.pack_start(Gtk.Separator.new(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0) self.pack_start(self._songs_window, True, True, 0) def clear(self, *args): self._songs_view.clear() self.search_entry.set_text("") self._tags.remove_all() def _on_disconnected(self, *args): self._tags.set_sensitive(False) self.search_entry.set_sensitive(False) self.clear() def _on_reconnected(self, *args): self._tags.append_text("any") for tag in self._client.wrapped_call("tagtypes"): if not tag.startswith("MUSICBRAINZ"): self._tags.append_text(tag) self._tags.set_active(0) self._tags.set_sensitive(True) self.search_entry.set_sensitive(True) def _on_search_changed(self, widget): self._songs_view.clear() self._hits_label.set_text("") if len(self.search_entry.get_text()) > 1: songs=self._client.wrapped_call("search", self._tags.get_active_text(), self.search_entry.get_text()) for s in songs: song=ClientHelper.extend_song_for_display(ClientHelper.song_to_str_dict(s)) self._store.append([ song["track"], song["title"], song["artist"], song["album"], song["human_duration"], song["file"], int(song.get("track", 0)) ]) self._hits_label.set_text(_("%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(str, str, str, str) for s in songs: song=ClientHelper.extend_song_for_display(s) if type(song["title"]) == list: # could be impossible title=(', '.join(song["title"])) else: title=song["title"] if type(song["artist"]) == list: try: song["artist"].remove(album_artist) except: pass artist=(', '.join(song["artist"])) else: artist=song["artist"] if artist == album_artist: title_artist=""+title+"" else: title_artist=""+title+" - "+artist title_artist=title_artist.replace("&", "&") store.append([song["track"], title_artist, song["human_duration"], song["file"]]) # songs window songs_window=SongsWindow(self._client, store, 3) # songs view songs_view=songs_window.get_treeview() # columns renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True) renderer_text_ralign=Gtk.CellRendererText(xalign=1.0) column_track=Gtk.TreeViewColumn(_("No"), renderer_text_ralign, text=0) column_track.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) column_track.set_property("resizable", False) songs_view.append_column(column_track) column_title=Gtk.TreeViewColumn(_("Title"), renderer_text, markup=1) column_title.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) column_title.set_property("resizable", False) column_title.set_property("expand", True) songs_view.append_column(column_title) column_time=Gtk.TreeViewColumn(_("Length"), renderer_text, text=2) column_time.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) column_time.set_property("resizable", False) songs_view.append_column(column_time) # close button close_button=Gtk.ToggleButton(image=Gtk.Image.new_from_icon_name("window-close", Gtk.IconSize.BUTTON), label=_("Close")) # action bar action_bar=songs_window.get_action_bar() action_bar.pack_end(close_button) # connect close_button.connect("clicked", self._on_close_button_clicked) # packing self.vbox.pack_start(songs_window, True, True, 0) # vbox default widget of dialogs self.show_all() def open(self): response=self.run() def _on_close_button_clicked(self, *args): self.destroy() class AlbumWindow(FocusFrame): def __init__(self, client, settings, artist_window, window): super().__init__() # adding vars self._settings=settings self._client=client self._artist_window=artist_window self._window=window self._button_event=(None, None) self.stop_flag=False self._done=True self._pending=[] # cover, display_label, display_label_artist, tooltip(titles), album, year, artist self._store=Gtk.ListStore(GdkPixbuf.Pixbuf, str, str, str, str, str, str) self._sort_settings() # iconview self._iconview=Gtk.IconView(model=self._store, item_width=0, pixbuf_column=0, markup_column=1) self._tooltip_settings() # scroll scroll=Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scroll.add(self._iconview) # connect self._iconview.connect("item-activated", self._on_item_activated) self._iconview.connect("button-release-event", self._on_button_release_event) self._iconview.connect("button-press-event", self._on_button_press_event) self._key_press_event=self.connect("key-press-event", self._on_key_press_event) self._client.emitter.connect("update", self._clear) self._client.emitter.connect("disconnected", self._on_disconnected) self._client.emitter.connect("reconnected", self._on_reconnected) self._settings.connect("changed::show-album-view-tooltips", self._tooltip_settings) self._settings.connect("changed::sort-albums-by-year", self._sort_settings) self._settings.connect("changed::album-cover", self._on_cover_size_changed) self._settings.connect("changed::use-album-artist", self._clear) self._artist_window.connect("artists_changed", self._refresh) self.set_widget(self._iconview) self.add(scroll) def _workaround_clear(self): self._store.clear() # workaround (scrollbar still visible after clear) self._iconview.set_model(None) self._iconview.set_model(self._store) def _clear(self, *args): if self._done: self._workaround_clear() elif not self._clear in self._pending: self.stop_flag=True self._pending.append(self._clear) def scroll_to_current_album(self): def callback(): song=ClientHelper.song_to_first_str_dict(self._client.wrapped_call("currentsong")) album=song.get("album", "") self._iconview.unselect_all() row_num=len(self._store) for i in range(0, row_num): path=Gtk.TreePath(i) treeiter=self._store.get_iter(path) if self._store.get_value(treeiter, 4) == album: self._iconview.set_cursor(path, None, False) self._iconview.select_path(path) self._iconview.scroll_to_path(path, True, 0, 0) break if self._done: callback() elif not self.scroll_to_current_album in self._pending: self._pending.append(self.scroll_to_current_album) def _tooltip_settings(self, *args): if self._settings.get_boolean("show-album-view-tooltips"): self._iconview.set_tooltip_column(3) else: self._iconview.set_tooltip_column(-1) def _sort_settings(self, *args): if self._settings.get_boolean("sort-albums-by-year"): self._store.set_sort_column_id(5, Gtk.SortType.ASCENDING) else: self._store.set_sort_column_id(1, Gtk.SortType.ASCENDING) def _add_row(self, row): # needed for GLib.idle self._store.append(row) return False # stop after one run def _refresh(self, *args): def callback(): GLib.idle_add(self._workaround_clear) try: # self._artist_window could still be empty genre, artists=self._artist_window.get_selected_artists() except: GLib.idle_add(self._done_callback) # show artist names if all albums are shown if len(artists) > 1: self._iconview.set_markup_column(2) else: self._iconview.set_markup_column(1) # prepare albmus list (run all mpd related commands) albums=[] artist_type=self._settings.get_artist_type() for artist in artists: try: # client cloud meanwhile disconnect if self.stop_flag: GLib.idle_add(self._done_callback) return else: if genre is None: album_candidates=self._client.wrapped_call("comp_list", "album", artist_type, artist) else: album_candidates=self._client.wrapped_call( "comp_list", "album", artist_type, artist, "genre", genre ) for album in album_candidates: years=self._client.wrapped_call("comp_list", "date", "album", album, artist_type, artist) for year in years: songs=self._client.wrapped_call( "find", "album", album, "date", year, artist_type, artist ) albums.append({"artist": artist, "album": album, "year": year, "songs": songs}) while Gtk.events_pending(): Gtk.main_iteration_do(True) except MPDBase.ConnectionError: GLib.idle_add(self._done_callback) return # display albums if self._settings.get_boolean("sort-albums-by-year"): albums=sorted(albums, key=lambda k: k['year']) else: albums=sorted(albums, key=lambda k: k['album']) size=self._settings.get_int("album-cover") for i, album in enumerate(albums): if self.stop_flag: break else: cover=Cover(self._settings, album["songs"][0]).get_pixbuf(size) # tooltip length_human_readable=ClientHelper.calc_display_length(album["songs"]) discs=int(album["songs"][-1].get("disc", 1)) if discs > 1: tooltip=(_("%(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: artist=song.get("artist", "") # deactivate genre filter to show all artists (if needed) try: if song['genre'] != self.genre_select.get_selected_genre(): self.genre_select.deactivate() except: self.genre_select.deactivate() # select artist if len(self._artist_window.get_selected_artists()[1]) <= 1: # one artist selected self._artist_window.select(artist) else: # all artists selected self.search_button.set_active(False) self._artist_window.highlight_selected() self._album_window.scroll_to_current_album() return False GLib.idle_add(callback) # ensure it will be executed even when albums are still loading def _on_search_toggled(self, widget): if widget.get_active(): self._stack.set_visible_child_name("search") self._search_window.search_entry.grab_focus() else: self._stack.set_visible_child_name("albums") def _on_reconnected(self, *args): self._back_to_current_album() self.back_to_current_album_button.set_sensitive(True) self.search_button.set_sensitive(True) def _on_disconnected(self, *args): self.back_to_current_album_button.set_sensitive(False) self.search_button.set_active(False) self.search_button.set_sensitive(False) def _on_artists_changed(self, *args): self.search_button.set_active(False) def _on_mini_player(self, obj, typestring): visibility=not(obj.get_property("mini-player")) self.set_property("visible", visibility) self.back_to_current_album_button.set_property("visible", visibility) self.search_button.set_property("visible", visibility) self.genre_select.set_property("visible", visibility) def _on_icon_size_changed(self, *args): pixel_size=self._settings.get_int("icon-size-sec") for icon in self._icons.values(): icon.set_pixel_size(pixel_size) ###################### # playlist and cover # ###################### class LyricsWindow(FocusFrame): def __init__(self, client, settings): super().__init__() # adding vars self._settings=settings self._client=client self._displayed_song_file=None self._lyrics_helper=LyricsHelper(debug=False) # 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) text=None if current_song.get("artist", "") != "" and current_song.get("title", "") != "": text=self._lyrics_helper.get_lyrics(current_song["artist"], current_song["title"]) if text is None: text=_("lyrics not found") GLib.idle_add(self._text_buffer.set_text, text, -1) def _refresh(self, *args): current_song=self._client.wrapped_call("currentsong") if current_song == {}: self._displayed_song_file=None self._text_buffer.set_text("", -1) else: self._displayed_song_file=current_song["file"] update_thread=threading.Thread( target=self._display_lyrics, kwargs={"current_song": ClientHelper.song_to_first_str_dict(current_song)}, daemon=True ) update_thread.start() def _on_disconnected(self, *args): self._displayed_song_file=None self._text_buffer.set_text("", -1) class AudioType(Gtk.Label): def __init__(self, client): super().__init__() # adding vars self._client=client self._init_vars() # connect self._client.emitter.connect("audio", self._on_audio) self._client.emitter.connect("bitrate", self._on_bitrate) self._client.emitter.connect("current_song_changed", self._on_song_changed) self._client.emitter.connect("disconnected", self.clear) self._client.emitter.connect("state", self._on_state) def clear(self, *args): self.set_text("") self._init_vars() def _init_vars(self): self.freq=0 self.res=0 self.chan=0 self.brate=0 self.file_type="" def _refresh(self, *args): string=(_("%(bitrate)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: artist=song.get("artist", "") album=song.get("album", "") album_year=song.get("date", "") if event.button == 1 and event.type == Gdk.EventType.BUTTON_PRESS: self._client.wrapped_call("album_to_playlist", album, artist, album_year) elif event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS: self._client.wrapped_call("album_to_playlist", album, artist, album_year, "append") elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS: album_dialog=AlbumDialog(self._window, self._client, self._settings, album, artist, album_year) album_dialog.open() album_dialog.destroy() def _on_mini_player(self, obj, typestring): if obj.get_property("mini-player"): self.handler_block(self._button_press_event) else: self.handler_unblock(self._button_press_event) class MainCover(Gtk.Frame): def __init__(self, client, settings): super().__init__() # css style_context=self.get_style_context() provider=Gtk.CssProvider() css=b"""* {background-color: @theme_base_color; border-width: 0px}""" provider.load_from_data(css) style_context.add_provider(provider, 800) # adding vars self._client=client self._settings=settings # cover self._cover=Gtk.Image.new() size=self._settings.get_int("track-cover") self._cover.set_from_pixbuf(Cover(self._settings, {}).get_pixbuf(size)) # set to fallback cover # set default size self._cover.set_size_request(size, size) # connect self._client.emitter.connect("current_song_changed", self._refresh) self._client.emitter.connect("disconnected", self._on_disconnected) self._client.emitter.connect("reconnected", self._on_reconnected) self._settings.connect("changed::track-cover", self._on_settings_changed) self.add(self._cover) def _refresh(self, *args): current_song=self._client.wrapped_call("currentsong") self._cover.set_from_pixbuf(Cover(self._settings, current_song).get_pixbuf(self._settings.get_int("track-cover"))) def _on_disconnected(self, *args): size=self._settings.get_int("track-cover") self._cover.set_from_pixbuf(Cover(self._settings, {}).get_pixbuf(size)) self._cover.set_sensitive(False) def _on_reconnected(self, *args): self._cover.set_sensitive(True) def _on_settings_changed(self, *args): size=self._settings.get_int("track-cover") self._cover.set_size_request(size, size) self._refresh() class PlaylistWindow(Gtk.Box): def __init__(self, client, settings): super().__init__(orientation=Gtk.Orientation.VERTICAL) # adding vars self._client=client self._settings=settings self._playlist_version=None self._icon_size=self._settings.get_int("icon-size-sec") # buttons self._icons={} icons_data=["go-previous-symbolic", "edit-clear-symbolic"] for data in icons_data: self._icons[data]=PixelSizedIcon(data, self._icon_size) provider=Gtk.CssProvider() css=b"""* {min-height: 8px;}""" # allow further shrinking provider.load_from_data(css) self._back_to_current_song_button=Gtk.Button( image=self._icons["go-previous-symbolic"], tooltip_text=_("Scroll to current song"), relief=Gtk.ReliefStyle.NONE ) 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)