#!/usr/bin/python3 # -*- coding: utf-8 -*- # # mpdevil - MPD Client. # Copyright (C) 2020-2021 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, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . 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 datetime import os import sys import re from gettext import gettext as _, ngettext, textdomain, bindtextdomain textdomain("mpdevil") if os.path.isfile("/.flatpak-info"): # test for flatpak environment bindtextdomain("mpdevil", "/app/share/locale") else: bindtextdomain("mpdevil", localedir=None) # replace "None" by a static path if needed (e.g when installing on a non-FHS distro) VERSION="1.1.1-dev" # sync with setup.py COVER_REGEX=r"^\.?(album|cover|folder|front).*\.(gif|jpeg|jpg|png)$" ######### # MPRIS # ######### class MPRISInterface: # TODO emit Seeked if needed """ based on 'Lollypop' (master 22.12.2020) by Cedric Bellegarde and 'mpDris2' (master 19.03.2020) by Jean-Philippe Braun , Mantas Mikulėnas """ _MPRIS_IFACE="org.mpris.MediaPlayer2" _MPRIS_PLAYER_IFACE="org.mpris.MediaPlayer2.Player" _MPRIS_NAME="org.mpris.MediaPlayer2.mpdevil" _MPRIS_PATH="/org/mpris/MediaPlayer2" _INTERFACES_XML=""" """ def __init__(self, window, client, settings): self._window=window self._client=client self._settings=settings self._metadata={} # MPRIS property mappings self._prop_mapping={ self._MPRIS_IFACE: {"CanQuit": (GLib.Variant("b", False), None), "CanRaise": (GLib.Variant("b", True), None), "HasTrackList": (GLib.Variant("b", False), None), "Identity": (GLib.Variant("s", "mpdevil"), None), "DesktopEntry": (GLib.Variant("s", "org.mpdevil.mpdevil"), None), "SupportedUriSchemes": (GLib.Variant("s", "None"), None), "SupportedMimeTypes": (GLib.Variant("s", "None"), None)}, self._MPRIS_PLAYER_IFACE: {"PlaybackStatus": (self._get_playback_status, None), "LoopStatus": (self._get_loop_status, self._set_loop_status), "Rate": (GLib.Variant("d", 1.0), None), "Shuffle": (self._get_shuffle, self._set_shuffle), "Metadata": (self._get_metadata, None), "Volume": (self._get_volume, self._set_volume), "Position": (self._get_position, None), "MinimumRate": (GLib.Variant("d", 1.0), None), "MaximumRate": (GLib.Variant("d", 1.0), None), "CanGoNext": (self._get_can_next_prev, None), "CanGoPrevious": (self._get_can_next_prev, None), "CanPlay": (self._get_can_play_pause_seek, None), "CanPause": (self._get_can_play_pause_seek, None), "CanSeek": (self._get_can_play_pause_seek, None), "CanControl": (GLib.Variant("b", True), None)}, } # start self._bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) Gio.bus_own_name_on_connection(self._bus, self._MPRIS_NAME, Gio.BusNameOwnerFlags.NONE, None, None) self._node_info=Gio.DBusNodeInfo.new_for_xml(self._INTERFACES_XML) for interface in self._node_info.interfaces: self._bus.register_object(self._MPRIS_PATH, interface, self._handle_method_call, None, None) # connect self._client.emitter.connect("state", self._on_state_changed) self._client.emitter.connect("current_song_changed", self._on_song_changed) self._client.emitter.connect("volume_changed", self._on_volume_changed) self._client.emitter.connect("repeat", self._on_loop_changed) self._client.emitter.connect("single", self._on_loop_changed) self._client.emitter.connect("random", self._on_random_changed) self._client.emitter.connect("connection_error", self._on_connection_error) self._client.emitter.connect("reconnected", self._on_reconnected) def _handle_method_call(self, connection, sender, object_path, interface_name, method_name, parameters, invocation): args=list(parameters.unpack()) result=getattr(self, method_name)(*args) out_args=self._node_info.lookup_interface(interface_name).lookup_method(method_name).out_args if out_args == []: invocation.return_value(None) else: signature="("+"".join([arg.signature for arg in out_args])+")" variant=GLib.Variant(signature, (result,)) invocation.return_value(variant) # setter and getter def _get_playback_status(self): if self._client.connected(): status=self._client.status() return GLib.Variant("s", {"play": "Playing", "pause": "Paused", "stop": "Stopped"}[status["state"]]) return GLib.Variant("s", "Stopped") def _set_loop_status(self, value): if self._client.connected(): if value == "Playlist": self._client.repeat(1) self._client.single(0) elif value == "Track": self._client.repeat(1) self._client.single(1) elif value == "None": self._client.repeat(0) self._client.single(0) def _get_loop_status(self): if self._client.connected(): status=self._client.status() if status["repeat"] == "1": if status.get("single", "0") == "0": return GLib.Variant("s", "Playlist") else: return GLib.Variant("s", "Track") else: return GLib.Variant("s", "None") return GLib.Variant("s", "None") def _set_shuffle(self, value): if self._client.connected(): if value: self._client.random("1") else: self._client.random("0") def _get_shuffle(self): if self._client.connected(): if self._client.status()["random"] == "1": return GLib.Variant("b", True) else: return GLib.Variant("b", False) return GLib.Variant("b", False) def _get_metadata(self): return GLib.Variant("a{sv}", self._metadata) def _get_volume(self): if self._client.connected(): return GLib.Variant("d", float(self._client.status().get("volume", 0))/100) return GLib.Variant("d", 0) def _set_volume(self, value): if self._client.connected(): if value >= 0 and value <= 1: self._client.setvol(int(value * 100)) def _get_position(self): if self._client.connected(): status=self._client.status() return GLib.Variant("x", float(status.get("elapsed", 0))*1000000) return GLib.Variant("x", 0) def _get_can_next_prev(self): if self._client.connected(): status=self._client.status() if status["state"] == "stop": return GLib.Variant("b", False) else: return GLib.Variant("b", True) return GLib.Variant("b", False) def _get_can_play_pause_seek(self): return GLib.Variant("b", self._client.connected()) # introspect methods def Introspect(self): return self._INTERFACES_XML # property methods def Get(self, interface_name, prop): getter, setter=self._prop_mapping[interface_name][prop] if callable(getter): return getter() return getter def Set(self, interface_name, prop, value): getter, setter=self._prop_mapping[interface_name][prop] if setter is not None: setter(value) def GetAll(self, interface_name): read_props={} try: props=self._prop_mapping[interface_name] for key, (getter, setter) in props.items(): if callable(getter): getter=getter() read_props[key]=getter except KeyError: # interface has no properties pass return read_props def PropertiesChanged(self, interface_name, changed_properties, invalidated_properties): self._bus.emit_signal( None, self._MPRIS_PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged", GLib.Variant.new_tuple( GLib.Variant("s", interface_name), GLib.Variant("a{sv}", changed_properties), GLib.Variant("as", invalidated_properties) ) ) # root methods def Raise(self): self._window.present() def Quit(self): app_action_group=self._window.get_action_group("app") quit_action=app_action_group.lookup_action("quit") quit_action.activate() # player methods def Next(self): self._client.next() def Previous(self): self._client.previous() def Pause(self): self._client.pause(1) def PlayPause(self): self._client.toggle_play() def Stop(self): self._client.stop() def Play(self): self._client.play() def Seek(self, offset): if offset > 0: offset="+"+str(offset/1000000) else: offset=str(offset/1000000) self._client.seekcur(offset) def SetPosition(self, trackid, position): song=self._client.currentsong() if str(trackid).split("/")[-1] != song["id"]: return mpd_pos=position/1000000 if mpd_pos >= 0 and mpd_pos <= float(song["duration"]): self._client.seekcur(str(mpd_pos)) def OpenUri(self, uri): pass def Seeked(self, position): self._bus.emit_signal( None, self._MPRIS_PATH, self._MPRIS_PLAYER_IFACE, "Seeked", GLib.Variant.new_tuple(GLib.Variant("x", position)) ) # other methods def _update_metadata(self): """ Translate metadata returned by MPD to the MPRIS v2 syntax. http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata """ mpd_meta=self._client.currentsong() # raw values needed for cover song=ClientHelper.song_to_list_dict(mpd_meta) self._metadata={} for tag, xesam_tag in (("album","album"),("title","title"),("date","contentCreated")): if tag in song: self._metadata["xesam:{}".format(xesam_tag)]=GLib.Variant("s", song[tag][0]) for tag, xesam_tag in (("track","trackNumber"),("disc","discNumber")): if tag in song: self._metadata["xesam:{}".format(xesam_tag)]=GLib.Variant("i", int(song[tag][0])) for tag, xesam_tag in (("albumartist","albumArtist"),("artist","artist"),("composer","composer"),("genre","genre")): if tag in song: self._metadata["xesam:{}".format(xesam_tag)]=GLib.Variant("as", song[tag]) if "id" in song: self._metadata["mpris:trackid"]=GLib.Variant("o", self._MPRIS_PATH+"/Track/{}".format(song["id"][0])) if "duration" in song: self._metadata["mpris:length"]=GLib.Variant("x", float(song["duration"][0])*1000000) if "file" in song: song_file=song["file"][0] if "://" in song_file: # remote file self._metadata["xesam:url"]=GLib.Variant("s", song_file) else: lib_path=self._settings.get_value("paths")[self._settings.get_int("active-profile")] self._metadata["xesam:url"]=GLib.Variant("s", "file://{}".format(os.path.join(lib_path, song_file))) cover_path=self._client.get_cover_path(mpd_meta) if cover_path is not None: self._metadata["mpris:artUrl"]=GLib.Variant("s", "file://{}".format(cover_path)) def _update_property(self, interface_name, prop): getter, setter=self._prop_mapping[interface_name][prop] if callable(getter): value=getter() else: value=getter self.PropertiesChanged(interface_name, {prop: value}, []) return value def _on_state_changed(self, *args): self._update_property(self._MPRIS_PLAYER_IFACE, "PlaybackStatus") self._update_property(self._MPRIS_PLAYER_IFACE, "CanGoNext") self._update_property(self._MPRIS_PLAYER_IFACE, "CanGoPrevious") def _on_song_changed(self, *args): self._update_metadata() self._update_property(self._MPRIS_PLAYER_IFACE, "Metadata") def _on_volume_changed(self, *args): self._update_property(self._MPRIS_PLAYER_IFACE, "Volume") def _on_loop_changed(self, *args): self._update_property(self._MPRIS_PLAYER_IFACE, "LoopStatus") def _on_random_changed(self, *args): self._update_property(self._MPRIS_PLAYER_IFACE, "Shuffle") def _on_reconnected(self, *args): properties=("CanPlay","CanPause","CanSeek") for p in properties: self._update_property(self._MPRIS_PLAYER_IFACE, p) def _on_connection_error(self, *args): self._metadata={} properties=("PlaybackStatus","CanGoNext","CanGoPrevious","Metadata","Volume","LoopStatus","Shuffle","CanPlay","CanPause","CanSeek") for p in properties: self._update_property(self._MPRIS_PLAYER_IFACE, p) ###################### # MPD client wrapper # ###################### class ClientHelper(): def seconds_to_display_time(seconds): delta=datetime.timedelta(seconds=seconds) if delta.days > 0: days=ngettext("{days} day", "{days} days", delta.days).format(days=delta.days) time_string=days+", "+str(datetime.timedelta(seconds=delta.seconds)) else: time_string=str(delta).lstrip("0").lstrip(":") return time_string.replace(":", "∶") # use 'ratio' as delimiter def song_to_str_dict(song): # converts tags with multiple values to comma separated strings return_song={} for tag, value in song.items(): if type(value) == list: return_song[tag]=(", ".join(value)) else: return_song[tag]=value return return_song def song_to_first_str_dict(song): # extracts the first value of multiple value tags return_song={} for tag, value in song.items(): if type(value) == list: return_song[tag]=value[0] else: return_song[tag]=value return return_song def song_to_list_dict(song): # converts all values to lists return_song={} for tag, value in song.items(): if type(value) != list: return_song[tag]=[value] else: return_song[tag]=value return return_song def pepare_song_for_display(song): base_song={ "title": _("Unknown Title"), "track": "", "disc": "", "artist": "", "album": "", "duration": "0.0", "date": "", "genre": "" } if "range" in song: # translate .cue 'range' to 'duration' if needed start, end=song["range"].split("-") if start != "" and end != "": base_song["duration"]=str((float(end)-float(start))) base_song.update(song) base_song["human_duration"]=ClientHelper.seconds_to_display_time(int(float(base_song["duration"]))) for tag in ("disc", "track"): # remove confusing multiple tags if tag in song: if type(song[tag]) == list: base_song[tag]=song[tag][0] return base_song def calc_display_length(songs): length=float(0) for song in songs: length=length+float(song.get("duration", 0.0)) return ClientHelper.seconds_to_display_time(int(length)) def binary_to_pixbuf(binary, size): loader=GdkPixbuf.PixbufLoader.new() loader.write(binary) loader.close() raw_pixbuf=loader.get_pixbuf() ratio=raw_pixbuf.get_width()/raw_pixbuf.get_height() if ratio > 1: pixbuf=raw_pixbuf.scale_simple(size,size/ratio,GdkPixbuf.InterpType.BILINEAR) else: pixbuf=raw_pixbuf.scale_simple(size*ratio,size,GdkPixbuf.InterpType.BILINEAR) return pixbuf class EventEmitter(GObject.Object): __gsignals__={ "update": (GObject.SignalFlags.RUN_FIRST, None, ()), "disconnected": (GObject.SignalFlags.RUN_FIRST, None, ()), "reconnected": (GObject.SignalFlags.RUN_FIRST, None, ()), "connection_error": (GObject.SignalFlags.RUN_FIRST, None, ()), "current_song_changed": (GObject.SignalFlags.RUN_FIRST, None, ()), "state": (GObject.SignalFlags.RUN_FIRST, None, (str,)), "elapsed_changed": (GObject.SignalFlags.RUN_FIRST, None, (float,float,)), "volume_changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)), "playlist_changed": (GObject.SignalFlags.RUN_FIRST, None, (int,)), "repeat": (GObject.SignalFlags.RUN_FIRST, None, (bool,)), "random": (GObject.SignalFlags.RUN_FIRST, None, (bool,)), "single": (GObject.SignalFlags.RUN_FIRST, None, (str,)), "consume": (GObject.SignalFlags.RUN_FIRST, None, (bool,)), "audio": (GObject.SignalFlags.RUN_FIRST, None, (str,str,str,)), "bitrate": (GObject.SignalFlags.RUN_FIRST, None, (float,)), "add_to_playlist": (GObject.SignalFlags.RUN_FIRST, None, (str,)), "show_info": (GObject.SignalFlags.RUN_FIRST, None, ()) } def __init__(self): super().__init__() class Client(MPDClient): def __init__(self, settings): super().__init__() self._settings=settings self.emitter=EventEmitter() self._last_status={} self._refresh_interval=self._settings.get_int("refresh-interval") self._main_timeout_id=None self.fallback_cover=Gtk.IconTheme.get_default().lookup_icon("media-optical", 128, Gtk.IconLookupFlags.FORCE_SVG).get_filename() # connect self._settings.connect("changed::active-profile", self._on_active_profile_changed) def start(self): self.emitter.emit("disconnected") # bring player in defined state active=self._settings.get_int("active-profile") try: self.connect(self._settings.get_value("hosts")[active], self._settings.get_value("ports")[active]) if self._settings.get_value("passwords")[active] != "": self.password(self._settings.get_value("passwords")[active]) except: self.emitter.emit("connection_error") return False # connect successful if "status" in self.commands(): self._main_timeout_id=GLib.timeout_add(self._refresh_interval, self._main_loop) self.emitter.emit("reconnected") return True else: self.disconnect() self.emitter.emit("connection_error") print("No read permission, check your mpd config.") return False def reconnect(self): if self._main_timeout_id is not None: GLib.source_remove(self._main_timeout_id) self._main_timeout_id=None self._last_status={} self.disconnect() self.start() def connected(self): try: self.ping() return True except: return False def files_to_playlist(self, files, mode="default"): # modes: default, play, append, enqueue def append(files): for f in files: self.add(f) def play(files): if files != []: self.clear() for f in files: self.add(f) self.play() def enqueue(files): status=self.status() if status["state"] == "stop": play(files) else: self.moveid(status["songid"], 0) current_song_file=self.playlistinfo()[0]["file"] try: self.delete((1,)) # delete all songs, but the first. bad song index possible except: pass for f in files: if f == current_song_file: self.move(0, (len(self.playlistinfo())-1)) else: self.add(f) if mode == "append": append(files) elif mode == "enqueue": enqueue(files) elif mode == "play": play(files) elif mode == "default": if self._settings.get_boolean("force-mode"): play(files) else: enqueue(files) def album_to_playlist(self, album, artist, year, genre, mode="default"): if genre is None: genre_filter=() else: genre_filter=("genre", genre) songs=self.find("album", album, "date", year, self._settings.get_artist_type(), artist, *genre_filter) self.files_to_playlist([song["file"] for song in songs], mode) def artist_to_playlist(self, artist, genre, mode): def append(): if self._settings.get_boolean("sort-albums-by-year"): sort_tag="date" else: sort_tag="album" if artist is None: # treat 'None' as 'all artists' if genre is None: self.searchadd("any", "", "sort", sort_tag) else: self.findadd("genre", genre, "sort", sort_tag) else: artist_type=self._settings.get_artist_type() if genre is None: self.findadd(artist_type, artist, "sort", sort_tag) else: self.findadd(artist_type, artist, "genre", genre, "sort", sort_tag) if mode == "append": append() elif mode == "play": self.clear() append() self.play() elif mode == "enqueue": status=self.status() if status["state"] == "stop": self.clear() append() self.play() else: self.moveid(status["songid"], 0) current_song_file=self.currentsong()["file"] try: self.delete((1,)) # delete all songs, but the first. bad song index possible except: pass append() duplicates=self.playlistfind("file", current_song_file) if len(duplicates) > 1: self.move(0, duplicates[1]["pos"]) self.delete(int(duplicates[1]["pos"])-1) def comp_list(self, *args): # simulates listing behavior of python-mpd2 1.0 native_list=self.list(*args) if len(native_list) > 0: if type(native_list[0]) == dict: return ([l[args[0]] for l in native_list]) else: return native_list else: return([]) def get_cover_path(self, raw_song): path=None song=ClientHelper.song_to_first_str_dict(raw_song) song_file=song.get("file") active_profile=self._settings.get_int("active-profile") lib_path=self._settings.get_lib_path() if lib_path is not None: regex_str=self._settings.get_value("regex")[active_profile] if regex_str == "": regex=re.compile(COVER_REGEX, flags=re.IGNORECASE) else: regex_str=regex_str.replace("%AlbumArtist%", song.get("albumartist", "")) regex_str=regex_str.replace("%Album%", song.get("album", "")) try: regex=re.compile(regex_str, flags=re.IGNORECASE) except: print("illegal regex:", regex_str) return (None, None) if song_file is not None: song_dir=os.path.join(lib_path, os.path.dirname(song_file)) if song_dir.endswith(".cue"): song_dir=os.path.dirname(song_dir) # get actual directory of .cue file if os.path.exists(song_dir): for f in os.listdir(song_dir): if regex.match(f): path=os.path.join(song_dir, f) break return path def get_cover_binary(self, uri): if uri is None: binary=None else: try: binary=self.albumart(uri)["binary"] except: try: binary=self.readpicture(uri)["binary"] except: binary=None return binary def get_cover(self, song, size): cover_path=self.get_cover_path(song) if cover_path is None: cover_binary=self.get_cover_binary(song.get("file")) if cover_binary is None: pixbuf=GdkPixbuf.Pixbuf.new_from_file_at_size(self.fallback_cover, size, size) else: pixbuf=ClientHelper.binary_to_pixbuf(cover_binary, size) else: try: pixbuf=GdkPixbuf.Pixbuf.new_from_file_at_size(cover_path, size, size) except: # load fallback if cover can't be loaded (GLib: Couldn’t recognize the image file format for file...) pixbuf=GdkPixbuf.Pixbuf.new_from_file_at_size(self.fallback_cover, size, size) return pixbuf def get_metadata(self, uri): meta_base=self.lsinfo(uri)[0] try: # .cue files produce an error here meta_extra=self.readcomments(uri) # contains comment tag meta_base.update(meta_extra) except: pass return meta_base def get_absolute_path(self, uri): lib_path=self._settings.get_lib_path() if lib_path is not None: path=os.path.join(lib_path, uri) if os.path.isfile(path): return path else: return None else: return None def get_albums(self, artist, genre): albums=[] artist_type=self._settings.get_artist_type() if genre is None: genre_filter=() else: genre_filter=("genre", genre) album_candidates=self.comp_list("album", artist_type, artist, *genre_filter) for album in album_candidates: years=self.comp_list("date", "album", album, artist_type, artist) for year in years: songs=self.find("album", album, "date", year, artist_type, artist, *genre_filter) cover_path=self.get_cover_path(songs[0]) if cover_path is None: cover_binary=self.get_cover_binary(songs[0].get("file")) if cover_binary is None: albums.append({"artist": artist, "album": album, "year": year, "songs": songs}) else: albums.append({"artist":artist,"album":album,"year":year,"songs":songs,"cover_binary":cover_binary}) else: albums.append({"artist": artist, "album": album, "year": year, "songs": songs, "cover_path": cover_path}) return albums def toggle_play(self): status=self.status() if status["state"] == "play": self.pause(1) elif status["state"] == "pause": self.pause(0) else: try: self.play() except: pass def toggle_option(self, option): # repeat, random, single, consume state=self.status()[option] if state != "1" and state != "0": # support single oneshot state="1" new_state=(int(state)+1)%2 # toggle 0,1 func=getattr(self, option) func(new_state) def _main_loop(self, *args): try: status=self.status() diff=set(status.items())-set(self._last_status.items()) for key, val in diff: if key == "elapsed": if "duration" in status: self.emitter.emit("elapsed_changed", float(val), float(status["duration"])) else: self.emitter.emit("elapsed_changed", 0.0, 0.0) elif key == "bitrate": self.emitter.emit("bitrate", float(val)) elif key == "songid": self.emitter.emit("current_song_changed") elif key in ("state", "single"): self.emitter.emit(key, val) elif key == "audio": # see: https://www.musicpd.org/doc/html/user.html#audio-output-format samplerate, bits, channels=val.split(":") if bits == "f": bits="32fp" self.emitter.emit("audio", samplerate, bits, channels) elif key == "volume": self.emitter.emit("volume_changed", float(val)) elif key == "playlist": self.emitter.emit("playlist_changed", int(val)) elif key in ("repeat", "random", "consume"): if val == "1": self.emitter.emit(key, True) else: self.emitter.emit(key, False) diff=set(self._last_status)-set(status) if "songid" in diff: self.emitter.emit("current_song_changed") if "volume" in diff: self.emitter.emit("volume_changed", -1) if "updating_db" in diff: self.emitter.emit("update") self._last_status=status except (MPDBase.ConnectionError, ConnectionResetError) as e: self.disconnect() self._last_status={} self.emitter.emit("disconnected") self.emitter.emit("connection_error") self._main_timeout_id=None return False return True def _on_active_profile_changed(self, *args): self.reconnect() ######################## # gio settings wrapper # ######################## class Settings(Gio.Settings): BASE_KEY="org.mpdevil.mpdevil" # temp settings mini_player=GObject.Property(type=bool, default=False) cursor_watch=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") def get_lib_path(self, profile=None): if profile is None: # use current profile if none is given profile=self.get_int("active-profile") lib_path=self.get_value("paths")[profile] if lib_path == "": lib_path=GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_MUSIC) return lib_path ################### # settings dialog # ################### class GeneralSettings(Gtk.Box): def __init__(self, settings): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6, border_width=18) 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]) ) # 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"), (_("Place playlist at the side"), "playlist-right"), (_("Use “Album Artist” tag"), "use-album-artist"), (_("Send notification on title change"), "send-notify"), (_("Stop playback on quit"), "stop-on-quit"), (_("Play selected albums and titles immediately"), "force-mode"), (_("Sort albums by year"), "sort-albums-by-year"), ] 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(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) # 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["playlist-right"], 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["sort-albums-by-year"], False, False, 0) self.pack_start(check_buttons["send-notify"], False, False, 0) self.pack_start(check_buttons["force-mode"], False, False, 0) self.pack_start(check_buttons["stop-on-quit"], 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_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_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) 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-symbolic", Gtk.IconSize.BUTTON)) delete_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("list-remove-symbolic", 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.new_with_mnemonic(_("_Connect")) 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=PasswordEntry(hexpand=True) 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-symbolic", 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) style_context=connect_button.get_style_context() style_context.add_class("suggested-action") 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.FileChooserNative(title=_("Choose directory"), transient_for=parent, action=Gtk.FileChooserAction.SELECT_FOLDER) folder=self._settings.get_lib_path(self._profiles_combo.get_active()) if folder is not None: dialog.set_current_folder(folder) response=dialog.run() if response == Gtk.ResponseType.ACCEPT: 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) 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.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_translator_credits("Martin de Reuver\nMartin Wagner") self.set_website("https://github.com/SoongNoonien/mpdevil") self.set_copyright("Copyright \xa9 2020-2021 Martin Wagner") self.set_license_type(Gtk.License.GPL_3_0) self.set_logo_icon_name("org.mpdevil.mpdevil") ########################### # 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) 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 PasswordEntry(Gtk.Entry): def __init__(self, **kwargs): super().__init__(visibility=False, caps_lock_warning=False, input_purpose=Gtk.InputPurpose.PASSWORD, **kwargs) self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "view-conceal-symbolic") # connect self.connect("icon-release", self._on_icon_release) def _on_icon_release(self, *args): if self.get_icon_name(Gtk.EntryIconPosition.SECONDARY) == "view-conceal-symbolic": self.set_visibility(True) self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "view-reveal-symbolic") else: self.set_visibility(False) self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "view-conceal-symbolic") 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: 0px; border-top-width: 3px;}""" provider.load_from_data(css) style_context.add_provider(provider, 600) 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, client, show_buttons=True): super().__init__() self._client=client self._rect=Gdk.Rectangle() self._uri=None box=Gtk.Box(orientation=Gtk.Orientation.VERTICAL, border_width=6, spacing=6) # open-with button open_button=Gtk.Button(image=Gtk.Image.new_from_icon_name("document-open-symbolic",Gtk.IconSize.BUTTON),tooltip_text=_("Open with…")) open_button.set_margin_bottom(6) open_button.set_margin_end(6) style_context=open_button.get_style_context() style_context.add_class("circular") # open button revealer self._open_button_revealer=Gtk.Revealer() self._open_button_revealer.set_halign(Gtk.Align.END) self._open_button_revealer.set_valign(Gtk.Align.END) self._open_button_revealer.add(open_button) # buttons if show_buttons: button_box=Gtk.ButtonBox(layout_style=Gtk.ButtonBoxStyle.EXPAND) data=((_("Append"), "list-add-symbolic", "append"), (_("Play"), "media-playback-start-symbolic", "play"), (_("Enqueue"), "insert-object-symbolic", "enqueue") ) for label, icon, mode in data: button=Gtk.Button(label=label, image=Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.BUTTON)) button.connect("clicked", self._on_button_clicked, mode) button_box.pack_start(button, True, True, 0) box.pack_end(button_box, False, False, 0) # treeview # (tag, display-value, tooltip) self._store=Gtk.ListStore(str, str, str) self._treeview=Gtk.TreeView(model=self._store, headers_visible=False, search_column=-1, tooltip_column=2) self._treeview.set_can_focus(False) self._treeview.get_selection().set_mode(Gtk.SelectionMode.NONE) # columns renderer_text=Gtk.CellRendererText(width_chars=50, ellipsize=Pango.EllipsizeMode.MIDDLE, ellipsize_set=True) renderer_text_ralign=Gtk.CellRendererText(xalign=1.0, weight=Pango.Weight.BOLD) column_tag=Gtk.TreeViewColumn(_("MPD-Tag"), renderer_text_ralign, text=0) column_tag.set_property("resizable", False) self._treeview.append_column(column_tag) column_value=Gtk.TreeViewColumn(_("Value"), renderer_text, text=1) column_value.set_property("resizable", False) self._treeview.append_column(column_value) # scroll self._scroll=Gtk.ScrolledWindow() self._scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) self._scroll.set_propagate_natural_height(True) self._scroll.add(self._treeview) # overlay overlay=Gtk.Overlay() overlay.add(self._scroll) overlay.add_overlay(self._open_button_revealer) # connect open_button.connect("clicked", self._on_open_button_clicked) # packing frame=Gtk.Frame() frame.add(overlay) box.pack_start(frame, True, True, 0) self.add(box) box.show_all() def open(self, uri, widget, x, y, offset=26): self._uri=uri self._rect.x=x # Gtk places popovers in treeviews 26px above the given position for no obvious reasons, so I move them 26px # This seems to be related to the width/height of the headers in treeviews self._rect.y=y+offset self.set_pointing_to(self._rect) self.set_relative_to(widget) window=self.get_toplevel() self._scroll.set_max_content_height(window.get_size()[1]//2) self._store.clear() song=ClientHelper.song_to_str_dict(self._client.get_metadata(uri)) for tag, value in song.items(): tooltip=value.replace("&", "&") if tag == "time": self._store.append([tag+":", ClientHelper.seconds_to_display_time(int(value)), tooltip]) elif tag == "last-modified": time=datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") self._store.append([tag+":", time.strftime("%a %d %B %Y, %H∶%M UTC"), tooltip]) else: self._store.append([tag+":", value, tooltip]) abs_path=self._client.get_absolute_path(uri) if abs_path is None: # show open with button when song is on the same computer self._open_button_revealer.set_reveal_child(False) else: self._gfile=Gio.File.new_for_path(abs_path) self._open_button_revealer.set_reveal_child(True) self.popup() self._treeview.columns_autosize() def _on_open_button_clicked(self, *args): self.popdown() dialog=Gtk.AppChooserDialog(gfile=self._gfile, transient_for=self.get_toplevel()) app_chooser=dialog.get_widget() response=dialog.run() if response == Gtk.ResponseType.OK: app=app_chooser.get_app_info() app.launch([self._gfile], None) dialog.destroy() def _on_button_clicked(self, widget, mode): self._client.files_to_playlist([self._uri], mode) self.popdown() class SongsView(Gtk.TreeView): def __init__(self, client, store, file_column_id): super().__init__(model=store, search_column=-1, activate_on_single_click=True) self._client=client self._store=store self._file_column_id=file_column_id # selection self._selection=self.get_selection() # song popover self._song_popover=SongPopover(self._client) # connect self.connect("row-activated", self._on_row_activated) self.connect("button-press-event", self._on_button_press_event) self._client.emitter.connect("show-info", self._on_show_info) self._client.emitter.connect("add-to-playlist", self._on_add_to_playlist) def clear(self): self._store.clear() def count(self): return len(self._store) def get_files(self): return_list=[] for row in self._store: return_list.append(row[self._file_column_id]) return return_list def _on_row_activated(self, widget, path, view_column): self._client.files_to_playlist([self._store[path][self._file_column_id]]) def _on_button_press_event(self, widget, event): path_re=widget.get_path_at_pos(int(event.x), int(event.y)) if path_re is not None: path=path_re[0] if event.button == 1 and event.type == Gdk.EventType._2BUTTON_PRESS: self._client.files_to_playlist([self._store[path][self._file_column_id]], "play") elif event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS: self._client.files_to_playlist([self._store[path][self._file_column_id]], "append") elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS: uri=self._store[path][self._file_column_id] if self.get_property("headers-visible"): self._song_popover.open(uri, widget, int(event.x), int(event.y)) else: self._song_popover.open(uri, widget, int(event.x), int(event.y), offset=0) def _on_show_info(self, *args): if self.has_focus(): treeview, treeiter=self._selection.get_selected() if treeiter is not None: path=self._store.get_path(treeiter) cell=self.get_cell_area(path, None) self._song_popover.open(self._store[path][self._file_column_id], self, cell.x, cell.y) def _on_add_to_playlist(self, emitter, mode): if self.has_focus(): treeview, treeiter=self._selection.get_selected() if treeiter is not None: self._client.files_to_playlist([self._store.get_value(treeiter, self._file_column_id)], mode) class SongsWindow(Gtk.Box): __gsignals__={"button-clicked": (GObject.SignalFlags.RUN_FIRST, None, ())} def __init__(self, client, store, file_column_id, popover_mode=False): if popover_mode: super().__init__(orientation=Gtk.Orientation.VERTICAL, border_width=6, spacing=6) else: super().__init__(orientation=Gtk.Orientation.VERTICAL) self._client=client # treeview self._songs_view=SongsView(client, store, file_column_id) # scroll self._scroll=Gtk.ScrolledWindow() self._scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) self._scroll.add(self._songs_view) # buttons button_box=Gtk.ButtonBox(layout_style=Gtk.ButtonBoxStyle.EXPAND) data=((_("_Append"), _("Add all titles to playlist"), "list-add-symbolic", "append"), (_("_Play"), _("Directly play all titles"), "media-playback-start-symbolic", "play"), (_("_Enqueue"), _("Append all titles after the currently playing track and clear the playlist from all other songs"), "insert-object-symbolic", "enqueue") ) for label, tooltip, icon, mode in data: button=Gtk.Button.new_with_mnemonic(label) button.set_image(Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.BUTTON)) button.set_tooltip_text(tooltip) button.connect("clicked", self._on_button_clicked, mode) button_box.pack_start(button, True, True, 0) # action bar self._action_bar=Gtk.ActionBar() # packing if popover_mode: self.pack_end(button_box, False, False, 0) frame=Gtk.Frame() else: self._action_bar.pack_start(button_box) self.pack_end(self._action_bar, False, False, 0) frame=FocusFrame() frame.set_widget(self._songs_view) frame.add(self._scroll) self.pack_start(frame, True, True, 0) def get_treeview(self): return self._songs_view def get_action_bar(self): return self._action_bar def get_scroll(self): return self._scroll def _on_button_clicked(self, widget, mode): self._client.files_to_playlist(self._songs_view.get_files(), mode) self.emit("button-clicked") class AlbumPopover(Gtk.Popover): def __init__(self, client, settings): super().__init__() self._client=client self._settings=settings self._rect=Gdk.Rectangle() # songs window # (track, title (artist), duration, file, search text) self._store=Gtk.ListStore(str, str, str, str, str) songs_window=SongsWindow(self._client, self._store, 3, popover_mode=True) # scroll self._scroll=songs_window.get_scroll() self._scroll.set_propagate_natural_height(True) self._scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) # songs view self._songs_view=songs_window.get_treeview() self._songs_view.set_property("headers-visible", False) self._songs_view.set_property("search-column", 4) # columns renderer_text=Gtk.CellRendererText(width_chars=80, ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True) renderer_text_ralign=Gtk.CellRendererText(xalign=1.0) column_track=Gtk.TreeViewColumn(_("No"), renderer_text_ralign, text=0) column_track.set_property("resizable", False) self._songs_view.append_column(column_track) column_title=Gtk.TreeViewColumn(_("Title"), renderer_text, markup=1) column_title.set_property("resizable", False) self._songs_view.append_column(column_title) column_time=Gtk.TreeViewColumn(_("Length"), renderer_text_ralign, text=2) column_time.set_property("resizable", False) self._songs_view.append_column(column_time) # connect songs_window.connect("button-clicked", lambda *args: self.popdown()) # packing self.add(songs_window) songs_window.show_all() def open(self, album, album_artist, date, genre, widget, x, y): self._rect.x=x self._rect.y=y self.set_pointing_to(self._rect) self.set_relative_to(widget) self._scroll.set_max_content_height(4*widget.get_allocated_height()//7) self._songs_view.set_model(None) # clear old scroll position self._store.clear() if genre is None: genre_filter=() else: genre_filter=("genre", genre) songs=self._client.find("album", album, "date", date, self._settings.get_artist_type(), album_artist, *genre_filter) for s in songs: song=ClientHelper.song_to_list_dict(ClientHelper.pepare_song_for_display(s)) track=song["track"][0] title=(", ".join(song["title"])) # only show artists =/= albumartist try: song["artist"].remove(album_artist) except: pass artist=(", ".join(song["artist"])) if artist == album_artist or artist == "": title_artist="{}".format(title) else: title_artist="{} - {}".format(title, artist) title_artist=title_artist.replace("&", "&") self._store.append([track, title_artist, song["human_duration"][0], song["file"][0], title]) self._songs_view.set_model(self._store) self.popup() self._songs_view.columns_autosize() class ArtistPopover(Gtk.Popover): def __init__(self, client): super().__init__() self._client=client self._rect=Gdk.Rectangle() self._artist=None self._genre=None # buttons vbox=Gtk.ButtonBox(orientation=Gtk.Orientation.VERTICAL, border_width=9) data=((_("Append"), _("Add all titles to playlist"), "list-add-symbolic", "append"), (_("Play"), _("Directly play all titles"), "media-playback-start-symbolic", "play"), (_("Enqueue"), _("Append all titles after the currently playing track and clear the playlist from all other songs"), "insert-object-symbolic", "enqueue") ) for label, tooltip, icon, mode in data: button=Gtk.ModelButton(label=label, tooltip_text=tooltip, image=Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.BUTTON)) button.get_child().set_property("xalign", 0) button.connect("clicked", self._on_button_clicked, mode) vbox.pack_start(button, True, True, 0) self.add(vbox) vbox.show_all() def open(self, artist, genre, widget, x, y): self._rect.x=x self._rect.y=y self.set_pointing_to(self._rect) self.set_relative_to(widget) self._artist=artist self._genre=genre self.popup() def _on_button_clicked(self, widget, mode): self._client.artist_to_playlist(self._artist, self._genre, mode) self.popdown() ########### # browser # ########### class SearchWindow(Gtk.Box): def __init__(self, client): super().__init__(orientation=Gtk.Orientation.VERTICAL) self._client=client self._stop_flag=False self._done=True self._pending=[] # tag switcher self._tag_combo_box=Gtk.ComboBoxText() # search entry self.search_entry=Gtk.SearchEntry() # 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_changed=self.search_entry.connect("search-changed", self._search) self._tag_combo_box_changed=self._tag_combo_box.connect("changed", self._search) self._client.emitter.connect("reconnected", self._on_reconnected) self._client.emitter.connect("disconnected", self._on_disconnected) # packing hbox=Gtk.Box(spacing=6, 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): if self._done: self.search_entry.handler_block(self._search_entry_changed) self._tag_combo_box.handler_block(self._tag_combo_box_changed) self._songs_view.clear() self.search_entry.set_text("") self._tag_combo_box.remove_all() self.search_entry.handler_unblock(self._search_entry_changed) self._tag_combo_box.handler_unblock(self._tag_combo_box_changed) elif not self._clear in self._pending: self._stop_flag=True self._pending.append(self._clear) def _on_disconnected(self, *args): self._tag_combo_box.set_sensitive(False) self.search_entry.set_sensitive(False) self._clear() def _on_reconnected(self, *args): if self._done: self._tag_combo_box.handler_block(self._tag_combo_box_changed) self._tag_combo_box.append_text(_("all tags")) for tag in self._client.tagtypes(): if not tag.startswith("MUSICBRAINZ"): self._tag_combo_box.append_text(tag) self._tag_combo_box.set_active(0) self._tag_combo_box.set_sensitive(True) self.search_entry.set_sensitive(True) self._tag_combo_box.handler_unblock(self._tag_combo_box_changed) elif not self._on_reconnected in self._pending: self._stop_flag=True self._pending.append(self._on_reconnected) def _search(self, *args): if self._done: self._done=False self._songs_view.clear() self._hits_label.set_text("") self._action_bar.set_sensitive(False) if len(self.search_entry.get_text()) > 0: if self._tag_combo_box.get_active() == 0: songs=self._client.search("any", self.search_entry.get_text()) else: songs=self._client.search(self._tag_combo_box.get_active_text(), self.search_entry.get_text()) hits=len(songs) self._hits_label.set_text(ngettext("{hits} hit", "{hits} hits", hits).format(hits=hits)) for i, s in enumerate(songs): if self._stop_flag: self._done_callback() return song=ClientHelper.song_to_str_dict(ClientHelper.pepare_song_for_display(s)) try: int_track=int(song["track"]) except: int_track=0 self._store.append([ song["track"], song["title"], song["artist"], song["album"], song["human_duration"], song["file"], int_track ]) if i%100 == 0: self.search_entry.set_progress_fraction((i+1)/hits) while Gtk.events_pending(): Gtk.main_iteration_do(True) if self._songs_view.count() > 0: self._action_bar.set_sensitive(True) self._done_callback() elif not self._search in self._pending: self._stop_flag=True self._pending.append(self._search) def _done_callback(self, *args): self.search_entry.set_progress_fraction(0.0) self._stop_flag=False self._done=True pending=self._pending self._pending=[] for p in pending: try: p() except: pass return False class GenreSelect(Gtk.ComboBoxText): __gsignals__={"genre_changed": (GObject.SignalFlags.RUN_FIRST, None, ())} def __init__(self, client): super().__init__(wrap_width=3) 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.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, ()), "clear": (GObject.SignalFlags.RUN_FIRST, None, ())} def __init__(self, client, settings, genre_select): super().__init__() self._client=client self._settings=settings self.genre_select=genre_select # treeview # (name, weight, initial-letter, weight-initials) self._store=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) # artist popover self._artist_popover=ArtistPopover(self._client) # connect self._treeview.connect("button-press-event", self._on_button_press_event) 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._client.emitter.connect("add-to-playlist", self._on_add_to_playlist) self._client.emitter.connect("show-info", self._on_show_info) self.genre_select.connect("genre_changed", self._refresh) self.set_widget(self._treeview) self.add(scroll) def _clear(self, *args): self._store.clear() self.emit("clear") def select(self, artist): row_num=len(self._store) for i in range(0, row_num): path=Gtk.TreePath(i) if self._store[path][0] == artist: self._treeview.set_cursor(path, None, False) self._treeview.row_activated(path, self._column_name) break def get_selected_artist(self): if self._store[Gtk.TreePath(0)][1] == Pango.Weight.BOLD: return None else: for row in self._store: if row[1] == Pango.Weight.BOLD: return row[0] def highlight_selected(self): for path, row in enumerate(self._store): if row[1] == Pango.Weight.BOLD: self._treeview.set_cursor(path, None, False) break def _refresh(self, *args): self._clear() self._store.append([_("all artists"), Pango.Weight.BOOK, "", Pango.Weight.BOOK]) genre=self.genre_select.get_selected_genre() if genre is None: artists=self._client.comp_list(self._settings.get_artist_type()) else: artists=self._client.comp_list(self._settings.get_artist_type(), "genre", genre) current_char="" for artist in artists: try: if current_char == artist[0]: self._store.append([artist, Pango.Weight.BOOK, "", Pango.Weight.BOOK]) else: self._store.append([artist, Pango.Weight.BOOK, artist[0], Pango.Weight.BOLD]) current_char=artist[0] except: self._store.append([artist, Pango.Weight.BOOK, "", Pango.Weight.BOOK]) if genre is not None: self._treeview.set_cursor(Gtk.TreePath(0), None, False) self._treeview.row_activated(Gtk.TreePath(0), self._column_name) else: song=ClientHelper.song_to_first_str_dict(self._client.currentsong()) if song != {}: artist=song.get(self._settings.get_artist_type()) if artist is None: artist=song.get("artist", "") self.select(artist) else: if len(self._store) > 1: path=Gtk.TreePath(1) else: path=Gtk.TreePath(0) self._treeview.set_cursor(path, None, False) self._treeview.row_activated(path, self._column_name) def _on_button_press_event(self, widget, event): if ((event.button in (2,3) and event.type == Gdk.EventType.BUTTON_PRESS) or (event.button == 1 and event.type == Gdk.EventType._2BUTTON_PRESS)): path_re=widget.get_path_at_pos(int(event.x), int(event.y)) if path_re is not None: path=path_re[0] genre=self.genre_select.get_selected_genre() if path == Gtk.TreePath(0): artist=None else: artist=self._store[path][0] if event.button == 1: self._client.artist_to_playlist(artist, genre, "play") elif event.button == 2: self._client.artist_to_playlist(artist, genre, "append") elif event.button == 3: self._artist_popover.open(artist, genre, self._treeview, event.x, event.y) def _on_row_activated(self, widget, path, view_column): if self._store[path][1] == Pango.Weight.BOOK: for row in self._store: # reset bold text row[1]=Pango.Weight.BOOK self._store[path][1]=Pango.Weight.BOLD self.emit("artists_changed") def _on_add_to_playlist(self, emitter, mode): if self._treeview.has_focus(): treeview, treeiter=self._selection.get_selected() if treeiter is not None: path=self._store.get_path(treeiter) genre=self.genre_select.get_selected_genre() if path == Gtk.TreePath(0): self._client.artist_to_playlist(None, genre, mode) else: artist=self._store[path][0] self._client.artist_to_playlist(artist, genre, mode) def _on_show_info(self, *args): if self._treeview.has_focus(): treeview, treeiter=self._selection.get_selected() if treeiter is not None: path=self._store.get_path(treeiter) cell=self._treeview.get_cell_area(path, None) genre=self.genre_select.get_selected_genre() if path == Gtk.TreePath(0): self._artist_popover.open(None, genre, self._treeview, cell.x, cell.y) else: self._artist_popover.open(self._store[path][0], genre, self._treeview, cell.x, cell.y) def _on_disconnected(self, *args): self.set_sensitive(False) self._artist_popover.popdown() self._clear() def _on_reconnected(self, *args): self._refresh() self.set_sensitive(True) def _on_show_initials_changed(self, *args): self._column_initials.set_visible(self._settings.get_boolean("show-initials")) class AlbumWindow(FocusFrame): def __init__(self, client, settings, artist_window): super().__init__() self._settings=settings self._client=client self._artist_window=artist_window self._stop_flag=False self._done=True self._pending=[] # cover, display_label, display_label_artist, tooltip(titles), album, year, artist self._store=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, tooltip_column=3, activate_on_single_click=True ) # scroll scroll=Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scroll.add(self._iconview) self._scroll_vadj=scroll.get_vadjustment() self._scroll_hadj=scroll.get_hadjustment() # progress bar self._progress_bar=Gtk.ProgressBar(no_show_all=True) # popover self._album_popover=AlbumPopover(self._client, self._settings) self._artist_popover=ArtistPopover(self._client) # connect self._iconview.connect("item-activated", self._on_item_activated) self._iconview.connect("button-press-event", self._on_button_press_event) self._client.emitter.connect("disconnected", self._on_disconnected) self._client.emitter.connect("reconnected", self._on_reconnected) self._client.emitter.connect("show-info", self._on_show_info) self._client.emitter.connect("add-to-playlist", self._on_add_to_playlist) self._settings.connect("changed::sort-albums-by-year", self._sort_settings) self._settings.connect("changed::album-cover", self._on_cover_size_changed) self._artist_window.connect("artists_changed", self._refresh) self._artist_window.connect("clear", self._clear) # packing self.set_widget(self._iconview) box=Gtk.Box(orientation=Gtk.Orientation.VERTICAL) box.pack_start(scroll, True, True, 0) box.pack_start(self._progress_bar, False, False, 0) self.add(box) def _workaround_clear(self): self._store.clear() # workaround (scrollbar still visible after clear) self._iconview.set_model(None) self._iconview.set_model(self._store) def _clear(self, *args): if self._done: self._workaround_clear() elif not self._clear in self._pending: self._stop_flag=True self._pending.append(self._clear) def scroll_to_current_album(self): def callback(): song=ClientHelper.song_to_first_str_dict(self._client.currentsong()) album=song.get("album", "") self._iconview.unselect_all() row_num=len(self._store) for i in range(0, row_num): path=Gtk.TreePath(i) treeiter=self._store.get_iter(path) if self._store.get_value(treeiter, 4) == album: self._iconview.set_cursor(path, None, False) self._iconview.select_path(path) self._iconview.scroll_to_path(path, True, 0, 0) break if self._done: callback() elif not self.scroll_to_current_album in self._pending: self._pending.append(self.scroll_to_current_album) def _sort_settings(self, *args): if self._settings.get_boolean("sort-albums-by-year"): self._store.set_sort_column_id(5, Gtk.SortType.ASCENDING) else: self._store.set_sort_column_id(1, Gtk.SortType.ASCENDING) def _refresh(self, *args): if self._done: self._done=False self._settings.set_property("cursor-watch", True) self._progress_bar.show() self._store.clear() self._iconview.set_model(None) try: # self._artist_window can still be empty (e.g. when client is not connected and cover size gets changed) artist=self._artist_window.get_selected_artist() genre=self._artist_window.genre_select.get_selected_genre() except: self._done_callback() return if artist is None: self._iconview.set_markup_column(2) # show artist names if genre is None: artists=self._client.comp_list(self._settings.get_artist_type()) else: artists=self._client.comp_list(self._settings.get_artist_type(), "genre", genre) else: self._iconview.set_markup_column(1) # hide artist names artists=[artist] # prepare albmus list (run all mpd related commands) albums=[] for i, artist in enumerate(artists): try: # client cloud meanwhile disconnect if self._stop_flag: self._done_callback() return else: if i > 0: # more than one artist to show (all artists) self._progress_bar.pulse() albums.extend(self._client.get_albums(artist, genre)) while Gtk.events_pending(): Gtk.main_iteration_do(True) except MPDBase.ConnectionError: self._done_callback() return def display_albums(): for i, album in enumerate(albums): # tooltip length_human_readable=ClientHelper.calc_display_length(album["songs"]) titles=len(album["songs"]) discs=album["songs"][-1].get("disc", 1) if type(discs) == list: discs=int(discs[0]) else: discs=int(discs) tooltip=ngettext("{titles} title", "{titles} titles", titles).format(titles=titles) if discs > 1: tooltip=" ".join((tooltip, _("on {discs} discs").format(discs=discs))) tooltip=" ".join((tooltip, "({length})".format(length=length_human_readable))) # album label if album["year"] == "": display_label="{}".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 self._store.append( [album["cover"], display_label, display_label_artist, tooltip, album["album"], album["year"], album["artist"]] ) self._iconview.set_model(self._store) self._done_callback() return False def render_covers(): size=self._settings.get_int("album-cover") total_albums=len(albums) for i, album in enumerate(albums): if self._stop_flag: break if "cover_path" in album: try: album["cover"]=GdkPixbuf.Pixbuf.new_from_file_at_size(album["cover_path"], size, size) except: # load fallback if cover can't be loaded album["cover"]=GdkPixbuf.Pixbuf.new_from_file_at_size(self._client.fallback_cover, size, size) else: if "cover_binary" in album: album["cover"]=ClientHelper.binary_to_pixbuf(album["cover_binary"], size) else: album["cover"]=GdkPixbuf.Pixbuf.new_from_file_at_size(self._client.fallback_cover, size, size) GLib.idle_add(self._progress_bar.set_fraction, (i+1)/total_albums) if self._stop_flag: GLib.idle_add(self._done_callback) else: GLib.idle_add(display_albums) cover_thread=threading.Thread(target=render_covers, daemon=True) cover_thread.start() elif not self._refresh in self._pending: self._stop_flag=True self._pending.append(self._refresh) def _path_to_playlist(self, path, mode="default"): album=self._store[path][4] year=self._store[path][5] artist=self._store[path][6] genre=self._artist_window.genre_select.get_selected_genre() self._client.album_to_playlist(album, artist, year, genre, mode) def _done_callback(self, *args): self._settings.set_property("cursor-watch", False) self._progress_bar.hide() self._progress_bar.set_fraction(0) self._stop_flag=False self._done=True pending=self._pending self._pending=[] for p in pending: try: p() except: pass return False def _on_button_press_event(self, widget, event): path=widget.get_path_at_pos(int(event.x), int(event.y)) if event.button == 1 and event.type == Gdk.EventType._2BUTTON_PRESS: if path is not None: self._path_to_playlist(path, "play") elif event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS: if path is not None: self._path_to_playlist(path, "append") elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS: v=self._scroll_vadj.get_value() h=self._scroll_hadj.get_value() genre=self._artist_window.genre_select.get_selected_genre() if path is not None: album=self._store[path][4] year=self._store[path][5] artist=self._store[path][6] # when using "button-press-event" in iconview popovers only show up in combination with idle_add (bug in GTK?) GLib.idle_add(self._album_popover.open, album, artist, year, genre, widget, event.x-h, event.y-v) else: artist=self._artist_window.get_selected_artist() GLib.idle_add(self._artist_popover.open, artist, genre, widget, event.x-h, event.y-v) def _on_item_activated(self, widget, path): treeiter=self._store.get_iter(path) album=self._store.get_value(treeiter, 4) year=self._store.get_value(treeiter, 5) artist=self._store.get_value(treeiter, 6) genre=self._artist_window.genre_select.get_selected_genre() self._client.album_to_playlist(album, artist, year, genre) def _on_disconnected(self, *args): self._iconview.set_sensitive(False) self._album_popover.popdown() self._artist_popover.popdown() def _on_reconnected(self, *args): self._iconview.set_sensitive(True) def _on_show_info(self, *args): if self._iconview.has_focus(): paths=self._iconview.get_selected_items() if len(paths) > 0: rect=self._iconview.get_cell_rect(paths[0], None)[1] x=rect.x+rect.width//2 y=rect.y+rect.height//2 genre=self._artist_window.genre_select.get_selected_genre() self._album_popover.open( self._store[paths[0]][4], self._store[paths[0]][6], self._store[paths[0]][5], genre, self._iconview, x, y ) def _on_add_to_playlist(self, emitter, mode): if self._iconview.has_focus(): paths=self._iconview.get_selected_items() if len(paths) != 0: self._path_to_playlist(paths[0], mode) def _on_cover_size_changed(self, *args): def callback(): self._refresh() return False GLib.idle_add(callback) class Browser(Gtk.Paned): __gsignals__={"search-focus-changed": (GObject.SignalFlags.RUN_FIRST, None, (bool,))} def __init__(self, client, settings): super().__init__(orientation=Gtk.Orientation.HORIZONTAL) 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) # 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) def _back_to_current_album(self, *args): song=ClientHelper.song_to_first_str_dict(self._client.currentsong()) if song != {}: self.search_button.set_active(False) # get artist name artist=song.get(self._settings.get_artist_type()) if artist is None: artist=song.get("artist", "") # deactivate genre filter to show all artists (if needed) if song.get("genre", "") != self._genre_select.get_selected_genre(): self._genre_select.deactivate() # select artist if self._artist_window.get_selected_artist() is None: # all artists selected self.search_button.set_active(False) self._artist_window.highlight_selected() else: # one artist selected self._artist_window.select(artist) self._album_window.scroll_to_current_album() def _on_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_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): state=obj.get_property("mini-player") self.set_property("no-show-all", state) self.back_to_current_album_button.set_property("no-show-all", state) self.search_button.set_property("no-show-all", state) self.set_property("visible", not(state)) self.back_to_current_album_button.set_property("visible", not(state)) self.search_button.set_property("visible", not(state)) if not state: self.show_all() ###################### # playlist and cover # ###################### class LyricsWindow(FocusFrame): def __init__(self, client, settings): super().__init__() self._settings=settings self._client=client self._displayed_song_file=None # text view self._text_view=Gtk.TextView( editable=False, cursor_visible=False, wrap_mode=Gtk.WrapMode.WORD, justification=Gtk.Justification.CENTER, opacity=0.9 ) self._text_view.set_left_margin(5) self._text_view.set_right_margin(5) self._text_view.set_bottom_margin(5) self._text_view.set_top_margin(3) self.set_widget(self._text_view) # text buffer self._text_buffer=self._text_view.get_buffer() # scroll scroll=Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scroll.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.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.currentsong() if current_song == {}: self._displayed_song_file=None self._text_buffer.set_text("", -1) else: self._displayed_song_file=current_song["file"] update_thread=threading.Thread( target=self._display_lyrics, kwargs={"current_song": ClientHelper.song_to_first_str_dict(current_song)}, daemon=True ) update_thread.start() def _on_disconnected(self, *args): self._displayed_song_file=None self._text_buffer.set_text("", -1) class AudioType(Gtk.Label): def __init__(self, client): super().__init__() self._client=client self.freq, self.res, self.chan, self.brate, self.file_type=(0, 0, 0, 0, "") # connect self._client.emitter.connect("audio", self._on_audio) self._client.emitter.connect("bitrate", self._on_bitrate) self._client.emitter.connect("current_song_changed", self._on_song_changed) self._client.emitter.connect("disconnected", self.clear) self._client.emitter.connect("state", self._on_state) def clear(self, *args): self.set_text("") self.freq, self.res, self.chan, self.brate, self.file_type=(0, 0, 0, 0, "") def _refresh(self, *args): try: int_chan=int(self.chan) except: int_chan=0 channels=ngettext("{channels} channel", "{channels} channels", int_chan).format(channels=self.chan) string="{} kb/s, {} kHz, {} bit, {}, {}".format(self.brate, self.freq, self.res, channels, self.file_type) self.set_text(string) def _on_audio(self, emitter, freq, res, chan): try: self.freq=str(int(freq)/1000) except: self.freq=freq self.res=res self.chan=chan self._refresh() def _on_bitrate(self, emitter, brate): self.brate=brate self._refresh() def _on_song_changed(self, *args): try: self.file_type=self._client.currentsong()["file"].split(".")[-1].split("/")[0] self._refresh() except: pass def _on_state(self, emitter, state): if state == "stop": self.clear() class CoverEventBox(Gtk.EventBox): def __init__(self, client, settings): super().__init__() self._client=client self._settings=settings # album popover self._album_popover=AlbumPopover(self._client, self._settings) # connect self._button_press_event=self.connect("button-press-event", self._on_button_press_event) self._client.emitter.connect("disconnected", self._on_disconnected) def _on_button_press_event(self, widget, event): if self._settings.get_property("mini-player"): if event.button == 1 and event.type == Gdk.EventType.BUTTON_PRESS: window=self.get_toplevel() window.begin_move_drag(1, event.x_root, event.y_root, Gdk.CURRENT_TIME) else: if self._client.connected(): song=ClientHelper.song_to_first_str_dict(self._client.currentsong()) if song != {}: try: artist=song[self._settings.get_artist_type()] except: artist=song.get("artist", "") album=song.get("album", "") year=song.get("date", "") if event.button == 1 and event.type == Gdk.EventType.BUTTON_PRESS: self._client.album_to_playlist(album, artist, year, None) elif event.button == 1 and event.type == Gdk.EventType._2BUTTON_PRESS: self._client.album_to_playlist(album, artist, year, None, "play") elif event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS: self._client.album_to_playlist(album, artist, year, None, "append") elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS: self._album_popover.open(album, artist, year, None, widget, event.x, event.y) def _on_disconnected(self, *args): self._album_popover.popdown() class MainCover(Gtk.Image): def __init__(self, client, settings): super().__init__() self._client=client self._settings=settings # set default size size=self._settings.get_int("track-cover") self.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) def _refresh(self, *args): current_song=self._client.currentsong() self.set_from_pixbuf(self._client.get_cover(current_song, self._settings.get_int("track-cover"))) def _on_disconnected(self, *args): size=self._settings.get_int("track-cover") self.set_from_pixbuf(GdkPixbuf.Pixbuf.new_from_file_at_size(self._client.fallback_cover, size, size)) self.set_sensitive(False) def _on_reconnected(self, *args): self.set_sensitive(True) def _on_settings_changed(self, *args): size=self._settings.get_int("track-cover") self.set_size_request(size, size) self._refresh() class PlaylistWindow(Gtk.Box): def __init__(self, client, settings): super().__init__(orientation=Gtk.Orientation.VERTICAL) self._client=client self._settings=settings self._playlist_version=None self._icon_size=self._settings.get_int("icon-size-sec") self._inserted_path=None # needed for drag and drop # buttons provider=Gtk.CssProvider() css=b"""* {min-height: 8px;}""" # allow further shrinking provider.load_from_data(css) self._back_to_current_song_button=Gtk.Button( 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, 600) 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, 600) # 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=2,fixed_height_mode=True) self._selection=self._treeview.get_selection() # columns renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True) renderer_text_ralign=Gtk.CellRendererText(xalign=1.0) self._columns=( Gtk.TreeViewColumn(_("No"), renderer_text_ralign, text=0, weight=9), Gtk.TreeViewColumn(_("Disc"), renderer_text_ralign, text=1, weight=9), Gtk.TreeViewColumn(_("Title"), renderer_text, text=2, weight=9), Gtk.TreeViewColumn(_("Artist"), renderer_text, text=3, weight=9), Gtk.TreeViewColumn(_("Album"), renderer_text, text=4, weight=9), Gtk.TreeViewColumn(_("Length"), renderer_text, text=5, weight=9), Gtk.TreeViewColumn(_("Year"), renderer_text, text=6, weight=9), Gtk.TreeViewColumn(_("Genre"), renderer_text, text=7, weight=9) ) for i, column in enumerate(self._columns): column.set_property("resizable", True) column.set_property("sizing", Gtk.TreeViewColumnSizing.FIXED) column.set_min_width(30) column.connect("notify::fixed-width", self._on_column_width, i) self._load_settings() # scroll scroll=Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scroll.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) # song popover self._song_popover=SongPopover(self._client, show_buttons=False) # connect self._treeview.connect("row-activated", self._on_row_activated) self._treeview.connect("button-press-event", self._on_button_press_event) self._treeview.connect("key-release-event", self._on_key_release_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._client.emitter.connect("show-info", self._on_show_info) 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 _on_column_width(self, obj, typestring, pos): self._settings.array_modify("ai", "column-sizes", pos, obj.get_property("fixed-width")) def _load_settings(self, *args): columns=self._treeview.get_columns() for column in columns: self._treeview.remove_column(column) sizes=self._settings.get_value("column-sizes").unpack() visibilities=self._settings.get_value("column-visibilities").unpack() for i in self._settings.get_value("column-permutation"): if sizes[i] > 0: self._columns[i].set_fixed_width(sizes[i]) self._columns[i].set_visible(visibilities[i]) self._treeview.append_column(self._columns[i]) def _clear(self, *args): self._playlist_info.set_text("") self._playlist_version=None self._store.handler_block(self._row_inserted) self._store.handler_block(self._row_deleted) self._store.clear() self._store.handler_unblock(self._row_inserted) self._store.handler_unblock(self._row_deleted) def _refresh_playlist_info(self): songs=self._client.playlistinfo() if songs == []: self._playlist_info.set_text("") else: length_human_readable=ClientHelper.calc_display_length(songs) titles=ngettext("{titles} title", "{titles} titles", len(songs)).format(titles=len(songs)) self._playlist_info.set_text(" ".join((titles, "({length})".format(length=length_human_readable)))) def _scroll_to_selected_title(self, *args): treeview, treeiter=self._selection.get_selected() if treeiter is not None: path=treeview.get_path(treeiter) self._treeview.scroll_to_cell(path, None, True, 0.25) def _refresh_selection(self): # Gtk.TreePath(len(self._store) is used to generate an invalid TreePath (needed to unset cursor) self._treeview.set_cursor(Gtk.TreePath(len(self._store)), None, False) for row in self._store: # reset bold text row[9]=Pango.Weight.BOOK try: song=self._client.status()["song"] path=Gtk.TreePath(int(song)) self._selection.select_path(path) self._store[path][9]=Pango.Weight.BOLD except: self._selection.unselect_all() def _on_button_press_event(self, widget, event): path_re=widget.get_path_at_pos(int(event.x), int(event.y)) if path_re is not None: path=path_re[0] if event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS: self._store.remove(self._store.get_iter(path)) elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS: self._song_popover.open(self._store[path][8], widget, int(event.x), int(event.y)) def _on_key_release_event(self, widget, 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 def _on_row_deleted(self, model, path): # sync treeview to mpd try: if self._inserted_path is not None: # move path=int(path.to_string()) if path > self._inserted_path: path=path-1 if path < self._inserted_path: self._inserted_path=self._inserted_path-1 self._client.move(path, self._inserted_path) self._inserted_path=None else: # delete self._client.delete(path) # bad song index possible self._playlist_version=int(self._client.status()["playlist"]) except MPDBase.CommandError as e: self._playlist_version=None self._client.emitter.emit("playlist_changed", int(self._client.status()["playlist"])) raise e # propagate exception def _on_row_inserted(self, model, path, treeiter): self._inserted_path=int(path.to_string()) def _on_row_activated(self, widget, path, view_column): self._client.play(path) def _on_playlist_changed(self, emitter, version): self._store.handler_block(self._row_inserted) self._store.handler_block(self._row_deleted) songs=[] if self._playlist_version is not None: songs=self._client.plchanges(self._playlist_version) else: songs=self._client.playlistinfo() if songs != []: self._playlist_info.set_text("") for s in songs: song=ClientHelper.song_to_str_dict(ClientHelper.pepare_song_for_display(s)) try: treeiter=self._store.get_iter(song["pos"]) self._store.set(treeiter, 0, song["track"], 1, song["disc"], 2, song["title"], 3, song["artist"], 4, song["album"], 5, song["human_duration"], 6, song["date"], 7, song["genre"], 8, song["file"], 9, Pango.Weight.BOOK ) except: self._store.append([ song["track"], song["disc"], song["title"], song["artist"], song["album"], song["human_duration"], song["date"], song["genre"], song["file"], Pango.Weight.BOOK ]) for i in reversed(range(int(self._client.status()["playlistlength"]), len(self._store))): treeiter=self._store.get_iter(i) self._store.remove(treeiter) self._refresh_playlist_info() if self._playlist_version is None or songs != []: self._refresh_selection() self._scroll_to_selected_title() self._playlist_version=version self._store.handler_unblock(self._row_inserted) self._store.handler_unblock(self._row_deleted) def _on_song_changed(self, *args): self._refresh_selection() if self._client.status()["state"] == "play": self._scroll_to_selected_title() def _on_back_to_current_song_button_clicked(self, *args): self._treeview.set_cursor(Gtk.TreePath(len(self._store)), None, False) # set to invalid TreePath (needed to unset cursor) for path, row in enumerate(self._store): if row[9] == Pango.Weight.BOLD: self._selection.select_path(path) break self._scroll_to_selected_title() def _on_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._song_popover.popdown() 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_show_info(self, *args): if self._treeview.has_focus(): treeview, treeiter=self._selection.get_selected() if treeiter is not None: path=self._store.get_path(treeiter) cell=self._treeview.get_cell_area(path, None) self._song_popover.open(self._store[path][8], self._treeview, int(cell.x), int(cell.y)) def _on_mini_player(self, obj, typestring): if obj.get_property("mini-player"): self.set_property("no-show-all", True) self.set_property("visible", False) else: self.set_property("no-show-all", False) self.show_all() class CoverPlaylistWindow(Gtk.Paned): def __init__(self, client, settings): super().__init__() self._client=client self._settings=settings # cover main_cover=MainCover(self._client, self._settings) self._cover_event_box=CoverEventBox(self._client, self._settings) # playlist self._playlist_window=PlaylistWindow(self._client, self._settings) # lyrics button self.lyrics_button=Gtk.ToggleButton( 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.OVER_DOWN_UP) self._stack.add_named(self._cover_event_box, "cover") self._stack.add_named(self._lyrics_window, "lyrics") self._stack.set_visible_child(self._cover_event_box) # connect self.lyrics_button.connect("toggled", self._on_lyrics_toggled) self._client.emitter.connect("disconnected", self._on_disconnected) self._client.emitter.connect("reconnected", self._on_reconnected) self._settings.connect("changed::show-lyrics-button", self._on_settings_changed) # packing overlay=Gtk.Overlay() overlay.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._on_settings_changed() # hide lyrics button def _on_reconnected(self, *args): self.lyrics_button.set_sensitive(True) def _on_disconnected(self, *args): self.lyrics_button.set_active(False) self.lyrics_button.set_sensitive(False) def _on_lyrics_toggled(self, widget): if widget.get_active(): self._stack.set_visible_child(self._lyrics_window) self._lyrics_window.enable() else: self._stack.set_visible_child(self._cover_event_box) self._lyrics_window.disable() def _on_settings_changed(self, *args): if self._settings.get_boolean("show-lyrics-button"): self._lyrics_button_revealer.set_reveal_child(True) else: self._lyrics_button_revealer.set_reveal_child(False) ################### # control widgets # ################### class PlaybackControl(Gtk.ButtonBox): def __init__(self, client, settings): super().__init__(layout_style=Gtk.ButtonBoxStyle.EXPAND) 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("playlist_changed", self._refresh_tooltips) self._client.emitter.connect("current_song_changed", self._refresh_tooltips) 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 _refresh_tooltips(self, *args): try: songs=self._client.playlistinfo() song=int(self._client.status()["song"]) elapsed=ClientHelper.calc_display_length(songs[:song]) rest=ClientHelper.calc_display_length(songs[song+1:]) elapsed_titles=ngettext("{titles} title", "{titles} titles", song).format(titles=song) rest_titles=ngettext("{titles} title", "{titles} titles", (len(songs)-(song+1))).format(titles=(len(songs)-(song+1))) self._prev_button.set_tooltip_text(" ".join((elapsed_titles, "({length})".format(length=elapsed)))) self._next_button.set_tooltip_text(" ".join((rest_titles, "({length})".format(length=rest)))) except: self._prev_button.set_tooltip_text("") self._next_button.set_tooltip_text("") def _on_play_clicked(self, widget): self._client.toggle_play() def _on_stop_clicked(self, widget): self._client.stop() def _on_prev_clicked(self, widget): self._client.previous() def _on_next_clicked(self, widget): self._client.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) self._prev_button.set_tooltip_text("") self._next_button.set_tooltip_text("") 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) 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, 600) # connect self._elapsed_event_box.connect("button-release-event", self._on_elapsed_button_release_event) self._rest_event_box.connect("button-release-event", self._on_rest_button_release_event) 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) elif 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.seekcur(self._scale.get_value()) self._jumped=False else: # restore state status=self._client.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.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.seekcur("-"+str(self._adjustment.get_property("step-increment"))) elif event.button == 3: self._client.seekcur("+"+str(self._adjustment.get_property("step-increment"))) def _on_rest_button_release_event(self, widget, event): if event.button == 1: self._client.seekcur("+"+str(self._adjustment.get_property("step-increment"))) elif event.button == 3: self._client.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) self._client=client # widgets box=Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, border_width=6) for output in self._client.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.enableoutput(out_id) else: self._client.disableoutput(out_id) class PlaybackOptions(Gtk.Box): def __init__(self, client, settings): super().__init__(spacing=6) self._client=client self._settings=settings self._popover=None # widgets icons={} for icon_name in ("media-playlist-shuffle-symbolic","media-playlist-repeat-symbolic", "org.mpdevil.mpdevil-single-symbolic","org.mpdevil.mpdevil-consume-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["org.mpdevil.mpdevil-single-symbolic"], tooltip_text=_("Single mode")) self._single_button.set_can_focus(False) self._consume_button=Gtk.ToggleButton(image=icons["org.mpdevil.mpdevil-consume-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._single_button.connect("button-press-event", self._on_single_button_press_event) 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): func=getattr(self._client, option) if widget.get_active(): func("1") else: func("0") def _set_volume(self, widget, value): self._client.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) if val == "1": self._single_button.get_style_context().remove_class("destructive-action") self._single_button.set_active(True) elif val == "oneshot": self._single_button.get_style_context().add_class("destructive-action") self._single_button.set_active(False) else: self._single_button.get_style_context().remove_class("destructive-action") self._single_button.set_active(False) 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: self._popover=OutputPopover(self._client, self._volume_button) self._popover.popup() def _on_single_button_press_event(self, widget, event): if event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS: state=self._client.status()["single"] if state == "oneshot": self._client.single("0") else: self._client.single("oneshot") 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, "0") self._consume_refresh(None, False) self._volume_refresh(None, -1) if self._popover is not None: self._popover.destroy() self._popover=None def _on_mini_player(self, obj, typestring): if obj.get_property("mini-player"): self._button_box.set_property("no-show-all", True) self._button_box.set_property("visible", False) else: self._button_box.set_property("no-show-all", False) self._button_box.show_all() 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__() self._client=client # actions self._simple_actions_data=( "toggle-play","stop","next","prev","seek-forward","seek-backward","clear","update", "repeat","random","single","consume" ) for name in self._simple_actions_data: action=Gio.SimpleAction.new(name, None) action.connect("activate", getattr(self, ("_on_"+name.replace("-","_")))) self.add_action(action) # connect self._client.emitter.connect("state", self._on_state) self._client.emitter.connect("disconnected", self._on_disconnected) self._client.emitter.connect("reconnected", self._on_reconnected) def _on_toggle_play(self, action, param): self._client.toggle_play() def _on_stop(self, action, param): self._client.stop() def _on_next(self, action, param): self._client.next() def _on_prev(self, action, param): self._client.previous() def _on_seek_forward(self, action, param): self._client.seekcur("+10") def _on_seek_backward(self, action, param): self._client.seekcur("-10") def _on_clear(self, action, param): self._client.clear() def _on_update(self, action, param): self._client.update() def _on_repeat(self, action, param): self._client.toggle_option("repeat") def _on_random(self, action, param): self._client.toggle_option("random") def _on_single(self, action, param): self._client.toggle_option("single") def _on_consume(self, action, param): self._client.toggle_option("consume") def _on_state(self, emitter, state): state_dict={"play": True, "pause": True, "stop": False} for action in ("next","prev","seek-forward","seek-backward"): self.lookup_action(action).set_enabled(state_dict[state]) def _on_disconnected(self, *args): for action in self._simple_actions_data: self.lookup_action(action).set_enabled(False) def _on_reconnected(self, *args): for action in self._simple_actions_data: self.lookup_action(action).set_enabled(True) #################### # shortcuts window # #################### class ShortcutsWindow(Gtk.ShortcutsWindow): def __init__(self): super().__init__() general_group=Gtk.ShortcutsGroup(title=_("General"), visible=True) window_group=Gtk.ShortcutsGroup(title=_("Window"), visible=True) playback_group=Gtk.ShortcutsGroup(title=_("Playback"), visible=True) items_group=Gtk.ShortcutsGroup(title=_("Search, Album Dialog, Album List and Artist List"), visible=True) playlist_group=Gtk.ShortcutsGroup(title=_("Playlist"), visible=True) section=Gtk.ShortcutsSection(section_name="shortcuts", visible=True) section.add(general_group) section.add(window_group) section.add(playback_group) section.add(items_group) section.add(playlist_group) shortcut_data=( ("F1", _("Open online help"), None, general_group), ("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), ("e", _("Enqueue selected item"), None, items_group), ("plus", _("Append selected item"), _("Middle-click"), items_group), ("Return", _("Play selected item immediately"), _("Double-click"), items_group), ("i Menu", _("Show additional information"), _("Right-click"), items_group), ("Delete", _("Remove selected song"), _("Middle-click"), playlist_group), ("Delete", _("Clear playlist"), None, playlist_group), ("i Menu", _("Show additional information"), _("Right-click"), playlist_group) ) for accel, title, subtitle, group in shortcut_data: shortcut=Gtk.ShortcutsShortcut(visible=True, accelerator=accel, title=title, subtitle=subtitle) group.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) 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="org.mpdevil.mpdevil", application=app) self.set_default_icon_name("org.mpdevil.mpdevil") self.set_default_size(settings.get_int("width"), settings.get_int("height")) if settings.get_boolean("maximize"): self.maximize() # request maximize Notify.init("mpdevil") self._client=client self._settings=settings self._use_csd=self._settings.get_boolean("use-csd") self._size=None # needed for window size saving # MPRIS dbus_service=MPRISInterface(self, self._client, self._settings) # actions simple_actions_data=( "settings","stats","help","menu", "toggle-lyrics","back-to-current-album","toggle-search", "profile-next","profile-prev","show-info","append","play","enqueue" ) for name in simple_actions_data: action=Gio.SimpleAction.new(name, None) action.connect("activate", getattr(self, ("_on_"+name.replace("-","_")))) self.add_action(action) mini_player_action=Gio.PropertyAction.new("mini-player", self._settings, "mini-player") self.add_action(mini_player_action) self._profiles_action=Gio.SimpleAction.new_stateful("profiles", GLib.VariantType.new("i"), GLib.Variant("i", 0)) self._profiles_action.connect("change-state", self._on_profiles) self.add_action(self._profiles_action) self._mpd_action_group=MPDActionGroup(self._client) self.insert_action_group("mpd", self._mpd_action_group) # shortcuts shortcuts_window=ShortcutsWindow() self.set_help_overlay(shortcuts_window) shortcuts_window.set_modal(False) # 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._paned=Gtk.Paned() self._browser=Browser(self._client, self._settings) self._cover_playlist_window=CoverPlaylistWindow(self._client, self._settings) playback_control=PlaybackControl(self._client, self._settings) seek_bar=SeekBar(self._client) playback_options=PlaybackOptions(self._client, self._settings) connection_notify=ConnectionNotify(self._client, self._settings) # menu subsection=Gio.Menu() subsection.append(_("Settings"), "win.settings") subsection.append(_("Keyboard shortcuts"), "win.show-help-overlay") subsection.append(_("Help"), "win.help") subsection.append(_("About"), "app.about") subsection.append(_("Quit"), "app.quit") mpd_subsection=Gio.Menu() mpd_subsection.append(_("Update database"), "mpd.update") mpd_subsection.append(_("Server stats"), "win.stats") self._profiles_submenu=Gio.Menu() self._refresh_profiles_menu() menu=Gio.Menu() menu.append_submenu(_("Profiles"), self._profiles_submenu) menu.append(_("Mini player"), "win.mini-player") menu.append_section(None, mpd_subsection) menu.append_section(None, subsection) # menu button / popover self._menu_button=Gtk.MenuButton(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_after("notify::cursor-watch", self._on_cursor_watch) 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) # auto save window state and size self.connect("size-allocate", self._on_size_allocate) self._settings.bind("maximize", self, "is-maximized", Gio.SettingsBindFlags.SET) # save and restore mini player self._settings.bind("mini-player", self._settings, "mini-player", Gio.SettingsBindFlags.DEFAULT) # packing self._on_playlist_pos_changed() # set orientation self._paned.pack1(self._browser, True, False) self._paned.pack2(self._cover_playlist_window, False, False) vbox=Gtk.Box(orientation=Gtk.Orientation.VERTICAL) vbox.pack_start(self._paned, 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(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() while Gtk.events_pending(): # ensure window is visible Gtk.main_iteration_do(True) # restore paned settings when window is visible (fixes a bug when window is maximized) self._cover_playlist_window.set_position(self._settings.get_int("paned0")) self._browser.set_position(self._settings.get_int("paned1")) self._paned.set_position(self._settings.get_int("paned2")) # auto save paned positions self._cover_playlist_window.connect("notify::position", self._on_paned_position, "paned0") self._browser.connect("notify::position", self._on_paned_position, "paned1") self._paned.connect("notify::position", self._on_paned_position, "paned2") # start client def callback(*args): self._client.start() # connect client return False GLib.idle_add(callback) def _on_toggle_lyrics(self, action, param): self._cover_playlist_window.lyrics_button.set_active(not(self._cover_playlist_window.lyrics_button.get_active())) def _on_back_to_current_album(self, action, param): self._browser.back_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_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_show_info(self, action, param): self._client.emitter.emit("show-info") def _on_append(self, action, param): self._client.emitter.emit("add-to-playlist", "append") def _on_play(self, action, param): self._client.emitter.emit("add-to-playlist", "play") def _on_enqueue(self, action, param): self._client.emitter.emit("add-to-playlist", "enqueue") def _on_profiles(self, action, param): self._settings.set_int("active-profile", param.unpack()) action.set_state(param) def _on_song_changed(self, *args): song=self._client.currentsong() if song == {}: 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.status()["state"] == "play": notify=Notify.Notification.new(song["title"], song["artist"]+"\n"+song["album"]+date) pixbuf=self._client.get_cover(song, 400) notify.set_image_from_pixbuf(pixbuf) notify.show() def _on_reconnected(self, *args): for action in ("stats","toggle-lyrics","back-to-current-album","toggle-search"): self.lookup_action(action).set_enabled(True) def _on_disconnected(self, *args): self.set_title("mpdevil") if self._use_csd: self._header_bar.set_subtitle("") for action in ("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_size_allocate(self, widget, rect): if not self.is_maximized() and not self._settings.get_property("mini-player"): size=self.get_size() if size != self._size: # prevent unneeded write operations self._settings.set_int("width", size[0]) self._settings.set_int("height", size[1]) self._size=size def _on_paned_position(self, obj, typestring, key): self._settings.set_int(key, obj.get_position()) def _on_mini_player(self, obj, typestring): if obj.get_property("mini-player"): if self.is_maximized(): self.unmaximize() self.resize(1,1) else: self.resize(self._settings.get_int("width"), self._settings.get_int("height")) def _on_cursor_watch(self, obj, typestring): if obj.get_property("cursor-watch"): watch_cursor = Gdk.Cursor(Gdk.CursorType.WATCH) self.get_window().set_cursor(watch_cursor) else: self.get_window().set_cursor(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._paned.set_orientation(Gtk.Orientation.HORIZONTAL) else: self._cover_playlist_window.set_orientation(Gtk.Orientation.HORIZONTAL) self._paned.set_orientation(Gtk.Orientation.VERTICAL) def _refresh_profiles_menu(self, *args): self._profiles_submenu.remove_all() for num, profile in enumerate(self._settings.get_value("profiles")): item=Gio.MenuItem.new(profile, None) item.set_action_and_target_value("win.profiles", GLib.Variant("i", num)) self._profiles_submenu.append_item(item) self._profiles_action.set_state(GLib.Variant("i", self._settings.get_int("active-profile"))) def _on_active_profile_changed(self, *args): self._profiles_action.set_state(GLib.Variant("i", self._settings.get_int("active-profile"))) ################### # Gtk application # ################### class mpdevil(Gtk.Application): def __init__(self, *args, **kwargs): super().__init__(*args, application_id="org.mpdevil.mpdevil", flags=Gio.ApplicationFlags.FLAGS_NONE, **kwargs) self._settings=Settings() self._client=Client(self._settings) self._window=None def do_activate(self): if not self._window: # allow just one instance self._window=MainWindow(self, self._client, self._settings) self._window.connect("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"]), ("win.show-info", ["i","Menu"]),("win.append", ["plus"]), ("win.play", ["Return"]),("win.enqueue", ["e"]) ) for action, accels in action_accels: self.set_accels_for_action(action, accels) # disable item activation on space key pressed in treeviews Gtk.binding_entry_remove(Gtk.binding_set_find('GtkTreeView'), Gdk.keyval_from_name("space"), Gdk.ModifierType.MOD2_MASK) 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.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.stop() self.quit() if __name__ == "__main__": app=mpdevil() app.run(sys.argv)