mpdevil/src/mpdevil.py

3554 lines
127 KiB
Python
Raw Normal View History

2020-01-19 22:48:49 +03:00
#!/usr/bin/python3
2020-01-11 13:25:15 +03:00
# -*- coding: utf-8 -*-
#
# mpdevil - MPD Client.
2023-01-04 17:33:08 +03:00
# Copyright (C) 2020-2023 Martin Wagner <martin.wagner.dev@gmail.com>
2020-01-11 13:25:15 +03:00
#
2021-01-01 16:11:59 +03:00
# This program is free software: you can redistribute it and/or modify
2020-01-11 13:25:15 +03:00
# it under the terms of the GNU General Public License as published by
2021-01-01 16:11:59 +03:00
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
2020-01-11 13:25:15 +03:00
#
2021-01-01 16:11:59 +03:00
# 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.
2020-01-11 13:25:15 +03:00
#
# You should have received a copy of the GNU General Public License
2021-01-01 16:11:59 +03:00
# along with this program. If not, see <https://www.gnu.org/licenses/>.
2020-01-11 13:25:15 +03:00
2020-03-22 19:05:51 +03:00
import gi
2020-09-24 22:17:10 +03:00
gi.require_version("Gtk", "3.0")
2022-03-15 19:18:43 +03:00
from gi.repository import Gtk, Gio, Gdk, GdkPixbuf, Pango, GObject, GLib
from mpd import MPDClient, CommandError, ConnectionError
2022-03-15 21:50:23 +03:00
from html.parser import HTMLParser
2022-03-16 02:21:24 +03:00
import urllib.request
import urllib.parse
import urllib.error
import threading
import functools
2022-02-14 00:15:54 +03:00
import itertools
2021-08-04 15:03:33 +03:00
import collections
2020-01-11 13:25:15 +03:00
import os
import sys
2022-04-16 23:02:18 +03:00
import signal
2020-04-07 19:02:43 +03:00
import re
2021-07-03 16:05:56 +03:00
import locale
2021-12-29 19:20:52 +03:00
from gettext import gettext as _, ngettext, textdomain, bindtextdomain
try:
locale.setlocale(locale.LC_ALL, "")
except locale.Error as e:
print(e)
locale.bindtextdomain("mpdevil", "@LOCALE_DIR@")
locale.textdomain("mpdevil")
2021-12-29 19:20:52 +03:00
bindtextdomain("mpdevil", localedir="@LOCALE_DIR@")
textdomain("mpdevil")
2021-12-29 18:48:42 +03:00
Gio.Resource._register(Gio.resource_load(os.path.join("@RESOURCES_DIR@", "mpdevil.gresource")))
2020-01-11 13:25:15 +03:00
2022-11-06 12:40:28 +03:00
FALLBACK_REGEX=r"^\.?(album|cover|folder|front).*\.(gif|jpeg|jpg|png)$"
2021-06-02 22:34:01 +03:00
FALLBACK_COVER=Gtk.IconTheme.get_default().lookup_icon("media-optical", 128, Gtk.IconLookupFlags.FORCE_SVG).get_filename()
FALLBACK_SOCKET=os.path.join(GLib.get_user_runtime_dir(), "mpd/socket")
FALLBACK_MUSIC_DIRECTORY=GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_MUSIC)
2020-03-22 19:05:51 +03:00
############################
# decorators and functions #
############################
def idle_add(*args, **kwargs):
2022-08-27 15:05:58 +03:00
GLib.idle_add(*args, priority=GLib.PRIORITY_DEFAULT, **kwargs)
def main_thread_function(func):
@functools.wraps(func)
def wrapper_decorator(*args, **kwargs):
def glib_callback(event, result, *args, **kwargs):
try:
result.append(func(*args, **kwargs))
except Exception as e: # handle exceptions to avoid deadlocks
result.append(e)
event.set()
return False
event=threading.Event()
result=[]
2022-02-20 01:52:50 +03:00
idle_add(glib_callback, event, result, *args, **kwargs)
event.wait()
if isinstance(result[0], Exception):
raise result[0]
else:
return result[0]
return wrapper_decorator
2020-03-22 19:05:51 +03:00
2020-07-04 14:16:17 +03:00
#########
# MPRIS #
#########
2020-01-11 13:25:15 +03:00
2020-12-24 15:21:08 +03:00
class MPRISInterface: # TODO emit Seeked if needed
"""
2020-12-24 15:21:08 +03:00
based on 'Lollypop' (master 22.12.2020) by Cedric Bellegarde <cedric.bellegarde@adishatz.org>
and 'mpDris2' (master 19.03.2020) by Jean-Philippe Braun <eon@patapon.info>, Mantas Mikulėnas <grawity@gmail.com>
"""
_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="""
<!DOCTYPE node PUBLIC
"-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<node>
<interface name="org.freedesktop.DBus.Introspectable">
<method name="Introspect">
<arg name="data" direction="out" type="s"/>
</method>
</interface>
<interface name="org.freedesktop.DBus.Properties">
<method name="Get">
<arg name="interface" direction="in" type="s"/>
<arg name="property" direction="in" type="s"/>
<arg name="value" direction="out" type="v"/>
</method>
<method name="Set">
<arg name="interface_name" direction="in" type="s"/>
<arg name="property_name" direction="in" type="s"/>
<arg name="value" direction="in" type="v"/>
</method>
<method name="GetAll">
<arg name="interface" direction="in" type="s"/>
<arg name="properties" direction="out" type="a{sv}"/>
</method>
</interface>
<interface name="org.mpris.MediaPlayer2">
<method name="Raise">
</method>
<method name="Quit">
</method>
<property name="CanQuit" type="b" access="read" />
<property name="CanRaise" type="b" access="read" />
<property name="HasTrackList" type="b" access="read"/>
<property name="Identity" type="s" access="read"/>
<property name="DesktopEntry" type="s" access="read"/>
<property name="SupportedUriSchemes" type="as" access="read"/>
<property name="SupportedMimeTypes" type="as" access="read"/>
</interface>
<interface name="org.mpris.MediaPlayer2.Player">
<method name="Next"/>
<method name="Previous"/>
<method name="Pause"/>
<method name="PlayPause"/>
<method name="Stop"/>
<method name="Play"/>
<method name="Seek">
<arg direction="in" name="Offset" type="x"/>
</method>
<method name="SetPosition">
<arg direction="in" name="TrackId" type="o"/>
<arg direction="in" name="Position" type="x"/>
</method>
<method name="OpenUri">
<arg direction="in" name="Uri" type="s"/>
</method>
<signal name="Seeked">
<arg name="Position" type="x"/>
</signal>
<property name="PlaybackStatus" type="s" access="read"/>
<property name="LoopStatus" type="s" access="readwrite"/>
<property name="Rate" type="d" access="readwrite"/>
<property name="Shuffle" type="b" access="readwrite"/>
<property name="Metadata" type="a{sv}" access="read"/>
<property name="Volume" type="d" access="readwrite"/>
<property name="Position" type="x" access="read"/>
<property name="MinimumRate" type="d" access="read"/>
<property name="MaximumRate" type="d" access="read"/>
<property name="CanGoNext" type="b" access="read"/>
<property name="CanGoPrevious" type="b" access="read"/>
<property name="CanPlay" type="b" access="read"/>
<property name="CanPause" type="b" access="read"/>
<property name="CanSeek" type="b" access="read"/>
<property name="CanControl" type="b" access="read"/>
</interface>
</node>
"""
def __init__(self, application, window, client, settings):
self._application=application
self._window=window
self._client=client
self._settings=settings
self._metadata={}
2022-03-15 01:18:16 +03:00
self._tmp_cover_file,_=Gio.File.new_tmp(None)
2020-12-24 15:21:08 +03:00
# 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("as", []), None),
"SupportedMimeTypes": (GLib.Variant("as", []), None)},
2020-12-24 15:21:08 +03:00
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)},
}
2020-03-30 18:08:59 +03:00
2020-09-25 18:13:39 +03:00
# start
2021-07-18 23:39:35 +03:00
self._bus=Gio.bus_get_sync(Gio.BusType.SESSION, None)
2020-12-24 15:21:08 +03:00
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)
2020-09-25 18:13:39 +03:00
2020-07-04 14:16:17 +03:00
# connect
self._application.connect("shutdown", lambda *args: self._tmp_cover_file.delete(None))
self._client.emitter.connect("state", self._on_state_changed)
2021-10-30 22:58:35 +03:00
self._client.emitter.connect("current_song", self._on_song_changed)
self._client.emitter.connect("volume", 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)
2020-09-25 18:13:39 +03:00
self._client.emitter.connect("connection_error", self._on_connection_error)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect("connected", self._on_connected)
2022-02-20 18:21:20 +03:00
self._client.emitter.connect("disconnected", self._on_disconnected)
2020-12-24 15:21:08 +03:00
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:
2020-12-24 15:21:08 +03:00
signature="("+"".join([arg.signature for arg in out_args])+")"
variant=GLib.Variant(signature, (result,))
invocation.return_value(variant)
else:
invocation.return_value(None)
2020-12-24 15:21:08 +03:00
# setter and getter
def _get_playback_status(self):
2020-09-25 18:13:39 +03:00
if self._client.connected():
status=self._client.status()
2020-12-24 15:21:08 +03:00
return GLib.Variant("s", {"play": "Playing", "pause": "Paused", "stop": "Stopped"}[status["state"]])
return GLib.Variant("s", "Stopped")
2020-12-24 15:21:08 +03:00
def _set_loop_status(self, value):
2020-09-25 18:13:39 +03:00
if self._client.connected():
if value == "Playlist":
self._client.repeat(1)
self._client.single(0)
2020-09-25 18:13:39 +03:00
elif value == "Track":
self._client.repeat(1)
self._client.single(1)
2020-09-25 18:13:39 +03:00
elif value == "None":
self._client.repeat(0)
self._client.single(0)
2020-12-24 15:21:08 +03:00
def _get_loop_status(self):
2020-09-25 18:13:39 +03:00
if self._client.connected():
status=self._client.status()
2020-12-24 15:47:28 +03:00
if status["repeat"] == "1":
if status.get("single", "0") == "0":
2020-12-24 15:21:08 +03:00
return GLib.Variant("s", "Playlist")
2020-12-24 15:47:28 +03:00
else:
return GLib.Variant("s", "Track")
2020-07-04 14:16:17 +03:00
else:
2020-12-24 15:21:08 +03:00
return GLib.Variant("s", "None")
return GLib.Variant("s", "None")
2020-07-04 14:16:17 +03:00
2020-12-24 15:21:08 +03:00
def _set_shuffle(self, value):
2020-09-25 18:13:39 +03:00
if self._client.connected():
2020-12-24 15:21:08 +03:00
if value:
self._client.random("1")
2020-12-24 15:21:08 +03:00
else:
self._client.random("0")
2020-07-04 14:16:17 +03:00
2020-12-24 15:21:08 +03:00
def _get_shuffle(self):
2020-09-25 18:13:39 +03:00
if self._client.connected():
if self._client.status()["random"] == "1":
2020-12-24 15:21:08 +03:00
return GLib.Variant("b", True)
2020-09-25 18:13:39 +03:00
else:
2020-12-24 15:21:08 +03:00
return GLib.Variant("b", False)
return GLib.Variant("b", False)
2020-12-24 15:21:08 +03:00
def _get_metadata(self):
return GLib.Variant("a{sv}", self._metadata)
2020-03-30 12:54:04 +03:00
2020-12-24 15:21:08 +03:00
def _get_volume(self):
2020-09-25 18:13:39 +03:00
if self._client.connected():
return GLib.Variant("d", float(self._client.status().get("volume", 0))/100)
2020-12-24 15:21:08 +03:00
return GLib.Variant("d", 0)
2020-12-24 15:21:08 +03:00
def _set_volume(self, value):
2020-09-25 18:13:39 +03:00
if self._client.connected():
2022-03-23 19:23:56 +03:00
if 0 <= value <= 1:
self._client.setvol(int(value * 100))
2020-03-30 12:54:04 +03:00
2020-12-24 15:21:08 +03:00
def _get_position(self):
2020-09-25 18:13:39 +03:00
if self._client.connected():
status=self._client.status()
2020-12-24 15:21:08 +03:00
return GLib.Variant("x", float(status.get("elapsed", 0))*1000000)
return GLib.Variant("x", 0)
2020-12-24 15:21:08 +03:00
def _get_can_next_prev(self):
2020-09-25 18:13:39 +03:00
if self._client.connected():
status=self._client.status()
2020-09-25 18:13:39 +03:00
if status["state"] == "stop":
2020-12-24 15:21:08 +03:00
return GLib.Variant("b", False)
2020-09-25 18:13:39 +03:00
else:
2020-12-24 15:21:08 +03:00
return GLib.Variant("b", True)
return GLib.Variant("b", False)
2020-03-21 00:09:13 +03:00
2020-12-24 15:21:08 +03:00
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]
2020-07-04 14:16:17 +03:00
if callable(getter):
2020-12-24 15:21:08 +03:00
return getter()
2020-07-04 14:16:17 +03:00
return getter
2020-03-21 00:09:13 +03:00
2020-12-24 15:21:08 +03:00
def Set(self, interface_name, prop, value):
getter, setter=self._prop_mapping[interface_name][prop]
2020-07-04 14:16:17 +03:00
if setter is not None:
2020-12-24 15:21:08 +03:00
setter(value)
2020-03-21 00:09:13 +03:00
2020-12-24 15:21:08 +03:00
def GetAll(self, interface_name):
try:
props=self._prop_mapping[interface_name]
2022-08-08 19:25:23 +03:00
except KeyError: # interface has no properties
return {}
else:
read_props={}
2020-12-24 15:21:08 +03:00
for key, (getter, setter) in props.items():
if callable(getter):
getter=getter()
read_props[key]=getter
2022-08-08 19:25:23 +03:00
return read_props
2020-07-04 14:16:17 +03:00
2020-12-24 15:21:08 +03:00
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
2020-07-04 14:16:17 +03:00
def Raise(self):
self._window.present()
2020-07-04 14:16:17 +03:00
def Quit(self):
self._application.quit()
2020-07-04 14:16:17 +03:00
2020-12-24 15:21:08 +03:00
# player methods
2020-07-04 14:16:17 +03:00
def Next(self):
self._client.next()
2020-07-04 14:16:17 +03:00
def Previous(self):
self._client.previous()
2020-07-04 14:16:17 +03:00
def Pause(self):
self._client.pause(1)
2020-07-04 14:16:17 +03:00
def PlayPause(self):
self._client.toggle_play()
2020-07-04 14:16:17 +03:00
def Stop(self):
self._client.stop()
2020-07-04 14:16:17 +03:00
def Play(self):
self._client.play()
2020-07-04 14:16:17 +03:00
2020-09-25 13:24:35 +03:00
def Seek(self, offset):
if offset > 0:
offset="+"+str(offset/1000000)
else:
offset=str(offset/1000000)
self._client.seekcur(offset)
2020-07-04 14:16:17 +03:00
def SetPosition(self, trackid, position):
song=self._client.currentsong()
2020-09-25 13:24:35 +03:00
if str(trackid).split("/")[-1] != song["id"]:
2020-07-04 14:16:17 +03:00
return
2020-09-25 13:24:35 +03:00
mpd_pos=position/1000000
2022-03-23 19:23:56 +03:00
if 0 <= mpd_pos <= float(song["duration"]):
self._client.seekcur(str(mpd_pos))
2020-07-04 14:16:17 +03:00
2020-09-25 13:24:35 +03:00
def OpenUri(self, uri):
2020-12-24 15:21:08 +03:00
pass
2020-09-25 13:24:35 +03:00
2020-12-24 15:21:08 +03:00
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
2020-09-25 13:24:35 +03:00
def _update_metadata(self):
"""
Translate metadata returned by MPD to the MPRIS v2 syntax.
http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata
"""
2021-08-04 15:03:33 +03:00
song=self._client.currentsong()
2020-09-26 14:29:31 +03:00
self._metadata={}
2022-03-15 01:18:16 +03:00
self._tmp_cover_file.replace_contents(b"", None, False, Gio.FileCreateFlags.NONE, None)
2020-12-24 15:21:08 +03:00
for tag, xesam_tag in (("album","album"),("title","title"),("date","contentCreated")):
2020-10-17 12:30:00 +03:00
if tag in song:
2021-07-24 13:13:09 +03:00
self._metadata[f"xesam:{xesam_tag}"]=GLib.Variant("s", song[tag][0])
2020-12-24 15:21:08 +03:00
for tag, xesam_tag in (("track","trackNumber"),("disc","discNumber")):
if tag in song:
2021-07-24 13:13:09 +03:00
self._metadata[f"xesam:{xesam_tag}"]=GLib.Variant("i", int(song[tag][0]))
2020-09-25 13:24:35 +03:00
for tag, xesam_tag in (("albumartist","albumArtist"),("artist","artist"),("composer","composer"),("genre","genre")):
2020-10-17 12:30:00 +03:00
if tag in song:
2021-07-24 13:13:09 +03:00
self._metadata[f"xesam:{xesam_tag}"]=GLib.Variant("as", song[tag])
2020-10-17 12:30:00 +03:00
if "id" in song:
2021-08-04 15:03:33 +03:00
self._metadata["mpris:trackid"]=GLib.Variant("o", f"{self._MPRIS_PATH}/Track/{song['id']}")
if "duration" in song:
2021-08-04 15:03:33 +03:00
self._metadata["mpris:length"]=GLib.Variant("x", float(song["duration"])*1000000)
2020-10-17 12:30:00 +03:00
if "file" in song:
2022-03-23 19:23:56 +03:00
if "://" in (song_file:=song["file"]): # remote file
self._metadata["xesam:url"]=GLib.Variant("s", song_file)
else:
2022-03-23 19:23:56 +03:00
if (song_path:=self._client.get_absolute_path(song_file)) is not None:
2022-03-07 01:15:27 +03:00
self._metadata["xesam:url"]=GLib.Variant("s", Gio.File.new_for_path(song_path).get_uri())
2022-03-20 15:26:45 +03:00
if isinstance(self._client.current_cover, FileCover):
self._metadata["mpris:artUrl"]=GLib.Variant("s", Gio.File.new_for_path(self._client.current_cover).get_uri())
elif isinstance(self._client.current_cover, BinaryCover):
self._tmp_cover_file.replace_contents(self._client.current_cover, None, False, Gio.FileCreateFlags.NONE, None)
self._metadata["mpris:artUrl"]=GLib.Variant("s", self._tmp_cover_file.get_uri())
2020-12-24 15:21:08 +03:00
def _update_property(self, interface_name, prop):
getter, setter=self._prop_mapping[interface_name][prop]
2020-09-25 13:24:35 +03:00
if callable(getter):
2020-12-24 15:21:08 +03:00
value=getter()
2020-09-25 13:24:35 +03:00
else:
value=getter
2020-12-24 15:21:08 +03:00
self.PropertiesChanged(interface_name, {prop: value}, [])
2020-09-25 13:24:35 +03:00
return value
2020-08-21 19:03:36 +03:00
def _on_state_changed(self, *args):
2020-12-24 15:21:08 +03:00
self._update_property(self._MPRIS_PLAYER_IFACE, "PlaybackStatus")
self._update_property(self._MPRIS_PLAYER_IFACE, "CanGoNext")
self._update_property(self._MPRIS_PLAYER_IFACE, "CanGoPrevious")
2020-08-21 19:03:36 +03:00
def _on_song_changed(self, *args):
2020-09-25 13:24:35 +03:00
self._update_metadata()
2020-12-24 15:21:08 +03:00
self._update_property(self._MPRIS_PLAYER_IFACE, "Metadata")
2020-08-21 19:03:36 +03:00
def _on_volume_changed(self, *args):
2020-12-24 15:21:08 +03:00
self._update_property(self._MPRIS_PLAYER_IFACE, "Volume")
2020-08-21 19:03:36 +03:00
def _on_loop_changed(self, *args):
2020-12-24 15:21:08 +03:00
self._update_property(self._MPRIS_PLAYER_IFACE, "LoopStatus")
2020-08-21 19:03:36 +03:00
def _on_random_changed(self, *args):
2020-12-24 15:21:08 +03:00
self._update_property(self._MPRIS_PLAYER_IFACE, "Shuffle")
2020-08-21 19:03:36 +03:00
2022-09-20 19:42:37 +03:00
def _on_connected(self, *args):
2022-03-23 19:23:56 +03:00
for p in ("CanPlay","CanPause","CanSeek"):
2020-12-24 15:21:08 +03:00
self._update_property(self._MPRIS_PLAYER_IFACE, p)
2022-02-20 18:21:20 +03:00
def _on_disconnected(self, *args):
self._metadata={}
2022-03-15 01:18:16 +03:00
self._tmp_cover_file.replace_contents(b"", None, False, Gio.FileCreateFlags.NONE, None)
2022-02-20 18:21:20 +03:00
self._update_property(self._MPRIS_PLAYER_IFACE, "Metadata")
2020-09-25 18:13:39 +03:00
def _on_connection_error(self, *args):
self._metadata={}
2022-03-15 01:18:16 +03:00
self._tmp_cover_file.replace_contents(b"", None, False, Gio.FileCreateFlags.NONE, None)
2022-03-23 19:23:56 +03:00
for p in ("PlaybackStatus","CanGoNext","CanGoPrevious","Metadata","Volume","LoopStatus","Shuffle","CanPlay","CanPause","CanSeek"):
2020-12-24 15:21:08 +03:00
self._update_property(self._MPRIS_PLAYER_IFACE, p)
2020-07-04 14:16:17 +03:00
######################
# MPD client wrapper #
######################
2020-03-21 00:09:13 +03:00
2021-08-05 17:46:34 +03:00
class Duration():
2022-05-28 15:16:56 +03:00
def __init__(self, seconds=None):
if seconds is None:
2021-08-05 17:46:34 +03:00
self._fallback=True
2022-05-28 15:16:56 +03:00
self._seconds=0.0
2021-08-05 17:46:34 +03:00
else:
self._fallback=False
2022-05-28 15:16:56 +03:00
self._seconds=float(seconds)
2021-08-05 17:46:34 +03:00
2021-08-04 15:03:33 +03:00
def __str__(self):
2021-08-05 17:46:34 +03:00
if self._fallback:
2021-08-05 18:18:55 +03:00
return ""
else:
2022-05-28 15:16:56 +03:00
seconds=int(self._seconds)
days,seconds=divmod(seconds, 86400) # 86400 seconds make a day
hours,seconds=divmod(seconds, 3600) # 3600 seconds make an hour
minutes,seconds=divmod(seconds, 60)
if days > 0:
days_string=ngettext("{days} day", "{days} days", days).format(days=days)
return f"{days_string}, {hours:02d}{minutes:02d}{seconds:02d}"
elif hours > 0:
return f"{hours}{minutes:02d}{seconds:02d}"
2021-08-05 20:32:23 +03:00
else:
2022-05-28 15:16:56 +03:00
return f"{minutes:02d}{seconds:02d}"
2021-08-05 17:46:34 +03:00
def __float__(self):
2022-05-28 15:16:56 +03:00
return self._seconds
2020-09-30 12:06:00 +03:00
2021-08-04 15:03:33 +03:00
class LastModified():
def __init__(self, date):
self._date=date
def __str__(self):
2022-05-27 23:52:02 +03:00
return GLib.DateTime.new_from_iso8601(self._date).to_local().format("%a %d %B %Y, %H%M")
2021-08-04 15:03:33 +03:00
def raw(self):
return self._date
class Format():
def __init__(self, audio_format):
self._format=audio_format
def __str__(self):
# see: https://www.musicpd.org/doc/html/user.html#audio-output-format
2021-08-04 15:03:33 +03:00
samplerate, bits, channels=self._format.split(":")
if bits == "f":
bits="32fp"
try:
int_chan=int(channels)
2021-08-04 18:38:12 +03:00
except ValueError:
int_chan=0
try:
freq=locale.str(int(samplerate)/1000)
2021-08-04 18:38:12 +03:00
except ValueError:
freq=samplerate
2021-07-24 13:13:09 +03:00
channels=ngettext("{channels} channel", "{channels} channels", int_chan).format(channels=int_chan)
return f"{freq}kHz • {bits}bit • {channels}"
2021-08-04 15:03:33 +03:00
def raw(self):
return self._format
class MultiTag(list):
def __str__(self):
return ", ".join(self)
class Song(collections.UserDict):
def __setitem__(self, key, value):
2021-08-05 00:34:00 +03:00
if key == "time": # time is deprecated https://mpd.readthedocs.io/en/latest/protocol.html#other-metadata
pass
elif key == "duration":
super().__setitem__(key, Duration(value))
elif key == "format":
super().__setitem__(key, Format(value))
elif key == "last-modified":
super().__setitem__(key, LastModified(value))
elif key in ("range", "file", "pos", "id"):
super().__setitem__(key, value)
else:
if isinstance(value, list):
super().__setitem__(key, MultiTag(value))
2020-10-17 12:30:00 +03:00
else:
2021-08-05 00:34:00 +03:00
super().__setitem__(key, MultiTag([value]))
2020-10-17 12:30:00 +03:00
2021-08-04 15:03:33 +03:00
def __missing__(self, key):
2021-08-05 21:33:50 +03:00
if self.data:
2021-08-11 18:04:06 +03:00
if key == "albumartist":
return self["artist"]
elif key == "albumartistsort":
return self["albumartist"]
elif key == "artistsort":
return self["artist"]
elif key == "albumsort":
return self["album"]
2021-08-11 18:04:06 +03:00
elif key == "title":
2021-08-05 21:33:50 +03:00
return MultiTag([os.path.basename(self.data["file"])])
elif key == "duration":
return Duration()
else:
return MultiTag([""])
2021-08-04 15:03:33 +03:00
else:
2021-08-05 21:33:50 +03:00
return None
2020-03-21 00:09:13 +03:00
def get_album_with_date(self):
2022-02-01 21:51:07 +03:00
if "date" in self:
return f"{self['album'][0]} ({self['date']})"
2022-02-01 21:51:07 +03:00
else:
return self["album"][0]
def get_markup(self):
2022-02-01 21:51:07 +03:00
if "artist" in self:
title=f"<b>{GLib.markup_escape_text(self['title'][0])}</b> • {GLib.markup_escape_text(str(self['artist']))}"
else:
title=f"<b>{GLib.markup_escape_text(self['title'][0])}</b>"
return f"{title}\n<small>{GLib.markup_escape_text(self.get_album_with_date())}</small>"
2022-02-01 21:51:07 +03:00
2021-08-04 18:07:21 +03:00
class BinaryCover(bytes):
2022-04-21 18:14:43 +03:00
def get_pixbuf(self, size=-1):
2021-07-19 23:41:32 +03:00
loader=GdkPixbuf.PixbufLoader()
2021-06-02 22:34:01 +03:00
try:
2021-08-04 18:07:21 +03:00
loader.write(self)
2022-08-08 19:25:23 +03:00
except gi.repository.GLib.Error: # load fallback if cover can't be loaded
pixbuf=GdkPixbuf.Pixbuf.new_from_file_at_size(FALLBACK_COVER, size, size)
else:
2021-06-02 22:34:01 +03:00
loader.close()
2022-04-21 18:14:43 +03:00
if size == -1:
pixbuf=loader.get_pixbuf()
2021-06-02 22:34:01 +03:00
else:
2022-04-21 18:14:43 +03:00
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)
2021-06-02 22:34:01 +03:00
return pixbuf
2021-08-04 18:07:21 +03:00
class FileCover(str):
2022-04-21 18:14:43 +03:00
def get_pixbuf(self, size=-1):
2021-06-02 22:34:01 +03:00
try:
2021-08-04 18:07:21 +03:00
pixbuf=GdkPixbuf.Pixbuf.new_from_file_at_size(self, size, size)
2021-06-02 22:34:01 +03:00
except gi.repository.GLib.Error: # load fallback if cover can't be loaded
pixbuf=GdkPixbuf.Pixbuf.new_from_file_at_size(FALLBACK_COVER, size, size)
return pixbuf
2021-03-26 18:39:21 +03:00
class EventEmitter(GObject.Object):
2020-07-04 14:16:17 +03:00
__gsignals__={
2021-10-19 16:23:04 +03:00
"updating_db": (GObject.SignalFlags.RUN_FIRST, None, ()),
"updated_db": (GObject.SignalFlags.RUN_FIRST, None, ()),
2020-09-24 22:17:10 +03:00
"disconnected": (GObject.SignalFlags.RUN_FIRST, None, ()),
2022-09-20 19:42:37 +03:00
"connected": (GObject.SignalFlags.RUN_FIRST, None, ()),
"connecting": (GObject.SignalFlags.RUN_FIRST, None, ()),
2020-09-24 22:17:10 +03:00
"connection_error": (GObject.SignalFlags.RUN_FIRST, None, ()),
2021-10-30 22:58:35 +03:00
"current_song": (GObject.SignalFlags.RUN_FIRST, None, ()),
2020-09-24 22:17:10 +03:00
"state": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
2021-10-30 22:58:35 +03:00
"elapsed": (GObject.SignalFlags.RUN_FIRST, None, (float,float,)),
"volume": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
"playlist": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
2020-09-24 22:17:10 +03:00
"repeat": (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
"random": (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
2020-11-01 15:27:23 +03:00
"single": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
2020-09-24 22:17:10 +03:00
"consume": (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
"audio": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
"bitrate": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
2020-03-21 00:09:13 +03:00
}
2020-07-04 14:16:17 +03:00
class Client(MPDClient):
def __init__(self, settings):
2020-08-31 11:44:23 +03:00
super().__init__()
self._settings=settings
2021-03-26 18:39:21 +03:00
self.emitter=EventEmitter()
self._last_status={}
self._refresh_interval=self._settings.get_int("refresh-interval")
self._main_timeout_id=None
2022-10-01 18:06:00 +03:00
self._start_idle_id=None
self.music_directory=None
2022-03-20 15:26:45 +03:00
self.current_cover=None
2023-02-26 13:53:32 +03:00
self._bus=Gio.bus_get_sync(Gio.BusType.SESSION, None) # used for "show in file manager"
2020-03-21 00:09:13 +03:00
# connect
self._settings.connect("changed::socket-connection", lambda *args: self.reconnect())
2022-09-20 00:18:32 +03:00
# overloads to use Song class
2021-08-04 15:03:33 +03:00
def currentsong(self, *args):
return Song(super().currentsong(*args))
def search(self, *args):
return [Song(song) for song in super().search(*args)]
def find(self, *args):
return [Song(song) for song in super().find(*args)]
def playlistinfo(self):
return [Song(song) for song in super().playlistinfo()]
def plchanges(self, version):
return [Song(song) for song in super().plchanges(version)]
2021-08-04 15:28:55 +03:00
def lsinfo(self, uri):
return [Song(song) for song in super().lsinfo(uri)]
2022-02-22 21:09:39 +03:00
def listplaylistinfo(self, name):
return [Song(song) for song in super().listplaylistinfo(name)]
2021-08-04 15:03:33 +03:00
2020-07-04 14:16:17 +03:00
def start(self):
2022-09-20 19:42:37 +03:00
self.emitter.emit("connecting")
def callback():
2022-11-06 12:30:55 +03:00
if self._settings.get_boolean("socket-connection"):
2022-11-06 12:40:28 +03:00
args=(self._settings.get_socket(), None)
2022-09-20 19:42:37 +03:00
else:
2022-11-06 12:30:55 +03:00
args=(self._settings.get_string("host"), self._settings.get_int("port"))
2022-09-20 19:42:37 +03:00
try:
self.connect(*args)
2022-11-06 12:30:55 +03:00
if self._settings.get_string("password"):
self.password(self._settings.get_string("password"))
2022-09-20 19:42:37 +03:00
except:
self.emitter.emit("connection_error")
2022-10-01 18:06:00 +03:00
self._start_idle_id=None
2022-09-20 19:42:37 +03:00
return False
# connect successful
2022-11-06 12:30:55 +03:00
if self._settings.get_boolean("socket-connection"):
if "config" in self.commands():
self.music_directory=self.config()
else:
print("No permission to get music directory.")
2022-09-20 19:42:37 +03:00
else:
self.music_directory=self._settings.get_music_directory()
2022-09-20 19:42:37 +03:00
if "status" in self.commands():
self._main_timeout_id=GLib.timeout_add(self._refresh_interval, self._main_loop)
self.emitter.emit("connected")
else:
self.disconnect()
self.emitter.emit("connection_error")
print("No read permission, check your mpd config.")
2022-10-01 18:06:00 +03:00
self._start_idle_id=None
2020-12-22 14:33:21 +03:00
return False
2022-10-01 18:06:00 +03:00
self._start_idle_id=GLib.idle_add(callback)
def reconnect(self):
if self._main_timeout_id is not None:
GLib.source_remove(self._main_timeout_id)
self._main_timeout_id=None
2022-10-01 18:06:00 +03:00
if self._start_idle_id is not None:
GLib.source_remove(self._start_idle_id)
self._start_idle_id=None
self.disconnect()
self.start()
2020-03-21 00:09:13 +03:00
2022-09-20 00:18:32 +03:00
def disconnect(self):
super().disconnect()
self._last_status={}
self.emitter.emit("disconnected")
2020-07-04 14:16:17 +03:00
def connected(self):
try:
self.ping()
2020-07-04 14:16:17 +03:00
return True
except:
return False
2020-03-21 00:09:13 +03:00
2022-11-23 20:18:56 +03:00
def _to_playlist(self, append, mode): # modes: play, append, enqueue
2021-03-26 18:39:21 +03:00
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()
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
2022-03-17 01:03:58 +03:00
except CommandError:
2021-03-26 18:39:21 +03:00
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)
2022-11-23 20:18:56 +03:00
def files_to_playlist(self, files, mode):
def append():
for f in files:
self.add(f)
self._to_playlist(append, mode)
2022-11-23 20:18:56 +03:00
def filter_to_playlist(self, tag_filter, mode):
def append():
if tag_filter:
self.findadd(*tag_filter)
else:
self.searchadd("any", "")
self._to_playlist(append, mode)
2022-11-23 20:18:56 +03:00
def album_to_playlist(self, albumartist, album, date, mode):
2022-02-14 00:15:54 +03:00
self.filter_to_playlist(("albumartist", albumartist, "album", album, "date", date), mode)
2020-07-04 14:16:17 +03:00
def comp_list(self, *args): # simulates listing behavior of python-mpd2 1.0
native_list=self.list(*args)
if len(native_list) > 0:
2021-07-22 20:42:49 +03:00
if isinstance(native_list[0], dict):
2020-07-04 14:16:17 +03:00
return ([l[args[0]] for l in native_list])
else:
return native_list
else:
return([])
2021-08-04 15:03:33 +03:00
def get_cover_path(self, song):
path=None
2021-08-04 15:03:33 +03:00
song_file=song["file"]
if self.music_directory is not None:
2022-11-06 12:30:55 +03:00
regex_str=self._settings.get_string("regex")
if regex_str:
2021-08-06 00:24:18 +03:00
regex_str=regex_str.replace("%AlbumArtist%", re.escape(song["albumartist"][0]))
regex_str=regex_str.replace("%Album%", re.escape(song["album"][0]))
try:
regex=re.compile(regex_str, flags=re.IGNORECASE)
2021-08-06 00:24:18 +03:00
except re.error:
print("illegal regex:", regex_str)
2021-08-06 00:24:18 +03:00
return None
else:
2022-11-06 12:40:28 +03:00
regex=re.compile(FALLBACK_REGEX, flags=re.IGNORECASE)
song_dir=os.path.join(self.music_directory, os.path.dirname(song_file))
2021-09-23 00:23:02 +03:00
if song_dir.lower().endswith(".cue"):
2021-08-05 21:33:50 +03:00
song_dir=os.path.dirname(song_dir) # get actual directory of .cue file
2021-09-23 00:23:02 +03:00
if os.path.isdir(song_dir):
2021-08-05 21:33:50 +03:00
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):
2021-08-05 21:33:50 +03:00
try:
binary=self.albumart(uri)["binary"]
except:
try:
2021-08-05 21:33:50 +03:00
binary=self.readpicture(uri)["binary"]
except:
2021-08-05 21:33:50 +03:00
binary=None
return binary
2021-08-04 18:07:21 +03:00
def get_cover(self, song):
2022-03-20 15:26:45 +03:00
if (cover_path:=self.get_cover_path(song)) is not None:
return FileCover(cover_path)
elif (cover_binary:=self.get_cover_binary(song["file"])) is not None:
return BinaryCover(cover_binary)
else:
2022-03-20 15:26:45 +03:00
return None
def get_absolute_path(self, uri):
if self.music_directory is not None:
path=re.sub(r"(.*\.cue)\/track\d+$", r"\1", os.path.join(self.music_directory, uri), flags=re.IGNORECASE)
if os.path.isfile(path):
return path
else:
return None
else:
return None
2023-02-26 13:53:32 +03:00
def can_show_in_file_manager(self, uri):
try:
self._bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "StartServiceByName",
2023-02-28 02:32:18 +03:00
GLib.Variant("(su)",("org.freedesktop.FileManager1",0)), GLib.VariantType("(u)"), Gio.DBusCallFlags.NONE, -1, None)
except GLib.GError:
return False
return self.get_absolute_path(uri) is not None
2023-02-26 13:53:32 +03:00
2022-11-28 21:12:34 +03:00
def show_in_file_manager(self, uri):
2023-02-26 13:53:32 +03:00
file=Gio.File.new_for_path(self.get_absolute_path(uri))
self._bus.call_sync("org.freedesktop.FileManager1", "/org/freedesktop/FileManager1", "org.freedesktop.FileManager1",
2023-02-28 02:32:18 +03:00
"ShowItems", GLib.Variant("(ass)", ((file.get_uri(),),"")), None, Gio.DBusCallFlags.NONE, -1, None)
2022-11-28 21:12:34 +03:00
2020-09-29 13:39:21 +03:00
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
2020-09-29 14:02:51 +03:00
def toggle_option(self, option): # repeat, random, single, consume
2021-08-13 23:05:14 +03:00
new_state=int(self.status()[option] == "0")
2020-09-29 13:39:21 +03:00
func=getattr(self, option)
func(new_state)
def conditional_previous(self):
if self._settings.get_boolean("rewind-mode"):
double_click_time=Gtk.Settings.get_default().get_property("gtk-double-click-time")
status=self.status()
if float(status.get("elapsed", 0))*1000 > double_click_time:
self.seekcur(0)
else:
self.previous()
else:
self.previous()
2021-08-03 21:05:07 +03:00
def restrict_tagtypes(self, *tags):
self.command_list_ok_begin()
self.tagtypes("clear")
for tag in tags:
self.tagtypes("enable", tag)
self.command_list_end()
def _main_loop(self, *args):
2020-07-04 14:16:17 +03:00
try:
status=self.status()
diff=set(status.items())-set(self._last_status.items())
2020-07-12 16:20:29 +03:00
for key, val in diff:
if key == "elapsed":
if "duration" in status:
2021-10-30 22:58:35 +03:00
self.emitter.emit("elapsed", float(val), float(status["duration"]))
else:
2021-10-30 22:58:35 +03:00
self.emitter.emit("elapsed", float(val), 0.0)
elif key == "bitrate":
if val == "0":
self.emitter.emit("bitrate", None)
else:
self.emitter.emit("bitrate", val)
2020-07-12 16:20:29 +03:00
elif key == "songid":
2022-03-20 15:26:45 +03:00
self.current_cover=self.get_cover(self.currentsong())
2021-10-30 22:58:35 +03:00
self.emitter.emit("current_song")
elif key in ("state", "single", "audio"):
2020-11-01 15:27:23 +03:00
self.emitter.emit(key, val)
2020-07-12 16:20:29 +03:00
elif key == "volume":
2021-10-30 22:58:35 +03:00
self.emitter.emit("volume", float(val))
2020-07-12 16:20:29 +03:00
elif key == "playlist":
2021-10-30 22:58:35 +03:00
self.emitter.emit("playlist", int(val))
2021-03-27 14:50:45 +03:00
elif key in ("repeat", "random", "consume"):
if val == "1":
self.emitter.emit(key, True)
else:
self.emitter.emit(key, False)
2021-10-19 16:23:04 +03:00
elif key == "updating_db":
self.emitter.emit("updating_db")
diff=set(self._last_status)-set(status)
2021-07-16 20:39:48 +03:00
for key in diff:
if "songid" == key:
2022-03-20 15:26:45 +03:00
self.current_cover=None
2021-10-30 22:58:35 +03:00
self.emitter.emit("current_song")
2021-07-16 20:39:48 +03:00
elif "volume" == key:
2021-10-30 22:58:35 +03:00
self.emitter.emit("volume", -1)
2021-07-16 20:39:48 +03:00
elif "updating_db" == key:
2021-10-19 16:23:04 +03:00
self.emitter.emit("updated_db")
2021-07-16 20:39:48 +03:00
elif "bitrate" == key:
self.emitter.emit("bitrate", None)
2021-07-16 20:39:48 +03:00
elif "audio" == key:
self.emitter.emit("audio", None)
self._last_status=status
except (ConnectionError, ConnectionResetError) as e:
2020-08-14 23:08:56 +03:00
self.disconnect()
self.emitter.emit("connection_error")
self._main_timeout_id=None
self.music_directory=None
2022-03-20 15:26:45 +03:00
self.current_cover=None
2020-07-04 14:16:17 +03:00
return False
return True
2020-03-21 00:09:13 +03:00
2020-07-04 14:16:17 +03:00
########################
# gio settings wrapper #
########################
2020-03-21 00:09:13 +03:00
2020-01-28 20:39:18 +03:00
class Settings(Gio.Settings):
BASE_KEY="org.mpdevil.mpdevil"
2020-09-15 19:45:30 +03:00
# temp settings
cursor_watch=GObject.Property(type=bool, default=False)
2020-01-28 20:39:18 +03:00
def __init__(self):
2020-01-28 21:59:14 +03:00
super().__init__(schema=self.BASE_KEY)
2021-08-21 01:23:04 +03:00
2022-11-06 12:40:28 +03:00
def get_socket(self):
socket=self.get_string("socket")
if not socket:
socket=FALLBACK_SOCKET
return socket
def get_music_directory(self):
music_directory=self.get_string("music-directory")
if not music_directory:
music_directory=FALLBACK_MUSIC_DIRECTORY
return music_directory
2022-11-06 12:40:28 +03:00
2020-09-16 16:08:56 +03:00
###################
# settings dialog #
###################
2020-05-26 16:04:16 +03:00
2021-08-15 21:58:56 +03:00
class ToggleRow(Gtk.ListBoxRow):
def __init__(self, label, settings, key, restart_required=False):
super().__init__()
label=Gtk.Label(label=label, xalign=0, valign=Gtk.Align.CENTER, margin=6)
self._switch=Gtk.Switch(halign=Gtk.Align.END, valign=Gtk.Align.CENTER, margin_top=6, margin_bottom=6, margin_start=12, margin_end=12)
settings.bind(key, self._switch, "active", Gio.SettingsBindFlags.DEFAULT)
box=Gtk.Box()
box.pack_start(label, False, False, 0)
box.pack_end(self._switch, False, False, 0)
if restart_required:
box.pack_end(Gtk.Label(label=_("(restart required)"), margin=6, sensitive=False), False, False, 0)
self.add(box)
def toggle(self):
self._switch.set_active(not self._switch.get_active())
class IntRow(Gtk.ListBoxRow):
def __init__(self, label, vmin, vmax, step, settings, key):
super().__init__(activatable=False)
label=Gtk.Label(label=label, xalign=0, valign=Gtk.Align.CENTER, margin=6)
spin_button=Gtk.SpinButton.new_with_range(vmin, vmax, step)
spin_button.set_valign(Gtk.Align.CENTER)
spin_button.set_halign(Gtk.Align.END)
spin_button.set_margin_end(12)
spin_button.set_margin_start(12)
spin_button.set_margin_top(6)
spin_button.set_margin_bottom(6)
settings.bind(key, spin_button, "value", Gio.SettingsBindFlags.DEFAULT)
box=Gtk.Box()
box.pack_start(label, False, False, 0)
box.pack_end(spin_button, False, False, 0)
self.add(box)
class SettingsList(Gtk.Frame):
def __init__(self):
super().__init__(border_width=18, valign=Gtk.Align.START)
self._list_box=Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
self._list_box.set_header_func(self._header_func)
self._list_box.connect("row-activated", self._on_row_activated)
self.add(self._list_box)
def append(self, row):
self._list_box.insert(row, -1)
def _header_func(self, row, before, *args):
if before is not None:
row.set_header(Gtk.Separator())
2020-07-04 14:16:17 +03:00
2021-08-15 21:58:56 +03:00
def _on_row_activated(self, list_box, row):
if isinstance(row, ToggleRow):
row.toggle()
class ViewSettings(SettingsList):
def __init__(self, settings):
super().__init__()
2022-03-23 19:23:56 +03:00
toggle_data=(
2021-08-15 21:58:56 +03:00
(_("Use Client-side decoration"), "use-csd", True),
(_("Show stop button"), "show-stop", False),
(_("Show audio format"), "show-audio-format", False),
(_("Show lyrics button"), "show-lyrics-button", False),
(_("Place playlist at the side"), "playlist-right", False),
2022-03-23 19:23:56 +03:00
)
2021-08-15 21:58:56 +03:00
for label, key, restart_required in toggle_data:
row=ToggleRow(label, settings, key, restart_required)
self.append(row)
2022-03-23 19:23:56 +03:00
int_data=(
2021-08-15 21:58:56 +03:00
(_("Album view cover size"), (50, 600, 10), "album-cover"),
(_("Action bar icon size"), (16, 64, 2), "icon-size"),
2022-03-23 19:23:56 +03:00
)
2021-08-15 21:58:56 +03:00
for label, (vmin, vmax, step), key in int_data:
row=IntRow(label, vmin, vmax, step, settings, key)
self.append(row)
2020-07-04 14:16:17 +03:00
2021-08-15 21:58:56 +03:00
class BehaviorSettings(SettingsList):
def __init__(self, settings):
super().__init__()
2022-03-23 19:23:56 +03:00
toggle_data=(
2021-08-15 21:58:56 +03:00
(_("Support “MPRIS”"), "mpris", True),
(_("Sort albums by year"), "sort-albums-by-year", False),
(_("Send notification on title change"), "send-notify", False),
(_("Rewind via previous button"), "rewind-mode", False),
(_("Stop playback on quit"), "stop-on-quit", False),
2022-03-23 19:23:56 +03:00
)
2021-08-15 21:58:56 +03:00
for label, key, restart_required in toggle_data:
row=ToggleRow(label, settings, key, restart_required)
self.append(row)
2021-08-21 01:23:04 +03:00
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")
self.connect("icon-release", self._on_icon_release)
2021-08-21 01:23:04 +03:00
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 MusicDirectoryEntry(Gtk.Entry):
2021-08-21 01:23:04 +03:00
def __init__(self, parent, **kwargs):
super().__init__(placeholder_text=FALLBACK_MUSIC_DIRECTORY, **kwargs)
2021-08-21 01:23:04 +03:00
self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "folder-open-symbolic")
self.connect("icon-release", self._on_icon_release, parent)
def _on_icon_release(self, widget, icon_pos, event, parent):
dialog=Gtk.FileChooserNative(title=_("Choose directory"), transient_for=parent, action=Gtk.FileChooserAction.SELECT_FOLDER)
folder=self.get_text()
if not folder:
folder=self.get_placeholder_text()
dialog.set_current_folder(folder)
response=dialog.run()
if response == Gtk.ResponseType.ACCEPT:
self.set_text(dialog.get_filename())
dialog.destroy()
2022-11-06 13:14:15 +03:00
class ConnectionSettings(Gtk.Grid):
2022-11-06 12:30:55 +03:00
def __init__(self, parent, client, settings):
2022-11-06 13:14:15 +03:00
super().__init__(row_spacing=6, column_spacing=6, border_width=18)
2022-11-06 12:30:55 +03:00
2022-11-06 13:14:15 +03:00
# labels and entries
socket_button=Gtk.CheckButton(label=_("Connect via Unix domain socket"))
2022-11-06 12:30:55 +03:00
settings.bind("socket-connection", socket_button, "active", Gio.SettingsBindFlags.DEFAULT)
socket_entry=Gtk.Entry(placeholder_text=FALLBACK_SOCKET, hexpand=True, no_show_all=True)
2022-11-06 12:30:55 +03:00
settings.bind("socket", socket_entry, "text", Gio.SettingsBindFlags.DEFAULT)
settings.bind("socket-connection", socket_entry, "visible", Gio.SettingsBindFlags.GET)
host_entry=Gtk.Entry(hexpand=True, no_show_all=True)
2022-11-06 12:30:55 +03:00
settings.bind("host", host_entry, "text", Gio.SettingsBindFlags.DEFAULT)
settings.bind("socket-connection", host_entry, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
2021-08-21 01:23:04 +03:00
port_entry=Gtk.SpinButton.new_with_range(0, 65535, 1)
port_entry.set_property("no-show-all", True)
2022-11-06 12:30:55 +03:00
settings.bind("port", port_entry, "value", Gio.SettingsBindFlags.DEFAULT)
settings.bind("socket-connection", port_entry, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
2021-08-21 01:23:04 +03:00
password_entry=PasswordEntry(hexpand=True)
2022-11-06 12:30:55 +03:00
settings.bind("password", password_entry, "text", Gio.SettingsBindFlags.DEFAULT)
music_directory_entry=MusicDirectoryEntry(parent, hexpand=True, no_show_all=True)
settings.bind("music-directory", music_directory_entry, "text", Gio.SettingsBindFlags.DEFAULT)
settings.bind("socket-connection", music_directory_entry, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
2022-11-06 12:40:28 +03:00
regex_entry=Gtk.Entry(hexpand=True, placeholder_text=FALLBACK_REGEX)
2021-08-21 01:23:04 +03:00
regex_entry.set_tooltip_text(
2020-09-16 16:08:56 +03:00
_("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.")
)
2022-11-06 12:30:55 +03:00
settings.bind("regex", regex_entry, "text", Gio.SettingsBindFlags.DEFAULT)
socket_label=Gtk.Label(label=_("Socket:"), xalign=1, margin_end=6, no_show_all=True)
2022-11-06 12:30:55 +03:00
settings.bind("socket-connection", socket_label, "visible", Gio.SettingsBindFlags.GET)
host_label=Gtk.Label(label=_("Host:"), xalign=1, margin_end=6, no_show_all=True)
2022-11-06 12:30:55 +03:00
settings.bind("socket-connection", host_label, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
password_label=Gtk.Label(label=_("Password:"), xalign=1, margin_end=6)
music_directory_label=Gtk.Label(label=_("Music lib:"), xalign=1, margin_end=6, no_show_all=True)
settings.bind("socket-connection", music_directory_label, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
regex_label=Gtk.Label(label=_("Cover regex:"), xalign=1, margin_end=6)
2020-01-18 00:13:58 +03:00
2021-08-21 01:23:04 +03:00
# connect button
2022-11-06 13:14:15 +03:00
connect_button=Gtk.Button(label=_("Connect"), margin_start=18, margin_end=18, margin_top=18, halign=Gtk.Align.CENTER)
2021-10-21 10:48:44 +03:00
connect_button.get_style_context().add_class("suggested-action")
2022-11-06 12:30:55 +03:00
connect_button.connect("clicked", lambda *args: client.reconnect())
2021-08-21 01:23:04 +03:00
# packing
2022-11-06 13:14:15 +03:00
self.attach(socket_button, 0, 0, 3, 1)
self.attach(socket_label, 0, 1, 1, 1)
self.attach_next_to(host_label, socket_label, Gtk.PositionType.BOTTOM, 1, 1)
self.attach_next_to(password_label, host_label, Gtk.PositionType.BOTTOM, 1, 1)
self.attach_next_to(music_directory_label, password_label, Gtk.PositionType.BOTTOM, 1, 1)
self.attach_next_to(regex_label, music_directory_label, Gtk.PositionType.BOTTOM, 1, 1)
2022-11-06 13:14:15 +03:00
self.attach_next_to(socket_entry, socket_label, Gtk.PositionType.RIGHT, 2, 1)
self.attach_next_to(host_entry, host_label, Gtk.PositionType.RIGHT, 1, 1)
self.attach_next_to(port_entry, host_entry, Gtk.PositionType.RIGHT, 1, 1)
self.attach_next_to(password_entry, password_label, Gtk.PositionType.RIGHT, 2, 1)
self.attach_next_to(music_directory_entry, music_directory_label, Gtk.PositionType.RIGHT, 2, 1)
2022-11-06 13:14:15 +03:00
self.attach_next_to(regex_entry, regex_label, Gtk.PositionType.RIGHT, 2, 1)
self.attach(connect_button, 0, 6, 3, 1)
2020-08-16 18:07:41 +03:00
2020-09-16 16:08:56 +03:00
class SettingsDialog(Gtk.Dialog):
2021-08-15 21:58:56 +03:00
def __init__(self, parent, client, settings, tab="view"):
2020-09-16 16:08:56 +03:00
use_csd=settings.get_boolean("use-csd")
if use_csd:
2021-09-14 15:39:08 +03:00
super().__init__(title=_("Preferences"), transient_for=parent, use_header_bar=True)
2020-09-16 16:08:56 +03:00
else:
2021-09-14 15:39:08 +03:00
super().__init__(title=_("Preferences"), transient_for=parent)
2020-09-16 16:08:56 +03:00
self.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK)
2020-02-27 22:05:48 +03:00
2020-09-16 16:08:56 +03:00
# widgets
2021-08-15 21:58:56 +03:00
view=ViewSettings(settings)
behavior=BehaviorSettings(settings)
2022-11-06 12:30:55 +03:00
connection=ConnectionSettings(parent, client, settings)
2020-08-09 23:58:48 +03:00
2020-09-16 16:08:56 +03:00
# packing
vbox=self.get_content_area()
if use_csd:
2021-08-16 00:05:38 +03:00
stack=Gtk.Stack(transition_type=Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
2021-08-15 21:58:56 +03:00
stack.add_titled(view, "view", _("View"))
stack.add_titled(behavior, "behavior", _("Behavior"))
2022-11-06 12:30:55 +03:00
stack.add_titled(connection, "connection", _("Connection"))
stack_switcher=Gtk.StackSwitcher(stack=stack)
2021-08-21 01:23:04 +03:00
vbox.set_property("border-width", 0)
vbox.pack_start(stack, True, True, 0)
header_bar=self.get_header_bar()
header_bar.set_custom_title(stack_switcher)
else:
tabs=Gtk.Notebook()
2021-08-15 21:58:56 +03:00
tabs.append_page(view, Gtk.Label(label=_("View")))
tabs.append_page(behavior, Gtk.Label(label=_("Behavior")))
2022-11-06 12:30:55 +03:00
tabs.append_page(connection, Gtk.Label(label=_("Connection")))
vbox.set_property("spacing", 6)
vbox.set_property("border-width", 6)
vbox.pack_start(tabs, True, True, 0)
2020-09-16 16:08:56 +03:00
self.show_all()
if use_csd:
stack.set_visible_child_name(tab)
else:
2022-11-06 12:30:55 +03:00
tabs.set_current_page({"view": 0, "behavior": 1, "connection": 2}[tab])
2020-09-16 15:51:28 +03:00
2020-09-16 16:08:56 +03:00
#################
# other dialogs #
#################
2020-01-11 13:25:15 +03:00
2020-09-16 16:08:56 +03:00
class ServerStats(Gtk.Dialog):
def __init__(self, parent, client, settings):
use_csd=settings.get_boolean("use-csd")
2021-10-23 12:59:18 +03:00
super().__init__(title=_("Stats"), transient_for=parent, use_header_bar=use_csd, resizable=False)
2020-10-20 19:14:20 +03:00
if not use_csd:
self.add_button(Gtk.STOCK_OK, Gtk.ResponseType.OK)
2020-01-11 13:25:15 +03:00
2020-10-20 19:14:20 +03:00
# grid
grid=Gtk.Grid(row_spacing=6, column_spacing=12, border_width=6)
2020-03-31 18:36:41 +03:00
2020-10-13 18:12:54 +03:00
# populate
2020-10-20 19:14:20 +03:00
display_str={
"protocol": _("<b>Protocol:</b>"),
"uptime": _("<b>Uptime:</b>"),
"playtime": _("<b>Playtime:</b>"),
"artists": _("<b>Artists:</b>"),
"albums": _("<b>Albums:</b>"),
"songs": _("<b>Songs:</b>"),
"db_playtime": _("<b>Total Playtime:</b>"),
"db_update": _("<b>Database Update:</b>")
}
stats=client.stats()
2020-10-20 19:14:20 +03:00
stats["protocol"]=str(client.mpd_version)
for key in ("uptime","playtime","db_playtime"):
2021-08-04 15:03:33 +03:00
stats[key]=str(Duration(stats[key]))
2022-05-27 23:52:02 +03:00
stats["db_update"]=GLib.DateTime.new_from_unix_local(int(stats["db_update"])).format("%a %d %B %Y, %H%M")
2020-10-20 19:14:20 +03:00
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)
2020-10-13 18:12:54 +03:00
# packing
vbox=self.get_content_area()
2020-10-20 19:14:20 +03:00
vbox.set_property("border-width", 6)
vbox.pack_start(grid, True, True, 0)
2020-09-16 16:08:56 +03:00
self.show_all()
self.run()
2020-01-11 13:25:15 +03:00
2020-12-06 02:45:25 +03:00
###########################
# general purpose widgets #
###########################
2021-11-07 19:15:45 +03:00
class TreeView(Gtk.TreeView):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def get_popover_point(self, path):
cell=self.get_cell_area(path, None)
cell.x,cell.y=self.convert_bin_window_to_widget_coords(cell.x,cell.y)
rect=self.get_visible_rect()
rect.x,rect.y=self.convert_tree_to_widget_coords(rect.x,rect.y)
return (rect.x+rect.width//2, max(min(cell.y+cell.height//2, rect.y+rect.height), rect.y))
2022-07-18 20:29:46 +03:00
def save_set_cursor(self, *args, **kwargs):
# The standard set_cursor function should scroll normally, but it doesn't work as it should when the treeview is not completely
2022-07-18 20:29:46 +03:00
# initialized. This usually happens when the program is freshly started and the treeview isn't done with its internal tasks.
# See: https://lazka.github.io/pgi-docs/GLib-2.0/constants.html#GLib.PRIORITY_HIGH_IDLE
# Running set_cursor with a lower priority ensures that the treeview is done before it gets scrolled.
GLib.idle_add(self.set_cursor, *args, **kwargs)
def save_scroll_to_cell(self, *args, **kwargs):
# Similar problem as above.
GLib.idle_add(self.scroll_to_cell, *args, **kwargs)
2020-10-21 12:14:17 +03:00
class AutoSizedIcon(Gtk.Image):
def __init__(self, icon_name, settings_key, settings):
2021-06-26 00:21:06 +03:00
super().__init__(icon_name=icon_name)
settings.bind(settings_key, self, "pixel-size", Gio.SettingsBindFlags.GET)
2020-10-20 14:13:50 +03:00
2022-02-11 18:38:34 +03:00
class SongsList(TreeView):
2022-11-23 19:42:42 +03:00
def __init__(self, client):
super().__init__(activate_on_single_click=True, headers_visible=False, enable_search=False, search_column=4)
2020-09-16 16:08:56 +03:00
self._client=client
2022-02-11 18:38:34 +03:00
# store
2022-11-23 19:42:42 +03:00
# (track, title, duration, file, search string)
self._store=Gtk.ListStore(str, str, str, str, str)
2022-02-11 18:38:34 +03:00
self.set_model(self._store)
# columns
2022-11-23 19:42:42 +03:00
renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True)
2022-02-11 18:38:34 +03:00
attrs=Pango.AttrList()
attrs.insert(Pango.AttrFontFeatures.new("tnum 1"))
2022-02-20 01:42:35 +03:00
renderer_text_ralign_tnum=Gtk.CellRendererText(xalign=1, attributes=attrs, ypad=6)
2022-02-11 18:38:34 +03:00
renderer_text_centered_tnum=Gtk.CellRendererText(xalign=0.5, attributes=attrs)
columns=(
Gtk.TreeViewColumn(_("No"), renderer_text_centered_tnum, text=0),
Gtk.TreeViewColumn(_("Title"), renderer_text, markup=1),
2022-02-20 01:42:35 +03:00
Gtk.TreeViewColumn(_("Length"), renderer_text_ralign_tnum, text=2)
2022-02-11 18:38:34 +03:00
)
for column in columns:
column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
column.set_property("resizable", False)
self.append_column(column)
columns[1].set_property("expand", True)
2020-09-16 16:08:56 +03:00
# selection
self._selection=self.get_selection()
2022-11-23 20:21:26 +03:00
self._selection.set_mode(Gtk.SelectionMode.BROWSE)
2020-09-16 16:08:56 +03:00
2022-02-11 18:38:34 +03:00
# buttons
self.buttons=Gtk.ButtonBox(layout_style=Gtk.ButtonBoxStyle.EXPAND)
2022-12-19 00:44:54 +03:00
data=((_("Append"), "list-add-symbolic", "append"),
(_("Play"), "media-playback-start-symbolic", "play")
2022-02-11 18:38:34 +03:00
)
for tooltip, icon, mode in data:
button=Gtk.Button(image=Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.BUTTON))
button.set_tooltip_text(tooltip)
button.connect("clicked", self._on_button_clicked, mode)
self.buttons.pack_start(button, True, True, 0)
2022-11-28 21:12:34 +03:00
# menu
2022-11-29 01:10:45 +03:00
action_group=Gio.SimpleActionGroup()
action=Gio.SimpleAction.new("append", None)
action.connect("activate", lambda *args: self._client.files_to_playlist([self._store[self.get_cursor()[0]][3]], "append"))
action_group.add_action(action)
action=Gio.SimpleAction.new("play", None)
action.connect("activate", lambda *args: self._client.files_to_playlist([self._store[self.get_cursor()[0]][3]], "play"))
action_group.add_action(action)
self._show_action=Gio.SimpleAction.new("show", None)
self._show_action.connect("activate", lambda *args: self._client.show_in_file_manager(self._store[self.get_cursor()[0]][3]))
action_group.add_action(self._show_action)
self.insert_action_group("menu", action_group)
menu=Gio.Menu()
menu.append(_("Append"), "menu.append")
menu.append(_("Play"), "menu.play")
2022-12-02 20:08:28 +03:00
menu.append(_("Show"), "menu.show")
2022-11-29 01:10:45 +03:00
self._menu=Gtk.Popover.new_from_model(self, menu)
2022-11-29 01:19:58 +03:00
self._menu.set_position(Gtk.PositionType.BOTTOM)
2021-03-24 19:35:05 +03:00
2020-09-16 16:08:56 +03:00
# connect
self.connect("row-activated", self._on_row_activated)
self.connect("button-press-event", self._on_button_press_event)
2022-11-28 23:34:59 +03:00
self.connect("key-press-event", self._on_key_press_event)
2020-09-16 16:08:56 +03:00
def clear(self):
2022-11-28 21:12:34 +03:00
self._menu.popdown()
2020-09-16 16:08:56 +03:00
self._store.clear()
2022-11-23 19:42:42 +03:00
def append(self, track, title, duration, file, search_string=""):
self._store.insert_with_valuesv(-1, range(5), [track, title, duration, file, search_string])
2020-09-16 16:08:56 +03:00
2022-11-29 01:10:45 +03:00
def _open_menu(self, uri, x, y):
rect=Gdk.Rectangle()
rect.x,rect.y=x,y
self._menu.set_pointing_to(rect)
2023-02-26 13:53:32 +03:00
self._show_action.set_enabled(self._client.can_show_in_file_manager(uri))
2022-11-29 01:10:45 +03:00
self._menu.popup()
2020-09-16 16:08:56 +03:00
def _on_row_activated(self, widget, path, view_column):
2022-11-23 20:18:56 +03:00
self._client.files_to_playlist([self._store[path][3]], "play")
2020-09-16 16:08:56 +03:00
def _on_button_press_event(self, widget, event):
2022-03-23 19:23:56 +03:00
if (path_re:=widget.get_path_at_pos(int(event.x), int(event.y))) is not None:
path=path_re[0]
if event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS:
2022-02-11 18:38:34 +03:00
self._client.files_to_playlist([self._store[path][3]], "append")
2021-03-26 21:19:34 +03:00
elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS:
2022-02-11 18:38:34 +03:00
uri=self._store[path][3]
2021-11-07 21:54:39 +03:00
point=self.convert_bin_window_to_widget_coords(event.x,event.y)
2022-11-29 01:10:45 +03:00
self._open_menu(uri, *point)
2022-11-28 23:34:59 +03:00
def _on_key_press_event(self, widget, event):
if event.state & Gdk.ModifierType.CONTROL_MASK and event.keyval == Gdk.keyval_from_name("plus"):
if (path:=self.get_cursor()[0]) is not None:
self._client.files_to_playlist([self._store[path][3]], "append")
elif event.keyval == Gdk.keyval_from_name("Menu"):
if (path:=self.get_cursor()[0]) is not None:
2022-11-29 01:10:45 +03:00
self._open_menu(self._store[path][3], *self.get_popover_point(path))
2022-11-28 23:34:59 +03:00
2022-02-11 18:38:34 +03:00
def _on_button_clicked(self, widget, mode):
self._client.files_to_playlist((row[3] for row in self._store), mode)
2021-10-03 19:15:41 +03:00
##########
# search #
##########
2020-12-06 02:45:25 +03:00
class SearchThread(threading.Thread):
2022-02-11 18:38:34 +03:00
def __init__(self, client, search_entry, songs_list, hits_label, search_tag):
super().__init__(daemon=True)
self._client=client
self._search_entry=search_entry
2022-02-11 18:38:34 +03:00
self._songs_list=songs_list
self._hits_label=hits_label
self._search_tag=search_tag
self._stop_flag=False
self._callback=None
def set_callback(self, callback):
self._callback=callback
def stop(self):
self._stop_flag=True
def start(self):
2022-02-11 18:38:34 +03:00
self._songs_list.clear()
self._hits_label.set_text("")
2022-02-11 18:38:34 +03:00
self._songs_list.buttons.set_sensitive(False)
self._search_text=self._search_entry.get_text()
if self._search_text:
super().start()
else:
self._exit()
def run(self):
hits=0
stripe_size=1000
songs=self._get_songs(0, stripe_size)
stripe_start=stripe_size
while songs:
hits+=len(songs)
if not self._append_songs(songs):
self._exit()
return
idle_add(self._search_entry.progress_pulse)
idle_add(self._hits_label.set_text, ngettext("{hits} hit", "{hits} hits", hits).format(hits=hits))
stripe_end=stripe_start+stripe_size
songs=self._get_songs(stripe_start, stripe_end)
stripe_start=stripe_end
if hits > 0:
idle_add(self._songs_list.buttons.set_sensitive, True)
self._exit()
def _exit(self):
def callback():
self._search_entry.set_progress_fraction(0.0)
2022-02-11 18:38:34 +03:00
self._songs_list.columns_autosize()
if self._callback is not None:
self._callback()
return False
idle_add(callback)
@main_thread_function
def _get_songs(self, start, end):
if self._stop_flag:
return []
else:
self._client.restrict_tagtypes("track", "title", "artist", "album", "date")
songs=self._client.search(self._search_tag, self._search_text, "window", f"{start}:{end}")
self._client.tagtypes("all")
return songs
@main_thread_function
def _append_songs(self, songs):
for song in songs:
if self._stop_flag:
return False
2022-02-11 18:38:34 +03:00
self._songs_list.append(song["track"][0], song.get_markup(), str(song["duration"]), song["file"])
self._songs_list.columns_autosize()
return True
2020-09-16 16:08:56 +03:00
class SearchWindow(Gtk.Box):
def __init__(self, client):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self._client=client
2020-07-04 14:16:17 +03:00
# widgets
2020-10-19 00:45:36 +03:00
self._tag_combo_box=Gtk.ComboBoxText()
self.search_entry=Gtk.SearchEntry(max_width_chars=20, truncate_multiline=True)
2022-02-11 18:38:34 +03:00
self._hits_label=Gtk.Label(xalign=1, ellipsize=Pango.EllipsizeMode.END)
2020-07-04 14:16:17 +03:00
2022-02-11 18:38:34 +03:00
# songs list
self._songs_list=SongsList(self._client)
2020-08-08 14:31:33 +03:00
# search thread
2022-02-11 18:38:34 +03:00
self._search_thread=SearchThread(self._client, self.search_entry, self._songs_list, self._hits_label, "any")
2020-08-08 14:31:33 +03:00
# connect
2021-07-04 20:38:05 +03:00
self.search_entry.connect("activate", self._search)
self._search_entry_changed=self.search_entry.connect("search-changed", self._search)
2021-04-23 23:06:13 +03:00
self.search_entry.connect("focus_in_event", self._on_search_entry_focus_event, True)
self.search_entry.connect("focus_out_event", self._on_search_entry_focus_event, False)
self._tag_combo_box_changed=self._tag_combo_box.connect("changed", self._search)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect("connected", self._on_connected)
2020-09-16 16:08:56 +03:00
self._client.emitter.connect("disconnected", self._on_disconnected)
2021-10-19 16:23:04 +03:00
self._client.emitter.connect("updated_db", self._search)
2020-08-08 14:31:33 +03:00
# packing
2020-09-16 16:08:56 +03:00
hbox=Gtk.Box(spacing=6, border_width=6)
2022-02-11 18:38:34 +03:00
hbox.pack_start(self._tag_combo_box, False, False, 0)
hbox.set_center_widget(self.search_entry)
hbox.pack_end(self._songs_list.buttons, False, False, 0)
hbox.pack_end(self._hits_label, False, False, 6)
2020-09-16 16:08:56 +03:00
self.pack_start(hbox, False, False, 0)
2021-07-19 23:41:32 +03:00
self.pack_start(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0)
2022-02-11 18:38:34 +03:00
self.pack_start(Gtk.ScrolledWindow(child=self._songs_list), True, True, 0)
2020-08-08 14:31:33 +03:00
2020-09-16 16:08:56 +03:00
def _on_disconnected(self, *args):
self._search_thread.stop()
2020-08-08 14:31:33 +03:00
2022-09-20 19:42:37 +03:00
def _on_connected(self, *args):
def callback():
2022-02-11 18:38:34 +03:00
self._songs_list.buttons.set_sensitive(False)
self._songs_list.clear()
self._hits_label.set_text("")
self.search_entry.handler_block(self._search_entry_changed)
self.search_entry.set_text("")
self.search_entry.handler_unblock(self._search_entry_changed)
self._tag_combo_box.handler_block(self._tag_combo_box_changed)
self._tag_combo_box.remove_all()
2021-03-19 21:38:17 +03:00
self._tag_combo_box.append_text(_("all tags"))
for tag in self._client.tagtypes():
2021-03-19 21:38:17 +03:00
if not tag.startswith("MUSICBRAINZ"):
self._tag_combo_box.append_text(tag)
self._tag_combo_box.set_active(0)
self._tag_combo_box.handler_unblock(self._tag_combo_box_changed)
if self._search_thread.is_alive():
self._search_thread.set_callback(callback)
self._search_thread.stop()
else:
callback()
2021-03-19 21:38:17 +03:00
def _search(self, *args):
def callback():
if self._tag_combo_box.get_active() == 0:
search_tag="any"
else:
search_tag=self._tag_combo_box.get_active_text()
2022-02-11 18:38:34 +03:00
self._search_thread=SearchThread(self._client, self.search_entry, self._songs_list, self._hits_label, search_tag)
self._search_thread.start()
if self._search_thread.is_alive():
self._search_thread.set_callback(callback)
self._search_thread.stop()
else:
callback()
2020-08-08 14:31:33 +03:00
2021-04-23 23:06:13 +03:00
def _on_search_entry_focus_event(self, widget, event, focus):
app=self.get_toplevel().get_application()
if focus:
app.set_accels_for_action("mpd.toggle-play", [])
else:
app.set_accels_for_action("mpd.toggle-play", ["space"])
2021-10-03 19:15:41 +03:00
###########
# browser #
###########
2021-11-07 19:15:45 +03:00
class SelectionList(TreeView):
2022-11-23 19:42:42 +03:00
__gsignals__={"item-selected": (GObject.SignalFlags.RUN_FIRST, None, ()),
"item-reselected": (GObject.SignalFlags.RUN_FIRST, None, ()),
"clear": (GObject.SignalFlags.RUN_FIRST, None, ())}
2021-06-24 22:24:22 +03:00
def __init__(self, select_all_string):
2022-08-27 14:39:13 +03:00
super().__init__(search_column=0, headers_visible=False, fixed_height_mode=True)
2021-06-24 22:24:22 +03:00
self.select_all_string=select_all_string
self._selected_path=None
2021-06-24 22:24:22 +03:00
# store
2022-08-27 14:39:13 +03:00
# item, initial-letter, weight-initials
self._store=Gtk.ListStore(str, str, Pango.Weight)
self._store.append([self.select_all_string, "", Pango.Weight.NORMAL])
2021-06-24 22:24:22 +03:00
self.set_model(self._store)
self._selection=self.get_selection()
2022-08-27 14:39:13 +03:00
self._selection.set_mode(Gtk.SelectionMode.BROWSE)
2021-06-24 22:24:22 +03:00
# columns
renderer_text_malign=Gtk.CellRendererText(xalign=0.5)
2022-08-27 14:39:13 +03:00
self._column_initial=Gtk.TreeViewColumn("", renderer_text_malign, text=1, weight=2)
2021-06-24 22:24:22 +03:00
self._column_initial.set_property("sizing", Gtk.TreeViewColumnSizing.FIXED)
self._column_initial.set_property("min-width", 30)
self.append_column(self._column_initial)
2022-02-08 23:21:31 +03:00
renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True, ypad=6)
2022-08-27 14:39:13 +03:00
self._column_item=Gtk.TreeViewColumn("", renderer_text, text=0)
2021-06-24 22:24:22 +03:00
self._column_item.set_property("sizing", Gtk.TreeViewColumnSizing.FIXED)
self._column_item.set_property("expand", True)
self.append_column(self._column_item)
# connect
2022-08-27 14:39:13 +03:00
self._selection.connect("changed", self._on_selection_changed)
2021-06-24 22:24:22 +03:00
def clear(self):
2022-10-01 18:06:00 +03:00
self._selection.set_mode(Gtk.SelectionMode.NONE)
2021-06-24 22:24:22 +03:00
self._store.clear()
2022-08-27 14:39:13 +03:00
self._store.append([self.select_all_string, "", Pango.Weight.NORMAL])
self._selected_path=None
2021-06-24 22:24:22 +03:00
self.emit("clear")
def set_items(self, items):
self.clear()
2022-02-08 23:15:42 +03:00
letters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
items.extend(zip([None]*len(letters), letters))
items.sort(key=lambda item: locale.strxfrm(item[1]))
2022-02-08 23:15:42 +03:00
char=""
2021-06-24 22:24:22 +03:00
for item in items:
2022-02-08 23:15:42 +03:00
if item[0] is None:
char=item[1]
2021-07-03 16:05:56 +03:00
else:
2022-08-27 14:39:13 +03:00
self._store.insert_with_valuesv(-1, range(3), [item[0], char, Pango.Weight.BOLD])
2022-02-08 23:15:42 +03:00
char=""
2022-10-01 18:06:00 +03:00
self._selection.set_mode(Gtk.SelectionMode.BROWSE)
2021-06-24 22:24:22 +03:00
def get_item_at_path(self, path):
2021-06-24 22:24:22 +03:00
if path == Gtk.TreePath(0):
return None
else:
2022-02-14 00:15:54 +03:00
return self._store[path][0]
2021-06-24 22:24:22 +03:00
def length(self):
return len(self._store)-1
def select_path(self, path):
self.set_cursor(path, None, False)
2021-06-24 22:24:22 +03:00
def select(self, item):
row_num=len(self._store)
for i in range(0, row_num):
path=Gtk.TreePath(i)
2022-02-14 00:15:54 +03:00
if self._store[path][0] == item:
2021-06-24 22:24:22 +03:00
self.select_path(path)
break
def select_all(self):
2022-08-27 14:39:13 +03:00
self.select_path(Gtk.TreePath(0))
2021-06-24 22:24:22 +03:00
def get_path_selected(self):
if self._selected_path is None:
2021-06-24 22:24:22 +03:00
raise ValueError("None selected")
else:
return self._selected_path
def get_item_selected(self):
return self.get_item_at_path(self.get_path_selected())
2021-06-24 22:24:22 +03:00
2022-08-27 14:39:13 +03:00
def scroll_to_selected(self):
self.save_scroll_to_cell(self._selected_path, None, True, 0.25)
2022-08-27 14:39:13 +03:00
def _on_selection_changed(self, *args):
if (treeiter:=self._selection.get_selected()[1]) is not None:
2022-11-23 19:42:42 +03:00
if (path:=self._store.get_path(treeiter)) == self._selected_path:
self.emit("item-reselected")
else:
self._selected_path=path
self.emit("item-selected")
2021-06-24 22:24:22 +03:00
2021-10-23 11:47:34 +03:00
class GenreList(SelectionList):
2020-07-04 14:16:17 +03:00
def __init__(self, client):
2021-06-24 22:24:22 +03:00
super().__init__(_("all genres"))
self._client=client
2020-07-04 14:16:17 +03:00
# connect
2020-09-16 16:08:56 +03:00
self._client.emitter.connect("disconnected", self._on_disconnected)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect_after("connected", self._on_connected)
2021-10-19 16:23:04 +03:00
self._client.emitter.connect("updated_db", self._refresh)
2020-07-04 14:16:17 +03:00
def _refresh(self, *args):
l=self._client.comp_list("genre")
self.set_items(list(zip(l,l)))
2021-06-24 22:24:22 +03:00
self.select_all()
2020-09-16 16:08:56 +03:00
def _on_disconnected(self, *args):
self.set_sensitive(False)
2021-06-24 22:24:22 +03:00
self.clear()
2022-09-20 19:42:37 +03:00
def _on_connected(self, *args):
2020-09-16 16:08:56 +03:00
self._refresh()
self.set_sensitive(True)
2020-07-04 14:16:17 +03:00
2021-10-23 11:47:34 +03:00
class ArtistList(SelectionList):
def __init__(self, client, settings, genre_list):
2021-06-24 22:24:22 +03:00
super().__init__(_("all artists"))
self._client=client
self._settings=settings
2021-10-23 11:47:34 +03:00
self.genre_list=genre_list
2020-03-03 15:41:46 +03:00
2021-06-24 22:24:22 +03:00
# selection
self._selection=self.get_selection()
2020-07-04 13:35:39 +03:00
# connect
self._client.emitter.connect("disconnected", self._on_disconnected)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect("connected", self._on_connected)
2021-10-23 11:47:34 +03:00
self.genre_list.connect_after("item-selected", self._refresh)
2020-09-16 16:08:56 +03:00
def _refresh(self, *args):
genre=self.genre_list.get_item_selected()
2022-02-14 00:15:54 +03:00
if genre is None:
artists=self._client.list("albumartistsort", "group", "albumartist")
else:
artists=self._client.list("albumartistsort", "genre", genre, "group", "albumartist")
filtered_artists=[]
for name, artist in itertools.groupby(((artist["albumartist"], artist["albumartistsort"]) for artist in artists), key=lambda x: x[0]):
filtered_artists.append(next(artist))
# ignore multiple albumartistsort values
if next(artist, None) is not None:
filtered_artists[-1]=(name, name)
self.set_items(filtered_artists)
if genre is not None:
2021-06-24 22:24:22 +03:00
self.select_all()
2022-03-23 19:23:56 +03:00
elif (song:=self._client.currentsong()):
artist=song["albumartist"][0]
self.select(artist)
elif self.length() > 0:
self.select_path(Gtk.TreePath(1))
else:
2022-03-23 19:23:56 +03:00
self.select_path(Gtk.TreePath(0))
self.scroll_to_selected()
2020-09-16 16:08:56 +03:00
def get_artist_at_path(self, path):
genre=self.genre_list.get_item_selected()
artist=self.get_item_at_path(path)
return (artist, genre)
def get_artist_selected(self):
return self.get_artist_at_path(self.get_path_selected())
2020-09-16 16:08:56 +03:00
def _on_disconnected(self, *args):
self.set_sensitive(False)
2021-06-24 22:24:22 +03:00
self.clear()
2020-09-16 16:08:56 +03:00
2022-09-20 19:42:37 +03:00
def _on_connected(self, *args):
2020-09-16 16:08:56 +03:00
self.set_sensitive(True)
class AlbumLoadingThread(threading.Thread):
def __init__(self, client, settings, progress_bar, iconview, store, artist, genre):
super().__init__(daemon=True)
self._client=client
self._settings=settings
self._progress_bar=progress_bar
self._iconview=iconview
self._store=store
self._artist=artist
self._genre=genre
def _get_albums(self):
2022-10-01 18:06:00 +03:00
@main_thread_function
def client_list(*args):
if self._stop_flag:
raise ValueError("Stop requested")
else:
return self._client.list(*args)
2022-02-14 00:15:54 +03:00
for albumartist in self._artists:
2022-10-01 18:06:00 +03:00
try:
albums=client_list("albumsort", "albumartist", albumartist, *self._genre_filter, "group", "date", "group", "album")
except ValueError:
break
2022-02-14 00:15:54 +03:00
for _, album in itertools.groupby(albums, key=lambda x: (x["album"], x["date"])):
tmp=next(album)
# ignore multiple albumsort values
if next(album, None) is None:
yield (albumartist, tmp["album"], tmp["date"], tmp["albumsort"])
else:
yield (albumartist, tmp["album"], tmp["date"], tmp["album"])
def set_callback(self, callback):
self._callback=callback
def stop(self):
self._stop_flag=True
def start(self):
2021-09-23 18:14:55 +03:00
self._settings.set_property("cursor-watch", True)
self._progress_bar.show()
self._callback=None
self._stop_flag=False
self._iconview.set_model(None)
self._store.clear()
self._cover_size=self._settings.get_int("album-cover")
if self._artist is None:
self._iconview.set_markup_column(2) # show artist names
else:
self._iconview.set_markup_column(1) # hide artist names
if self._genre is None:
self._genre_filter=()
else:
self._genre_filter=("genre", self._genre)
if self._artist is None:
2022-02-14 01:06:03 +03:00
self._artists=self._client.comp_list("albumartist", *self._genre_filter)
else:
self._artists=[self._artist]
super().start()
def run(self):
# temporarily display all albums with fallback cover
fallback_cover=GdkPixbuf.Pixbuf.new_from_file_at_size(FALLBACK_COVER, self._cover_size, self._cover_size)
add=main_thread_function(self._store.append)
2022-02-14 00:15:54 +03:00
for i, (albumartist, album, date, albumsort) in enumerate(self._get_albums()):
# album label
2022-02-14 00:15:54 +03:00
if date:
display_label=f"<b>{GLib.markup_escape_text(album)}</b> ({GLib.markup_escape_text(date)})"
else:
2022-02-14 00:15:54 +03:00
display_label=f"<b>{GLib.markup_escape_text(album)}</b>"
display_label_artist=f"{display_label}\n{GLib.markup_escape_text(albumartist)}"
# add album
2022-02-14 00:15:54 +03:00
add([fallback_cover, display_label, display_label_artist, albumartist, album, date, albumsort])
if i%10 == 0:
if self._stop_flag:
self._exit()
return
idle_add(self._progress_bar.pulse)
2022-10-01 18:06:00 +03:00
if self._stop_flag:
self._exit()
return
2021-09-23 18:14:55 +03:00
# sort model
if main_thread_function(self._settings.get_boolean)("sort-albums-by-year"):
2022-02-14 00:15:54 +03:00
main_thread_function(self._store.set_sort_column_id)(5, Gtk.SortType.ASCENDING)
else:
main_thread_function(self._store.set_sort_column_id)(6, Gtk.SortType.ASCENDING)
idle_add(self._iconview.set_model, self._store)
# select album
2022-10-01 18:06:00 +03:00
@main_thread_function
def get_current_album_path():
if self._stop_flag:
raise ValueError("Stop requested")
else:
return self._iconview.get_current_album_path()
try:
path=get_current_album_path()
except ValueError:
self._exit()
return
if path is None:
path=Gtk.TreePath(0)
idle_add(self._iconview.set_cursor, path, None, False)
idle_add(self._iconview.select_path, path)
2022-09-03 11:38:24 +03:00
idle_add(self._iconview.scroll_to_path, path, True, 0.25, 0)
# load covers
total=2*len(self._store)
@main_thread_function
def get_cover(row):
if self._stop_flag:
2022-03-20 15:26:45 +03:00
raise ValueError("Stop requested")
else:
self._client.restrict_tagtypes("albumartist", "album")
2022-02-14 00:15:54 +03:00
song=self._client.find("albumartist", row[3], "album", row[4], "date", row[5], "window", "0:1")[0]
self._client.tagtypes("all")
return self._client.get_cover(song)
covers=[]
for i, row in enumerate(self._store):
2022-03-20 15:26:45 +03:00
try:
cover=get_cover(row)
except ValueError:
self._exit()
return
covers.append(cover)
idle_add(self._progress_bar.set_fraction, (i+1)/total)
treeiter=self._store.get_iter_first()
i=0
def set_cover(treeiter, cover):
if self._store.iter_is_valid(treeiter):
self._store.set_value(treeiter, 0, cover)
while treeiter is not None:
if self._stop_flag:
self._exit()
return
2022-03-20 15:26:45 +03:00
if covers[i] is not None:
cover=covers[i].get_pixbuf(self._cover_size)
idle_add(set_cover, treeiter, cover)
idle_add(self._progress_bar.set_fraction, 0.5+(i+1)/total)
i+=1
treeiter=self._store.iter_next(treeiter)
self._exit()
def _exit(self):
2021-09-23 18:14:55 +03:00
def callback():
self._settings.set_property("cursor-watch", False)
self._progress_bar.hide()
self._progress_bar.set_fraction(0)
if self._callback is not None:
self._callback()
return False
idle_add(callback)
2021-10-30 22:49:01 +03:00
class AlbumList(Gtk.IconView):
2022-11-23 20:18:56 +03:00
__gsignals__={"album-selected": (GObject.SignalFlags.RUN_FIRST, None, (str,str,str,))}
2021-10-23 11:47:34 +03:00
def __init__(self, client, settings, artist_list):
super().__init__(item_width=0,pixbuf_column=0,markup_column=1,activate_on_single_click=True,selection_mode=Gtk.SelectionMode.BROWSE)
2020-09-16 16:08:56 +03:00
self._settings=settings
self._client=client
2021-10-23 11:47:34 +03:00
self._artist_list=artist_list
2020-09-16 16:08:56 +03:00
2022-02-14 00:15:54 +03:00
# cover, display_label, display_label_artist, albumartist, album, date, albumsort
self._store=Gtk.ListStore(GdkPixbuf.Pixbuf, str, str, str, str, str, str)
self._store.set_default_sort_func(lambda *args: 0)
2021-10-30 22:49:01 +03:00
self.set_model(self._store)
2020-09-16 16:08:56 +03:00
# progress bar
2022-03-07 18:17:47 +03:00
self.progress_bar=Gtk.ProgressBar(no_show_all=True, valign=Gtk.Align.END, vexpand=False)
self.progress_bar.get_style_context().add_class("osd")
# cover thread
2021-10-30 22:49:01 +03:00
self._cover_thread=AlbumLoadingThread(self._client, self._settings, self.progress_bar, self, self._store, None, None)
2020-09-16 16:08:56 +03:00
# connect
2021-10-30 22:49:01 +03:00
self.connect("item-activated", self._on_item_activated)
2020-09-16 16:08:56 +03:00
self._client.emitter.connect("disconnected", self._on_disconnected)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect("connected", self._on_connected)
2020-09-16 16:08:56 +03:00
self._settings.connect("changed::sort-albums-by-year", self._sort_settings)
self._settings.connect("changed::album-cover", self._on_cover_size_changed)
2021-10-23 11:47:34 +03:00
self._artist_list.connect("item-selected", self._refresh)
self._artist_list.connect("clear", self._clear)
2020-09-16 16:08:56 +03:00
def _workaround_clear(self):
self._store.clear()
# workaround (scrollbar still visible after clear)
2021-10-30 22:49:01 +03:00
self.set_model(None)
self.set_model(self._store)
2020-09-16 16:08:56 +03:00
def _clear(self, *args):
def callback():
2020-09-16 16:08:56 +03:00
self._workaround_clear()
if self._cover_thread.is_alive():
self._cover_thread.set_callback(callback)
self._cover_thread.stop()
else:
callback()
2020-09-16 16:08:56 +03:00
def get_current_album_path(self):
2022-09-03 12:15:34 +03:00
if (song:=self._client.currentsong()):
album=[song["albumartist"][0], song["album"][0], song["date"][0]]
2020-09-16 16:08:56 +03:00
row_num=len(self._store)
for i in range(0, row_num):
path=Gtk.TreePath(i)
if self._store[path][3:6] == album:
return path
return None
else:
return None
def scroll_to_current_album(self):
def callback():
if (path:=self.get_current_album_path()) is not None:
self.set_cursor(path, None, False)
self.select_path(path)
2022-09-03 11:38:24 +03:00
self.scroll_to_path(path, True, 0.25, 0)
if self._cover_thread.is_alive():
self._cover_thread.set_callback(callback)
else:
2020-09-16 16:08:56 +03:00
callback()
def _sort_settings(self, *args):
if not self._cover_thread.is_alive():
if self._settings.get_boolean("sort-albums-by-year"):
2022-02-14 00:15:54 +03:00
self._store.set_sort_column_id(5, Gtk.SortType.ASCENDING)
else:
self._store.set_sort_column_id(6, Gtk.SortType.ASCENDING)
2020-09-16 16:08:56 +03:00
def _refresh(self, *args):
def callback():
if self._cover_thread.is_alive(): # already started?
return False
artist,genre=self._artist_list.get_artist_selected()
2021-10-30 22:49:01 +03:00
self._cover_thread=AlbumLoadingThread(self._client,self._settings,self.progress_bar,self,self._store,artist,genre)
self._cover_thread.start()
if self._cover_thread.is_alive():
self._cover_thread.set_callback(callback)
self._cover_thread.stop()
else:
callback()
2020-09-16 16:08:56 +03:00
2022-11-23 20:18:56 +03:00
def _path_to_playlist(self, path, mode):
2022-02-14 00:15:54 +03:00
tags=self._store[path][3:6]
self._client.album_to_playlist(*tags, mode)
2020-09-16 16:08:56 +03:00
def _on_item_activated(self, widget, path):
2022-11-23 20:18:56 +03:00
tags=self._store[path][3:6]
self.emit("album-selected", *tags)
2020-03-03 15:41:46 +03:00
def _on_disconnected(self, *args):
2021-10-30 22:49:01 +03:00
self.set_sensitive(False)
2022-09-20 19:42:37 +03:00
def _on_connected(self, *args):
2021-10-30 22:49:01 +03:00
self.set_sensitive(True)
2020-09-16 16:08:56 +03:00
def _on_cover_size_changed(self, *args):
if self._client.connected():
2020-09-16 16:08:56 +03:00
self._refresh()
2020-09-15 19:45:30 +03:00
2022-11-23 19:42:42 +03:00
class AlbumView(Gtk.Box):
__gsignals__={"close": (GObject.SignalFlags.RUN_FIRST, None, ())}
def __init__(self, client, settings):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self._client=client
self._settings=settings
# songs list
self.songs_list=SongsList(self._client)
self.songs_list.set_enable_search(True)
self.songs_list.buttons.set_halign(Gtk.Align.END)
scroll=Gtk.ScrolledWindow(child=self.songs_list)
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
# cover
self._cover=Gtk.Image()
size=self._settings.get_int("album-cover")*1.5
pixbuf=GdkPixbuf.Pixbuf.new_from_file_at_size(FALLBACK_COVER, size, size)
self._cover.set_from_pixbuf(pixbuf)
# labels
self._title=Gtk.Label(margin_start=12, margin_end=12, xalign=0)
self._title.set_line_wrap(True) # wrap=True is not working
self._duration=Gtk.Label(xalign=1, ellipsize=Pango.EllipsizeMode.END)
2022-12-24 00:53:51 +03:00
# event box
event_box=Gtk.EventBox()
2022-11-23 19:42:42 +03:00
# connect
self.connect("hide", lambda *args: print("test"))
event_box.connect("button-release-event", self._on_button_release_event)
2022-11-23 19:42:42 +03:00
# packing
2022-12-24 00:53:51 +03:00
event_box.add(self._cover)
2022-11-23 19:42:42 +03:00
hbox=Gtk.Box(spacing=12)
hbox.pack_end(self.songs_list.buttons, False, False, 0)
hbox.pack_end(self._duration, False, False, 0)
vbox=Gtk.Box(orientation=Gtk.Orientation.VERTICAL, border_width=6)
vbox.set_center_widget(self._title)
vbox.pack_end(hbox, False, False, 0)
header=Gtk.Box()
2022-12-24 00:53:51 +03:00
header.pack_start(event_box, False, False, 0)
2022-11-23 19:42:42 +03:00
header.pack_start(Gtk.Separator(), False, False, 0)
header.pack_start(vbox, True, True, 0)
self.pack_start(header, False, False, 0)
self.pack_start(Gtk.Separator(), False, False, 0)
self.pack_start(scroll, True, True, 0)
def display(self, albumartist, album, date):
if date:
self._title.set_markup(f"<b>{GLib.markup_escape_text(album)}</b> ({GLib.markup_escape_text(date)})\n"
f"{GLib.markup_escape_text(albumartist)}")
else:
self._title.set_markup(f"<b>{GLib.markup_escape_text(album)}</b>\n{GLib.markup_escape_text(albumartist)}")
self.songs_list.clear()
tag_filter=("albumartist", albumartist, "album", album, "date", date)
count=self._client.count(*tag_filter)
duration=str(Duration(count["playtime"]))
length=int(count["songs"])
text=ngettext("{number} song ({duration})", "{number} songs ({duration})", length).format(number=length, duration=duration)
self._duration.set_text(text)
self._client.restrict_tagtypes("track", "title", "artist")
songs=self._client.find(*tag_filter)
self._client.tagtypes("all")
for song in songs:
# only show artists =/= albumartist
try:
song["artist"].remove(albumartist)
except ValueError:
pass
artist=str(song['artist'])
if artist == albumartist or not artist:
title_artist=f"<b>{GLib.markup_escape_text(song['title'][0])}</b>"
else:
title_artist=f"<b>{GLib.markup_escape_text(song['title'][0])}</b> • {GLib.markup_escape_text(artist)}"
self.songs_list.append(song["track"][0], title_artist, str(song["duration"]), song["file"], song["title"][0])
self.songs_list.save_set_cursor(Gtk.TreePath(0), None, False)
2022-11-23 19:42:42 +03:00
self.songs_list.columns_autosize()
if (cover:=self._client.get_cover({"file": songs[0]["file"], "albumartist": albumartist, "album": album})) is None:
size=self._settings.get_int("album-cover")*1.5
pixbuf=GdkPixbuf.Pixbuf.new_from_file_at_size(FALLBACK_COVER, size, size)
self._cover.set_from_pixbuf(pixbuf)
else:
size=self._settings.get_int("album-cover")*1.5
self._cover.set_from_pixbuf(cover.get_pixbuf(size))
def _on_button_release_event(self, widget, event):
if event.button == 1:
if 0 <= event.x <= widget.get_allocated_width() and 0 <= event.y <= widget.get_allocated_height():
self.emit("close")
2022-12-24 00:53:51 +03:00
2021-10-03 19:15:41 +03:00
class Browser(Gtk.Paned):
def __init__(self, client, settings):
2021-10-06 19:03:14 +03:00
super().__init__()
self._client=client
self._settings=settings
2020-01-11 13:25:15 +03:00
2020-09-16 16:08:56 +03:00
# widgets
2021-10-23 11:47:34 +03:00
self._genre_list=GenreList(self._client)
self._artist_list=ArtistList(self._client, self._settings, self._genre_list)
2021-10-30 22:49:01 +03:00
self._album_list=AlbumList(self._client, self._settings, self._artist_list)
2021-10-23 11:47:34 +03:00
genre_window=Gtk.ScrolledWindow(child=self._genre_list)
artist_window=Gtk.ScrolledWindow(child=self._artist_list)
2021-10-30 22:49:01 +03:00
album_window=Gtk.ScrolledWindow(child=self._album_list)
2022-11-23 19:42:42 +03:00
self._album_view=AlbumView(self._client, self._settings)
# album overlay
album_overlay=Gtk.Overlay(child=album_window)
album_overlay.add_overlay(self._album_list.progress_bar)
# album stack
self._album_stack=Gtk.Stack(transition_type=Gtk.StackTransitionType.SLIDE_LEFT_RIGHT, homogeneous=False)
self._album_stack.add_named(album_overlay, "album_list")
self._album_stack.add_named(self._album_view, "album_view")
2020-01-27 22:27:35 +03:00
2021-10-06 19:03:14 +03:00
# hide/show genre filter
2021-10-23 11:47:34 +03:00
self._genre_list.set_property("visible", True)
self._settings.bind("genre-filter", genre_window, "no-show-all", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
self._settings.bind("genre-filter", genre_window, "visible", Gio.SettingsBindFlags.GET)
2021-10-06 19:03:14 +03:00
self._settings.connect("changed::genre-filter", self._on_genre_filter_changed)
2022-11-23 19:42:42 +03:00
# connect
2022-11-23 20:18:56 +03:00
self._album_list.connect("album-selected", self._on_album_list_show_info)
2022-11-23 19:42:42 +03:00
self._album_view.connect("close", lambda *args: self._album_stack.set_visible_child_name("album_list"))
self._artist_list.connect("item-selected", lambda *args: self._album_stack.set_visible_child_name("album_list"))
self._artist_list.connect("item-reselected", lambda *args: self._album_stack.set_visible_child_name("album_list"))
self._client.emitter.connect("disconnected", lambda *args: self._album_stack.set_visible_child_name("album_list"))
self._settings.connect("changed::album-cover", lambda *args: self._album_stack.set_visible_child_name("album_list"))
2020-07-04 13:35:39 +03:00
# packing
2021-10-06 19:03:14 +03:00
self.paned1=Gtk.Paned()
2021-10-23 11:47:34 +03:00
self.paned1.pack1(artist_window, False, False)
2022-11-23 19:42:42 +03:00
self.paned1.pack2(self._album_stack, True, False)
2021-10-23 11:47:34 +03:00
self.pack1(genre_window, False, False)
2021-10-06 19:03:14 +03:00
self.pack2(self.paned1, True, False)
2021-10-03 19:15:41 +03:00
2022-12-24 00:53:51 +03:00
def back(self):
if self._album_stack.get_visible_child_name() == "album_view":
self._album_stack.set_visible_child_name("album_list")
else:
if (song:=self._client.currentsong()):
self._to_album(song)
def _to_album(self, song):
2022-04-16 22:21:50 +03:00
artist,genre=self._artist_list.get_artist_selected()
2022-08-27 14:39:13 +03:00
if genre is None or song["genre"][0] == genre:
if artist is None or song["albumartist"][0] == artist:
2022-09-03 11:38:24 +03:00
self._album_list.scroll_to_current_album()
else:
self._artist_list.select(song["albumartist"][0])
self._artist_list.scroll_to_selected()
2022-08-27 14:39:13 +03:00
else:
self._genre_list.select_all()
self._genre_list.scroll_to_selected()
2020-08-21 19:47:17 +03:00
2021-10-06 19:03:14 +03:00
def _on_genre_filter_changed(self, settings, key):
if self._client.connected():
if not settings.get_boolean(key):
self._genre_list.select_all()
2020-08-09 23:58:48 +03:00
2022-11-23 19:42:42 +03:00
def _on_album_list_show_info(self, widget, *tags):
self._album_view.display(*tags)
self._album_stack.set_visible_child_name("album_view")
GLib.idle_add(self._album_view.songs_list.grab_focus)
2021-10-04 18:35:51 +03:00
############
# playlist #
############
2020-03-24 18:14:01 +03:00
2021-11-07 19:15:45 +03:00
class PlaylistView(TreeView):
2022-04-23 13:36:51 +03:00
selected_path=GObject.Property(type=Gtk.TreePath, default=None) # currently marked song
2020-09-16 16:08:56 +03:00
def __init__(self, client, settings):
super().__init__(activate_on_single_click=True, reorderable=True, search_column=4, headers_visible=False)
self._client=client
self._settings=settings
2020-09-16 16:08:56 +03:00
self._playlist_version=None
self._inserted_path=None # needed for drag and drop
2022-04-23 13:36:51 +03:00
# selection
2021-10-30 22:49:01 +03:00
self._selection=self.get_selection()
2022-04-23 13:36:51 +03:00
self._selection.set_select_function(self._select_function)
2020-03-04 18:39:59 +03:00
2021-10-30 22:49:01 +03:00
# store
# (track, title, duration, file, search)
self._store=Gtk.ListStore(str, str, str, str, str)
2021-10-30 22:49:01 +03:00
self.set_model(self._store)
2020-03-24 18:14:01 +03:00
2020-10-13 18:12:54 +03:00
# columns
2020-09-16 16:08:56 +03:00
renderer_text=Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END, ellipsize_set=True)
2022-01-17 00:45:03 +03:00
attrs=Pango.AttrList()
attrs.insert(Pango.AttrFontFeatures.new("tnum 1"))
2022-02-20 01:42:35 +03:00
renderer_text_ralign_tnum=Gtk.CellRendererText(xalign=1, attributes=attrs)
2022-01-31 01:56:33 +03:00
renderer_text_centered_tnum=Gtk.CellRendererText(xalign=0.5, attributes=attrs)
columns=(
2022-04-23 13:36:51 +03:00
Gtk.TreeViewColumn(_("No"), renderer_text_centered_tnum, text=0),
Gtk.TreeViewColumn(_("Title"), renderer_text, markup=1),
Gtk.TreeViewColumn(_("Length"), renderer_text_ralign_tnum, text=2)
2020-10-13 18:12:54 +03:00
)
2022-01-31 01:56:33 +03:00
for column in columns:
column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
2022-01-31 01:56:33 +03:00
column.set_property("resizable", False)
self.append_column(column)
self._column_title=columns[1]
self._column_title.set_property("expand", True)
2020-08-21 19:03:36 +03:00
2022-11-28 21:12:34 +03:00
# menu
action_group=Gio.SimpleActionGroup()
action=Gio.SimpleAction.new("remove", None)
action.connect("activate", lambda *args: self._store.remove(self._store.get_iter(self.get_cursor()[0])))
action_group.add_action(action)
2022-11-29 01:10:45 +03:00
self._show_action=Gio.SimpleAction.new("show", None)
self._show_action.connect("activate", lambda *args: self._client.show_in_file_manager(self._store[self.get_cursor()[0]][3]))
action_group.add_action(self._show_action)
2022-11-28 21:12:34 +03:00
self.insert_action_group("menu", action_group)
2022-11-29 01:10:45 +03:00
menu=Gio.Menu()
2022-12-02 20:08:28 +03:00
menu.append(_("Remove"), "menu.remove")
menu.append(_("Show"), "menu.show")
current_song_section=Gio.Menu()
current_song_section.append(_("Enqueue Album"), "mpd.enqueue")
current_song_section.append(_("Tidy"), "mpd.tidy")
2022-11-29 01:10:45 +03:00
subsection=Gio.Menu()
2022-12-02 20:08:28 +03:00
subsection.append(_("Clear"), "mpd.clear")
menu.append_section(None, current_song_section)
2022-11-29 01:10:45 +03:00
menu.append_section(None, subsection)
self._menu=Gtk.Popover.new_from_model(self, menu)
2022-11-29 01:19:58 +03:00
self._menu.set_position(Gtk.PositionType.BOTTOM)
2021-03-24 19:35:05 +03:00
2020-09-16 16:08:56 +03:00
# connect
2021-10-30 22:49:01 +03:00
self.connect("row-activated", self._on_row_activated)
self.connect("button-press-event", self._on_button_press_event)
2022-11-28 23:34:59 +03:00
self.connect("key-press-event", self._on_key_press_event)
self._row_deleted=self._store.connect("row-deleted", self._on_row_deleted)
self._row_inserted=self._store.connect("row-inserted", self._on_row_inserted)
2021-10-30 22:58:35 +03:00
self._client.emitter.connect("playlist", self._on_playlist_changed)
self._client.emitter.connect("current_song", self._on_song_changed)
2020-09-16 16:08:56 +03:00
self._client.emitter.connect("disconnected", self._on_disconnected)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect("connected", self._on_connected)
2020-01-11 13:25:15 +03:00
2022-11-29 01:12:16 +03:00
def scroll_to_selected_title(self):
if (path:=self.get_property("selected-path")) is not None:
self._scroll_to_path(path)
2022-11-29 01:10:45 +03:00
def _open_menu(self, uri, x, y):
rect=Gdk.Rectangle()
rect.x,rect.y=x,y
self._menu.set_pointing_to(rect)
2023-02-26 13:53:32 +03:00
self._show_action.set_enabled(self._client.can_show_in_file_manager(uri))
2022-11-29 01:10:45 +03:00
self._menu.popup()
2020-09-16 16:08:56 +03:00
def _clear(self, *args):
2022-11-28 21:12:34 +03:00
self._menu.popdown()
2020-09-16 16:08:56 +03:00
self._playlist_version=None
2021-07-16 20:39:48 +03:00
self.set_property("selected-path", None)
2020-10-25 13:43:02 +03:00
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)
2020-01-11 13:25:15 +03:00
2021-06-27 20:08:32 +03:00
def _select(self, path):
self._unselect()
try:
2021-07-16 20:39:48 +03:00
self.set_property("selected-path", path)
2022-04-23 13:36:51 +03:00
self._selection.select_path(path)
2021-06-27 20:08:32 +03:00
except IndexError: # invalid path
pass
def _unselect(self):
2022-04-23 13:36:51 +03:00
if (path:=self.get_property("selected-path")) is not None:
self.set_property("selected-path", None)
2021-06-27 20:08:32 +03:00
try:
2022-04-23 13:36:51 +03:00
self._selection.unselect_path(path)
2021-06-27 20:08:32 +03:00
except IndexError: # invalid path
2022-04-23 13:36:51 +03:00
pass
2020-01-11 13:25:15 +03:00
2022-11-23 20:18:56 +03:00
def _delete(self, path):
if path == self.get_property("selected-path"):
self._client.files_to_playlist([self._store[path][3]], "enqueue")
else:
self._store.remove(self._store.get_iter(path))
2022-10-21 18:53:11 +03:00
def _scroll_to_path(self, path):
self.set_cursor(path, None, False)
2022-10-21 18:53:11 +03:00
self.save_scroll_to_cell(path, None, True, 0.25)
def _refresh_selection(self):
2021-06-27 20:08:32 +03:00
song=self._client.status().get("song")
if song is None:
self._unselect()
else:
2020-09-16 16:08:56 +03:00
path=Gtk.TreePath(int(song))
2021-06-27 20:08:32 +03:00
self._select(path)
2020-01-11 13:25:15 +03:00
def _on_button_press_event(self, widget, event):
2022-03-23 19:23:56 +03:00
if (path_re:=widget.get_path_at_pos(int(event.x), int(event.y))) is not None:
path=path_re[0]
2021-03-26 21:19:34 +03:00
if event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS:
2022-11-23 20:18:56 +03:00
self._delete(path)
2021-03-26 21:19:34 +03:00
elif event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS:
2021-11-07 21:54:39 +03:00
point=self.convert_bin_window_to_widget_coords(event.x,event.y)
2022-11-29 01:10:45 +03:00
self._open_menu(self._store[path][3], *point)
2022-11-28 23:34:59 +03:00
def _on_key_press_event(self, widget, event):
2020-09-17 13:52:07 +03:00
if event.keyval == Gdk.keyval_from_name("Delete"):
2022-04-23 13:36:51 +03:00
if (path:=self.get_cursor()[0]) is not None:
2022-11-28 23:34:59 +03:00
self._delete(path)
elif event.keyval == Gdk.keyval_from_name("Menu"):
if (path:=self.get_cursor()[0]) is not None:
2022-11-29 01:10:45 +03:00
self._open_menu(self._store[path][3], *self.get_popover_point(path))
2020-01-11 13:25:15 +03:00
def _on_row_deleted(self, model, path): # sync treeview to mpd
2020-12-22 14:33:21 +03:00
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)
2020-12-22 14:33:21 +03:00
self._inserted_path=None
else: # delete
self._client.delete(path) # bad song index possible
self._playlist_version=int(self._client.status()["playlist"])
2022-03-17 01:03:58 +03:00
except CommandError as e:
2020-12-22 14:33:21 +03:00
self._playlist_version=None
2021-10-30 22:58:35 +03:00
self._client.emitter.emit("playlist", int(self._client.status()["playlist"]))
2020-12-22 14:33:21 +03:00
raise e # propagate exception
def _on_row_inserted(self, model, path, treeiter):
self._inserted_path=int(path.to_string())
2020-09-16 16:08:56 +03:00
def _on_row_activated(self, widget, path, view_column):
self._client.play(path)
2020-01-11 13:25:15 +03:00
2020-09-16 16:08:56 +03:00
def _on_playlist_changed(self, emitter, version):
self._store.handler_block(self._row_inserted)
self._store.handler_block(self._row_deleted)
2022-11-28 21:12:34 +03:00
self._menu.popdown()
2021-06-27 20:08:32 +03:00
self._unselect()
self._client.restrict_tagtypes("track", "title", "artist", "album", "date")
2020-09-16 16:08:56 +03:00
songs=[]
if self._playlist_version is not None:
songs=self._client.plchanges(self._playlist_version)
2020-09-16 16:08:56 +03:00
else:
songs=self._client.playlistinfo()
2021-08-03 21:05:07 +03:00
self._client.tagtypes("all")
2021-08-04 15:03:33 +03:00
if songs:
2021-10-30 22:49:01 +03:00
self.freeze_child_notify()
2021-08-04 15:03:33 +03:00
for song in songs:
2022-02-01 21:51:07 +03:00
title=song.get_markup()
2020-09-16 16:08:56 +03:00
try:
treeiter=self._store.get_iter(song["pos"])
2022-08-08 19:25:23 +03:00
except ValueError:
self._store.insert_with_valuesv(-1, range(5),
[song["track"][0], title, str(song["duration"]), song["file"], song["title"][0]]
)
2022-08-08 19:25:23 +03:00
else:
2020-09-16 16:08:56 +03:00
self._store.set(treeiter,
0, song["track"][0], 1, title, 2, str(song["duration"]), 3, song["file"], 4, song["title"][0]
2020-09-16 16:08:56 +03:00
)
2021-10-30 22:49:01 +03:00
self.thaw_child_notify()
for i in reversed(range(int(self._client.status()["playlistlength"]), len(self._store))):
2020-09-16 16:08:56 +03:00
treeiter=self._store.get_iter(i)
self._store.remove(treeiter)
2021-06-27 20:08:32 +03:00
self._refresh_selection()
2022-10-21 18:53:11 +03:00
if (path:=self.get_property("selected-path")) is None:
if len(self._store) > 0:
self._scroll_to_path(Gtk.TreePath(0))
else:
self._scroll_to_path(path)
2020-09-16 16:08:56 +03:00
self._playlist_version=version
self._store.handler_unblock(self._row_inserted)
self._store.handler_unblock(self._row_deleted)
2020-06-27 17:11:41 +03:00
2020-09-16 16:08:56 +03:00
def _on_song_changed(self, *args):
self._refresh_selection()
if self._client.status()["state"] == "play":
2022-10-21 18:53:11 +03:00
self._scroll_to_path(self.get_property("selected-path"))
2020-01-11 13:25:15 +03:00
2020-09-16 16:08:56 +03:00
def _on_disconnected(self, *args):
2021-10-30 22:49:01 +03:00
self.set_sensitive(False)
2020-10-25 13:43:02 +03:00
self._clear()
2020-01-11 13:25:15 +03:00
2022-09-20 19:42:37 +03:00
def _on_connected(self, *args):
2021-10-30 22:49:01 +03:00
self.set_sensitive(True)
2020-09-16 16:08:56 +03:00
2022-04-23 13:36:51 +03:00
def _select_function(self, selection, model, path, path_currently_selected):
return (path == self.get_property("selected-path")) == (not path_currently_selected)
2021-10-30 22:49:01 +03:00
class PlaylistWindow(Gtk.Overlay):
def __init__(self, client, settings):
super().__init__()
self._back_button_icon=Gtk.Image.new_from_icon_name("go-down-symbolic", Gtk.IconSize.BUTTON)
self._back_to_current_song_button=Gtk.Button(image=self._back_button_icon, tooltip_text=_("Scroll to current song"), can_focus=False)
2021-10-30 22:49:01 +03:00
self._back_to_current_song_button.get_style_context().add_class("osd")
self._back_button_revealer=Gtk.Revealer(
child=self._back_to_current_song_button, transition_duration=0,
margin_bottom=6, margin_top=6, halign=Gtk.Align.CENTER, valign=Gtk.Align.END
2021-10-30 22:49:01 +03:00
)
self._treeview=PlaylistView(client, settings)
scroll=Gtk.ScrolledWindow(child=self._treeview)
2022-02-17 11:45:02 +03:00
2021-10-30 22:49:01 +03:00
# connect
self._back_to_current_song_button.connect("clicked", self._on_back_to_current_song_button_clicked)
scroll.get_vadjustment().connect("value-changed", self._on_show_hide_back_button)
self._treeview.connect("notify::selected-path", self._on_show_hide_back_button)
2021-12-29 14:56:24 +03:00
settings.bind("mini-player", self, "no-show-all", Gio.SettingsBindFlags.GET)
settings.bind("mini-player", self, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
2021-10-30 22:49:01 +03:00
# packing
self.add(scroll)
self.add_overlay(self._back_button_revealer)
def _on_show_hide_back_button(self, *args):
def callback():
visible_range=self._treeview.get_visible_range() # not always accurate possibly due to a bug in Gtk
if visible_range is None or self._treeview.get_property("selected-path") is None:
self._back_button_revealer.set_reveal_child(False)
else:
if visible_range[0] > self._treeview.get_property("selected-path"): # current song is above upper edge
self._back_button_icon.set_property("icon-name", "go-up-symbolic")
self._back_button_revealer.set_valign(Gtk.Align.START)
self._back_button_revealer.set_reveal_child(True)
elif self._treeview.get_property("selected-path") > visible_range[1]: # current song is below lower edge
self._back_button_icon.set_property("icon-name", "go-down-symbolic")
self._back_button_revealer.set_valign(Gtk.Align.END)
self._back_button_revealer.set_reveal_child(True)
else: # current song is visible
self._back_button_revealer.set_reveal_child(False)
GLib.idle_add(callback) # workaround for the Gtk bug from above
2021-10-30 22:49:01 +03:00
def _on_back_to_current_song_button_clicked(self, *args):
self._treeview.scroll_to_selected_title()
2021-03-26 18:39:21 +03:00
2021-10-04 18:35:51 +03:00
####################
# cover and lyrics #
####################
2022-03-15 21:50:23 +03:00
class LetrasParser(HTMLParser):
def __init__(self):
super().__init__()
self._found_text=False
self.text=""
def handle_starttag(self, tag, attrs):
if tag == "div" and ("id", "letra-cnt") in attrs:
self._found_text=True
def handle_endtag(self, tag):
if self._found_text:
if tag == "p":
self.text+="\n"
elif tag == "div":
self._found_text=False
def handle_data(self, data):
if self._found_text and data:
self.text+=data+"\n"
class LyricsWindow(Gtk.ScrolledWindow):
2021-10-04 18:35:51 +03:00
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(
2021-10-23 12:59:18 +03:00
editable=False, cursor_visible=False, wrap_mode=Gtk.WrapMode.WORD,
justification=Gtk.Justification.CENTER,
2021-10-23 12:59:18 +03:00
left_margin=5, right_margin=5, bottom_margin=5, top_margin=3
2021-10-04 18:35:51 +03:00
)
# text buffer
self._text_buffer=self._text_view.get_buffer()
2023-01-23 21:02:00 +03:00
# css zoom
self._scale=100
self._provider=Gtk.CssProvider()
self._text_view.get_style_context().add_provider(self._provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
2021-10-04 18:35:51 +03:00
# connect
2023-01-23 21:02:00 +03:00
self._text_view.connect("scroll-event", self._on_scroll_event)
self._text_view.connect("key-press-event", self._on_key_press_event)
2021-10-04 18:35:51 +03:00
self._client.emitter.connect("disconnected", self._on_disconnected)
2021-10-30 22:58:35 +03:00
self._song_changed=self._client.emitter.connect("current_song", self._refresh)
2021-10-04 18:35:51 +03:00
self._client.emitter.handler_block(self._song_changed)
# packing
self.add(self._text_view)
2021-10-04 18:35:51 +03:00
def enable(self, *args):
2022-03-23 19:23:56 +03:00
if (song:=self._client.currentsong()):
if song["file"] != self._displayed_song_file:
2021-10-04 18:35:51 +03:00
self._refresh()
else:
if self._displayed_song_file is not None:
self._refresh()
self._client.emitter.handler_unblock(self._song_changed)
idle_add(self._text_view.grab_focus) # focus textview
2021-10-04 18:35:51 +03:00
def disable(self, *args):
self._client.emitter.handler_block(self._song_changed)
2023-01-23 21:02:00 +03:00
def _zoom(self, scale):
if 30 <= scale <= 500:
self._provider.load_from_data(bytes(f"textview{{font-size: {scale}%}}", "utf-8"))
self._scale=scale
2021-10-04 18:35:51 +03:00
def _get_lyrics(self, title, artist):
2022-03-16 02:21:24 +03:00
title=urllib.parse.quote_plus(title)
artist=urllib.parse.quote_plus(artist)
2022-03-15 21:50:23 +03:00
parser=LetrasParser()
2022-03-16 02:21:24 +03:00
with urllib.request.urlopen(f"https://www.letras.mus.br/winamp.php?musica={title}&artista={artist}") as response:
2022-03-15 21:50:23 +03:00
parser.feed(response.read().decode("utf-8"))
if not parser.text:
2021-10-04 18:35:51 +03:00
raise ValueError("Not found")
2022-03-15 21:50:23 +03:00
return parser.text.strip("\n ")
2021-10-04 18:35:51 +03:00
2022-03-23 19:23:56 +03:00
def _display_lyrics(self, song):
idle_add(self._text_buffer.set_text, _("searching…"), -1)
2021-10-04 18:35:51 +03:00
try:
2022-03-23 19:23:56 +03:00
text=self._get_lyrics(song["title"][0], song["artist"][0])
2022-03-16 02:21:24 +03:00
except urllib.error.URLError:
2021-10-04 18:35:51 +03:00
self._displayed_song_file=None
text=_("connection error")
except ValueError:
text=_("lyrics not found")
idle_add(self._text_buffer.set_text, text, -1)
2021-10-04 18:35:51 +03:00
def _refresh(self, *args):
2022-03-23 19:23:56 +03:00
if (song:=self._client.currentsong()):
self._displayed_song_file=song["file"]
2021-10-04 18:35:51 +03:00
update_thread=threading.Thread(
target=self._display_lyrics,
2022-03-23 19:23:56 +03:00
kwargs={"song": song},
2021-10-04 18:35:51 +03:00
daemon=True
)
update_thread.start()
else:
self._displayed_song_file=None
self._text_buffer.set_text("", -1)
2023-01-23 21:02:00 +03:00
def _on_scroll_event(self, widget, event):
if event.state & Gdk.ModifierType.CONTROL_MASK:
if event.delta_y < 0:
self._zoom(self._scale+10)
elif event.delta_y > 0:
self._zoom(self._scale-10)
return True
else:
return False
def _on_key_press_event(self, widget, event):
if event.state & Gdk.ModifierType.CONTROL_MASK:
if event.keyval == Gdk.keyval_from_name("plus"):
self._zoom(self._scale+10)
elif event.keyval == Gdk.keyval_from_name("minus"):
self._zoom(self._scale-10)
elif event.keyval == Gdk.keyval_from_name("0"):
self._zoom(100)
2021-10-04 18:35:51 +03:00
def _on_disconnected(self, *args):
self._displayed_song_file=None
self._text_buffer.set_text("", -1)
class CoverEventBox(Gtk.EventBox):
def __init__(self, client, settings):
super().__init__()
self._client=client
self._settings=settings
self._click_pos=()
self.set_events(Gdk.EventMask.POINTER_MOTION_MASK)
2021-10-04 18:35:51 +03:00
# connect
self.connect("button-press-event", self._on_button_press_event)
self.connect("button-release-event", self._on_button_release_event)
self.connect("motion-notify-event", self._on_motion_notify_event)
2021-10-04 18:35:51 +03:00
def _on_button_press_event(self, widget, event):
if event.button == 1 and event.type == Gdk.EventType.BUTTON_PRESS:
self._click_pos=(event.x, event.y)
def _on_button_release_event(self, widget, event):
2022-12-02 20:18:39 +03:00
if event.button == 1 and not self._settings.get_boolean("mini-player") and self._client.connected():
if (song:=self._client.currentsong()):
tags=(song["albumartist"][0], song["album"][0], song["date"][0])
self._client.album_to_playlist(*tags, "enqueue")
self._click_pos=()
def _on_motion_notify_event(self, widget, event):
if self._click_pos:
# gtk-double-click-distance seems to be the right threshold for this
# according to: https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/1839
# I verified this via manipulating gtk-double-click-distance.
pointer_travel=max(abs(self._click_pos[0]-event.x), abs(self._click_pos[1]-event.y))
if pointer_travel > Gtk.Settings.get_default().get_property("gtk-double-click-distance"):
window=self.get_toplevel()
window.begin_move_drag(1, event.x_root, event.y_root, Gdk.CURRENT_TIME)
self._click_pos=()
2021-10-04 18:35:51 +03:00
2022-04-21 18:14:43 +03:00
class MainCover(Gtk.DrawingArea):
2022-11-23 20:23:53 +03:00
def __init__(self, client):
2021-10-04 18:35:51 +03:00
super().__init__()
self._client=client
2022-05-14 15:46:46 +03:00
self._fallback=True
2021-10-04 18:35:51 +03:00
# connect
2021-10-30 22:58:35 +03:00
self._client.emitter.connect("current_song", self._refresh)
2021-10-04 18:35:51 +03:00
self._client.emitter.connect("disconnected", self._on_disconnected)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect("connected", self._on_connected)
2021-10-04 18:35:51 +03:00
def _clear(self):
2022-05-14 15:46:46 +03:00
self._fallback=True
2022-04-21 18:14:43 +03:00
self.queue_draw()
2021-10-04 18:35:51 +03:00
def _refresh(self, *args):
2022-03-20 15:26:45 +03:00
if self._client.current_cover is None:
2021-10-04 18:35:51 +03:00
self._clear()
2022-03-20 15:26:45 +03:00
else:
2022-04-21 18:14:43 +03:00
self._pixbuf=self._client.current_cover.get_pixbuf()
self._surface=Gdk.cairo_surface_create_from_pixbuf(self._pixbuf, 0, None)
2022-05-14 15:46:46 +03:00
self._fallback=False
2022-04-21 18:14:43 +03:00
self.queue_draw()
2021-10-04 18:35:51 +03:00
def _on_disconnected(self, *args):
self.set_sensitive(False)
self._clear()
2022-09-20 19:42:37 +03:00
def _on_connected(self, *args):
2021-10-04 18:35:51 +03:00
self.set_sensitive(True)
2022-04-21 18:14:43 +03:00
def do_draw(self, context):
2022-05-14 15:46:46 +03:00
if self._fallback:
size=min(self.get_allocated_height(), self.get_allocated_width())
self._pixbuf=GdkPixbuf.Pixbuf.new_from_file_at_size(FALLBACK_COVER, size, size)
self._surface=Gdk.cairo_surface_create_from_pixbuf(self._pixbuf, 0, None)
2022-05-14 16:22:19 +03:00
scale_factor=1
2022-05-14 15:46:46 +03:00
else:
2022-05-14 16:22:19 +03:00
scale_factor=min(self.get_allocated_width()/self._pixbuf.get_width(), self.get_allocated_height()/self._pixbuf.get_height())
context.scale(scale_factor, scale_factor)
x=((self.get_allocated_width()/scale_factor)-self._pixbuf.get_width())/2
y=((self.get_allocated_height()/scale_factor)-self._pixbuf.get_height())/2
context.set_source_surface(self._surface, x, y)
2022-04-21 18:14:43 +03:00
context.paint()
2021-10-04 18:35:51 +03:00
class CoverLyricsWindow(Gtk.Overlay):
def __init__(self, client, settings):
2020-09-16 16:08:56 +03:00
super().__init__()
self._client=client
self._settings=settings
2020-09-16 16:08:56 +03:00
# cover
2022-11-23 20:23:53 +03:00
main_cover=MainCover(self._client)
2020-12-06 02:45:25 +03:00
self._cover_event_box=CoverEventBox(self._client, self._settings)
self._cover_event_box.add(Gtk.AspectFrame(child=main_cover, shadow_type=Gtk.ShadowType.NONE))
2020-03-28 15:23:56 +03:00
2020-09-16 16:08:56 +03:00
# lyrics button
self.lyrics_button=Gtk.ToggleButton(
2021-10-23 14:04:25 +03:00
image=Gtk.Image.new_from_icon_name("org.mpdevil.mpdevil-lyrics-symbolic", Gtk.IconSize.BUTTON), tooltip_text=_("Lyrics"),
2021-10-23 13:19:10 +03:00
can_focus=False
2020-09-16 16:08:56 +03:00
)
2021-10-23 10:46:21 +03:00
self.lyrics_button.get_style_context().add_class("osd")
2020-03-28 15:23:56 +03:00
2020-09-16 17:57:58 +03:00
# lyrics window
self._lyrics_window=LyricsWindow(self._client, self._settings)
2020-09-16 16:08:56 +03:00
# revealer
2021-10-23 13:19:10 +03:00
self._lyrics_button_revealer=Gtk.Revealer(
child=self.lyrics_button, transition_duration=0, margin_top=6, margin_end=6, halign=Gtk.Align.END, valign=Gtk.Align.START)
2021-07-17 14:58:19 +03:00
self._settings.bind("show-lyrics-button", self._lyrics_button_revealer, "reveal-child", Gio.SettingsBindFlags.DEFAULT)
2020-01-11 13:25:15 +03:00
2020-09-16 21:56:43 +03:00
# stack
self._stack=Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE)
2020-09-16 21:56:43 +03:00
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)
2020-09-16 16:08:56 +03:00
# connect
self.lyrics_button.connect("toggled", self._on_lyrics_toggled)
2020-09-16 16:08:56 +03:00
self._client.emitter.connect("disconnected", self._on_disconnected)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect("connected", self._on_connected)
2020-03-03 18:11:30 +03:00
# packing
self.add(self._stack)
2021-10-04 18:35:51 +03:00
self.add_overlay(self._lyrics_button_revealer)
2022-09-20 19:42:37 +03:00
def _on_connected(self, *args):
self.lyrics_button.set_sensitive(True)
2020-09-16 16:08:56 +03:00
def _on_disconnected(self, *args):
self.lyrics_button.set_active(False)
self.lyrics_button.set_sensitive(False)
2020-02-16 14:20:38 +03:00
2020-09-16 17:57:58 +03:00
def _on_lyrics_toggled(self, widget):
if widget.get_active():
2020-09-16 21:56:43 +03:00
self._stack.set_visible_child(self._lyrics_window)
2020-09-16 17:57:58 +03:00
self._lyrics_window.enable()
else:
2020-09-16 21:56:43 +03:00
self._stack.set_visible_child(self._cover_event_box)
2020-09-16 17:57:58 +03:00
self._lyrics_window.disable()
2020-09-16 16:08:56 +03:00
2021-07-16 20:39:48 +03:00
######################
# action bar widgets #
######################
2020-07-04 14:16:17 +03:00
2020-08-21 13:40:48 +03:00
class PlaybackControl(Gtk.ButtonBox):
2020-03-30 12:54:04 +03:00
def __init__(self, client, settings):
2020-08-31 16:49:06 +03:00
super().__init__(layout_style=Gtk.ButtonBoxStyle.EXPAND)
self._client=client
self._settings=settings
2020-01-11 13:25:15 +03:00
2020-07-04 13:35:39 +03:00
# widgets
2021-07-17 15:07:04 +03:00
self._play_button_icon=AutoSizedIcon("media-playback-start-symbolic", "icon-size", self._settings)
2022-09-16 18:19:48 +03:00
self._play_button=Gtk.Button(
2022-09-19 21:07:39 +03:00
image=self._play_button_icon, action_name="mpd.toggle-play", tooltip_text=_("Play"), can_focus=False)
2021-10-23 12:59:18 +03:00
self._stop_button=Gtk.Button(
2022-09-16 18:19:48 +03:00
image=AutoSizedIcon("media-playback-stop-symbolic", "icon-size", self._settings), tooltip_text=_("Stop"),
action_name="mpd.stop", can_focus=False, no_show_all=True)
2021-10-23 12:59:18 +03:00
self._prev_button=Gtk.Button(
2022-09-16 18:19:48 +03:00
image=AutoSizedIcon("media-skip-backward-symbolic", "icon-size", self._settings),
tooltip_text=_("Previous title"), action_name="mpd.prev", can_focus=False)
2021-10-23 12:59:18 +03:00
self._next_button=Gtk.Button(
2022-09-16 18:19:48 +03:00
image=AutoSizedIcon("media-skip-forward-symbolic", "icon-size", self._settings),
tooltip_text=_("Next title"), action_name="mpd.next", can_focus=False)
2020-01-11 13:25:15 +03:00
2020-07-04 13:35:39 +03:00
# connect
2021-10-04 19:02:18 +03:00
self._settings.connect("changed::mini-player", self._mini_player)
self._settings.connect("changed::show-stop", self._mini_player)
self._client.emitter.connect("state", self._on_state)
2020-01-11 13:25:15 +03:00
2020-07-04 13:35:39 +03:00
# packing
2021-04-24 12:42:35 +03:00
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)
2021-10-04 19:02:18 +03:00
self._mini_player()
2020-01-11 13:25:15 +03:00
2021-10-04 19:02:18 +03:00
def _mini_player(self, *args):
visibility=(self._settings.get_boolean("show-stop") and not self._settings.get_boolean("mini-player"))
self._stop_button.set_property("visible", visibility)
2020-09-29 13:39:21 +03:00
def _on_state(self, emitter, state):
if state == "play":
2021-07-17 15:07:04 +03:00
self._play_button_icon.set_property("icon-name", "media-playback-pause-symbolic")
2022-09-19 21:07:39 +03:00
self._play_button.set_tooltip_text(_("Pause"))
2020-09-29 13:39:21 +03:00
else:
2021-07-17 15:07:04 +03:00
self._play_button_icon.set_property("icon-name", "media-playback-start-symbolic")
2022-09-19 21:07:39 +03:00
self._play_button.set_tooltip_text(_("Play"))
2020-09-29 13:39:21 +03:00
2020-07-04 14:16:17 +03:00
class SeekBar(Gtk.Box):
2020-01-11 13:25:15 +03:00
def __init__(self, client):
2021-08-16 17:09:48 +03:00
super().__init__(hexpand=True, margin_start=6, margin_right=6)
self._client=client
self._update=True
2022-12-26 22:42:11 +03:00
self._first_mark=None
self._second_mark=None
2020-01-11 13:25:15 +03:00
2020-07-04 14:16:17 +03:00
# labels
2022-01-17 00:45:03 +03:00
attrs=Pango.AttrList()
attrs.insert(Pango.AttrFontFeatures.new("tnum 1"))
self._elapsed=Gtk.Label(xalign=0, attributes=attrs)
self._rest=Gtk.Label(xalign=1, attributes=attrs)
2020-01-11 13:25:15 +03:00
2020-09-18 23:28:51 +03:00
# event boxes
2021-10-23 12:16:07 +03:00
elapsed_event_box=Gtk.EventBox(child=self._elapsed)
rest_event_box=Gtk.EventBox(child=self._rest)
2020-09-18 23:28:51 +03:00
2020-07-04 14:16:17 +03:00
# progress bar
2021-10-23 12:59:18 +03:00
self._scale=Gtk.Scale(
orientation=Gtk.Orientation.HORIZONTAL, show_fill_level=True, restrict_to_fill_level=False, draw_value=False, can_focus=False)
2020-09-29 13:39:21 +03:00
self._scale.set_increments(10, 60)
self._adjustment=self._scale.get_adjustment()
2020-01-11 13:25:15 +03:00
2020-07-04 14:16:17 +03:00
# connect
2022-03-13 21:10:05 +03:00
elapsed_dict={1: Gtk.ScrollType.STEP_BACKWARD, 3: Gtk.ScrollType.STEP_FORWARD}
rest_dict={1: Gtk.ScrollType.STEP_FORWARD, 3: Gtk.ScrollType.STEP_BACKWARD}
elapsed_event_box.connect("button-release-event", self._on_label_button_release_event, elapsed_dict)
2022-12-26 22:42:11 +03:00
elapsed_event_box.connect("button-press-event", self._on_label_button_press_event)
2022-03-13 21:10:05 +03:00
rest_event_box.connect("button-release-event", self._on_label_button_release_event, rest_dict)
2022-12-26 22:42:11 +03:00
rest_event_box.connect("button-press-event", self._on_label_button_press_event)
2020-09-29 13:39:21 +03:00
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)
2022-03-15 14:12:20 +03:00
self._adjustment.connect("notify::value", self._update_labels)
self._adjustment.connect("notify::upper", self._update_labels)
2022-12-26 22:42:11 +03:00
self._adjustment.connect("notify::upper", self._clear_marks)
2020-09-18 23:28:51 +03:00
self._client.emitter.connect("disconnected", self._disable)
self._client.emitter.connect("state", self._on_state)
2021-10-30 22:58:35 +03:00
self._client.emitter.connect("elapsed", self._refresh)
2020-07-04 14:16:17 +03:00
# packing
2021-10-23 12:16:07 +03:00
self.pack_start(elapsed_event_box, False, False, 0)
2020-09-29 13:39:21 +03:00
self.pack_start(self._scale, True, True, 0)
2021-10-23 12:16:07 +03:00
self.pack_end(rest_event_box, False, False, 0)
2020-01-11 13:25:15 +03:00
2020-08-21 19:03:36 +03:00
def _refresh(self, emitter, elapsed, duration):
2020-09-18 23:28:51 +03:00
self.set_sensitive(True)
2021-08-01 14:56:49 +03:00
if duration > 0:
self._adjustment.set_upper(duration)
if self._update:
2022-12-26 22:42:11 +03:00
if self._second_mark is not None:
if elapsed > self._second_mark:
self._client.seekcur(self._first_mark)
return
2021-08-01 14:56:49 +03:00
self._scale.set_value(elapsed)
self._scale.set_fill_level(elapsed)
else:
self._disable()
2021-08-04 15:03:33 +03:00
self._elapsed.set_text(str(Duration(elapsed)))
2020-08-21 19:03:36 +03:00
2022-03-15 14:12:20 +03:00
def _update_labels(self, *args):
duration=self._adjustment.get_upper()
value=self._scale.get_value()
if value > duration: # fix display error
elapsed=duration
else:
elapsed=value
if duration > 0:
self._elapsed.set_text(str(Duration(elapsed)))
self._rest.set_text(str(Duration(duration-elapsed)))
else:
2022-03-15 18:43:23 +03:00
self._elapsed.set_text("")
self._rest.set_text("")
2022-03-15 14:12:20 +03:00
2020-08-21 19:03:36 +03:00
def _disable(self, *args):
self.set_sensitive(False)
2020-09-29 13:39:21 +03:00
self._scale.set_fill_level(0)
self._scale.set_range(0, 0)
2022-12-26 22:42:11 +03:00
self._clear_marks()
def _clear_marks(self, *args):
self._first_mark=None
self._second_mark=None
self._scale.clear_marks()
2020-08-21 19:03:36 +03:00
def _on_scale_button_press_event(self, widget, event):
2022-12-29 20:43:57 +03:00
if (event.button == 1 or event.button == 3) and event.type == Gdk.EventType.BUTTON_PRESS:
self._update=False
2020-06-05 18:43:34 +03:00
def _on_scale_button_release_event(self, widget, event):
2022-12-29 20:43:57 +03:00
if event.button == 1 or event.button == 3:
self._update=True
self._client.seekcur(self._scale.get_value())
2022-03-13 21:10:05 +03:00
def _on_change_value(self, scale, scroll, value):
if scroll in (Gtk.ScrollType.STEP_BACKWARD, Gtk.ScrollType.STEP_FORWARD , Gtk.ScrollType.PAGE_BACKWARD, Gtk.ScrollType.PAGE_FORWARD):
self._client.seekcur(value)
2022-03-13 21:10:05 +03:00
def _on_label_button_release_event(self, widget, event, scroll_type):
if 0 <= event.x <= widget.get_allocated_width() and 0 <= event.y <= widget.get_allocated_height():
self._scale.emit("move-slider", scroll_type.get(event.button, Gtk.ScrollType.NONE))
2022-12-26 22:42:11 +03:00
def _on_label_button_press_event(self, widget, event):
if event.button == 2 and event.type == Gdk.EventType.BUTTON_PRESS:
value=self._scale.get_value()
if self._first_mark is None:
self._first_mark=value
self._scale.add_mark(value, Gtk.PositionType.BOTTOM, None)
elif self._second_mark is None:
if value < self._first_mark:
self._second_mark=self._first_mark
self._first_mark=value
else:
self._second_mark=value
self._scale.add_mark(value, Gtk.PositionType.BOTTOM, None)
else:
self._clear_marks()
def _on_state(self, emitter, state):
if state == "stop":
self._disable()
2020-08-11 20:10:34 +03:00
2021-07-16 21:07:52 +03:00
class AudioFormat(Gtk.Box):
2020-07-04 14:16:17 +03:00
def __init__(self, client, settings):
2022-03-14 15:07:05 +03:00
super().__init__(orientation=Gtk.Orientation.VERTICAL, valign=Gtk.Align.CENTER)
self._client=client
2021-07-16 20:39:48 +03:00
self._settings=settings
2021-10-24 10:37:27 +03:00
self._file_type_label=Gtk.Label(xalign=1, visible=True)
self._separator_label=Gtk.Label(xalign=1, visible=True)
2022-01-17 00:45:03 +03:00
attrs=Pango.AttrList()
attrs.insert(Pango.AttrFontFeatures.new("tnum 1"))
self._brate_label=Gtk.Label(xalign=1, width_chars=5, visible=True, attributes=attrs)
2021-10-24 10:37:27 +03:00
self._format_label=Gtk.Label(visible=True)
2020-01-11 13:25:15 +03:00
2020-07-04 14:16:17 +03:00
# connect
2021-10-04 19:02:18 +03:00
self._settings.connect("changed::mini-player", self._mini_player)
self._settings.connect("changed::show-audio-format", self._mini_player)
2021-07-16 20:39:48 +03:00
self._client.emitter.connect("audio", self._on_audio)
self._client.emitter.connect("bitrate", self._on_bitrate)
2021-10-30 22:58:35 +03:00
self._client.emitter.connect("current_song", self._on_song_changed)
self._client.emitter.connect("disconnected", self._on_disconnected)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect("connected", self._on_connected)
2021-07-16 20:39:48 +03:00
# packing
2021-10-24 10:37:27 +03:00
hbox=Gtk.Box(halign=Gtk.Align.END, visible=True)
2021-07-16 20:39:48 +03:00
hbox.pack_start(self._brate_label, False, False, 0)
hbox.pack_start(self._separator_label, False, False, 0)
hbox.pack_start(self._file_type_label, False, False, 0)
2022-03-14 15:07:05 +03:00
self.pack_start(hbox, False, False, 0)
self.pack_start(self._format_label, False, False, 0)
2021-10-04 19:02:18 +03:00
self._mini_player()
def _mini_player(self, *args):
visibility=(self._settings.get_boolean("show-audio-format") and not self._settings.get_boolean("mini-player"))
self.set_property("no-show-all", not(visibility))
self.set_property("visible", visibility)
2021-07-16 20:39:48 +03:00
def _on_audio(self, emitter, audio_format):
if audio_format is None:
self._format_label.set_markup("<small> </small>")
else:
2021-08-04 15:03:33 +03:00
self._format_label.set_markup(f"<small>{Format(audio_format)}</small>")
2021-07-16 20:39:48 +03:00
def _on_bitrate(self, emitter, brate):
# handle unknown bitrates: https://github.com/MusicPlayerDaemon/MPD/issues/428#issuecomment-442430365
if brate is None:
2021-08-05 18:18:55 +03:00
self._brate_label.set_text("")
else:
self._brate_label.set_text(brate)
2021-07-16 20:39:48 +03:00
def _on_song_changed(self, *args):
2022-03-23 19:23:56 +03:00
if (song:=self._client.currentsong()):
file_type=song["file"].split(".")[-1].split("/")[0].upper()
self._separator_label.set_text("kbs • ")
self._file_type_label.set_text(file_type)
else:
self._file_type_label.set_text("")
self._separator_label.set_text("kbs")
self._format_label.set_markup("<small> </small>")
2021-07-16 20:39:48 +03:00
def _on_disconnected(self, *args):
self.set_sensitive(False)
2021-08-05 18:18:55 +03:00
self._brate_label.set_text("")
self._separator_label.set_text("kb/s")
self._file_type_label.set_text("")
2021-07-16 20:39:48 +03:00
self._format_label.set_markup("<small> </small>")
2022-09-20 19:42:37 +03:00
def _on_connected(self, *args):
2021-07-16 20:39:48 +03:00
self.set_sensitive(True)
class PlaybackOptions(Gtk.ButtonBox):
def __init__(self, client, settings):
2022-02-17 11:45:02 +03:00
super().__init__(layout_style=Gtk.ButtonBoxStyle.EXPAND, homogeneous=False)
self._client=client
self._settings=settings
# buttons
self._buttons={}
data=(
("repeat", "media-playlist-repeat-symbolic", _("Repeat mode")),
2021-04-24 11:24:59 +03:00
("random", "media-playlist-shuffle-symbolic", _("Random mode")),
("single", "org.mpdevil.mpdevil-single-symbolic", _("Single mode")),
("consume", "org.mpdevil.mpdevil-consume-symbolic", _("Consume mode")),
)
for name, icon, tooltip in data:
2021-04-24 11:24:59 +03:00
button=Gtk.ToggleButton(image=AutoSizedIcon(icon, "icon-size", self._settings), tooltip_text=tooltip, can_focus=False)
handler=button.connect("toggled", self._set_option, name)
2021-04-24 12:42:35 +03:00
self.pack_start(button, True, True, 0)
2021-04-24 11:24:59 +03:00
self._buttons[name]=(button, handler)
# css
self._provider=Gtk.CssProvider()
2023-01-23 21:04:31 +03:00
self._provider.load_from_data(b"image {color: @error_color;}") # red icon
# connect
2021-04-24 11:24:59 +03:00
for name in ("repeat", "random", "consume"):
self._client.emitter.connect(name, self._button_refresh, name)
self._client.emitter.connect("single", self._single_refresh)
2021-04-24 11:24:59 +03:00
self._buttons["single"][0].connect("button-press-event", self._on_single_button_press_event)
self._client.emitter.connect("disconnected", self._on_disconnected)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect("connected", self._on_connected)
2021-10-04 19:02:18 +03:00
self._settings.bind("mini-player", self, "no-show-all", Gio.SettingsBindFlags.GET)
self._settings.bind("mini-player", self, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
2021-04-24 11:24:59 +03:00
def _set_option(self, widget, option):
func=getattr(self._client, option)
if widget.get_active():
func("1")
else:
func("0")
2021-04-24 11:24:59 +03:00
def _button_refresh(self, emitter, val, name):
self._buttons[name][0].handler_block(self._buttons[name][1])
self._buttons[name][0].set_active(val)
self._buttons[name][0].handler_unblock(self._buttons[name][1])
def _single_refresh(self, emitter, val):
2021-04-24 11:24:59 +03:00
self._buttons["single"][0].handler_block(self._buttons["single"][1])
self._buttons["single"][0].set_active((val in ("1", "oneshot")))
if val == "oneshot":
2023-01-23 21:04:31 +03:00
self._buttons["single"][0].get_image().get_style_context().add_provider(
self._provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
2020-11-01 15:27:23 +03:00
else:
2021-04-25 12:54:33 +03:00
self._buttons["single"][0].get_image().get_style_context().remove_provider(self._provider)
2021-04-24 11:24:59 +03:00
self._buttons["single"][0].handler_unblock(self._buttons["single"][1])
2020-11-01 15:27:23 +03:00
def _on_single_button_press_event(self, widget, event):
if event.button == 3 and event.type == Gdk.EventType.BUTTON_PRESS:
2022-03-23 19:23:56 +03:00
if self._client.status()["single"] == "oneshot":
self._client.single("0")
2020-11-01 15:27:23 +03:00
else:
self._client.single("oneshot")
2020-11-01 15:27:23 +03:00
def _on_disconnected(self, *args):
2021-04-24 11:24:59 +03:00
self.set_sensitive(False)
for name in ("repeat", "random", "consume"):
self._button_refresh(None, False, name)
2020-11-01 15:27:23 +03:00
self._single_refresh(None, "0")
2021-04-24 11:24:59 +03:00
2022-09-20 19:42:37 +03:00
def _on_connected(self, *args):
2021-04-24 11:24:59 +03:00
self.set_sensitive(True)
2020-08-13 18:40:27 +03:00
2021-07-16 20:39:48 +03:00
class VolumeButton(Gtk.VolumeButton):
def __init__(self, client, settings):
2022-03-08 20:22:27 +03:00
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, use_symbolic=True, can_focus=False)
2021-07-16 20:39:48 +03:00
self._client=client
self._adj=self.get_adjustment()
self._adj.set_step_increment(5)
self._adj.set_page_increment(10)
2021-07-16 20:39:48 +03:00
self._adj.set_upper(0) # do not allow volume change by user when MPD has not yet reported volume (no output enabled/avail)
settings.bind("icon-size", self.get_child(), "pixel-size", Gio.SettingsBindFlags.GET)
2022-03-08 20:22:27 +03:00
# output plugins
self._output_box=Gtk.Box(orientation=Gtk.Orientation.VERTICAL, margin_start=10, margin_end=10, margin_bottom=10)
# popover
popover=self.get_popup()
scale_box=popover.get_child()
2022-03-08 21:27:30 +03:00
scale_box.get_children()[1].set_hexpand(True) # expand scale
2022-03-08 20:22:27 +03:00
popover.remove(scale_box)
box=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
box.pack_start(scale_box, False, False, 0)
box.pack_start(self._output_box, False, False, 0)
popover.add(box)
box.show_all()
2021-07-16 20:39:48 +03:00
# connect
2022-03-08 20:22:27 +03:00
popover.connect("show", self._on_show)
2021-07-16 20:39:48 +03:00
self._changed=self.connect("value-changed", self._set_volume)
2021-10-30 22:58:35 +03:00
self._client.emitter.connect("volume", self._refresh)
2021-07-16 20:39:48 +03:00
self._client.emitter.connect("disconnected", self._on_disconnected)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect("connected", self._on_connected)
2021-07-16 20:39:48 +03:00
def _set_volume(self, widget, value):
self._client.setvol(str(int(value)))
2021-07-16 20:39:48 +03:00
def _refresh(self, emitter, volume):
self.handler_block(self._changed)
if volume < 0:
self.set_value(0)
self._adj.set_upper(0)
else:
self._adj.set_upper(100)
self.set_value(volume)
2021-07-16 20:39:48 +03:00
self.handler_unblock(self._changed)
2022-03-08 20:22:27 +03:00
def _on_show(self, *args):
for button in self._output_box.get_children():
self._output_box.remove(button)
for output in self._client.outputs():
button=Gtk.ModelButton(label=f"{output['outputname']} ({output['plugin']})", role=Gtk.ButtonRole.CHECK, visible=True)
button.get_child().set_property("xalign", 0)
if output["outputenabled"] == "1":
button.set_property("active", True)
button.connect("clicked", self._on_button_clicked, output["outputid"])
self._output_box.pack_start(button, False, False, 0)
def _on_button_clicked(self, button, out_id):
if button.get_property("active"):
self._client.disableoutput(out_id)
button.set_property("active", False)
else:
self._client.enableoutput(out_id)
button.set_property("active", True)
2021-07-16 20:39:48 +03:00
2022-09-20 19:42:37 +03:00
def _on_connected(self, *args):
2021-07-16 20:39:48 +03:00
self.set_sensitive(True)
def _on_disconnected(self, *args):
self.set_sensitive(False)
self._refresh(None, -1)
2020-09-29 13:39:21 +03:00
###################
# MPD gio actions #
###################
class MPDActionGroup(Gio.SimpleActionGroup):
def __init__(self, client):
super().__init__()
self._client=client
# actions
2022-12-02 20:08:28 +03:00
self._disable_on_stop_data=("next","prev","seek-forward","seek-backward","tidy","enqueue")
2021-10-23 13:35:51 +03:00
self._enable_on_reconnect_data=("toggle-play","stop","clear","update","repeat","random","single","consume","single-oneshot")
self._data=self._disable_on_stop_data+self._enable_on_reconnect_data
for name in self._data:
2020-09-29 13:39:21 +03:00
action=Gio.SimpleAction.new(name, None)
action.connect("activate", getattr(self, ("_on_"+name.replace("-","_"))))
self.add_action(action)
# connect
2020-09-30 12:21:41 +03:00
self._client.emitter.connect("state", self._on_state)
2020-09-29 13:39:21 +03:00
self._client.emitter.connect("disconnected", self._on_disconnected)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect("connected", self._on_connected)
2020-09-29 13:39:21 +03:00
def _on_toggle_play(self, action, param):
self._client.toggle_play()
2020-09-29 13:39:21 +03:00
def _on_stop(self, action, param):
self._client.stop()
2020-09-29 13:39:21 +03:00
def _on_next(self, action, param):
self._client.next()
2020-09-29 13:39:21 +03:00
def _on_prev(self, action, param):
self._client.conditional_previous()
2020-09-29 13:39:21 +03:00
def _on_seek_forward(self, action, param):
self._client.seekcur("+10")
2020-09-29 13:39:21 +03:00
def _on_seek_backward(self, action, param):
self._client.seekcur("-10")
2020-09-29 13:39:21 +03:00
2022-12-02 20:08:28 +03:00
def _on_tidy(self, action, param):
2022-11-28 21:12:34 +03:00
self._client.files_to_playlist([self._client.currentsong()["file"]], "enqueue")
2022-12-02 20:08:28 +03:00
def _on_enqueue(self, action, param):
song=self._client.currentsong()
self._client.album_to_playlist(song["albumartist"][0], song["album"][0], song["date"][0], "enqueue")
2020-09-29 13:39:21 +03:00
def _on_clear(self, action, param):
self._client.clear()
2020-09-29 13:39:21 +03:00
def _on_update(self, action, param):
self._client.update()
2020-09-29 13:39:21 +03:00
def _on_repeat(self, action, param):
self._client.toggle_option("repeat")
2020-09-29 13:39:21 +03:00
def _on_random(self, action, param):
self._client.toggle_option("random")
2020-09-29 13:39:21 +03:00
def _on_single(self, action, param):
self._client.toggle_option("single")
2020-09-29 13:39:21 +03:00
def _on_consume(self, action, param):
self._client.toggle_option("consume")
2020-09-29 13:39:21 +03:00
2021-10-23 13:35:51 +03:00
def _on_single_oneshot(self, action, param):
self._client.single("oneshot")
2020-09-30 12:21:41 +03:00
def _on_state(self, emitter, state):
state_dict={"play": True, "pause": True, "stop": False}
2021-10-23 13:35:51 +03:00
for action in self._disable_on_stop_data:
2020-09-30 12:21:41 +03:00
self.lookup_action(action).set_enabled(state_dict[state])
2020-09-29 13:39:21 +03:00
def _on_disconnected(self, *args):
2021-10-23 13:35:51 +03:00
for action in self._data:
2020-09-29 13:39:21 +03:00
self.lookup_action(action).set_enabled(False)
2022-09-20 19:42:37 +03:00
def _on_connected(self, *args):
2021-10-23 13:35:51 +03:00
for action in self._enable_on_reconnect_data:
2020-09-29 13:39:21 +03:00
self.lookup_action(action).set_enabled(True)
2020-07-04 14:16:17 +03:00
###############
# main window #
###############
2021-10-19 16:23:04 +03:00
class UpdateNotify(Gtk.Revealer):
def __init__(self, client):
super().__init__(valign=Gtk.Align.START, halign=Gtk.Align.CENTER)
self._client=client
# widgets
self._spinner=Gtk.Spinner()
label=Gtk.Label(label=_("Updating Database…"))
# connect
self._client.emitter.connect("updating_db", self._show)
self._client.emitter.connect("updated_db", self._hide)
self._client.emitter.connect("disconnected", self._hide)
# packing
box=Gtk.Box(spacing=12)
box.get_style_context().add_class("app-notification")
box.pack_start(self._spinner, False, False, 0)
box.pack_end(label, True, True, 0)
self.add(box)
def _show(self, *args):
self._spinner.start()
self.set_reveal_child(True)
def _hide(self, *args):
self._spinner.stop()
self.set_reveal_child(False)
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)
connect_button=Gtk.Button(label=_("Connect"))
2022-11-06 12:30:55 +03:00
settings_button=Gtk.Button(label=_("Preferences"), action_name="win.connection-settings")
# connect
connect_button.connect("clicked", self._on_connect_button_clicked)
self._client.emitter.connect("connection_error", self._on_connection_error)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect("connected", self._on_connected)
# 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(connect_button, False, True, 0)
box.pack_end(settings_button, False, True, 0)
self.add(box)
def _on_connection_error(self, *args):
2022-11-06 12:30:55 +03:00
if self._settings.get_boolean("socket-connection"):
2022-11-06 12:40:28 +03:00
text=_("Connection to “{socket}” failed").format(socket=self._settings.get_socket())
else:
2022-11-06 12:30:55 +03:00
text=_("Connection to “{host}:{port}” failed").format(
host=self._settings.get_string("host"), port=self._settings.get_int("port"))
self._label.set_text(text)
self.set_reveal_child(True)
2022-09-20 19:42:37 +03:00
def _on_connected(self, *args):
self.set_reveal_child(False)
def _on_connect_button_clicked(self, *args):
self._client.reconnect()
2020-01-11 13:25:15 +03:00
class MainWindow(Gtk.ApplicationWindow):
2022-03-15 19:18:43 +03:00
def __init__(self, client, settings, **kwargs):
2021-07-19 23:33:54 +03:00
super().__init__(title=("mpdevil"), icon_name="org.mpdevil.mpdevil", **kwargs)
2020-12-27 14:09:25 +03:00
self.set_default_icon_name("org.mpdevil.mpdevil")
self._client=client
self._settings=settings
self._use_csd=self._settings.get_boolean("use-csd")
self._size=None # needed for window size saving
2020-01-11 13:25:15 +03:00
2020-07-04 13:35:39 +03:00
# actions
2022-12-24 00:53:51 +03:00
simple_actions_data=("settings","connection-settings","stats","help","menu","toggle-lyrics","back","toggle-search")
2020-09-16 15:38:58 +03:00
for name in simple_actions_data:
action=Gio.SimpleAction.new(name, None)
action.connect("activate", getattr(self, ("_on_"+name.replace("-","_"))))
self.add_action(action)
self.add_action(self._settings.create_action("mini-player"))
2021-10-06 19:03:14 +03:00
self.add_action(self._settings.create_action("genre-filter"))
2020-01-11 13:25:15 +03:00
2021-03-27 14:50:45 +03:00
# shortcuts
2021-12-29 18:48:42 +03:00
builder=Gtk.Builder()
builder.add_from_resource("/org/mpdevil/mpdevil/ShortcutsWindow.ui")
self.set_help_overlay(builder.get_object("shortcuts_window"))
2021-03-27 14:50:45 +03:00
2020-07-04 13:35:39 +03:00
# widgets
2021-10-04 18:35:51 +03:00
self._paned0=Gtk.Paned()
self._paned2=Gtk.Paned()
self._browser=Browser(self._client, self._settings)
2021-10-03 19:15:41 +03:00
self._search_window=SearchWindow(self._client)
2021-10-04 18:35:51 +03:00
self._cover_lyrics_window=CoverLyricsWindow(self._client, self._settings)
playlist_window=PlaylistWindow(self._client, self._settings)
2020-09-29 13:39:21 +03:00
playback_control=PlaybackControl(self._client, self._settings)
seek_bar=SeekBar(self._client)
2021-07-16 21:07:52 +03:00
audio=AudioFormat(self._client, self._settings)
playback_options=PlaybackOptions(self._client, self._settings)
volume_button=VolumeButton(self._client, self._settings)
2021-10-19 16:23:04 +03:00
update_notify=UpdateNotify(self._client)
connection_notify=ConnectionNotify(self._client, self._settings)
2021-10-03 19:15:41 +03:00
def icon(name):
if self._use_csd:
return Gtk.Image.new_from_icon_name(name, Gtk.IconSize.BUTTON)
else:
return AutoSizedIcon(name, "icon-size", self._settings)
2021-10-23 12:59:18 +03:00
self._search_button=Gtk.ToggleButton(
image=icon("system-search-symbolic"), tooltip_text=_("Search"), can_focus=False, no_show_all=True)
2021-10-03 19:15:41 +03:00
self._settings.bind("mini-player", self._search_button, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
2022-04-16 22:21:50 +03:00
back_button=Gtk.Button(
2022-12-24 00:53:51 +03:00
image=icon("go-previous-symbolic"), tooltip_text=_("Back"),
action_name="win.back", can_focus=False, no_show_all=True)
2022-04-16 22:21:50 +03:00
self._settings.bind("mini-player", back_button, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
2021-10-03 19:15:41 +03:00
# stack
self._stack=Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE)
self._stack.add_named(self._browser, "browser")
self._stack.add_named(self._search_window, "search")
2021-10-04 19:02:18 +03:00
self._settings.bind("mini-player", self._stack, "no-show-all", Gio.SettingsBindFlags.GET)
self._settings.bind("mini-player", self._stack, "visible", Gio.SettingsBindFlags.INVERT_BOOLEAN|Gio.SettingsBindFlags.GET)
2020-01-11 13:25:15 +03:00
2020-07-04 13:35:39 +03:00
# menu
2020-05-28 23:46:38 +03:00
subsection=Gio.Menu()
2021-09-14 15:39:08 +03:00
subsection.append(_("Preferences"), "win.settings")
2021-09-14 14:46:18 +03:00
subsection.append(_("Keyboard Shortcuts"), "win.show-help-overlay")
2020-05-28 23:46:38 +03:00
subsection.append(_("Help"), "win.help")
2021-09-14 14:46:18 +03:00
subsection.append(_("About mpdevil"), "app.about")
2020-09-11 14:48:40 +03:00
mpd_subsection=Gio.Menu()
2021-09-14 14:46:18 +03:00
mpd_subsection.append(_("Update Database"), "mpd.update")
mpd_subsection.append(_("Server Stats"), "win.stats")
2020-04-09 01:26:21 +03:00
menu=Gio.Menu()
2021-09-14 14:46:18 +03:00
menu.append(_("Mini Player"), "win.mini-player")
2021-10-21 10:43:38 +03:00
menu.append(_("Genre Filter"), "win.genre-filter")
2020-09-11 14:48:40 +03:00
menu.append_section(None, mpd_subsection)
2020-05-28 23:46:38 +03:00
menu.append_section(None, subsection)
2020-01-11 13:25:15 +03:00
2020-09-29 23:19:55 +03:00
# menu button / popover
2021-09-14 17:54:12 +03:00
if self._use_csd:
menu_icon=Gtk.Image.new_from_icon_name("open-menu-symbolic", Gtk.IconSize.BUTTON)
else:
menu_icon=AutoSizedIcon("open-menu-symbolic", "icon-size", self._settings)
2021-10-23 12:59:18 +03:00
self._menu_button=Gtk.MenuButton(image=menu_icon, tooltip_text=_("Menu"), can_focus=False)
2020-09-29 13:39:21 +03:00
menu_popover=Gtk.Popover.new_from_model(self._menu_button, menu)
self._menu_button.set_popover(menu_popover)
2020-07-04 13:35:39 +03:00
# connect
2021-10-03 19:15:41 +03:00
self._search_button.connect("toggled", self._on_search_button_toggled)
2021-10-04 19:02:18 +03:00
self._settings.connect_after("changed::mini-player", self._mini_player)
self._settings.connect_after("notify::cursor-watch", self._on_cursor_watch)
self._settings.connect("changed::playlist-right", self._on_playlist_pos_changed)
2021-10-30 22:58:35 +03:00
self._client.emitter.connect("current_song", self._on_song_changed)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect("connected", self._on_connected)
self._client.emitter.connect("disconnected", self._on_disconnected)
2022-09-20 19:42:37 +03:00
self._client.emitter.connect("connecting", self._on_connecting)
2022-09-20 00:27:38 +03:00
self._client.emitter.connect("connection_error", self._on_connection_error)
2021-02-08 11:32:55 +03:00
# auto save window state and size
2021-02-07 21:05:30 +03:00
self.connect("size-allocate", self._on_size_allocate)
2020-01-11 13:25:15 +03:00
2020-07-04 13:35:39 +03:00
# packing
self._on_playlist_pos_changed() # set orientation
2021-10-04 18:35:51 +03:00
self._paned0.pack1(self._cover_lyrics_window, False, False)
self._paned0.pack2(playlist_window, True, False)
self._paned2.pack1(self._stack, True, False)
self._paned2.pack2(self._paned0, False, False)
2021-10-02 14:58:18 +03:00
action_bar=Gtk.ActionBar()
if self._use_csd:
2021-10-23 12:59:18 +03:00
self._header_bar=Gtk.HeaderBar(show_close_button=True)
self.set_titlebar(self._header_bar)
2022-04-16 22:21:50 +03:00
self._header_bar.pack_start(back_button)
2020-09-29 13:39:21 +03:00
self._header_bar.pack_end(self._menu_button)
2021-10-03 19:15:41 +03:00
self._header_bar.pack_end(self._search_button)
else:
2022-04-16 22:21:50 +03:00
action_bar.pack_start(back_button)
2021-10-02 14:58:18 +03:00
action_bar.pack_end(self._menu_button)
2021-10-03 19:15:41 +03:00
action_bar.pack_end(self._search_button)
2021-10-02 14:58:18 +03:00
action_bar.pack_start(playback_control)
action_bar.pack_start(seek_bar)
action_bar.pack_start(audio)
action_bar.pack_start(playback_options)
action_bar.pack_start(volume_button)
vbox=Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
2021-10-04 18:35:51 +03:00
vbox.pack_start(self._paned2, True, True, 0)
2021-10-02 14:58:18 +03:00
vbox.pack_start(action_bar, False, False, 0)
2021-10-23 12:08:24 +03:00
overlay=Gtk.Overlay(child=vbox)
2021-10-19 16:23:04 +03:00
overlay.add_overlay(update_notify)
2021-10-02 14:58:18 +03:00
overlay.add_overlay(connection_notify)
self.add(overlay)
2022-04-23 15:01:23 +03:00
2022-09-20 00:18:32 +03:00
def open(self):
# bring player in consistent state
self._client.emitter.emit("disconnected")
2022-04-23 15:01:23 +03:00
# set default window size
if self._settings.get_boolean("mini-player"):
2022-09-20 00:18:32 +03:00
self.set_default_size(self._settings.get_int("mini-player-width"), self._settings.get_int("mini-player-height"))
2022-04-23 15:01:23 +03:00
else:
2022-09-20 00:18:32 +03:00
self.set_default_size(self._settings.get_int("width"), self._settings.get_int("height"))
if self._settings.get_boolean("maximize"):
2022-04-23 15:01:23 +03:00
self.maximize() # request maximize
2020-01-19 02:00:40 +03:00
self.show_all()
while Gtk.events_pending(): # ensure window is visible
Gtk.main_iteration_do(True)
2022-04-23 15:01:23 +03:00
if not self._settings.get_boolean("mini-player"):
self._bind_paned_settings() # restore paned settings when window is visible (fixes a bug when window is maximized)
2022-11-23 20:49:34 +03:00
self._settings.bind("maximize", self, "is-maximized", Gio.SettingsBindFlags.SET) # same problem as one line above
2022-09-20 19:42:37 +03:00
self._client.start()
2020-01-11 13:25:15 +03:00
2022-09-03 12:30:27 +03:00
def _clear_title(self):
self.set_title("mpdevil")
if self._use_csd:
self._header_bar.set_subtitle("")
2022-04-23 15:01:23 +03:00
def _bind_paned_settings(self):
self._settings.bind("paned0", self._paned0, "position", Gio.SettingsBindFlags.DEFAULT)
self._settings.bind("paned1", self._browser.paned1, "position", Gio.SettingsBindFlags.DEFAULT)
self._settings.bind("paned2", self._paned2, "position", Gio.SettingsBindFlags.DEFAULT)
self._settings.bind("paned3", self._browser, "position", Gio.SettingsBindFlags.DEFAULT)
def _unbind_paned_settings(self):
self._settings.unbind(self._paned0, "position")
self._settings.unbind(self._browser.paned1, "position")
self._settings.unbind(self._paned2, "position")
self._settings.unbind(self._browser, "position")
2021-10-04 19:02:18 +03:00
def _mini_player(self, *args):
2022-04-21 18:14:43 +03:00
if self.is_maximized():
self.unmaximize()
2021-10-04 19:02:18 +03:00
if self._settings.get_boolean("mini-player"):
2022-04-23 15:01:23 +03:00
self._unbind_paned_settings()
2022-04-21 18:14:43 +03:00
self.resize(self._settings.get_int("mini-player-width"), self._settings.get_int("mini-player-height"))
2021-10-04 19:02:18 +03:00
else:
self.resize(self._settings.get_int("width"), self._settings.get_int("height"))
2022-04-23 15:01:23 +03:00
while Gtk.events_pending(): # ensure window is resized
Gtk.main_iteration_do(True)
self._bind_paned_settings()
self.show_all() # show hidden gui elements
2021-10-04 19:02:18 +03:00
2020-09-29 13:39:21 +03:00
def _on_toggle_lyrics(self, action, param):
2021-10-04 18:35:51 +03:00
self._cover_lyrics_window.lyrics_button.emit("clicked")
2020-09-29 13:39:21 +03:00
2022-12-24 00:53:51 +03:00
def _on_back(self, action, param):
if self._search_button.get_active():
self._search_button.set_active(False)
else:
self._browser.back()
2020-09-29 13:39:21 +03:00
def _on_toggle_search(self, action, param):
2021-10-03 19:15:41 +03:00
self._search_button.emit("clicked")
2020-09-29 13:39:21 +03:00
def _on_settings(self, action, param):
settings=SettingsDialog(self, self._client, self._settings)
settings.run()
settings.destroy()
2022-11-06 12:30:55 +03:00
def _on_connection_settings(self, action, param):
settings=SettingsDialog(self, self._client, self._settings, "connection")
settings.run()
settings.destroy()
2020-09-29 13:39:21 +03:00
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")
2021-10-03 19:15:41 +03:00
def _on_search_button_toggled(self, button):
if button.get_active():
self._stack.set_visible_child_name("search")
self._search_window.search_entry.grab_focus()
else:
self._stack.set_visible_child_name("browser")
def _on_song_changed(self, *args):
2022-03-23 19:23:56 +03:00
if (song:=self._client.currentsong()):
album=song.get_album_with_date()
title="".join(filter(None, (song["title"][0], str(song["artist"]))))
if self._use_csd:
self.set_title(title)
self._header_bar.set_subtitle(album)
else:
self.set_title("".join(filter(None, (title, album))))
if self._settings.get_boolean("send-notify"):
if not self.is_active() and self._client.status()["state"] == "play":
2022-03-15 19:18:43 +03:00
notify=Gio.Notification()
2022-03-26 00:21:53 +03:00
notify.set_title(title)
notify.set_body(album)
2022-03-20 15:26:45 +03:00
if isinstance(self._client.current_cover, FileCover):
notify.set_icon(Gio.FileIcon.new(Gio.File.new_for_path(self._client.current_cover)))
elif isinstance(self._client.current_cover, BinaryCover):
notify.set_icon(Gio.BytesIcon.new(GLib.Bytes.new(self._client.current_cover)))
2022-03-15 19:18:43 +03:00
self.get_application().send_notification("title-change", notify)
2022-03-17 00:44:34 +03:00
else:
self.get_application().withdraw_notification("title-change")
2021-08-04 15:03:33 +03:00
else:
2022-09-03 12:30:27 +03:00
self._clear_title()
2022-03-26 00:21:53 +03:00
self.get_application().withdraw_notification("title-change")
2022-09-20 19:42:37 +03:00
def _on_connected(self, *args):
2022-09-03 12:30:27 +03:00
self._clear_title()
2022-12-24 00:53:51 +03:00
for action in ("stats","toggle-lyrics","back","toggle-search"):
2020-09-29 13:39:21 +03:00
self.lookup_action(action).set_enabled(True)
2021-10-03 19:15:41 +03:00
self._search_button.set_sensitive(True)
def _on_disconnected(self, *args):
2022-09-03 12:30:27 +03:00
self._clear_title()
2022-12-24 00:53:51 +03:00
for action in ("stats","toggle-lyrics","back","toggle-search"):
2020-09-29 13:39:21 +03:00
self.lookup_action(action).set_enabled(False)
2021-10-03 19:15:41 +03:00
self._search_button.set_active(False)
self._search_button.set_sensitive(False)
2020-01-11 13:25:15 +03:00
2022-09-20 19:42:37 +03:00
def _on_connecting(self, *args):
if self._use_csd:
self._header_bar.set_subtitle(_("connecting…"))
else:
self.set_title("mpdevil "+_("connecting…"))
2022-09-20 00:27:38 +03:00
def _on_connection_error(self, *args):
self._clear_title()
2021-02-08 11:32:55 +03:00
def _on_size_allocate(self, widget, rect):
2022-04-21 18:14:43 +03:00
if not self.is_maximized():
2022-03-23 19:23:56 +03:00
if (size:=self.get_size()) != self._size: # prevent unneeded write operations
2022-04-21 18:14:43 +03:00
if self._settings.get_boolean("mini-player"):
self._settings.set_int("mini-player-width", size[0])
self._settings.set_int("mini-player-height", size[1])
else:
self._settings.set_int("width", size[0])
self._settings.set_int("height", size[1])
self._size=size
2021-02-08 11:32:55 +03:00
def _on_cursor_watch(self, obj, typestring):
if obj.get_property("cursor-watch"):
2021-07-18 23:39:35 +03:00
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"):
2021-10-04 18:35:51 +03:00
self._paned0.set_orientation(Gtk.Orientation.VERTICAL)
self._paned2.set_orientation(Gtk.Orientation.HORIZONTAL)
else:
2021-10-04 18:35:51 +03:00
self._paned0.set_orientation(Gtk.Orientation.HORIZONTAL)
self._paned2.set_orientation(Gtk.Orientation.VERTICAL)
2020-07-04 14:16:17 +03:00
###################
# Gtk application #
###################
2020-01-11 13:25:15 +03:00
class mpdevil(Gtk.Application):
2021-07-19 23:33:54 +03:00
def __init__(self):
super().__init__(application_id="org.mpdevil.mpdevil", flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
self.add_main_option("debug", ord("d"), GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("Debug mode"), None)
2022-03-15 02:06:37 +03:00
def do_startup(self):
Gtk.Application.do_startup(self)
self._settings=Settings()
self._client=Client(self._settings)
2022-03-15 19:18:43 +03:00
self._window=MainWindow(self._client, self._settings, application=self)
2022-03-15 02:06:37 +03:00
self._window.connect("delete-event", self._on_quit)
self._window.insert_action_group("mpd", MPDActionGroup(self._client))
2022-09-20 00:18:32 +03:00
self._window.open()
2022-03-15 02:10:03 +03:00
# MPRIS
if self._settings.get_boolean("mpris"):
dbus_service=MPRISInterface(self, self._window, self._client, self._settings)
2022-03-15 02:06:37 +03:00
# actions
2020-04-09 01:26:21 +03:00
action=Gio.SimpleAction.new("about", None)
action.connect("activate", self._on_about)
2020-01-11 13:25:15 +03:00
self.add_action(action)
2020-04-09 01:26:21 +03:00
action=Gio.SimpleAction.new("quit", None)
action.connect("activate", self._on_quit)
2020-01-11 13:25:15 +03:00
self.add_action(action)
2022-03-15 02:06:37 +03:00
# accelerators
action_accels=(
("app.quit", ["<Control>q"]),("win.mini-player", ["<Control>m"]),("win.help", ["F1"]),("win.menu", ["F10"]),
("win.show-help-overlay", ["<Control>question"]),("win.toggle-lyrics", ["<Control>l"]),
2022-12-24 00:53:51 +03:00
("win.genre-filter", ["<Control>g"]),("win.back", ["Escape"]),("win.toggle-search", ["<Control>f"]),
2022-10-22 11:00:49 +03:00
("mpd.update", ["F5"]),("mpd.clear", ["<Shift>Delete"]),("mpd.toggle-play", ["space"]),("mpd.stop", ["<Shift>space"]),
2022-12-24 12:21:43 +03:00
("mpd.next", ["<Alt>Down", "KP_Add"]),("mpd.prev", ["<Alt>Up", "KP_Subtract"]),("mpd.repeat", ["<Control>r"]),
("mpd.random", ["<Control>n"]),("mpd.single", ["<Control>s"]),("mpd.consume", ["<Control>o"]),
("mpd.single-oneshot", ["<Shift><Control>s"]),
("mpd.seek-forward", ["<Alt>Right", "KP_Multiply"]),("mpd.seek-backward", ["<Alt>Left", "KP_Divide"])
2022-03-15 02:06:37 +03:00
)
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)
def do_activate(self):
2022-04-23 14:20:10 +03:00
try:
self._window.present()
except: # failed to show window so the user can't see anything
self.quit()
2020-01-11 13:25:15 +03:00
2022-03-15 01:43:51 +03:00
def do_shutdown(self):
Gtk.Application.do_shutdown(self)
if self._settings.get_boolean("stop-on-quit") and self._client.connected():
self._client.stop()
2022-03-15 19:18:43 +03:00
self.withdraw_notification("title-change")
2022-03-15 01:43:51 +03:00
2021-06-27 20:43:18 +03:00
def do_command_line(self, command_line):
# convert GVariantDict -> GVariant -> dict
options=command_line.get_options_dict().end().unpack()
if "debug" in options:
import logging
logging.basicConfig(level=logging.DEBUG)
self.activate()
return 0
2020-01-11 13:25:15 +03:00
2021-06-27 20:43:18 +03:00
def _on_about(self, *args):
2021-12-29 18:48:42 +03:00
builder=Gtk.Builder()
builder.add_from_resource("/org/mpdevil/mpdevil/AboutDialog.ui")
dialog=builder.get_object("about_dialog")
dialog.set_transient_for(self._window)
2020-01-11 13:25:15 +03:00
dialog.run()
dialog.destroy()
2021-06-27 20:43:18 +03:00
def _on_quit(self, *args):
2020-01-11 13:25:15 +03:00
self.quit()
2020-09-24 22:17:10 +03:00
if __name__ == "__main__":
2020-04-09 01:26:21 +03:00
app=mpdevil()
2022-04-16 23:02:18 +03:00
signal.signal(signal.SIGINT, signal.SIG_DFL) # allow using ctrl-c to terminate
2020-01-11 13:25:15 +03:00
app.run(sys.argv)