#!/usr/bin/python3 # -*- coding: utf-8 -*- # # mpdevil - MPD Client. # Copyright 2020 Martin Wagner # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; version 3 of the License. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 # USA import gi gi.require_version("Gtk", "3.0") gi.require_version("Notify", "0.7") from gi.repository import Gtk, Gio, Gdk, GdkPixbuf, Pango, GObject, GLib, Notify from mpd import MPDClient, base as MPDBase import requests from bs4 import BeautifulSoup import threading import gettext gettext.textdomain("mpdevil") _=gettext.gettext import datetime import os import sys import re # MPRIS modules import dbus import dbus.service from dbus.mainloop.glib import DBusGMainLoop DBusGMainLoop(set_as_default=True) VERSION="0.9.5-dev" # sync with setup.py COVER_REGEX=r"^\.?(album|cover|folder|front).*\.(gif|jpeg|jpg|png)$" ######### # MPRIS # ######### class MPRISInterface(dbus.service.Object): # TODO emit Seeked if needed """ based on 'mpDris2' (master 19.03.2020) by Jean-Philippe Braun , Mantas Mikulėnas """ def __init__(self, window, client, settings): super().__init__(dbus.SessionBus(), "/org/mpris/MediaPlayer2") self._name="org.mpris.MediaPlayer2.mpdevil" # adding vars self._window=window self._client=client self._settings=settings self._metadata={} self._bus=dbus.SessionBus() # start self._bus_name=dbus.service.BusName(self._name, bus=self._bus, allow_replacement=True, replace_existing=True) # TODO # connect self._client.emitter.connect("state", self._on_state_changed) self._client.emitter.connect("current_song_changed", self._on_song_changed) self._client.emitter.connect("volume_changed", self._on_volume_changed) self._client.emitter.connect("repeat", self._on_loop_changed) self._client.emitter.connect("single", self._on_loop_changed) self._client.emitter.connect("random", self._on_random_changed) self._client.emitter.connect("connection_error", self._on_connection_error) self._client.emitter.connect("reconnected", self._on_reconnected) # Interfaces __prop_interface=dbus.PROPERTIES_IFACE __root_interface="org.mpris.MediaPlayer2" __root_props={ "CanQuit": (False, None), "CanRaise": (True, None), "DesktopEntry": ("mpdevil", None), "HasTrackList": (False, None), "Identity": ("mpdevil", None), "SupportedUriSchemes": (dbus.Array(signature="s"), None), "SupportedMimeTypes": (dbus.Array(signature="s"), None) } def __get_playback_status(self): if self._client.connected(): status=self._client.wrapped_call("status") return {"play": "Playing", "pause": "Paused", "stop": "Stopped"}[status["state"]] return "Stopped" def __set_loop_status(self, value): if self._client.connected(): if value == "Playlist": self._client.wrapped_call("repeat", 1) self._client.wrapped_call("single", 0) elif value == "Track": self._client.wrapped_call("repeat", 1) self._client.wrapped_call("single", 1) elif value == "None": self._client.wrapped_call("repeat", 0) self._client.wrapped_call("single", 0) else: raise dbus.exceptions.DBusException("Loop mode '{}' not supported".format(value)) return def __get_loop_status(self): if self._client.connected(): status=self._client.wrapped_call("status") if int(status["repeat"]) == 1: if int(status.get("single", 0)) == 1: return "Track" else: return "Playlist" else: return "None" return "None" def __set_shuffle(self, value): if self._client.connected(): self._client.wrapped_call("random", value) return def __get_shuffle(self): if self._client.connected(): if int(self._client.wrapped_call("status")["random"]) == 1: return True else: return False return False def __get_metadata(self): return dbus.Dictionary(self._metadata, signature="sv") def __get_volume(self): if self._client.connected(): return float(self._client.wrapped_call("status").get("volume", 0))/100 return 0.0 def __set_volume(self, value): if self._client.connected(): if value >= 0 and value <= 1: self._client.wrapped_call("setvol", int(value * 100)) return def __get_position(self): if self._client.connected(): status=self._client.wrapped_call("status") return dbus.Int64(float(status.get("elapsed", 0))*1000000) return dbus.Int64(0) def __get_can_next_prev(self): if self._client.connected(): status=self._client.wrapped_call("status") if status["state"] == "stop": return False else: return True return False def __get_can_play_pause_seek(self): return self._client.connected() __player_interface="org.mpris.MediaPlayer2.Player" __player_props={ "PlaybackStatus": (__get_playback_status, None), "LoopStatus": (__get_loop_status, __set_loop_status), "Rate": (1.0, None), "Shuffle": (__get_shuffle, __set_shuffle), "Metadata": (__get_metadata, None), "Volume": (__get_volume, __set_volume), "Position": (__get_position, None), "MinimumRate": (1.0, None), "MaximumRate": (1.0, None), "CanGoNext": (__get_can_next_prev, None), "CanGoPrevious": (__get_can_next_prev, None), "CanPlay": (__get_can_play_pause_seek, None), "CanPause": (__get_can_play_pause_seek, None), "CanSeek": (__get_can_play_pause_seek, None), "CanControl": (True, None), } __prop_mapping={ __player_interface: __player_props, __root_interface: __root_props, } # Prop methods @dbus.service.signal(__prop_interface, signature="sa{sv}as") def PropertiesChanged(self, interface, changed_properties, invalidated_properties): pass @dbus.service.method(__prop_interface, in_signature="ss", out_signature="v") def Get(self, interface, prop): getter, setter=self.__prop_mapping[interface][prop] if callable(getter): return getter(self) return getter @dbus.service.method(__prop_interface, in_signature="ssv", out_signature="") def Set(self, interface, prop, value): getter, setter=self.__prop_mapping[interface][prop] if setter is not None: setter(self, value) @dbus.service.method(__prop_interface, in_signature="s", out_signature="a{sv}") def GetAll(self, interface): read_props={} props=self.__prop_mapping[interface] for key, (getter, setter) in props.items(): if callable(getter): getter=getter(self) read_props[key]=getter return read_props # Root methods @dbus.service.method(__root_interface, in_signature="", out_signature="") def Raise(self): self._window.present() return @dbus.service.method(__root_interface, in_signature="", out_signature="") def Quit(self): return # Player methods @dbus.service.signal(__player_interface, signature="x") def Seeked(self, position): return float(position) @dbus.service.method(__player_interface, in_signature="", out_signature="") def Next(self): self._client.wrapped_call("next") return @dbus.service.method(__player_interface, in_signature="", out_signature="") def Previous(self): self._client.wrapped_call("previous") return @dbus.service.method(__player_interface, in_signature="", out_signature="") def Pause(self): self._client.wrapped_call("pause", 1) return @dbus.service.method(__player_interface, in_signature="", out_signature="") def PlayPause(self): status=self._client.wrapped_call("status") if status["state"] == "play": self._client.wrapped_call("pause", 1) else: self._client.wrapped_call("play") return @dbus.service.method(__player_interface, in_signature="", out_signature="") def Stop(self): self._client.wrapped_call("stop") return @dbus.service.method(__player_interface, in_signature="", out_signature="") def Play(self): self._client.wrapped_call("play") return @dbus.service.method(__player_interface, in_signature="x", out_signature="") def Seek(self, offset): if offset > 0: offset="+"+str(offset/1000000) else: offset=str(offset/1000000) self._client.wrapped_call("seekcur", offset) return @dbus.service.method(__player_interface, in_signature="ox", out_signature="") def SetPosition(self, trackid, position): song=self._client.wrapped_call("currentsong") if str(trackid).split("/")[-1] != song["id"]: return mpd_pos=position/1000000 if mpd_pos >= 0 and mpd_pos <= float(song["duration"]): self._client.wrapped_call("seekcur", str(mpd_pos)) return @dbus.service.method(__player_interface, in_signature="s", out_signature="") def OpenUri(self, uri): return # MPRIS implemented metadata tags (all: http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata) implemented_tags={ "mpris:trackid": dbus.ObjectPath, "mpris:length": dbus.Int64, "mpris:artUrl": str, "xesam:album": str, "xesam:albumArtist": list, "xesam:artist": list, "xesam:composer": list, "xesam:contentCreated": str, "xesam:discNumber": int, "xesam:genre": list, "xesam:title": str, "xesam:trackNumber": int, "xesam:url": str, } def _update_metadata(self): """ Translate metadata returned by MPD to the MPRIS v2 syntax. http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata """ mpd_meta=self._client.wrapped_call("currentsong") # raw values needed for cover song=ClientHelper.song_to_list_dict(mpd_meta) self._metadata={} for tag, xesam_tag in (("album","album"),("title","title"),("track","trackNumber"),("disc","discNumber"),("date","contentCreated")): if tag in song: self._metadata["xesam:{}".format(xesam_tag)]=song[tag][0] for tag, xesam_tag in (("albumartist","albumArtist"),("artist","artist"),("composer","composer"),("genre","genre")): if tag in song: self._metadata["xesam:{}".format(xesam_tag)]=song[tag] if "id" in song: self._metadata["mpris:trackid"]="/org/mpris/MediaPlayer2/Track/{}".format(song["id"][0]) if "time" in song: self._metadata["mpris:length"]=float(song["duration"][0]) * 1000000 if "file" in song: song_file=song["file"][0] lib_path=self._settings.get_value("paths")[self._settings.get_int("active-profile")] self._metadata["xesam:url"]="file://{}".format(os.path.join(lib_path, song_file)) cover=Cover(self._settings, mpd_meta) if cover.path is not None: self._metadata["mpris:artUrl"]="file://{}".format(cover.path) # Cast self._metadata to the correct type, or discard it for key, value in self._metadata.items(): try: self._metadata[key]=self.implemented_tags[key](value) except: self._metadata[key]="" def _update_property(self, interface, prop): getter, setter=self.__prop_mapping[interface][prop] if callable(getter): value=getter(self) else: value=getter self.PropertiesChanged(interface, {prop: value}, []) return value def _on_state_changed(self, *args): self._update_property("org.mpris.MediaPlayer2.Player", "PlaybackStatus") self._update_property("org.mpris.MediaPlayer2.Player", "CanGoNext") self._update_property("org.mpris.MediaPlayer2.Player", "CanGoPrevious") def _on_song_changed(self, *args): self._update_metadata() self._update_property("org.mpris.MediaPlayer2.Player", "Metadata") def _on_volume_changed(self, *args): self._update_property("org.mpris.MediaPlayer2.Player", "Volume") def _on_loop_changed(self, *args): self._update_property("org.mpris.MediaPlayer2.Player", "LoopStatus") def _on_random_changed(self, *args): self._update_property("org.mpris.MediaPlayer2.Player", "Shuffle") def _on_reconnected(self, *args): properties=("CanPlay","CanPause","CanSeek") for p in properties: self._update_property("org.mpris.MediaPlayer2.Player", p) def _on_connection_error(self, *args): self._metadata={} properties=("PlaybackStatus","CanGoNext","CanGoPrevious","Metadata","Volume","LoopStatus","Shuffle","CanPlay","CanPause","CanSeek") for p in properties: self._update_property("org.mpris.MediaPlayer2.Player", p) ###################### # MPD client wrapper # ###################### class ClientHelper(): def seconds_to_display_time(seconds): raw_time_string=str(datetime.timedelta(seconds=seconds)) stript_time_string=raw_time_string.lstrip("0").lstrip(":") return stript_time_string.replace(":", "∶") # use 'ratio' as delimiter def song_to_str_dict(song): # converts tags with multiple values to comma separated strings return_song={} for tag, value in song.items(): if type(value) == list: return_song[tag]=(", ".join(value)) else: return_song[tag]=value return return_song def song_to_first_str_dict(song): # extracts the first value of multiple value tags return_song={} for tag, value in song.items(): if type(value) == list: return_song[tag]=value[0] else: return_song[tag]=value return return_song def song_to_list_dict(song): # converts all values to lists return_song={} for tag, value in song.items(): if type(value) != list: return_song[tag]=[value] else: return_song[tag]=value return return_song def pepare_song_for_display(song): base_song={ "title": _("Unknown Title"), "track": "", "disc": "", "artist": "", "album": "", "duration": "0.0", "date": "", "genre": "" } if "range" in song: # translate .cue 'range' to 'duration' if needed start, end=song["range"].split("-") if start != "" and end != "": base_song["duration"]=str((float(end)-float(start))) base_song.update(song) base_song["human_duration"]=ClientHelper.seconds_to_display_time(int(float(base_song["duration"]))) for tag in ("disc", "track"): # remove confusing multiple tags if tag in song: if type(song[tag]) == list: base_song[tag]=song[tag][0] return base_song def calc_display_length(songs): length=float(0) for song in songs: length=length+float(song.get("duration", 0.0)) return ClientHelper.seconds_to_display_time(int(length)) class MpdEventEmitter(GObject.Object): __gsignals__={ "update": (GObject.SignalFlags.RUN_FIRST, None, ()), "disconnected": (GObject.SignalFlags.RUN_FIRST, None, ()), "reconnected": (GObject.SignalFlags.RUN_FIRST, None, ()), "connection_error": (GObject.SignalFlags.RUN_FIRST, None, ()), "current_song_changed": (GObject.SignalFlags.RUN_FIRST, None, ()), "state": (GObject.SignalFlags.RUN_FIRST, None, (str,)), "elapsed_changed": (GObject.SignalFlags.RUN_FIRST, None, (float,float,)), "volume_changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)), "playlist_changed": (GObject.SignalFlags.RUN_FIRST, None, (int,)), "repeat": (GObject.SignalFlags.RUN_FIRST, None, (bool,)), "random": (GObject.SignalFlags.RUN_FIRST, None, (bool,)), "single": (GObject.SignalFlags.RUN_FIRST, None, (bool,)), "consume": (GObject.SignalFlags.RUN_FIRST, None, (bool,)), "audio": (GObject.SignalFlags.RUN_FIRST, None, (str,str,str,)), "bitrate": (GObject.SignalFlags.RUN_FIRST, None, (float,)) } def __init__(self): super().__init__() class Client(MPDClient): def __init__(self, settings): super().__init__() # adding vars self._settings=settings self.emitter=MpdEventEmitter() self._last_status={} self._refresh_interval=self._settings.get_int("refresh-interval") self._main_timeout_id=None # connect self._settings.connect("changed::active-profile", self._on_active_profile_changed) def wrapped_call(self, name, *args): try: func=getattr(self, name) except: raise ValueError return func(*args) def start(self): self.emitter.emit("disconnected") # bring player in defined state active=self._settings.get_int("active-profile") try: self.connect(self._settings.get_value("hosts")[active], self._settings.get_value("ports")[active]) if self._settings.get_value("passwords")[active] != "": self.password(self._settings.get_value("passwords")[active]) except: self.emitter.emit("connection_error") return False # connect successful self._main_timeout_id=GLib.timeout_add(self._refresh_interval, self._main_loop) self.emitter.emit("reconnected") return True def reconnect(self): if self._main_timeout_id is not None: GLib.source_remove(self._main_timeout_id) self._main_timeout_id=None self._last_status={} self.disconnect() self.start() def connected(self): try: self.wrapped_call("ping") return True except: return False def files_to_playlist(self, files, mode="default"): # modes: default, play, append, enqueue def append(files): for f in files: self.add(f) def play(files): if files != []: self.clear() for f in files: self.add(f) self.play() def enqueue(files): status=self.status() if status["state"] == "stop": play(files) else: self.moveid(status["songid"], 0) current_song_file=self.playlistinfo()[0]["file"] try: self.delete((1,)) # delete all songs, but the first. bad song index possible except: pass for f in files: if f == current_song_file: self.move(0, (len(self.playlistinfo())-1)) else: self.add(f) if mode == "append": append(files) elif mode == "enqueue": enqueue(files) elif mode == "play": play(files) elif mode == "default": if self._settings.get_boolean("force-mode"): play(files) else: enqueue(files) def album_to_playlist(self, album, artist, year, mode="default"): songs=self.find("album", album, "date", year, self._settings.get_artist_type(), artist) self.files_to_playlist([song["file"] for song in songs], mode) def comp_list(self, *args): # simulates listing behavior of python-mpd2 1.0 native_list=self.list(*args) if len(native_list) > 0: if type(native_list[0]) == dict: return ([l[args[0]] for l in native_list]) else: return native_list else: return([]) def get_metadata(self, uri): meta_base=self.lsinfo(uri)[0] try: # .cue files produce an error here meta_extra=self.readcomments(uri) # contains comment tag meta_base.update(meta_extra) except: pass return meta_base def toggle_play(self): status=self.status() if status["state"] == "play": self.pause(1) elif status["state"] == "pause": self.pause(0) else: try: self.play() except: pass def toggle_option(self, option): # repeat, random, single, consume new_state=(int(self.status()[option])+1)%2 # toggle 0,1 func=getattr(self, option) func(new_state) def _main_loop(self, *args): try: status=self.status() diff=set(status.items())-set(self._last_status.items()) for key, val in diff: if key == "elapsed": self.emitter.emit("elapsed_changed", float(val), float(status["duration"])) elif key == "bitrate": self.emitter.emit("bitrate", float(val)) elif key == "songid": self.emitter.emit("current_song_changed") elif key == "state": self.emitter.emit("state", val) elif key == "audio": # see: https://www.musicpd.org/doc/html/user.html#audio-output-format samplerate, bits, channels=val.split(":") if bits == "f": bits="32fp" self.emitter.emit("audio", samplerate, bits, channels) elif key == "volume": self.emitter.emit("volume_changed", float(val)) elif key == "playlist": self.emitter.emit("playlist_changed", int(val)) elif key in ["repeat", "random", "single", "consume"]: if val == "1": self.emitter.emit(key, True) else: self.emitter.emit(key, False) diff=set(self._last_status)-set(status) if "songid" in diff: self.emitter.emit("current_song_changed") if "volume" in diff: self.emitter.emit("volume_changed", -1) if "updating_db" in diff: self.emitter.emit("update") self._last_status=status except (MPDBase.ConnectionError, ConnectionResetError) as e: self.disconnect() self._last_status={} self.emitter.emit("disconnected") self.emitter.emit("connection_error") self._main_timeout_id=None return False return True def _on_active_profile_changed(self, *args): self.reconnect() ######################## # gio settings wrapper # ######################## class Settings(Gio.Settings): BASE_KEY="org.mpdevil" # temp settings mini_player=GObject.Property(type=bool, default=False) def __init__(self): super().__init__(schema=self.BASE_KEY) # fix profile settings if len(self.get_value("profiles")) < (self.get_int("active-profile")+1): self.set_int("active-profile", 0) profile_keys=[ ("as", "profiles", "new profile"), ("as", "hosts", "localhost"), ("ai", "ports", 6600), ("as", "passwords", ""), ("as", "paths", ""), ("as", "regex", "") ] profile_arrays=[] for vtype, key, default in profile_keys: profile_arrays.append(self.get_value(key).unpack()) max_len=max(len(x) for x in profile_arrays) for index, (vtype, key, default) in enumerate(profile_keys): profile_arrays[index]=(profile_arrays[index]+max_len*[default])[:max_len] self.set_value(key, GLib.Variant(vtype, profile_arrays[index])) def array_append(self, vtype, key, value): # append to Gio.Settings (self._settings) array array=self.get_value(key).unpack() array.append(value) self.set_value(key, GLib.Variant(vtype, array)) def array_delete(self, vtype, key, pos): # delete entry of Gio.Settings (self._settings) array array=self.get_value(key).unpack() array.pop(pos) self.set_value(key, GLib.Variant(vtype, array)) def array_modify(self, vtype, key, pos, value): # modify entry of Gio.Settings (self._settings) array array=self.get_value(key).unpack() array[pos]=value self.set_value(key, GLib.Variant(vtype, array)) def get_gtk_icon_size(self, key): icon_size=self.get_int(key) sizes=[(48, Gtk.IconSize.DIALOG), (32, Gtk.IconSize.DND), (24, Gtk.IconSize.LARGE_TOOLBAR), (16, Gtk.IconSize.BUTTON)] for pixel_size, gtk_size in sizes: if icon_size >= pixel_size: return gtk_size return Gtk.IconSize.INVALID def get_artist_type(self): if self.get_boolean("use-album-artist"): return ("albumartist") else: return ("artist") ################### # settings dialog # ################### class GeneralSettings(Gtk.Box): def __init__(self, settings): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6, border_width=18) # adding vars self._settings=settings self._settings_handlers=[] # int settings int_settings={} int_settings_data=[ (_("Main cover size:"), (100, 1200, 10), "track-cover"), (_("Album view cover size:"), (50, 600, 10), "album-cover"), (_("Action bar icon size:"), (16, 64, 2), "icon-size"), (_("Secondary icon size:"), (16, 64, 2), "icon-size-sec") ] for label, (vmin, vmax, step), key in int_settings_data: int_settings[key]=(Gtk.Label(label=label, xalign=0), Gtk.SpinButton.new_with_range(vmin, vmax, step)) int_settings[key][1].set_value(self._settings.get_int(key)) int_settings[key][1].connect("value-changed", self._on_int_changed, key) self._settings_handlers.append( self._settings.connect("changed::{}".format(key), self._on_int_settings_changed, int_settings[key][1]) ) # combo_settings combo_settings={} combo_settings_data=[ (_("Sort albums by:"), _("name"), _("year"), "sort-albums-by-year"), (_("Position of playlist:"), _("bottom"), _("right"), "playlist-right") ] for label, vfalse, vtrue, key in combo_settings_data: combo_settings[key]=(Gtk.Label(label=label, xalign=0), Gtk.ComboBoxText(entry_text_column=0)) combo_settings[key][1].append_text(vfalse) combo_settings[key][1].append_text(vtrue) if self._settings.get_boolean(key): combo_settings[key][1].set_active(1) else: combo_settings[key][1].set_active(0) combo_settings[key][1].connect("changed", self._on_combo_changed, key) self._settings_handlers.append( self._settings.connect("changed::{}".format(key), self._on_combo_settings_changed, combo_settings[key][1]) ) # check buttons check_buttons={} check_buttons_data=[ (_("Use Client-side decoration"), "use-csd"), (_("Show stop button"), "show-stop"), (_("Show lyrics button"), "show-lyrics-button"), (_("Show initials in artist view"), "show-initials"), (_("Show tooltips in album view"), "show-album-view-tooltips"), (_("Use “Album Artist” tag"), "use-album-artist"), (_("Send notification on title change"), "send-notify"), (_("Stop playback on quit"), "stop-on-quit"), (_("Play selected albums and titles immediately"), "force-mode") ] for label, key in check_buttons_data: check_buttons[key]=Gtk.CheckButton(label=label) check_buttons[key].set_active(self._settings.get_boolean(key)) check_buttons[key].set_margin_start(12) check_buttons[key].connect("toggled", self._on_toggled, key) self._settings_handlers.append( self._settings.connect("changed::{}".format(key), self._on_check_settings_changed, check_buttons[key]) ) # headings view_heading=Gtk.Label(label=_("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, placeholder_text=GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_MUSIC)) self._path_select_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("folder-open", 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_changed=self._profiles_combo.connect("changed", self._on_profiles_changed) self.entry_changed_handlers=[] self.entry_changed_handlers.append((self._profile_entry, self._profile_entry.connect("changed", self._on_profile_entry_changed))) self.entry_changed_handlers.append((self._host_entry, self._host_entry.connect("changed", self._on_host_entry_changed))) self.entry_changed_handlers.append((self._port_entry, self._port_entry.connect("value-changed", self._on_port_entry_changed))) self.entry_changed_handlers.append((self._password_entry, self._password_entry.connect("changed", self._on_password_entry_changed))) self.entry_changed_handlers.append((self._path_entry, self._path_entry.connect("changed", self._on_path_entry_changed))) self.entry_changed_handlers.append((self._regex_entry, self._regex_entry.connect("changed", self._on_regex_entry_changed))) self._settings_handlers=[] self._settings_handlers.append(self._settings.connect("changed::profiles", self._on_settings_changed)) self._settings_handlers.append(self._settings.connect("changed::hosts", self._on_settings_changed)) self._settings_handlers.append(self._settings.connect("changed::ports", self._on_settings_changed)) self._settings_handlers.append(self._settings.connect("changed::passwords", self._on_settings_changed)) self._settings_handlers.append(self._settings.connect("changed::paths", self._on_settings_changed)) self._settings_handlers.append(self._settings.connect("changed::regex", self._on_settings_changed)) self.connect("destroy", self._remove_handlers) self._profiles_combo_reload() self._profiles_combo.set_active(0) # packing self.add(profiles_label) self.attach_next_to(profile_label, profiles_label, Gtk.PositionType.BOTTOM, 1, 1) self.attach_next_to(host_label, profile_label, Gtk.PositionType.BOTTOM, 1, 1) self.attach_next_to(password_label, host_label, Gtk.PositionType.BOTTOM, 1, 1) self.attach_next_to(path_label, password_label, Gtk.PositionType.BOTTOM, 1, 1) self.attach_next_to(regex_label, path_label, Gtk.PositionType.BOTTOM, 1, 1) self.attach_next_to(self._profiles_combo, profiles_label, Gtk.PositionType.RIGHT, 2, 1) self.attach_next_to(add_delete_buttons, self._profiles_combo, Gtk.PositionType.RIGHT, 1, 1) self.attach_next_to(self._profile_entry, profile_label, Gtk.PositionType.RIGHT, 2, 1) self.attach_next_to(address_entry, host_label, Gtk.PositionType.RIGHT, 2, 1) self.attach_next_to(self._password_entry, password_label, Gtk.PositionType.RIGHT, 2, 1) self.attach_next_to(path_box, path_label, Gtk.PositionType.RIGHT, 2, 1) self.attach_next_to(self._regex_entry, regex_label, Gtk.PositionType.RIGHT, 2, 1) connect_button.set_margin_top(12) self.attach_next_to(connect_button, self._regex_entry, Gtk.PositionType.BOTTOM, 2, 1) def _block_entry_changed_handlers(self, *args): for obj, handler in self.entry_changed_handlers: obj.handler_block(handler) def _unblock_entry_changed_handlers(self, *args): for obj, handler in self.entry_changed_handlers: obj.handler_unblock(handler) def _profiles_combo_reload(self, *args): self._profiles_combo.handler_block(self._profiles_changed) self._profiles_combo.remove_all() for profile in self._settings.get_value("profiles"): self._profiles_combo.append_text(profile) self._profiles_combo.handler_unblock(self._profiles_changed) def _remove_handlers(self, *args): for handler in self._settings_handlers: self._settings.disconnect(handler) def _on_settings_changed(self, *args): if self._gui_modification: self._gui_modification=False else: self._profiles_combo_reload() self._profiles_combo.set_active(0) def _on_add_button_clicked(self, *args): model=self._profiles_combo.get_model() self._settings.array_append("as", "profiles", "new profile ({})".format(len(model))) self._settings.array_append("as", "hosts", "localhost") self._settings.array_append("ai", "ports", 6600) self._settings.array_append("as", "passwords", "") self._settings.array_append("as", "paths", "") self._settings.array_append("as", "regex", "") self._profiles_combo_reload() new_pos=len(model)-1 self._profiles_combo.set_active(new_pos) def _on_delete_button_clicked(self, *args): pos=self._profiles_combo.get_active() self._settings.array_delete("as", "profiles", pos) self._settings.array_delete("as", "hosts", pos) self._settings.array_delete("ai", "ports", pos) self._settings.array_delete("as", "passwords", pos) self._settings.array_delete("as", "paths", pos) self._settings.array_delete("as", "regex", pos) if len(self._settings.get_value("profiles")) == 0: self._on_add_button_clicked() else: self._profiles_combo_reload() new_pos=max(pos-1,0) self._profiles_combo.set_active(new_pos) def _on_connect_button_clicked(self, *args): self._settings.set_int("active-profile", self._profiles_combo.get_active()) self._client.reconnect() def _on_profile_entry_changed(self, *args): self._gui_modification=True pos=self._profiles_combo.get_active() self._settings.array_modify("as", "profiles", pos, self._profile_entry.get_text()) self._profiles_combo_reload() self._profiles_combo.handler_block(self._profiles_changed) # do not reload all settings self._profiles_combo.set_active(pos) self._profiles_combo.handler_unblock(self._profiles_changed) def _on_host_entry_changed(self, *args): self._gui_modification=True self._settings.array_modify("as", "hosts", self._profiles_combo.get_active(), self._host_entry.get_text()) def _on_port_entry_changed(self, *args): self._gui_modification=True self._settings.array_modify("ai", "ports", self._profiles_combo.get_active(), int(self._port_entry.get_value())) def _on_password_entry_changed(self, *args): self._gui_modification=True self._settings.array_modify("as", "passwords", self._profiles_combo.get_active(), self._password_entry.get_text()) def _on_path_entry_changed(self, *args): self._gui_modification=True self._settings.array_modify("as", "paths", self._profiles_combo.get_active(), self._path_entry.get_text()) def _on_regex_entry_changed(self, *args): self._gui_modification=True self._settings.array_modify("as", "regex", self._profiles_combo.get_active(), self._regex_entry.get_text()) def _on_path_select_button_clicked(self, widget, parent): dialog=Gtk.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) folder=self._settings.get_value("paths")[self._profiles_combo.get_active()] if folder == "": dialog.set_current_folder(GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_MUSIC)) else: dialog.set_current_folder(folder) 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() if active >= 0: self._block_entry_changed_handlers() self._profile_entry.set_text(self._settings.get_value("profiles")[active]) self._host_entry.set_text(self._settings.get_value("hosts")[active]) self._port_entry.set_value(self._settings.get_value("ports")[active]) self._password_entry.set_text(self._settings.get_value("passwords")[active]) self._path_entry.set_text(self._settings.get_value("paths")[active]) self._regex_entry.set_text(self._settings.get_value("regex")[active]) self._unblock_entry_changed_handlers() class PlaylistSettings(Gtk.Box): def __init__(self, settings): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6, border_width=18) # adding vars self._settings=settings # label label=Gtk.Label(label=_("Choose the order of information to appear in the playlist:"), wrap=True, xalign=0) # treeview # (toggle, header, actual_index) self._store=Gtk.ListStore(bool, str, int) treeview=Gtk.TreeView(model=self._store, reorderable=True, headers_visible=False, search_column=-1) self._selection=treeview.get_selection() # columns renderer_text=Gtk.CellRendererText() renderer_toggle=Gtk.CellRendererToggle() column_toggle=Gtk.TreeViewColumn("", renderer_toggle, active=0) treeview.append_column(column_toggle) column_text=Gtk.TreeViewColumn("", renderer_text, text=1) treeview.append_column(column_text) # fill store self._headers=[_("No"), _("Disc"), _("Title"), _("Artist"), _("Album"), _("Length"), _("Year"), _("Genre")] self._fill() # scroll scroll=Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scroll.add(treeview) # toolbar toolbar=Gtk.Toolbar(icon_size=Gtk.IconSize.SMALL_TOOLBAR) style_context=toolbar.get_style_context() style_context.add_class("inline-toolbar") self._up_button=Gtk.ToolButton(icon_name="go-up-symbolic") self._up_button.set_sensitive(False) self._down_button=Gtk.ToolButton(icon_name="go-down-symbolic") self._down_button.set_sensitive(False) toolbar.insert(self._up_button, 0) toolbar.insert(self._down_button, 1) # column chooser frame=Gtk.Frame() frame.add(scroll) column_chooser=Gtk.Box(orientation=Gtk.Orientation.VERTICAL) column_chooser.pack_start(frame, True, True, 0) column_chooser.pack_start(toolbar, False, False, 0) # connect self._row_deleted=self._store.connect("row-deleted", self._save_permutation) renderer_toggle.connect("toggled", self._on_cell_toggled) self._up_button.connect("clicked", self._on_up_button_clicked) self._down_button.connect("clicked", self._on_down_button_clicked) self._selection.connect("changed", self._set_button_sensitivity) self._settings_handlers=[] self._settings_handlers.append(self._settings.connect("changed::column-visibilities", self._on_visibilities_changed)) self._settings_handlers.append(self._settings.connect("changed::column-permutation", self._on_permutation_changed)) self.connect("destroy", self._remove_handlers) # packing self.pack_start(label, False, False, 0) self.pack_start(column_chooser, True, True, 0) def _fill(self, *args): visibilities=self._settings.get_value("column-visibilities").unpack() for actual_index in self._settings.get_value("column-permutation"): self._store.append([visibilities[actual_index], self._headers[actual_index], actual_index]) def _save_permutation(self, *args): permutation=[] for row in self._store: permutation.append(row[2]) self._settings.set_value("column-permutation", GLib.Variant("ai", permutation)) def _set_button_sensitivity(self, *args): treeiter=self._selection.get_selected()[1] if treeiter is None: self._up_button.set_sensitive(False) self._down_button.set_sensitive(False) else: path=self._store.get_path(treeiter) if self._store.iter_next(treeiter) is None: self._up_button.set_sensitive(True) self._down_button.set_sensitive(False) elif not path.prev(): self._up_button.set_sensitive(False) self._down_button.set_sensitive(True) else: self._up_button.set_sensitive(True) self._down_button.set_sensitive(True) def _remove_handlers(self, *args): for handler in self._settings_handlers: self._settings.disconnect(handler) def _on_cell_toggled(self, widget, path): self._store[path][0]=not self._store[path][0] self._settings.array_modify("ab", "column-visibilities", self._store[path][2], self._store[path][0]) def _on_up_button_clicked(self, *args): treeiter=self._selection.get_selected()[1] path=self._store.get_path(treeiter) path.prev() prev=self._store.get_iter(path) self._store.move_before(treeiter, prev) self._set_button_sensitivity() self._save_permutation() def _on_down_button_clicked(self, *args): treeiter=self._selection.get_selected()[1] path=self._store.get_path(treeiter) next=self._store.iter_next(treeiter) self._store.move_after(treeiter, next) self._set_button_sensitivity() self._save_permutation() def _on_visibilities_changed(self, *args): visibilities=self._settings.get_value("column-visibilities").unpack() for i, actual_index in enumerate(self._settings.get_value("column-permutation")): self._store[i][0]=visibilities[actual_index] def _on_permutation_changed(self, *args): equal=True perm=self._settings.get_value("column-permutation") for i, e in enumerate(self._store): if e[2] != perm[i]: equal=False break if not equal: self._store.handler_block(self._row_deleted) self._store.clear() self._fill() self._store.handler_unblock(self._row_deleted) class SettingsDialog(Gtk.Dialog): def __init__(self, parent, client, settings): use_csd=settings.get_boolean("use-csd") if use_csd: super().__init__(title=_("Settings"), transient_for=parent, use_header_bar=True) else: super().__init__(title=_("Settings"), transient_for=parent) self.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) self.set_default_size(500, 400) # widgets general=GeneralSettings(settings) profiles=ProfileSettings(parent, client, settings) playlist=PlaylistSettings(settings) # packing vbox=self.get_content_area() if use_csd: stack=Gtk.Stack() stack.add_titled(general, "general", _("General")) stack.add_titled(profiles, "profiles", _("Profiles")) stack.add_titled(playlist, "playlist", _("Playlist")) stack_switcher=Gtk.StackSwitcher(stack=stack) vbox.pack_start(stack, True, True, 0) header_bar=self.get_header_bar() header_bar.set_custom_title(stack_switcher) else: tabs=Gtk.Notebook() tabs.append_page(general, Gtk.Label(label=_("General"))) tabs.append_page(profiles, Gtk.Label(label=_("Profiles"))) tabs.append_page(playlist, Gtk.Label(label=_("Playlist"))) vbox.set_property("spacing", 6) vbox.set_property("border-width", 6) vbox.pack_start(tabs, True, True, 0) self.show_all() ################# # other dialogs # ################# class ServerStats(Gtk.Dialog): def __init__(self, parent, client, settings): use_csd=settings.get_boolean("use-csd") super().__init__(title=_("Stats"), transient_for=parent, use_header_bar=use_csd) if not use_csd: self.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) self.set_resizable(False) # grid grid=Gtk.Grid(row_spacing=6, column_spacing=12, border_width=6) # populate display_str={ "protocol": _("Protocol:"), "uptime": _("Uptime:"), "playtime": _("Playtime:"), "artists": _("Artists:"), "albums": _("Albums:"), "songs": _("Songs:"), "db_playtime": _("Total Playtime:"), "db_update": _("Database Update:") } stats=client.wrapped_call("stats") stats["protocol"]=str(client.mpd_version) for key in ("uptime","playtime","db_playtime"): stats[key]=ClientHelper.seconds_to_display_time(int(stats[key])) stats["db_update"]=str(datetime.datetime.fromtimestamp(int(stats["db_update"]))).replace(":", "∶") for i, key in enumerate(("protocol","uptime","playtime","db_update","db_playtime","artists","albums","songs")): grid.attach(Gtk.Label(label=display_str[key], use_markup=True, xalign=1), 0, i, 1, 1) grid.attach(Gtk.Label(label=stats[key], xalign=0), 1, i, 1, 1) # packing vbox=self.get_content_area() vbox.set_property("border-width", 6) vbox.pack_start(grid, True, True, 0) self.show_all() self.run() class AboutDialog(Gtk.AboutDialog): def __init__(self, window): super().__init__(transient_for=window, modal=True) self.set_program_name("mpdevil") self.set_version(VERSION) self.set_comments(_("A simple music browser for MPD")) self.set_authors(["Martin Wagner"]) self.set_website("https://github.com/SoongNoonien/mpdevil") self.set_copyright("\xa9 2020 Martin Wagner") self.set_logo_icon_name("mpdevil") ################################# # small general purpose widgets # ################################# class AutoSizedIcon(Gtk.Image): def __init__(self, icon_name, settings_key, settings): super().__init__() self.set_from_icon_name(icon_name, Gtk.IconSize.BUTTON) pixel_size=settings.get_int(settings_key) if pixel_size > 0: self.set_pixel_size(pixel_size) # adding vars self._settings=settings self._settings_key=settings_key # connect self._settings.connect("changed::"+self._settings_key, self._on_icon_size_changed) def _on_icon_size_changed(self, *args): self.set_pixel_size(self._settings.get_int(self._settings_key)) class FocusFrame(Gtk.Overlay): def __init__(self): super().__init__() self._frame=Gtk.Frame(no_show_all=True) # css style_context=self._frame.get_style_context() provider=Gtk.CssProvider() css=b"""* {border-color: @theme_selected_bg_color; border-width: 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 disable(self): self._frame.hide() def enable(self): if self._widget.has_focus(): self._frame.show() def set_widget(self, widget): self._widget=widget self._widget.connect("focus-in-event", lambda *args: self._frame.show()) self._widget.connect("focus-out-event", lambda *args: self._frame.hide()) class SongPopover(Gtk.Popover): def __init__(self, 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) # treeview # (tag, display-value, tooltip) store=Gtk.ListStore(str, str, str) treeview=Gtk.TreeView(model=store, headers_visible=False, search_column=-1, tooltip_column=2) treeview.set_can_focus(False) treeview.get_selection().set_mode(Gtk.SelectionMode.NONE) # columns renderer_text=Gtk.CellRendererText(width_chars=50, ellipsize=Pango.EllipsizeMode.MIDDLE, ellipsize_set=True) renderer_text_ralign=Gtk.CellRendererText(xalign=1.0) 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) # populate song=ClientHelper.song_to_str_dict(song) for tag, value in song.items(): tooltip=value.replace("&", "&") if tag == "time": store.append([tag+":", ClientHelper.seconds_to_display_time(int(value)), tooltip]) elif tag == "last-modified": time=datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") store.append([tag+":", time.strftime("%a %d %B %Y, %H∶%M UTC"), tooltip]) else: store.append([tag+":", value, tooltip]) # packing frame=Gtk.Frame(border_width=3) frame.add(treeview) self.add(frame) frame.show_all() class Cover(object): def __init__(self, settings, raw_song): self.path=None song=ClientHelper.song_to_first_str_dict(raw_song) if song != {}: song_file=song["file"] active_profile=settings.get_int("active-profile") lib_path=settings.get_value("paths")[active_profile] if lib_path == "": lib_path=GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_MUSIC) regex_str=settings.get_value("regex")[active_profile] if regex_str == "": regex=re.compile(COVER_REGEX, flags=re.IGNORECASE) else: regex_str=regex_str.replace("%AlbumArtist%", song.get("albumartist", "")) regex_str=regex_str.replace("%Album%", song.get("album", "")) try: regex=re.compile(regex_str, flags=re.IGNORECASE) except: print("illegal regex:", regex_str) return if song_file is not None: song_dir=os.path.join(lib_path, os.path.dirname(song_file)) if song_dir.endswith(".cue"): song_dir=os.path.dirname(song_dir) # get actual directory of .cue file if os.path.exists(song_dir): for f in os.listdir(song_dir): if regex.match(f): 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, 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() # 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() 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() 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.new_with_mnemonic(_("_Append")) append_button.set_image(Gtk.Image.new_from_icon_name("list-add", Gtk.IconSize.BUTTON)) append_button.set_tooltip_text(_("Add all titles to playlist")) play_button=Gtk.Button.new_with_mnemonic(_("_Play")) play_button.set_image(Gtk.Image.new_from_icon_name("media-playback-start", Gtk.IconSize.BUTTON)) play_button.set_tooltip_text(_("Directly play all titles")) enqueue_button=Gtk.Button.new_with_mnemonic(_("_Enqueue")) enqueue_button.set_image(Gtk.Image.new_from_icon_name("insert-object", Gtk.IconSize.BUTTON)) 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._tag_combo_box=Gtk.ComboBoxText() # search entry self.search_entry=Gtk.SearchEntry() # label self._hits_label=Gtk.Label(xalign=1) # store # (track, title, artist, album, duration, file, sort track) self._store=Gtk.ListStore(str, str, str, str, str, str, int) self._store.set_default_sort_func(lambda *args: 0) # songs window self._songs_window=SongsWindow(self._client, self._store, 5) # action bar self._action_bar=self._songs_window.get_action_bar() self._action_bar.set_sensitive(False) # songs view self._songs_view=self._songs_window.get_treeview() # columns renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True) renderer_text_ralign=Gtk.CellRendererText(xalign=1.0) column_track=Gtk.TreeViewColumn(_("No"), renderer_text_ralign, text=0) column_track.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) column_track.set_property("resizable", False) self._songs_view.append_column(column_track) column_title=Gtk.TreeViewColumn(_("Title"), renderer_text, text=1) column_title.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) column_title.set_property("resizable", False) column_title.set_property("expand", True) self._songs_view.append_column(column_title) column_artist=Gtk.TreeViewColumn(_("Artist"), renderer_text, text=2) column_artist.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) column_artist.set_property("resizable", False) column_artist.set_property("expand", True) self._songs_view.append_column(column_artist) column_album=Gtk.TreeViewColumn(_("Album"), renderer_text, text=3) column_album.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) column_album.set_property("resizable", False) column_album.set_property("expand", True) self._songs_view.append_column(column_album) column_time=Gtk.TreeViewColumn(_("Length"), renderer_text, text=4) column_time.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) column_time.set_property("resizable", False) self._songs_view.append_column(column_time) column_track.set_sort_column_id(6) column_title.set_sort_column_id(1) column_artist.set_sort_column_id(2) column_album.set_sort_column_id(3) column_time.set_sort_column_id(4) # connect self.search_entry.connect("search-changed", self._on_search_changed) self._tag_combo_box.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._tag_combo_box, False, False, 0) self._hits_label.set_margin_end(6) self._action_bar.pack_end(self._hits_label) self.pack_start(hbox, False, False, 0) self.pack_start(Gtk.Separator.new(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0) self.pack_start(self._songs_window, True, True, 0) def clear(self, *args): self._songs_view.clear() self.search_entry.set_text("") self._tag_combo_box.remove_all() def _on_disconnected(self, *args): self._tag_combo_box.set_sensitive(False) self.search_entry.set_sensitive(False) self.clear() def _on_reconnected(self, *args): self._tag_combo_box.append_text(_("all tags")) for tag in self._client.wrapped_call("tagtypes"): if not tag.startswith("MUSICBRAINZ"): self._tag_combo_box.append_text(tag) self._tag_combo_box.set_active(0) self._tag_combo_box.set_sensitive(True) self.search_entry.set_sensitive(True) def _on_search_changed(self, widget): self._songs_view.clear() self._hits_label.set_text("") if len(self.search_entry.get_text()) > 0: if self._tag_combo_box.get_active() == 0: songs=self._client.wrapped_call("search", "any", self.search_entry.get_text()) else: songs=self._client.wrapped_call("search", self._tag_combo_box.get_active_text(), self.search_entry.get_text()) for s in songs: song=ClientHelper.song_to_str_dict(ClientHelper.pepare_song_for_display(s)) self._store.append([ song["track"], song["title"], song["artist"], song["album"], song["human_duration"], song["file"], int(song.get("track", 0)) ]) self._hits_label.set_text(_("{num} hits").format(num=self._songs_view.count())) if self._songs_view.count() == 0: self._action_bar.set_sensitive(False) else: self._action_bar.set_sensitive(True) class GenreSelect(Gtk.ComboBoxText): __gsignals__={"genre_changed": (GObject.SignalFlags.RUN_FIRST, None, ())} def __init__(self, client): super().__init__(wrap_width=3) # 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 # treeview # (name, weight, initial-letter, weight-initials) self._store=Gtk.ListStore(str, Pango.Weight, str, Pango.Weight) self._treeview=Gtk.TreeView(model=self._store, activate_on_single_click=True, search_column=0, headers_visible=False) self._treeview.columns_autosize() self._selection=self._treeview.get_selection() # columns renderer_text_malign=Gtk.CellRendererText(xalign=0.5) self._column_initials=Gtk.TreeViewColumn("", renderer_text_malign, text=2, weight=3) self._column_initials.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) self._column_initials.set_property("resizable", False) self._column_initials.set_visible(self._settings.get_boolean("show-initials")) self._treeview.append_column(self._column_initials) renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True) self._column_name=Gtk.TreeViewColumn("", renderer_text, text=0, weight=1) self._column_name.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) self._column_name.set_property("resizable", False) self._treeview.append_column(self._column_name) # scroll scroll=Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scroll.add(self._treeview) # 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() 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) # adding vars self._client=client self._settings=settings songs=self._client.wrapped_call("find", "album", album, "date", year, self._settings.get_artist_type(), album_artist) # determine size size=parent.get_size() diagonal=(size[0]**2+size[1]**2)**(0.5) h=diagonal//4 w=h*5//4 self.set_default_size(w, h) # title duration=ClientHelper.calc_display_length(songs) if use_csd: if year == "": self.set_title(album) else: self.set_title("{} ({})".format(album, year)) header_bar=self.get_header_bar() header_bar.set_subtitle("{} ({})".format(album_artist, duration)) else: if year == "": self.set_title("{} - {} ({})".format(album, album_artist, duration)) else: self.set_title("{} ({}) - {} ({})".format(album, year, album_artist, duration)) # store # (track, title (artist), duration, file) store=Gtk.ListStore(str, str, str, str) for s in songs: song=ClientHelper.song_to_list_dict(ClientHelper.pepare_song_for_display(s)) track=song["track"][0] title=(", ".join(song["title"])) # only show artists =/= albumartist try: song["artist"].remove(album_artist) except: pass artist=(", ".join(song["artist"])) if artist == album_artist or artist == "": title_artist="{}".format(title) else: title_artist="{} - {}".format(title, artist) title_artist=title_artist.replace("&", "&") store.append([track, title_artist, song["human_duration"][0], song["file"][0]]) # 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_property("resizable", False) songs_view.append_column(column_track) column_title=Gtk.TreeViewColumn(_("Title"), renderer_text, markup=1) 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_ralign, text=2) 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 vbox=self.get_content_area() vbox.set_property("border-width", 0) vbox.pack_start(songs_window, True, True, 0) 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=album["songs"][-1].get("disc", 1) if type(discs) == list: discs=int(discs[0]) else: discs=int(discs) if discs > 1: tooltip=_("{titles} titles on {discs} discs ({length})").format( titles=len(album["songs"]), discs=discs, length=length_human_readable) else: tooltip=_("{titles} titles ({length})").format( titles=len(album["songs"]), length=length_human_readable) # album label if album["year"] == "": display_label="{}".format(album["album"]) else: display_label="{} ({})".format(album["album"], album["year"]) display_label_artist=display_label+"\n"+album["artist"] display_label=display_label.replace("&", "&") display_label_artist=display_label_artist.replace("&", "&") # add album 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") # widgets icons={} icons_data=("go-previous-symbolic", "system-search-symbolic") if self._use_csd: for data in icons_data: icons[data]=Gtk.Image.new_from_icon_name(data, Gtk.IconSize.BUTTON) else: for data in icons_data: icons[data]=AutoSizedIcon(data, "icon-size-sec", self._settings) self.back_to_current_album_button=Gtk.Button(image=icons["go-previous-symbolic"], tooltip_text=_("Back to current album")) self.back_to_current_album_button.set_can_focus(False) self.search_button=Gtk.ToggleButton(image=icons["system-search-symbolic"], tooltip_text=_("Search")) self.search_button.set_can_focus(False) self._genre_select=GenreSelect(self._client) self._artist_window=ArtistWindow(self._client, self._settings, self._genre_select) self._search_window=SearchWindow(self._client) self._album_window=AlbumWindow(self._client, self._settings, self._artist_window, window) # connect self.back_to_current_album_button.connect("clicked", self._back_to_current_album) self.search_button.connect("toggled", self._on_search_toggled) self._search_window.search_entry.connect("focus_in_event", lambda *args: self.emit("search_focus_changed", True)) self._search_window.search_entry.connect("focus_out_event", lambda *args: self.emit("search_focus_changed", False)) self._artist_window.connect("artists_changed", self._on_artists_changed) self._settings.connect("notify::mini-player", self._on_mini_player) self._client.emitter.connect("disconnected", self._on_disconnected) self._client.emitter.connect("reconnected", self._on_reconnected) # packing self._stack=Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE) self._stack.add_named(self._album_window, "albums") self._stack.add_named(self._search_window, "search") hbox=Gtk.Box(spacing=6, border_width=6) if self._use_csd: hbox.pack_start(self._genre_select, True, True, 0) else: hbox.pack_start(self.back_to_current_album_button, False, False, 0) hbox.pack_start(self._genre_select, True, True, 0) hbox.pack_start(self.search_button, False, False, 0) box1=Gtk.Box(orientation=Gtk.Orientation.VERTICAL) box1.pack_start(hbox, False, False, 0) box1.pack_start(Gtk.Separator.new(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0) box1.pack_start(self._artist_window, True, True, 0) self.pack1(box1, False, False) self.pack2(self._stack, True, False) 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) ###################### # playlist and cover # ###################### class LyricsWindow(FocusFrame): def __init__(self, client, settings): super().__init__() # adding vars self._settings=settings self._client=client self._displayed_song_file=None # text view self._text_view=Gtk.TextView( editable=False, cursor_visible=False, wrap_mode=Gtk.WrapMode.WORD, justification=Gtk.Justification.CENTER, opacity=0.9 ) self._text_view.set_left_margin(5) self._text_view.set_right_margin(5) self._text_view.set_bottom_margin(5) self._text_view.set_top_margin(3) self.set_widget(self._text_view) # text buffer self._text_buffer=self._text_view.get_buffer() # scroll scroll=Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scroll.add(self._text_view) # connect self._client.emitter.connect("disconnected", self._on_disconnected) self._song_changed=self._client.emitter.connect("current_song_changed", self._refresh) self._client.emitter.handler_block(self._song_changed) # packing self.add(scroll) def enable(self, *args): current_song=self._client.wrapped_call("currentsong") if current_song == {}: if self._displayed_song_file is not None: self._refresh() else: if current_song["file"] != self._displayed_song_file: self._refresh() self._client.emitter.handler_unblock(self._song_changed) GLib.idle_add(self._text_view.grab_focus) # focus textview def disable(self, *args): self._client.emitter.handler_block(self._song_changed) def _get_lyrics(self, title, artist): replaces=((" ", "+"),(".", "_"),("@", "_"),(",", "_"),(";", "_"),("&", "_"),("\\", "_"),("/", "_"),('"', "_"),("(", "_"),(")", "_")) for char1, char2 in replaces: title=title.replace(char1, char2) artist=artist.replace(char1, char2) req=requests.get("https://www.letras.mus.br/winamp.php?musica={0}&artista={1}".format(title,artist)) soup=BeautifulSoup(req.text, "html.parser") soup=soup.find(id="letra-cnt") if soup is None: raise ValueError("Not found") paragraphs=[i for i in soup.children][1] # remove unneded paragraphs (NavigableString) lyrics="" for paragraph in paragraphs: for line in paragraph.stripped_strings: lyrics+=line+"\n" lyrics+="\n" output=lyrics[:-2] # omit last two newlines if output == "": # assume song is instrumental when lyrics are empty return "Instrumental" else: return output def _display_lyrics(self, current_song): GLib.idle_add(self._text_buffer.set_text, _("searching..."), -1) try: text=self._get_lyrics(current_song.get("title", ""), current_song.get("artist", "")) except requests.exceptions.ConnectionError: self._displayed_song_file=None text=_("connection error") except ValueError: text=_("lyrics not found") GLib.idle_add(self._text_buffer.set_text, text, -1) def _refresh(self, *args): current_song=self._client.wrapped_call("currentsong") if current_song == {}: self._displayed_song_file=None self._text_buffer.set_text("", -1) else: self._displayed_song_file=current_song["file"] update_thread=threading.Thread( target=self._display_lyrics, kwargs={"current_song": ClientHelper.song_to_first_str_dict(current_song)}, daemon=True ) update_thread.start() def _on_disconnected(self, *args): self._displayed_song_file=None self._text_buffer.set_text("", -1) class AudioType(Gtk.Label): def __init__(self, client): super().__init__() # adding vars self._client=client self._init_vars() # connect self._client.emitter.connect("audio", self._on_audio) self._client.emitter.connect("bitrate", self._on_bitrate) self._client.emitter.connect("current_song_changed", self._on_song_changed) self._client.emitter.connect("disconnected", self.clear) self._client.emitter.connect("state", self._on_state) def clear(self, *args): self.set_text("") self._init_vars() def _init_vars(self): self.freq=0 self.res=0 self.chan=0 self.brate=0 self.file_type="" def _refresh(self, *args): string=_("{bitrate} kb/s, {frequency} kHz, {resolution} bit, {channels} channels, {file_type}").format( bitrate=self.brate, frequency=self.freq, resolution=self.res, channels=self.chan, file_type=self.file_type) self.set_text(string) def _on_audio(self, emitter, freq, res, chan): try: self.freq=str(int(freq)/1000) except: self.freq=freq self.res=res self.chan=chan self._refresh() def _on_bitrate(self, emitter, brate): self.brate=brate self._refresh() def _on_song_changed(self, *args): try: self.file_type=self._client.wrapped_call("currentsong")["file"].split(".")[-1].split("/")[0] self._refresh() except: pass def _on_state(self, emitter, state): if state == "stop": self.clear() class CoverEventBox(Gtk.EventBox): def __init__(self, client, settings, 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") self._inserted_path=None # needed for drag and drop # buttons provider=Gtk.CssProvider() css=b"""* {min-height: 8px;}""" # allow further shrinking provider.load_from_data(css) self._back_to_current_song_button=Gtk.Button( image=AutoSizedIcon("go-previous-symbolic", "icon-size-sec", self._settings), tooltip_text=_("Scroll to current song"), relief=Gtk.ReliefStyle.NONE ) self._back_to_current_song_button.set_can_focus(False) style_context=self._back_to_current_song_button.get_style_context() style_context.add_provider(provider, 800) self._clear_button=Gtk.Button( image=AutoSizedIcon("edit-clear-symbolic", "icon-size-sec", self._settings), tooltip_text=_("Clear playlist"), relief=Gtk.ReliefStyle.NONE ) self._clear_button.set_can_focus(False) style_context=self._clear_button.get_style_context() style_context.add_class("destructive-action") style_context.add_provider(provider, 800) # treeview # (track, disc, title, artist, album, duration, date, genre, file, weight) self._store=Gtk.ListStore(str, str, str, str, str, str, str, str, str, Pango.Weight) self._treeview=Gtk.TreeView(model=self._store, activate_on_single_click=True, reorderable=True, search_column=-1) self._selection=self._treeview.get_selection() # columns renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True) renderer_text_ralign=Gtk.CellRendererText(xalign=1.0) self._columns=( Gtk.TreeViewColumn(_("No"), renderer_text_ralign, text=0, weight=9), Gtk.TreeViewColumn(_("Disc"), renderer_text_ralign, text=1, weight=9), Gtk.TreeViewColumn(_("Title"), renderer_text, text=2, weight=9), Gtk.TreeViewColumn(_("Artist"), renderer_text, text=3, weight=9), Gtk.TreeViewColumn(_("Album"), renderer_text, text=4, weight=9), Gtk.TreeViewColumn(_("Length"), renderer_text, text=5, weight=9), Gtk.TreeViewColumn(_("Year"), renderer_text, text=6, weight=9), Gtk.TreeViewColumn(_("Genre"), renderer_text, text=7, weight=9) ) for 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 self._frame=FocusFrame() self._frame.set_widget(self._treeview) self._frame.add(scroll) # audio infos audio=AudioType(self._client) audio.set_xalign(1) audio.set_ellipsize(Pango.EllipsizeMode.END) # playlist info self._playlist_info=Gtk.Label(xalign=0, ellipsize=Pango.EllipsizeMode.END) # action bar action_bar=Gtk.ActionBar() action_bar.pack_start(self._back_to_current_song_button) self._playlist_info.set_margin_start(3) action_bar.pack_start(self._playlist_info) audio.set_margin_end(3) audio.set_margin_start(12) action_bar.pack_end(self._clear_button) action_bar.pack_end(audio) # 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._treeview.connect("drag-begin", lambda *args: self._frame.disable()) self._treeview.connect("drag-end", lambda *args: self._frame.enable()) self._row_deleted=self._store.connect("row-deleted", self._on_row_deleted) self._row_inserted=self._store.connect("row-inserted", self._on_row_inserted) self._back_to_current_song_button.connect("clicked", self._on_back_to_current_song_button_clicked) self._clear_button.connect("clicked", self._on_clear_button_clicked) self._client.emitter.connect("playlist_changed", self._on_playlist_changed) self._client.emitter.connect("current_song_changed", self._on_song_changed) self._client.emitter.connect("disconnected", self._on_disconnected) self._client.emitter.connect("reconnected", self._on_reconnected) self._settings.connect("notify::mini-player", self._on_mini_player) self._settings.connect("changed::column-visibilities", self._load_settings) self._settings.connect("changed::column-permutation", self._load_settings) # packing self.pack_start(self._frame, True, True, 0) self.pack_end(action_bar, False, False, 0) def 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._playlist_version=None self._store.handler_block(self._row_inserted) self._store.handler_block(self._row_deleted) self._store.clear() self._store.handler_unblock(self._row_inserted) self._store.handler_unblock(self._row_deleted) def _refresh_playlist_info(self): songs=self._client.wrapped_call("playlistinfo") if songs == []: self._playlist_info.set_text("") else: length_human_readable=ClientHelper.calc_display_length(songs) self._playlist_info.set_text(_("{titles} titles ({length})").format(titles=len(songs), length=length_human_readable)) def _scroll_to_selected_title(self, *args): treeview, treeiter=self._selection.get_selected() if treeiter is not None: path=treeview.get_path(treeiter) self._treeview.scroll_to_cell(path, None, True, 0.25) def _refresh_selection(self): # Gtk.TreePath(len(self._store) is used to generate an invalid TreePath (needed to unset cursor) self._treeview.set_cursor(Gtk.TreePath(len(self._store)), None, False) for row in self._store: # reset bold text row[9]=Pango.Weight.BOOK try: song=self._client.wrapped_call("status")["song"] path=Gtk.TreePath(int(song)) self._selection.select_path(path) self._store[path][9]=Pango.Weight.BOLD except: self._selection.unselect_all() def _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: try: self._store.remove(treeiter) 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() 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._store.remove(self._store.get_iter(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_deleted(self, model, path): # sync treeview to mpd if self._inserted_path is not None: # move path=int(path.to_string()) if path > self._inserted_path: path=path-1 if path < self._inserted_path: self._inserted_path=self._inserted_path-1 self._client.wrapped_call("move", path, self._inserted_path) self._inserted_path=None else: # delete self._client.wrapped_call("delete", path) # bad song index possible self._playlist_version=self._client.wrapped_call("status")["playlist"] def _on_row_inserted(self, model, path, treeiter): self._inserted_path=int(path.to_string()) def _on_row_activated(self, widget, path, view_column): self._client.wrapped_call("play", path) def _on_playlist_changed(self, emitter, version): self._store.handler_block(self._row_inserted) self._store.handler_block(self._row_deleted) songs=[] if self._playlist_version is not None: songs=self._client.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.song_to_str_dict(ClientHelper.pepare_song_for_display(s)) try: treeiter=self._store.get_iter(song["pos"]) self._store.set(treeiter, 0, song["track"], 1, song["disc"], 2, song["title"], 3, song["artist"], 4, song["album"], 5, song["human_duration"], 6, song["date"], 7, song["genre"], 8, song["file"], 9, Pango.Weight.BOOK ) except: self._store.append([ song["track"], song["disc"], song["title"], song["artist"], song["album"], song["human_duration"], song["date"], song["genre"], song["file"], Pango.Weight.BOOK ]) for i in reversed(range(int(self._client.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 self._store.handler_unblock(self._row_inserted) self._store.handler_unblock(self._row_deleted) def _on_song_changed(self, *args): self._refresh_selection() if self._client.wrapped_call("status")["state"] == "play": self._scroll_to_selected_title() def _on_back_to_current_song_button_clicked(self, *args): self._treeview.set_cursor(Gtk.TreePath(len(self._store)), None, False) # set to invalid TreePath (needed to unset cursor) for path, row in enumerate(self._store): if row[9] == Pango.Weight.BOLD: self._selection.select_path(path) break self._scroll_to_selected_title() def _on_clear_button_clicked(self, *args): self._client.clear() def _on_disconnected(self, *args): self._treeview.set_sensitive(False) self._back_to_current_song_button.set_sensitive(False) self._clear_button.set_sensitive(False) self._clear() def _on_reconnected(self, *args): self._back_to_current_song_button.set_sensitive(True) self._clear_button.set_sensitive(True) self._treeview.set_sensitive(True) def _on_mini_player(self, obj, typestring): visibility=not(obj.get_property("mini-player")) self.set_property("visible", visibility) class CoverPlaylistWindow(Gtk.Paned): def __init__(self, client, settings, window): super().__init__() # adding vars self._client=client self._settings=settings self._window=window # cover main_cover=MainCover(self._client, self._settings) self._cover_event_box=CoverEventBox(self._client, self._settings, self._window) # playlist self._playlist_window=PlaylistWindow(self._client, self._settings) # lyrics button self.lyrics_button=Gtk.ToggleButton( image=Gtk.Image.new_from_icon_name("media-view-subtitles-symbolic", Gtk.IconSize.BUTTON), tooltip_text=_("Show lyrics") ) self.lyrics_button.set_can_focus(False) self.lyrics_button.set_margin_top(6) self.lyrics_button.set_margin_end(6) style_context=self.lyrics_button.get_style_context() style_context.add_class("circular") # lyrics window self._lyrics_window=LyricsWindow(self._client, self._settings) # revealer self._lyrics_button_revealer=Gtk.Revealer() self._lyrics_button_revealer.set_halign(Gtk.Align.END) self._lyrics_button_revealer.set_valign(Gtk.Align.START) self._lyrics_button_revealer.add(self.lyrics_button) # stack self._stack=Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE) self._stack.add_named(self._cover_event_box, "cover") self._stack.add_named(self._lyrics_window, "lyrics") self._stack.set_visible_child(self._cover_event_box) # connect self.lyrics_button.connect("toggled", self._on_lyrics_toggled) self._client.emitter.connect("disconnected", self._on_disconnected) self._client.emitter.connect("reconnected", self._on_reconnected) self._settings.connect("changed::show-lyrics-button", self._on_settings_changed) # packing overlay=Gtk.Overlay() overlay.add(main_cover) overlay.add_overlay(self._stack) overlay.add_overlay(self._lyrics_button_revealer) self.pack1(overlay, False, False) self.pack2(self._playlist_window, True, False) self.set_position(self._settings.get_int("paned0")) self._on_settings_changed() # hide lyrics button def save_settings(self): self._settings.set_int("paned0", self.get_position()) self._playlist_window.save_settings() def _on_reconnected(self, *args): self.lyrics_button.set_sensitive(True) def _on_disconnected(self, *args): self.lyrics_button.set_active(False) self.lyrics_button.set_sensitive(False) def _on_lyrics_toggled(self, widget): if widget.get_active(): self._stack.set_visible_child(self._lyrics_window) self._lyrics_window.enable() else: self._stack.set_visible_child(self._cover_event_box) self._lyrics_window.disable() def _on_settings_changed(self, *args): if self._settings.get_boolean("show-lyrics-button"): self._lyrics_button_revealer.set_reveal_child(True) else: self._lyrics_button_revealer.set_reveal_child(False) ################### # control widgets # ################### class PlaybackControl(Gtk.ButtonBox): def __init__(self, client, settings): super().__init__(layout_style=Gtk.ButtonBoxStyle.EXPAND) # adding vars self._client=client self._settings=settings # widgets self._play_icon=AutoSizedIcon("media-playback-start-symbolic", "icon-size", self._settings) self._pause_icon=AutoSizedIcon("media-playback-pause-symbolic", "icon-size", self._settings) self._play_button=Gtk.Button(image=self._play_icon) self._play_button.set_can_focus(False) self._stop_button=Gtk.Button(image=AutoSizedIcon("media-playback-stop-symbolic", "icon-size", self._settings)) self._stop_button.set_can_focus(False) self._prev_button=Gtk.Button(image=AutoSizedIcon("media-skip-backward-symbolic", "icon-size", self._settings)) self._prev_button.set_can_focus(False) self._next_button=Gtk.Button(image=AutoSizedIcon("media-skip-forward-symbolic", "icon-size", self._settings)) self._next_button.set_can_focus(False) # connect self._play_button.connect("clicked", self._on_play_clicked) self._stop_button.connect("clicked", self._on_stop_clicked) self._stop_button.set_property("no-show-all", not(self._settings.get_boolean("show-stop"))) self._prev_button.connect("clicked", self._on_prev_clicked) self._next_button.connect("clicked", self._on_next_clicked) self._settings.connect("notify::mini-player", self._on_mini_player) self._settings.connect("changed::show-stop", self._on_show_stop_changed) self._client.emitter.connect("state", self._on_state) self._client.emitter.connect("disconnected", self._on_disconnected) self._client.emitter.connect("reconnected", self._on_reconnected) # packing self.pack_start(self._prev_button, True, True, 0) self.pack_start(self._play_button, True, True, 0) self.pack_start(self._stop_button, True, True, 0) self.pack_start(self._next_button, True, True, 0) def _on_play_clicked(self, widget): self._client.wrapped_call("toggle_play") def _on_stop_clicked(self, widget): self._client.wrapped_call("stop") def _on_prev_clicked(self, widget): self._client.wrapped_call("previous") def _on_next_clicked(self, widget): self._client.wrapped_call("next") def _on_state(self, emitter, state): if state == "play": self._play_button.set_image(self._pause_icon) self._prev_button.set_sensitive(True) self._next_button.set_sensitive(True) elif state == "pause": self._play_button.set_image(self._play_icon) self._prev_button.set_sensitive(True) self._next_button.set_sensitive(True) else: self._play_button.set_image(self._play_icon) self._prev_button.set_sensitive(False) self._next_button.set_sensitive(False) def _on_disconnected(self, *args): self.set_sensitive(False) def _on_reconnected(self, *args): self.set_sensitive(True) def _on_mini_player(self, obj, typestring): self._on_show_stop_changed() def _on_show_stop_changed(self, *args): visibility=(self._settings.get_boolean("show-stop") and not self._settings.get_property("mini-player")) self._stop_button.set_property("visible", visibility) self._stop_button.set_property("no-show-all", not(visibility)) class SeekBar(Gtk.Box): def __init__(self, client): super().__init__(hexpand=True) # adding vars self._client=client self._update=True self._jumped=False # labels self._elapsed=Gtk.Label(width_chars=5) self._rest=Gtk.Label(width_chars=6) # event boxes self._elapsed_event_box=Gtk.EventBox() self._rest_event_box=Gtk.EventBox() # progress bar self._scale=Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL) self._scale.set_can_focus(False) self._scale.set_show_fill_level(True) self._scale.set_restrict_to_fill_level(False) self._scale.set_draw_value(False) self._scale.set_increments(10, 60) self._adjustment=self._scale.get_adjustment() # css (scale) style_context=self._scale.get_style_context() provider=Gtk.CssProvider() css=b"""scale fill { background-color: @theme_selected_bg_color; }""" provider.load_from_data(css) style_context.add_provider(provider, 800) # connect self._elapsed_event_box.connect("button-release-event", self._on_elapsed_button_release_event) self._elapsed_event_box.connect("button-press-event", lambda *args: self._scale.grab_focus()) self._rest_event_box.connect("button-release-event", self._on_rest_button_release_event) self._rest_event_box.connect("button-press-event", lambda *args: self._scale.grab_focus()) self._scale.connect("change-value", self._on_change_value) self._scale.connect("scroll-event", lambda *args: True) # disable mouse wheel self._scale.connect("button-press-event", self._on_scale_button_press_event) self._scale.connect("button-release-event", self._on_scale_button_release_event) self._client.emitter.connect("disconnected", self._disable) self._client.emitter.connect("state", self._on_state) self._client.emitter.connect("elapsed_changed", self._refresh) # packing self._elapsed_event_box.add(self._elapsed) self._rest_event_box.add(self._rest) self.pack_start(self._elapsed_event_box, False, False, 0) self.pack_start(self._scale, True, True, 0) self.pack_end(self._rest_event_box, False, False, 0) def _refresh(self, emitter, elapsed, duration): self.set_sensitive(True) if elapsed > duration: # fix display error elapsed=duration self._adjustment.set_upper(duration) if self._update: self._scale.set_value(elapsed) self._elapsed.set_text(ClientHelper.seconds_to_display_time(int(elapsed))) self._rest.set_text("-"+ClientHelper.seconds_to_display_time(int(duration-elapsed))) self._scale.set_fill_level(elapsed) def _disable(self, *args): self.set_sensitive(False) self._scale.set_fill_level(0) self._scale.set_range(0, 0) self._elapsed.set_text("––∶––") self._rest.set_text("––∶––") def _on_scale_button_press_event(self, widget, event): if event.button == 1 and event.type == Gdk.EventType.BUTTON_PRESS: self._update=False self._scale.set_has_origin(False) if event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS: self._jumped=False def _on_scale_button_release_event(self, widget, event): if event.button == 1: self._update=True self._scale.set_has_origin(True) if self._jumped: # actual seek self._client.wrapped_call("seekcur", self._scale.get_value()) self._jumped=False else: # restore state status=self._client.wrapped_call("status") self._refresh(None, float(status["elapsed"]), float(status["duration"])) def _on_change_value(self, range, scroll, value): # value is inaccurate (can be above upper limit) if (scroll == Gtk.ScrollType.STEP_BACKWARD or scroll == Gtk.ScrollType.STEP_FORWARD or scroll == Gtk.ScrollType.PAGE_BACKWARD or scroll == Gtk.ScrollType.PAGE_FORWARD): self._client.wrapped_call("seekcur", value) elif scroll == Gtk.ScrollType.JUMP: duration=self._adjustment.get_upper() if value > duration: # fix display error elapsed=duration else: elapsed=value self._elapsed.set_text(ClientHelper.seconds_to_display_time(int(elapsed))) self._rest.set_text("-"+ClientHelper.seconds_to_display_time(int(duration-elapsed))) self._jumped=True def _on_elapsed_button_release_event(self, widget, event): if event.button == 1: self._client.wrapped_call("seekcur", "-"+str(self._adjustment.get_property("step-increment"))) elif event.button == 3: self._client.wrapped_call("seekcur", "+"+str(self._adjustment.get_property("step-increment"))) def _on_rest_button_release_event(self, widget, event): if event.button == 1: self._client.wrapped_call("seekcur", "+"+str(self._adjustment.get_property("step-increment"))) elif event.button == 3: self._client.wrapped_call("seekcur", "-"+str(self._adjustment.get_property("step-increment"))) def _on_state(self, emitter, state): if state == "stop": self._disable() class OutputPopover(Gtk.Popover): def __init__(self, client, relative): super().__init__() self.set_relative_to(relative) # adding vars self._client=client # widgets box=Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, border_width=3) for output in self._client.wrapped_call("outputs"): button=Gtk.CheckButton(label="{} ({})".format(output["outputname"], output["plugin"])) if output["outputenabled"] == "1": button.set_active(True) button.connect("toggled", self._on_button_toggled, output["outputid"]) box.pack_start(button, False, False, 0) # packing self.add(box) box.show_all() def _on_button_toggled(self, button, out_id): if button.get_active(): self._client.wrapped_call("enableoutput", out_id) else: self._client.wrapped_call("disableoutput", out_id) class PlaybackOptions(Gtk.Box): def __init__(self, client, settings): super().__init__(spacing=6) # adding vars self._client=client self._settings=settings # widgets icons={} for icon_name in ("media-playlist-shuffle-symbolic","media-playlist-repeat-symbolic","zoom-original-symbolic","edit-cut-symbolic"): icons[icon_name]=AutoSizedIcon(icon_name, "icon-size", self._settings) self._random_button=Gtk.ToggleButton(image=icons["media-playlist-shuffle-symbolic"], tooltip_text=_("Random mode")) self._random_button.set_can_focus(False) self._repeat_button=Gtk.ToggleButton(image=icons["media-playlist-repeat-symbolic"], tooltip_text=_("Repeat mode")) self._repeat_button.set_can_focus(False) self._single_button=Gtk.ToggleButton(image=icons["zoom-original-symbolic"], tooltip_text=_("Single mode")) self._single_button.set_can_focus(False) self._consume_button=Gtk.ToggleButton(image=icons["edit-cut-symbolic"], tooltip_text=_("Consume mode")) self._consume_button.set_can_focus(False) self._volume_button=Gtk.VolumeButton(use_symbolic=True, size=self._settings.get_gtk_icon_size("icon-size")) self._volume_button.set_can_focus(False) self._adj=self._volume_button.get_adjustment() self._adj.set_step_increment(0.05) self._adj.set_page_increment(0.1) self._adj.set_upper(0) # do not allow volume change by user when MPD has not yet reported volume (no output enabled/avail) # connect self._random_button_toggled=self._random_button.connect("toggled", self._set_option, "random") self._repeat_button_toggled=self._repeat_button.connect("toggled", self._set_option, "repeat") self._single_button_toggled=self._single_button.connect("toggled", self._set_option, "single") self._consume_button_toggled=self._consume_button.connect("toggled", self._set_option, "consume") self._volume_button_changed=self._volume_button.connect("value-changed", self._set_volume) self._repeat_changed=self._client.emitter.connect("repeat", self._repeat_refresh) self._random_changed=self._client.emitter.connect("random", self._random_refresh) self._single_changed=self._client.emitter.connect("single", self._single_refresh) self._consume_changed=self._client.emitter.connect("consume", self._consume_refresh) self._volume_changed=self._client.emitter.connect("volume_changed", self._volume_refresh) self._volume_button.connect("button-press-event", self._on_volume_button_press_event) self._client.emitter.connect("disconnected", self._on_disconnected) self._client.emitter.connect("reconnected", self._on_reconnected) self._settings.connect("notify::mini-player", self._on_mini_player) self._settings.connect("changed::icon-size", self._on_icon_size_changed) # packing self._button_box=Gtk.ButtonBox(layout_style=Gtk.ButtonBoxStyle.EXPAND) self._button_box.pack_start(self._repeat_button, True, True, 0) self._button_box.pack_start(self._random_button, True, True, 0) self._button_box.pack_start(self._single_button, True, True, 0) self._button_box.pack_start(self._consume_button, True, True, 0) self.pack_start(self._button_box, True, True, 0) self.pack_start(self._volume_button, True, True, 0) def _set_option(self, widget, option): 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_value(0) self._adj.set_upper(0) else: self._adj.set_upper(1) self._volume_button.set_value(volume/100) self._volume_button.handler_unblock(self._volume_button_changed) def _on_volume_button_press_event(self, widget, event): if event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS: pop=OutputPopover(self._client, self._volume_button) pop.popup() def _on_reconnected(self, *args): self._repeat_button.set_sensitive(True) self._random_button.set_sensitive(True) self._single_button.set_sensitive(True) self._consume_button.set_sensitive(True) self._volume_button.set_sensitive(True) def _on_disconnected(self, *args): self._repeat_button.set_sensitive(False) self._random_button.set_sensitive(False) self._single_button.set_sensitive(False) self._consume_button.set_sensitive(False) self._volume_button.set_sensitive(False) self._repeat_refresh(None, False) self._random_refresh(None, False) self._single_refresh(None, 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): self._volume_button.set_property("size", self._settings.get_gtk_icon_size("icon-size")) ################### # MPD gio actions # ################### class MPDActionGroup(Gio.SimpleActionGroup): def __init__(self, client): super().__init__() # adding vars self._client=client # actions self._simple_actions_data=( "toggle-play","stop","next","prev","seek-forward","seek-backward","clear","update", "repeat","random","single","consume" ) for name in self._simple_actions_data: action=Gio.SimpleAction.new(name, None) action.connect("activate", getattr(self, ("_on_"+name.replace("-","_")))) self.add_action(action) # connect self._client.emitter.connect("state", self._on_state) self._client.emitter.connect("disconnected", self._on_disconnected) self._client.emitter.connect("reconnected", self._on_reconnected) def _on_toggle_play(self, action, param): self._client.wrapped_call("toggle_play") def _on_stop(self, action, param): self._client.wrapped_call("stop") def _on_next(self, action, param): self._client.wrapped_call("next") def _on_prev(self, action, param): self._client.wrapped_call("previous") def _on_seek_forward(self, action, param): self._client.wrapped_call("seekcur", "+10") def _on_seek_backward(self, action, param): self._client.wrapped_call("seekcur", "-10") def _on_clear(self, action, param): self._client.wrapped_call("clear") def _on_update(self, action, param): self._client.wrapped_call("update") def _on_repeat(self, action, param): self._client.wrapped_call("toggle_option", "repeat") def _on_random(self, action, param): self._client.wrapped_call("toggle_option", "random") def _on_single(self, action, param): self._client.wrapped_call("toggle_option", "single") def _on_consume(self, action, param): self._client.wrapped_call("toggle_option", "consume") def _on_state(self, emitter, state): state_dict={"play": True, "pause": True, "stop": False} for action in ("next","prev","seek-forward","seek-backward"): self.lookup_action(action).set_enabled(state_dict[state]) def _on_disconnected(self, *args): for action in self._simple_actions_data: self.lookup_action(action).set_enabled(False) def _on_reconnected(self, *args): for action in self._simple_actions_data: self.lookup_action(action).set_enabled(True) #################### # shortcuts window # #################### class ShortcutsWindow(Gtk.ShortcutsWindow): def __init__(self): super().__init__() general_group=Gtk.ShortcutsGroup(title=_("General"), visible=True) window_group=Gtk.ShortcutsGroup(title=_("Window"), visible=True) playback_group=Gtk.ShortcutsGroup(title=_("Playback"), visible=True) items_group=Gtk.ShortcutsGroup(title=_("Search, Album Dialog and Album List"), visible=True) playlist_group=Gtk.ShortcutsGroup(title=_("Playlist"), visible=True) section=Gtk.ShortcutsSection(section_name="shortcuts", visible=True) section.add(general_group) section.add(window_group) section.add(playback_group) section.add(items_group) section.add(playlist_group) shortcut_data=( ("F1", _("Open online help"), None, general_group), ("question", _("Open shortcuts window"), None, general_group), ("F10", _("Open menu"), None, general_group), ("F5", _("Update database"), None, general_group), ("q", _("Quit"), None, general_group), ("p", _("Cycle through profiles"), None, window_group), ("p", _("Cycle through profiles in reversed order"), None, window_group), ("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), ("space", _("Play/Pause"), None, playback_group), ("space", _("Stop"), None, playback_group), ("KP_Add", _("Next title"), None, playback_group), ("KP_Subtract", _("Previous title"), None, playback_group), ("KP_Multiply", _("Seek forward"), None, playback_group), ("KP_Divide", _("Seek backward"), None, playback_group), ("r", _("Toggle repeat mode"), None, playback_group), ("s", _("Toggle random mode"), None, playback_group), ("1", _("Toggle single mode"), None, playback_group), ("o", _("Toggle consume mode"), None, playback_group), ("p", _("Play selected item (next)"), _("Left-click"), items_group), ("a", _("Append selected item"), _("Middle-click"), items_group), ("Return", _("Play selected item immediately"), _("Double-click"), items_group), ("Menu", _("Show additional information"), _("Right-click"), items_group), ("Delete", _("Remove selected song"), _("Middle-click"), playlist_group), ("Delete", _("Clear playlist"), None, playlist_group), ("Menu", _("Show additional information"), _("Right-click"), playlist_group) ) for accel, title, subtitle, group in shortcut_data: shortcut=Gtk.ShortcutsShortcut(visible=True, accelerator=accel, title=title, subtitle=subtitle) group.pack_start(shortcut, False, False, 0) self.add(section) ############### # main window # ############### class ConnectionNotify(Gtk.Revealer): def __init__(self, client, settings): super().__init__(valign=Gtk.Align.START, halign=Gtk.Align.CENTER) # adding vars self._client=client self._settings=settings # widgets self._label=Gtk.Label(wrap=True) close_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("window-close-symbolic", Gtk.IconSize.BUTTON)) close_button.set_relief(Gtk.ReliefStyle.NONE) connect_button=Gtk.Button(label=_("Connect")) # connect close_button.connect("clicked", self._on_close_button_clicked) connect_button.connect("clicked", self._on_connect_button_clicked) self._client.emitter.connect("connection_error", self._on_connection_error) self._client.emitter.connect("reconnected", self._on_reconnected) # packing box=Gtk.Box(spacing=12) box.get_style_context().add_class("app-notification") box.pack_start(self._label, False, True, 6) box.pack_end(close_button, False, True, 0) box.pack_end(connect_button, False, True, 0) self.add(box) def _on_connection_error(self, *args): active=self._settings.get_int("active-profile") string=_("Connection to “{profile}” ({host}:{port}) failed").format( profile=self._settings.get_value("profiles")[active], host=self._settings.get_value("hosts")[active], port=self._settings.get_value("ports")[active] ) self._label.set_text(string) self.set_reveal_child(True) def _on_reconnected(self, *args): self.set_reveal_child(False) def _on_close_button_clicked(self, *args): self.set_reveal_child(False) def _on_connect_button_clicked(self, *args): self._client.reconnect() class MainWindow(Gtk.ApplicationWindow): def __init__(self, app, client, settings): super().__init__(title=("mpdevil"), icon_name="mpdevil", application=app) Notify.init("mpdevil") self.set_default_size(settings.get_int("width"), settings.get_int("height")) if settings.get_boolean("maximize"): self.maximize() # request maximize shortcuts_window=ShortcutsWindow() self.set_help_overlay(shortcuts_window) shortcuts_window.set_modal(False) # adding vars self._client=client self._settings=settings self._use_csd=self._settings.get_boolean("use-csd") self._tmp_saved_size=None # needed to restore size after leaving mini player mode self._tmp_saved_maximized=None # needed to restore maximize state after leaving mini player mode # MPRIS dbus_service=MPRISInterface(self, self._client, self._settings) # actions simple_actions_data=( "save","settings","stats","help","menu", "toggle-lyrics","back-to-current-album","toggle-search", "profile-next","profile-prev" ) for name in simple_actions_data: action=Gio.SimpleAction.new(name, None) action.connect("activate", getattr(self, ("_on_"+name.replace("-","_")))) self.add_action(action) mini_player_action=Gio.PropertyAction.new("mini-player", self._settings, "mini-player") self.add_action(mini_player_action) self._profiles_action=Gio.SimpleAction.new_stateful("profiles", GLib.VariantType.new("i"), GLib.Variant("i", 0)) self._profiles_action.connect("change-state", self._on_profiles) self.add_action(self._profiles_action) self._mpd_action_group=MPDActionGroup(self._client) self.insert_action_group("mpd", self._mpd_action_group) # widgets if self._use_csd: icons={"open-menu-symbolic": Gtk.Image.new_from_icon_name("open-menu-symbolic", Gtk.IconSize.BUTTON)} else: icons={"open-menu-symbolic": AutoSizedIcon("open-menu-symbolic", "icon-size", self._settings)} self._browser=Browser(self._client, self._settings, self) self._cover_playlist_window=CoverPlaylistWindow(self._client, self._settings, self) playback_control=PlaybackControl(self._client, self._settings) seek_bar=SeekBar(self._client) playback_options=PlaybackOptions(self._client, self._settings) connection_notify=ConnectionNotify(self._client, self._settings) # menu subsection=Gio.Menu() subsection.append(_("Settings"), "win.settings") subsection.append(_("Keyboard shortcuts"), "win.show-help-overlay") subsection.append(_("Help"), "win.help") subsection.append(_("About"), "app.about") subsection.append(_("Quit"), "app.quit") mpd_subsection=Gio.Menu() mpd_subsection.append(_("Update database"), "mpd.update") mpd_subsection.append(_("Server stats"), "win.stats") self._profiles_submenu=Gio.Menu() self._refresh_profiles_menu() menu=Gio.Menu() menu.append_submenu(_("Profiles"), self._profiles_submenu) menu.append(_("Mini player"), "win.mini-player") menu.append(_("Save window layout"), "win.save") menu.append_section(None, mpd_subsection) menu.append_section(None, subsection) # menu button / popover self._menu_button=Gtk.MenuButton(image=icons["open-menu-symbolic"], tooltip_text=_("Menu")) self._menu_button.set_can_focus(False) menu_popover=Gtk.Popover.new_from_model(self._menu_button, menu) self._menu_button.set_popover(menu_popover) # action bar action_bar=Gtk.ActionBar() action_bar.pack_start(playback_control) action_bar.pack_start(seek_bar) action_bar.pack_start(playback_options) # connect self._settings.connect("changed::profiles", self._refresh_profiles_menu) self._settings.connect("changed::active-profile", self._on_active_profile_changed) self._settings.connect_after("notify::mini-player", self._on_mini_player) self._settings.connect("changed::playlist-right", self._on_playlist_pos_changed) 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_end(self._menu_button) self._header_bar.pack_end(self._browser.search_button) else: action_bar.pack_start(Gtk.Separator.new(orientation=Gtk.Orientation.VERTICAL)) action_bar.pack_start(self._menu_button) self.add(overlay) self._client.emitter.emit("disconnected") # bring player in defined state # indicate connection process in window title if self._use_csd: self._header_bar.set_subtitle(_("connecting…")) else: self.set_title("mpdevil "+_("connecting…")) self.show_all() def callback(*args): self._client.start() # connect client return False GLib.idle_add(callback) def _on_toggle_lyrics(self, action, param): self._cover_playlist_window.lyrics_button.set_active(not(self._cover_playlist_window.lyrics_button.get_active())) def _on_back_to_current_album(self, action, param): self._browser.back_to_current_album_button.emit("clicked") def _on_toggle_search(self, action, param): self._browser.search_button.set_active(not(self._browser.search_button.get_active())) def _on_save(self, action, param): size=self.get_size() self._settings.set_int("width", size[0]) self._settings.set_int("height", size[1]) self._settings.set_boolean("maximize", self.is_maximized()) self._browser.save_settings() self._cover_playlist_window.save_settings() self._settings.set_int("paned2", self._paned2.get_position()) def _on_settings(self, action, param): settings=SettingsDialog(self, self._client, self._settings) settings.run() settings.destroy() def _on_stats(self, action, param): stats=ServerStats(self, self._client, self._settings) stats.destroy() def _on_help(self, action, param): Gtk.show_uri_on_window(self, "https://github.com/SoongNoonien/mpdevil/wiki/Usage", Gdk.CURRENT_TIME) def _on_menu(self, action, param): self._menu_button.emit("clicked") def _on_profile_next(self, action, param): total_profiles=len(self._settings.get_value("profiles")) current_profile=self._settings.get_int("active-profile") self._settings.set_int("active-profile", (current_profile+1)%total_profiles) def _on_profile_prev(self, action, param): total_profiles=len(self._settings.get_value("profiles")) current_profile=self._settings.get_int("active-profile") self._settings.set_int("active-profile", (current_profile-1)%total_profiles) def _on_profiles(self, action, param): self._settings.set_int("active-profile", param.unpack()) action.set_state(param) def _on_song_changed(self, *args): song=self._client.wrapped_call("currentsong") if song == {}: if self._use_csd: self.set_title("mpdevil") self._header_bar.set_subtitle("") else: self.set_title("mpdevil") else: song=ClientHelper.song_to_str_dict(ClientHelper.pepare_song_for_display(song)) if song["date"] == "": date="" else: date=" ("+song["date"]+")" if self._use_csd: self.set_title(song["title"]+" - "+song["artist"]) self._header_bar.set_subtitle(song["album"]+date) else: self.set_title(song["title"]+" - "+song["artist"]+" - "+song["album"]+date) if self._settings.get_boolean("send-notify"): if not self.is_active() and self._client.wrapped_call("status")["state"] == "play": notify=Notify.Notification.new(song["title"], song["artist"]+"\n"+song["album"]+date) pixbuf=Cover(self._settings, song).get_pixbuf(400) notify.set_image_from_pixbuf(pixbuf) notify.show() def _on_reconnected(self, *args): for action in ("save","stats","toggle-lyrics","back-to-current-album","toggle-search"): self.lookup_action(action).set_enabled(True) def _on_disconnected(self, *args): self.set_title("mpdevil") if self._use_csd: self._header_bar.set_subtitle("") for action in ("save","stats","toggle-lyrics","back-to-current-album","toggle-search"): self.lookup_action(action).set_enabled(False) def _on_search_focus_changed(self, obj, focus): self._mpd_action_group.lookup_action("toggle-play").set_enabled(not(focus)) def _on_mini_player(self, obj, typestring): if obj.get_property("mini-player"): self.lookup_action("save").set_enabled(False) self._tmp_saved_size=self.get_size() self._tmp_saved_miximized=self.is_maximized() if self._tmp_saved_miximized: self.unmaximize() self.resize(1,1) else: self.lookup_action("save").set_enabled(True) self.resize(self._tmp_saved_size[0], self._tmp_saved_size[1]) if self._tmp_saved_miximized: self.maximize() self._tmp_saved_size=None self._tmp_saved_maximized=None def _on_playlist_pos_changed(self, *args): if self._settings.get_boolean("playlist-right"): self._cover_playlist_window.set_orientation(Gtk.Orientation.VERTICAL) self._paned2.set_orientation(Gtk.Orientation.HORIZONTAL) else: self._cover_playlist_window.set_orientation(Gtk.Orientation.HORIZONTAL) self._paned2.set_orientation(Gtk.Orientation.VERTICAL) def _refresh_profiles_menu(self, *args): self._profiles_submenu.remove_all() for num, profile in enumerate(self._settings.get_value("profiles")): item=Gio.MenuItem.new(profile, None) item.set_action_and_target_value("win.profiles", GLib.Variant("i", num)) self._profiles_submenu.append_item(item) self._profiles_action.set_state(GLib.Variant("i", self._settings.get_int("active-profile"))) def _on_active_profile_changed(self, *args): self._profiles_action.set_state(GLib.Variant("i", self._settings.get_int("active-profile"))) ################### # Gtk application # ################### class mpdevil(Gtk.Application): def __init__(self, *args, **kwargs): super().__init__(*args, application_id="org.mpdevil", flags=Gio.ApplicationFlags.FLAGS_NONE, **kwargs) self._settings=Settings() self._client=Client(self._settings) self._window=None def do_activate(self): if not self._window: # allow just one instance self._window=MainWindow(self, self._client, self._settings) self._window.connect("delete-event", self._on_delete_event) # accelerators action_accels=( ("app.quit", ["q"]),("win.mini-player", ["m"]),("win.help", ["F1"]),("win.menu", ["F10"]), ("win.show-help-overlay", ["question"]),("win.toggle-lyrics", ["l"]), ("win.back-to-current-album", ["Escape"]),("win.toggle-search", ["f"]), ("mpd.update", ["F5"]),("mpd.clear", ["Delete"]),("mpd.toggle-play", ["space"]), ("mpd.stop", ["space"]),("mpd.next", ["KP_Add"]),("mpd.prev", ["KP_Subtract"]), ("mpd.repeat", ["r"]),("mpd.random", ["s"]),("mpd.single", ["1"]), ("mpd.consume", ["o"]),("mpd.seek-forward", ["KP_Multiply"]),("mpd.seek-backward", ["KP_Divide"]), ("win.profile-next", ["p"]),("win.profile-prev", ["p"]) ) for action, accels in action_accels: self.set_accels_for_action(action, accels) self._window.present() def do_startup(self): Gtk.Application.do_startup(self) action=Gio.SimpleAction.new("about", None) action.connect("activate", self._on_about) self.add_action(action) action=Gio.SimpleAction.new("quit", None) action.connect("activate", self._on_quit) self.add_action(action) def _on_delete_event(self, *args): if self._settings.get_boolean("stop-on-quit") and self._client.connected(): self._client.wrapped_call("stop") self.quit() def _on_about(self, action, param): dialog=AboutDialog(self._window) dialog.run() dialog.destroy() def _on_quit(self, action, param): if self._settings.get_boolean("stop-on-quit") and self._client.connected(): self._client.wrapped_call("stop") self.quit() if __name__ == "__main__": app=mpdevil() app.run(sys.argv)