diff --git a/main.py b/main.py index f3dcf42..7105fd5 100755 --- a/main.py +++ b/main.py @@ -13,7 +13,7 @@ from gi.repository import Gtk, Gdk, GLib, GdkPixbuf, GObject from window import SonistWindow from about_app import AboutWindow -from mpd.base import MPDClient +from mpd import MPDClient app_id = 'fun.wkit.sonist' home_dir = os.getenv('HOME') @@ -28,6 +28,17 @@ def run_async(func): return wrapper +def set_timeout(timeout = 0.5): + def decorator(callback): + def wrapper(*args): + t = threading.Timer(timeout, callback, args=args) + t.start() + return t + return wrapper + + return decorator + + def get_music_dir(): with open(f'{home_dir}/.mpd/mpd.conf', 'r') as f: data = f.read() @@ -51,8 +62,7 @@ class Application(Gtk.Application): def __init__(self): Gtk.Application.__init__(self, application_id = app_id) - self.mpd_state = None - self.mpd_curr_song = None + self.timer = None self.mpd = MPDClient() @@ -60,44 +70,7 @@ class Application(Gtk.Application): self.connect('window-removed', self.on_window_removed) - - - - @run_async - def ping(self): - if self.mpd_is_online: - while True: - try: - self.mpd.ping() - stat = self.mpd.status() - song = self.mpd.currentsong() or {} - state = stat.get('state') - - if self.mpd_curr_song != song.get('id'): - self.mpd_curr_song = song.get('id') - self.emit('song_changed', self.mpd_curr_song) - - if state == 'play': - self.emit('playing', False) - - if self.mpd_state != state: - self.emit('state_changed', state) - - self.mpd_state = state - time.sleep(0.5) - - except Exception as e: - self.mpd.kill() - time.sleep(2) - self.mpd_is_online = self.mpd_connect() - - - def mpd_connect(self): - try: - self.mpd.connect("127.0.0.1", 6600) - return True - except: - return False + self.mpd.connect() def do_activate(self): @@ -105,24 +78,22 @@ class Application(Gtk.Application): self.set_app_menu(None) self.set_menubar(None) - self.mpd_is_online = self.mpd_connect() - self.window = SonistWindow(self) self.about = AboutWindow() self.add_window(self.window) self.window.show_all() # self.about.show_all() - self.ping() - def on_window_removed(self, app, win): if len(self.get_windows()) == 0: + if self.timer is not None: + self.timer.cancel() + self.mpd.destroy() print('朕要休息了~~~') - """ class ApplicationService(dbus.service.Object): def __init__(self, app): self.app = app diff --git a/mpd/base.py b/mpd.py similarity index 82% rename from mpd/base.py rename to mpd.py index 56dfc8f..4cc467c 100644 --- a/mpd/base.py +++ b/mpd.py @@ -17,15 +17,11 @@ # You should have received a copy of the GNU Lesser General Public License # along with python-mpd2. If not, see . -import logging -import re -import socket -import sys -import warnings - +import re, socket, sys, warnings, threading, time from enum import Enum + VERSION = (3, 1, 0) HELLO_PREFIX = "OK MPD " ERROR_PREFIX = "ACK " @@ -34,22 +30,29 @@ SUCCESS = "OK" NEXT = "list_OK" +def run_async(func): + def wrapper(*args, **kwargs): + thread = threading.Thread(target=func, args=args, kwargs=kwargs) + thread.daemon = True + thread.start() + return thread + return wrapper + +def set_timeout(timeout = 0.5): + def decorator(callback): + def wrapper(*args): + t = threading.Timer(timeout, callback, args=args) + t.start() + return t + return wrapper + + return decorator + + def escape(text): return text.replace("\\", "\\\\").replace('"', '\\"') -try: - from logging import NullHandler -except ImportError: # NullHandler was introduced in python2.7 - - class NullHandler(logging.Handler): - def emit(self, record): - pass - - -logger = logging.getLogger(__name__) -logger.addHandler(NullHandler()) - # MPD Protocol errors as found in CommandError exceptions # https://github.com/MusicPlayerDaemon/MPD/blob/master/src/protocol/Ack.hxx @@ -177,26 +180,10 @@ class MPDClientBase(object): subclasses. """ - def __init__(self, use_unicode=None): + def __init__(self): self.iterate = False - if use_unicode is not None: - warnings.warn( - "use_unicode parameter to ``MPDClientBase`` constructor is " - "deprecated", - DeprecationWarning, - stacklevel=2, - ) self._reset() - @property - def use_unicode(self): - warnings.warn( - "``use_unicode`` is deprecated: python-mpd 2.x always uses " - "Unicode", - DeprecationWarning, - stacklevel=2, - ) - return True @classmethod def add_command(cls, name, callback): @@ -490,6 +477,17 @@ class _NotConnected(object): @mpd_command_provider class MPDClient(MPDClientBase): + + __events__ = {} + + connected = False + + try_conn_timer = None + heart_beat_timer = None + + current_song_id = None + current_state = 'stop' + idletimeout = None _timeout = None _wrap_iterator_parsers = [ @@ -508,22 +506,28 @@ class MPDClient(MPDClientBase): MPDClientBase._parse_plugins, ] - def __init__(self, use_unicode=None): - if use_unicode is not None: - warnings.warn( - "use_unicode parameter to ``MPDClient`` constructor is " - "deprecated", - DeprecationWarning, - stacklevel=2, - ) - super(MPDClient, self).__init__() + def __init__(self, host = '127.0.0.1', port = 6600): + super(MPDClient, self).__init__() + self.host = host + self.port = port def _reset(self): - super(MPDClient, self)._reset() - self._iterating = False - self._sock = None - self._rbfile = _NotConnected() - self._wfile = _NotConnected() + super(MPDClient, self)._reset() + self.connected = False + self._iterating = False + self._sock = None + self._rbfile = None + self._wfile = None + + def bind(self, event, callback): + self.__events__[event] = callback + + @run_async + def emit(self, event, *args): + # print('emit: ', event, args) + callback = self.__events__.get(event) + if callback is not None: + callback(*args) def _execute(self, command, args, retval): if self._iterating: @@ -542,15 +546,18 @@ class MPDClient(MPDClientBase): return retval def _write_line(self, line): - try: - self._wfile.write("{}\n".format(line)) - self._wfile.flush() - except socket.error as e: - error_message = "Connection to server was reset" - logger.info(error_message) - self._reset() - e = ConnectionError(error_message) - raise e.with_traceback(sys.exc_info()[2]) + try: + if self._wfile == None: + print('MPD server is not connected!!!') + else: + self._wfile.write("{}\n".format(line)) + self._wfile.flush() + + except socket.error as e: + error_message = "Connection to server was reset" + self._reset() + e = ConnectionError(error_message) + raise e.with_traceback(sys.exc_info()[2]) def _write_command(self, command, args=[]): parts = [command] @@ -565,11 +572,7 @@ class MPDClient(MPDClientBase): else: parts.append('"{}"'.format(escape(str(arg)))) # Minimize logging cost if the logging is not activated. - if logger.isEnabledFor(logging.DEBUG): - if command == "password": - logger.debug("Calling MPD password(******)") - else: - logger.debug("Calling MPD %s%r", command, args) + cmd = " ".join(parts) self._write_line(cmd) @@ -716,31 +719,35 @@ class MPDClient(MPDClientBase): self._iterating = True return self._iterator_wrapper(iterator) - def _hello(self, line): - if not line.endswith("\n"): - self.disconnect() - raise ConnectionError("Connection lost while reading MPD hello") - line = line.rstrip("\n") - if not line.startswith(HELLO_PREFIX): - raise ProtocolError("Got invalid MPD hello: '{}'".format(line)) - self.mpd_version = line[len(HELLO_PREFIX) :].strip() + def _check_is_mpd_server(self, line): + if not line.endswith("\n"): + self.disconnect() + return + + line = line.rstrip("\n") + if not line.startswith(HELLO_PREFIX): + return + + self.connected = True + self.mpd_version = line[len(HELLO_PREFIX) :].strip() - def _connect_unix(self, path): + def _connect_unix(self): if not hasattr(socket, "AF_UNIX"): raise ConnectionError("Unix domain sockets not supported on this platform") sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(self.timeout) - sock.connect(path) + sock.connect(self.host) return sock - def _connect_tcp(self, host, port): + def _connect_tcp(self): + try: flags = socket.AI_ADDRCONFIG except AttributeError: flags = 0 - err = None + for res in socket.getaddrinfo( - host, port, socket.AF_UNSPEC, socket.SOCK_STREAM, socket.IPPROTO_TCP, flags + self.host, self.port, socket.AF_UNSPEC, socket.SOCK_STREAM, socket.IPPROTO_TCP, flags ): af, socktype, proto, canonname, sa = res sock = None @@ -751,14 +758,16 @@ class MPDClient(MPDClientBase): sock.settimeout(self.timeout) sock.connect(sa) return sock - except socket.error as e: - err = e + except Exception as e: + + if e.strerror == 'Connection refused': + self.emit('offline') + else: + self.emit('error', e) + if sock is not None: sock.close() - if err is not None: - raise err - else: - raise ConnectionError("getaddrinfo returns an empty list") + @mpd_commands("idle") def _parse_idle(self, lines): @@ -777,51 +786,99 @@ class MPDClient(MPDClientBase): if self._sock is not None: self._sock.settimeout(timeout) - def connect(self, host, port=None, timeout=None): - logger.info("Calling MPD connect(%r, %r, timeout=%r)", host, port, timeout) - if self._sock is not None: - raise ConnectionError("Already connected") - if timeout is not None: - warnings.warn( - "The timeout parameter in connect() is deprecated! " - "Use MPDClient.timeout = yourtimeout instead.", - DeprecationWarning, - ) - self.timeout = timeout - if host.startswith("@"): - host = "\0" + host[1:] - if host.startswith(("/", "\0")): - self._sock = self._connect_unix(host) + @set_timeout(2) + def _try_connect(self): + + if self._sock is not None: + raise ConnectionError("Already connected") + + if self.host.startswith(("/", "\0")): + self._sock = self._connect_unix() + else: + self._sock = self._connect_tcp() + + + # - Force UTF-8 encoding, since this is dependant from the LC_CTYPE + # locale. + # - by setting newline explicit, we force to send '\n' also on + # windows + if self._sock is None: + self.connected = False + self.connect() + return + + + self._rbfile = self._sock.makefile("rb", newline="\n") + self._wfile = self._sock.makefile("w", encoding="utf-8", newline="\n") + + try: + helloline = self._rbfile.readline().decode("utf-8") + self._check_is_mpd_server(helloline) + if self.connected: + self.emit('online') + self.heart_beat_timer = self.heart_beat() else: - if port is None: - raise ValueError( - "port argument must be specified when connecting via tcp" - ) - self._sock = self._connect_tcp(host, port) + self.emit('error', ProtocolError('Connected server is not mpd server.')) - # - Force UTF-8 encoding, since this is dependant from the LC_CTYPE - # locale. - # - by setting newline explicit, we force to send '\n' also on - # windows - self._rbfile = self._sock.makefile("rb", newline="\n") - self._wfile = self._sock.makefile("w", encoding="utf-8", newline="\n") + except Exception as e: + self.connected = False + self.emit('error', e) + self.disconnect() + + @set_timeout(0.2) + def heart_beat(self): + + if self.heart_beat_timer is not None: + self.heart_beat_timer.cancel() + + if self.connected: try: - helloline = self._rbfile.readline().decode("utf-8") - self._hello(helloline) - except Exception: - self.disconnect() - raise + status = self.status() + song = self.currentsong() + state = status.get('state') + + if state != self.current_state: + self.emit('state_changed', state) + self.current_state = state + + + if song.get('id') != self.current_song_id: + if self.current_song_id is not None: + self.emit('song_changed', status, song) + self.current_song_id = song.get('id') + + + if state == 'play': + self.emit('playing', status, song) + + + self.heart_beat_timer = self.heart_beat() + + except Exception as err: + print(err) + self.disconnect() + self.connect() + + def connect(self): + self.try_conn_timer = self._try_connect() + + def destroy(self): + if self.try_conn_timer is not None: + self.try_conn_timer.cancel() + if self.heart_beat_timer is not None: + self.heart_beat_timer.cancel() + + self._reset() def disconnect(self): - logger.info("Calling MPD disconnect()") - if self._rbfile is not None and not isinstance(self._rbfile, _NotConnected): - self._rbfile.close() - if self._wfile is not None and not isinstance(self._wfile, _NotConnected): - self._wfile.close() - if self._sock is not None: - self._sock.close() - self._reset() + if self._rbfile is not None: + self._rbfile.close() + if self._wfile is not None: + self._wfile.close() + if self._sock is not None: + self._sock.close() + self._reset() def fileno(self): if self._sock is None: diff --git a/mpd/__init__.py b/mpd/__init__.py deleted file mode 100644 index 4332922..0000000 --- a/mpd/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -# python-mpd2: Python MPD client library -# -# Copyright (C) 2008-2010 J. Alexander Treuman -# Copyright (C) 2012 J. Thalheim -# Copyright (C) 2016 Robert Niederreiter -# -# python-mpd2 is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# python-mpd2 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 Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with python-mpd2. If not, see . - -from mpd.base import CommandError -from mpd.base import CommandListError -from mpd.base import ConnectionError -from mpd.base import FailureResponseCode -from mpd.base import IteratingError -from mpd.base import MPDClient -from mpd.base import MPDError -from mpd.base import PendingCommandError -from mpd.base import ProtocolError -from mpd.base import VERSION diff --git a/ui/ctrl_box.py b/ui/ctrl_box.py index 3ac17b1..12be310 100644 --- a/ui/ctrl_box.py +++ b/ui/ctrl_box.py @@ -16,7 +16,7 @@ class CtrlBox(Gtk.Box): def __init__(self, spacing = 6): Gtk.Box.__init__(self, spacing = spacing) - self.disabled = False + self.disabled = True self.modes = ['./usr/share/sonist/all.png','./usr/share/sonist/rand.png','./usr/share/sonist/single.png'] self.curr_mode = 0 diff --git a/ui/timebar.py b/ui/timebar.py index ed91599..a1f1070 100644 --- a/ui/timebar.py +++ b/ui/timebar.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import gi +import gi, threading gi.require_version('Gtk', '3.0') from gi.repository import Gtk, GObject @@ -25,6 +25,9 @@ class Timebar(Gtk.Fixed): def __init__(self): Gtk.Fixed.__init__(self) + self.timer = None + self.pending = False + self._duration = 0 self._time = 0 @@ -37,7 +40,8 @@ class Timebar(Gtk.Fixed): self.duration.set_xalign(1) self.duration.set_name('text') - self.slider.connect('change-value', self.on_timeupdate) + self.slider.connect('change-value', self.debounce) + self.slider.set_sensitive(False) self.put(self.curr, 3, 0) self.put(self.duration, 233, 0) @@ -47,14 +51,32 @@ class Timebar(Gtk.Fixed): def update_time(self, curr = 0, duration = 0): self._duration = duration self._time = curr - progress = curr * 100 / duration - self.curr.set_text(time_to_str(curr)) - self.duration.set_text(time_to_str(duration)) - self.slider.set_value(progress) + self.slider.set_sensitive(duration > 0) + if duration == 0: + self.curr.set_text('') + self.duration.set_text('') + self.slider.set_value(0) + else: + progress = curr * 100 / duration + self.curr.set_text(time_to_str(curr)) + self.duration.set_text(time_to_str(duration)) + # 优化拖拽进度时的更新 + if not self.pending: + self.slider.set_value(progress) - def on_timeupdate(self, slider, a, b): - # print(slider.get_value(), a, b) - p = slider.get_value() + + def on_timeupdate(self): + self.pending = False + p = self.slider.get_value() time = p * self._duration / 100 - self.emit('seeked', time) \ No newline at end of file + self.emit('seeked', time) + + + def debounce(self, slider, a, b): + if self.timer is not None: + self.timer.cancel() + + self.pending = True + self.timer = threading.Timer(0.2, self.on_timeupdate) + self.timer.start() \ No newline at end of file diff --git a/window.py b/window.py index 95e6ec3..7d64db5 100644 --- a/window.py +++ b/window.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import gi, sys, os, mutagen -from pprint import pprint as print +# from pprint import pprint as print gi.require_version('Gtk', '3.0') @@ -22,16 +22,23 @@ from ui.option_menu import OptionMenu class SonistWindow(Gtk.Window): + app = None + mpd = None + stat = {} + def __init__(self, app): Gtk.Window.__init__(self) self.app = app + self.mpd = app.mpd - self.connect("destroy", self.quit) + self.connect("destroy", self.quited) - self.app.connect('state_changed', lambda a, x: self.update_play_stat(x == 'play')) - self.app.connect('song_changed', lambda a, id: self.sync_state(False)) - self.app.connect('playing', lambda a, id: self.update_playtime()) + self.mpd.bind('offline', lambda : self.reset_player()) + self.mpd.bind('online', lambda : self.sync_state(None, None, True)) + self.mpd.bind('state_changed', lambda stat: self.update_play_stat(stat == 'play')) + self.mpd.bind('song_changed', lambda stat, song: self.sync_state(stat, song, False)) + self.mpd.bind('playing', lambda stat, song: self.update_playtime(stat)) self.set_name('SonistWindow') @@ -88,7 +95,7 @@ class SonistWindow(Gtk.Window): # 播放进度 self.timebar = Timebar() - self.timebar.connect('seeked', lambda a,v: self.app.mpd.seekcur(v)) + self.timebar.connect('seeked', lambda a,v: self.mpd.seekcur(v)) layout.put(self.timebar, 24, 270) @@ -96,22 +103,17 @@ class SonistWindow(Gtk.Window): self.ctrl_box = CtrlBox() self.ctrl_box.connect('clicked', self.ctrl_clicked) - self.ctrl_box.disabled = not self.app.mpd_is_online layout.put(self.ctrl_box, 48, 312) self.add(layout) - self.sync_state(True) - def get_mpd_stat(self): try: - self.stat = self.app.mpd.status() + self.stat = self.mpd.status() except: - self.app.ping() - self.stat = self.app.mpd.status() - + self.stat = {} return self.stat @@ -150,19 +152,19 @@ class SonistWindow(Gtk.Window): case 'mode_btn': # repeat all if self.ctrl_box.curr_mode == 0: - self.app.mpd.repeat(1) - self.app.mpd.random(0) - self.app.mpd.single(0) + self.mpd.repeat(1) + self.mpd.random(0) + self.mpd.single(0) # random elif self.ctrl_box.curr_mode == 1: - self.app.mpd.repeat(0) - self.app.mpd.random(1) - self.app.mpd.single(0) + self.mpd.repeat(0) + self.mpd.random(1) + self.mpd.single(0) # single else: - self.app.mpd.repeat(0) - self.app.mpd.random(0) - self.app.mpd.single(1) + self.mpd.repeat(0) + self.mpd.random(0) + self.mpd.single(1) case 'prev_btn': self.prev_song() @@ -178,39 +180,30 @@ class SonistWindow(Gtk.Window): def toggle_play(self): try: if self.stat.get('state') == 'stop': - self.app.mpd.play() + self.mpd.play() else: - self.app.mpd.pause() + self.mpd.pause() except: - self.app.ping() - self.toggle_play() - return + pass - # self.sync_state() self.update_play_stat(self.stat.get('state') == 'play') def prev_song(self): try: - self.app.mpd.previous() + self.mpd.previous() except: - self.app.ping() - self.prev_song() - return - # self.sync_state() + pass def next_song(self): try: - self.app.mpd.next() + self.mpd.next() except: - self.app.ping() - self.next_song() - return - # self.sync_state() + pass def update_play_stat(self, played = True): - if not self.app.mpd_is_online: + if not self.mpd.connected: return if played: @@ -222,19 +215,16 @@ class SonistWindow(Gtk.Window): self.ctrl_box.toggle_play_btn(played) - def update_playtime(self): - stat = self.get_mpd_stat() + def update_playtime(self, stat = {}): times = stat['time'].split(':') self.timebar.update_time(int(times[0]), int(times[1])) - def sync_state(self, first = False): - if not self.app.mpd_is_online: - return + def sync_state(self, stat = None, song = None, first = False): + self.ctrl_box.disabled = False - self.stat = self.get_mpd_stat() + self.stat = stat or self.get_mpd_stat() played = self.stat.get('state') - song = self.app.mpd.currentsong() if first: self.update_play_stat(played == 'play') @@ -244,16 +234,16 @@ class SonistWindow(Gtk.Window): elif self.stat.get('random') == '1': self.ctrl_box.toggle_mode_btn(mode = 'random') - + if played != 'stop': + song = song or self.mpd.currentsong() # 更新歌曲信息 - self.title_box.set_text("%s - %s" % (song.get('artist'), song.get('title'))) - self.update_playtime() + self.title_box.set_text(f"{song.get('artist')} - {song.get('title')}") + self.update_playtime(self.stat) filepath = f"./album/{song['title']}.png" songpath = f"{self.app.music_dir}/{song['file']}" - if os.path.isfile(filepath): self.update_album(filepath) else: @@ -271,12 +261,17 @@ class SonistWindow(Gtk.Window): self.update_album(album) except: pass - + + def reset_player(self): + self.ctrl_box.disabled = True + self.title_box.set_text('mpd is offline...') + self.timebar.update_time() + self.update_album('./usr/share/sonist/avatar.jpg') def update_album(self, filepath): self.set_background_image(filepath) self.album.reset(filepath).set_radius(64) - def quit(self, win): + def quited(self, win): self.app.remove_window(self)