def init_player(self): def mpv_log(loglevel, component, message): self.print('Mpv log: [{}] {}: {}'.format(loglevel, component, message)) mpv_args = [] mpv_kw = { 'wid': int(self.ui.video.winId()), 'keep-open': 'yes', 'rebase-start-time': 'no', 'framedrop': 'no', 'osd-level': '2', 'osd-fractions': 'yes', } for opt in self.mpv_options: if '=' in opt: k, v = opt.split('=', 1) mpv_kw[k] = v else: mpv_args.append(opt) player = MPV(*mpv_args, log_handler=mpv_log, **mpv_kw) self.player = player player.pause = True def on_player_loaded(): if self.ffmpeg_bin: self.check_ffmpeg_seek_problem() self.load_state() self.ui.loading.hide() self.state_loaded = True self.player_loaded.connect(on_player_loaded) def on_playback_len(s): self.playback_len = s player.unobserve_property('duration', on_playback_len) def on_playback_pos(s): if self.playback_pos is None: self.player_loaded.emit() self.playback_pos = s self.statusbar_update.emit() self.ui.seekbar.update() player.observe_property('time-pos', on_playback_pos) player.observe_property('duration', on_playback_len) player.play(self.filename)
class AudioPlayer: def __init__(self, filename): self.filename = filename self.mpv = MPV(input_default_bindings=True, input_vo_keyboard=True) self.mpv.pause = True self.mpv.play(self.filename) self.mpv.wait_for_property("length") self.length = self.mpv.length * 1000 def position(self): return self.mpv.time_pos * 1000 def progress(self): return self.position() / self.length def pause(self): self.mpv.pause = True def play(self): self.mpv.pause = False def toggle_pause(self): self.mpv.pause = not self.mpv.pause def seek_progress(self, progress): self.mpv.seek(self.mpv.length * progress, "absolute", "exact") def seek_relative(self, ms): self.mpv.time_pos += ms / 1000 def observe_position(self, observer): def converting_observer(prop, value): observer(value * 1000) self.mpv.observe_property("time-pos", converting_observer) def __del__(self): # TODO may never be called, see https://github.com/RafeKettler/magicmethods/blob/master/magicmethods.pdf self.mpv.quit() self.mpv.terminate()
class MpvWidget(QOpenGLWidget): _schedule_update = pyqtSignal() def __init__(self, api: Api, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self._api = api locale.setlocale(locale.LC_NUMERIC, "C") self._destroyed = False self._need_subs_refresh = False self.mpv = MPV(vo="opengl-cb", ytdl=False, loglevel="info", log_handler=print) self.mpv_gl = _mpv_get_sub_api(self.mpv.handle, MpvSubApi.MPV_SUB_API_OPENGL_CB) self.on_update_c = OpenGlCbUpdateFn(self.on_update) self.on_update_fake_c = OpenGlCbUpdateFn(self.on_update_fake) self.get_proc_addr_c = OpenGlCbGetProcAddrFn(get_proc_addr) _mpv_opengl_cb_set_update_callback(self.mpv_gl, self.on_update_c, None) self.frameSwapped.connect(self.swapped, Qt.ConnectionType.DirectConnection) for key, value in { # "config": False, "quiet": False, "msg-level": "all=info", "osc": False, "osd-bar": False, "input-cursor": False, "input-vo-keyboard": False, "input-default-bindings": False, "ytdl": False, "sub-auto": False, "audio-file-auto": False, "vo": "null" if api.args.no_video else "libmpv", "hwdec": "no", "pause": True, "idle": True, "blend-subtitles": "video", "video-sync": "display-vdrop", "keepaspect": True, "stop-playback-on-init-failure": False, "keep-open": True, "track-auto-selection": False, }.items(): setattr(self.mpv, key, value) self._opengl = None self._timer = QTimer(parent=None) self._timer.setInterval(api.cfg.opt["video"]["subs_sync_interval"]) self._timer.timeout.connect(self._refresh_subs_if_needed) api.subs.loaded.connect(self._on_subs_load) api.video.stream_created.connect(self._on_video_state_change) api.video.stream_unloaded.connect(self._on_video_state_change) api.video.current_stream_switched.connect(self._on_video_state_change) api.audio.stream_created.connect(self._on_audio_state_change) api.audio.stream_unloaded.connect(self._on_audio_state_change) api.audio.current_stream_switched.connect(self._on_audio_state_change) api.playback.request_seek.connect(self._on_request_seek, Qt.ConnectionType.DirectConnection) api.playback.request_playback.connect(self._on_request_playback) api.playback.playback_speed_changed.connect( self._on_playback_speed_change) api.playback.volume_changed.connect(self._on_volume_change) api.playback.mute_changed.connect(self._on_mute_change) api.playback.pause_changed.connect(self._on_pause_change) api.video.view.zoom_changed.connect(self._on_video_zoom_change) api.video.view.pan_changed.connect(self._on_video_pan_change) api.gui.terminated.connect(self.shutdown) self._schedule_update.connect(self.update) self.mpv.observe_property("time-pos", self._on_mpv_time_pos_change) self.mpv.observe_property("track-list", self._on_mpv_track_list_change) self.mpv.observe_property("pause", self._on_mpv_pause_change) self._timer.start() def initializeGL(self) -> None: _mpv_opengl_cb_init_gl(self.mpv_gl, None, self.get_proc_addr_c, None) def paintGL(self) -> None: ratio = self.devicePixelRatioF() w = int(self.width() * ratio) h = int(self.height() * ratio) _mpv_opengl_cb_draw(self.mpv_gl, self.defaultFramebufferObject(), w, -h) @pyqtSlot() def maybe_update(self) -> None: if self._destroyed: return if self.window().isMinimized(): self.makeCurrent() self.paintGL() self.context().swapBuffers(self.context().surface()) self.swapped() self.doneCurrent() else: self.update() def on_update(self, ctx: Any = None) -> None: # maybe_update method should run on the thread that creates the # OpenGLContext, which in general is the main thread. # QMetaObject.invokeMethod can do this trick. QMetaObject.invokeMethod(self, "maybe_update") def on_update_fake(self, ctx: Any = None) -> None: pass def swapped(self) -> None: _mpv_opengl_cb_report_flip(self.mpv_gl, 0) def closeEvent(self, _: Any) -> None: self.makeCurrent() if self.mpv_gl: _mpv_opengl_cb_set_update_callback(self.mpv_gl, self.on_update_fake_c, None) _mpv_opengl_cb_uninit_gl(self.mpv_gl) def _on_subs_load(self) -> None: self._api.subs.script_info.changed.subscribe( lambda _event: self._on_subs_change()) self._api.subs.events.changed.subscribe( lambda _event: self._on_subs_change()) self._api.subs.styles.changed.subscribe( lambda _event: self._on_subs_change()) def _on_video_state_change(self, stream: VideoStream) -> None: self._sync_media() self._need_subs_refresh = True def _on_audio_state_change(self, stream: AudioStream) -> None: self._sync_media() def _sync_media(self) -> None: self.mpv.pause = True self.mpv.loadfile("null://") external_files: set[str] = set() for video_stream in self._api.video.streams: external_files.add(str(video_stream.path)) for audio_stream in self._api.audio.streams: external_files.add(str(audio_stream.path)) self.mpv.external_files = list(external_files) if not external_files: self._api.playback.state = PlaybackFrontendState.NOT_READY else: self._api.playback.state = PlaybackFrontendState.LOADING def shutdown(self) -> None: self._destroyed = True self.makeCurrent() if self._opengl: self._opengl.set_update_callback(lambda: None) self._opengl.close() self.deleteLater() self._timer.stop() def _refresh_subs_if_needed(self) -> None: if self._need_subs_refresh: self._refresh_subs() def _refresh_subs(self) -> None: if not self._api.playback.is_ready: return if self.mpv.sub: try: self.mpv.command("sub_remove") except mpv.MPVError: pass with io.StringIO() as handle: write_ass(self._api.subs.ass_file, handle) self.mpv.command("sub_add", "memory://" + handle.getvalue()) self._need_subs_refresh = False def _set_end(self, end: Optional[int]) -> None: if not self._api.playback.is_ready: return if end is None: ret = "none" else: end = max(0, end - 1) ret = ms_to_str(end) if self.mpv.end != ret: self.mpv.end = ret def _on_request_seek(self, pts: int, precise: bool) -> None: self._set_end(None) # mpv refuses to seek beyond --end self.mpv.seek(ms_to_str(pts), "absolute", "exact") def _on_request_playback(self, start: Optional[int], end: Optional[int]) -> None: if start is not None: self.mpv.seek(ms_to_str(start), "absolute") self._set_end(end) self.mpv.pause = False def _on_playback_speed_change(self) -> None: self.mpv.speed = float(self._api.playback.playback_speed) def _on_volume_change(self) -> None: self.mpv.volume = float(self._api.playback.volume) def _on_mute_change(self) -> None: self.mpv.mute = self._api.playback.is_muted def _on_pause_change(self, is_paused: bool) -> None: self._set_end(None) self.mpv.pause = is_paused def _on_video_zoom_change(self) -> None: # ignore errors coming from setting extreme values try: self.mpv.video_zoom = float(self._api.video.view.zoom) except mpv.MPVError: pass def _on_video_pan_change(self) -> None: # ignore errors coming from setting extreme values try: self.mpv.video_pan_x = float(self._api.video.view.pan_x) self.mpv.video_pan_y = float(self._api.video.view.pan_y) except mpv.MPVError: pass def _on_subs_change(self) -> None: self._need_subs_refresh = True def _on_mpv_unload(self) -> None: self._api.playback.state = PlaybackFrontendState.NOT_READY def _on_mpv_load(self) -> None: self._api.playback.state = PlaybackFrontendState.READY self._need_subs_refresh = True def _on_track_list_ready(self, track_list: Any) -> None: # self._api.log.debug(json.dumps(track_list, indent=4)) vid: Optional[int] = None aid: Optional[int] = None current_audio_stream: Optional[AudioStream] try: current_audio_stream = self._api.audio.current_stream except ResourceUnavailable: current_audio_stream = None current_video_stream: Optional[VideoStream] try: current_video_stream = self._api.video.current_stream except ResourceUnavailable: current_video_stream = None for track in track_list: track_type = track["type"] track_path = track.get("external-filename") if (track_type == "video" and current_video_stream and current_video_stream.path.samefile(track_path)): vid = track["id"] if (track_type == "audio" and current_audio_stream and current_audio_stream.path.samefile(track_path)): aid = track["id"] if self.mpv.vid != vid: self.mpv.vid = vid if vid is not None else "no" self._api.log.debug(f"playback: changing vid to {vid}") if self.mpv.aid != aid: self.mpv.aid = aid if aid is not None else "no" self._api.log.debug(f"playback: changing aid to {aid}") delay = (current_audio_stream.delay if current_audio_stream else 0) / 1000.0 if self.mpv.audio_delay != delay: self.mpv.audio_delay = delay if vid is not None or aid is not None: self._api.playback.state = PlaybackFrontendState.READY else: self._api.playback.state = PlaybackFrontendState.NOT_READY def _on_mpv_time_pos_change(self, prop_name: str, new_value: Any) -> None: pts = round((new_value or 0) * 1000) self._api.playback.receive_current_pts_change.emit(pts) def _on_mpv_pause_change(self, prop_name: str, new_value: Any) -> None: self._api.playback.pause_changed.disconnect(self._on_pause_change) self._api.playback.is_paused = new_value self._api.playback.pause_changed.connect(self._on_pause_change) def _on_mpv_track_list_change(self, prop_name: str, new_value: Any) -> None: self._on_track_list_ready(new_value)
class MpvPlayer(AbstractPlayer): """ player will always play playlist current song. player will listening to playlist ``song_changed`` signal and change the current playback. TODO: make me singleton """ def __init__(self, audio_device=b'auto', winid=None, *args, **kwargs): super(MpvPlayer, self).__init__(*args, **kwargs) # https://github.com/cosven/FeelUOwn/issues/246 locale.setlocale(locale.LC_NUMERIC, 'C') mpvkwargs = {} if winid is not None: mpvkwargs['wid'] = winid mpvkwargs['vo'] = 'opengl-cb' # set log_handler if you want to debug # mpvkwargs['log_handler'] = self.__log_handler # mpvkwargs['msg_level'] = 'all=v' self._mpv = MPV(ytdl=True, input_default_bindings=True, input_vo_keyboard=True, **mpvkwargs) _mpv_set_property_string(self._mpv.handle, b'audio-device', audio_device) # TODO: 之后可以考虑将这个属性加入到 AbstractPlayer 中 self.video_format_changed = Signal() def initialize(self): self._mpv.observe_property( 'time-pos', lambda name, position: self._on_position_changed(position) ) self._mpv.observe_property( 'duration', lambda name, duration: self._on_duration_changed(duration) ) self._mpv.observe_property( 'video-format', lambda name, vformat: self._on_video_format_changed(vformat) ) # self._mpv.register_event_callback(lambda event: self._on_event(event)) self._mpv._event_callbacks.append(self._on_event) self._playlist.song_changed.connect(self._on_song_changed) self.song_finished.connect(self._on_song_finished) logger.info('Player initialize finished.') def shutdown(self): self._mpv.terminate() def play(self, url, video=True): # NOTE - API DESGIN: we should return None, see # QMediaPlayer API reference for more details. logger.debug("Player will play: '%s'", url) if video: # FIXME: for some property, we need to set via setattr, however, # some need to be set via _mpv_set_property_string self._mpv.handle.vid = b'auto' # it seems that ytdl will auto choose the default format # if we set ytdl-format to '' _mpv_set_property_string(self._mpv.handle, b'ytdl-format', b'') else: # set vid to no and ytdl-format to bestaudio/best # see https://mpv.io/manual/stable/#options-vid for more details self._mpv.handle.vid = b'no' _mpv_set_property_string(self._mpv.handle, b'ytdl-format', b'bestaudio/best') # Clear playlist before play next song, # otherwise, mpv will seek to the last position and play. self._mpv.playlist_clear() self._mpv.play(url) self._mpv.pause = False self.state = State.playing self._current_url = url self.media_changed.emit(url) def play_song(self, song): """播放指定歌曲 如果目标歌曲与当前歌曲不相同,则修改播放列表当前歌曲, 播放列表会发出 song_changed 信号,player 监听到信号后调用 play 方法, 到那时才会真正的播放新的歌曲。如果和当前播放歌曲相同,则忽略。 .. note:: 调用方不应该直接调用 playlist.current_song = song 来切换歌曲 """ if song is not None and song == self.current_song: logger.warning('The song is already under playing.') else: self._playlist.current_song = song def play_next(self): """播放下一首歌曲 .. note:: 这里不能使用 ``play_song(playlist.next_song)`` 方法来切换歌曲, ``play_song`` 和 ``playlist.current_song = song`` 是有区别的。 """ self.playlist.current_song = self.playlist.next_song def play_previous(self): self.playlist.current_song = self.playlist.previous_song def replay(self): self.playlist.current_song = self.current_song def resume(self): self._mpv.pause = False self.state = State.playing def pause(self): self._mpv.pause = True self.state = State.paused def toggle(self): self._mpv.pause = not self._mpv.pause if self._mpv.pause: self.state = State.paused else: self.state = State.playing def stop(self): self._mpv.pause = True self.state = State.stopped self._current_url = None self._mpv.playlist_clear() logger.info('Player stopped.') @property def position(self): return self._position @position.setter def position(self, position): self._mpv.seek(position, reference='absolute') self._position = position @AbstractPlayer.volume.setter def volume(self, value): super(MpvPlayer, MpvPlayer).volume.__set__(self, value) self._mpv.volume = self.volume @property def video_format(self): self._video_format @video_format.setter def video_format(self, vformat): self._video_format = vformat self.video_format_changed.emit(vformat) def _on_position_changed(self, position): self._position = position self.position_changed.emit(position) def _on_duration_changed(self, duration): """listening to mpv duration change event""" logger.debug('Player receive duration changed signal') self.duration = duration def _on_video_format_changed(self, vformat): self.video_format = vformat def _on_song_changed(self, song): """播放列表 current_song 发生变化后的回调 判断变化后的歌曲是否有效的,有效则播放,否则将它标记为无效歌曲。 如果变化后的歌曲是 None,则停止播放。 """ if song is not None: if song.url: self.play(song.url) else: self._playlist.mark_as_bad(song) self.play_next() else: self.stop() def _on_event(self, event): if event['event_id'] == MpvEventID.END_FILE: reason = event['event']['reason'] logger.debug('Current song finished. reason: %d' % reason) if self.state != State.stopped and reason != MpvEventEndFile.ABORTED: self.song_finished.emit() def _on_song_finished(self): if self._playlist.playback_mode == PlaybackMode.one_loop: self.replay() else: self.play_next() def __log_handler(self, loglevel, component, message): print('[{}] {}: {}'.format(loglevel, component, message))
class MpvPlayer(AbstractPlayer): """ player will always play playlist current song. player will listening to playlist ``song_changed`` signal and change the current playback. todo: make me singleton """ def __init__(self, audio_device=b'auto', winid=None, *args, **kwargs): super(MpvPlayer, self).__init__(*args, **kwargs) # https://github.com/cosven/FeelUOwn/issues/246 locale.setlocale(locale.LC_NUMERIC, 'C') mpvkwargs = {} if winid is not None: mpvkwargs['wid'] = winid mpvkwargs['vo'] = 'opengl-cb' # set log_handler if you want to debug # mpvkwargs['log_handler'] = self.__log_handler # mpvkwargs['msg_level'] = 'all=v' logger.info('libmpv version %s', _mpv_client_api_version()) self._mpv = MPV(ytdl=False, input_default_bindings=True, input_vo_keyboard=True, **mpvkwargs) _mpv_set_property_string(self._mpv.handle, b'audio-device', audio_device) # old version libmpv(for example: (1, 20)) should set option by using # _mpv_set_option_string, while newer version can use _mpv_set_property_string _mpv_set_option_string(self._mpv.handle, b'user-agent', b'Mozilla/5.0 (Windows NT 10.0; Win64; x64)') #: if video_format changes to None, there is no video available self.video_format_changed = Signal() def initialize(self): self._mpv.observe_property( 'time-pos', lambda name, position: self._on_position_changed(position) ) self._mpv.observe_property( 'duration', lambda name, duration: self._on_duration_changed(duration) ) self._mpv.observe_property( 'video-format', lambda name, vformat: self._on_video_format_changed(vformat) ) # self._mpv.register_event_callback(lambda event: self._on_event(event)) self._mpv._event_callbacks.append(self._on_event) self._playlist.song_changed.connect(self._on_song_changed) self.song_finished.connect(self._on_song_finished) logger.debug('Player initialize finished.') def shutdown(self): self._mpv.terminate() def play(self, media, video=True): logger.debug("Player will play: '%s'", media) if isinstance(media, Media): media = media else: # media is a url media = Media(media) self._set_http_headers(media.http_headers) url = media.url # Clear playlist before play next song, # otherwise, mpv will seek to the last position and play. self._mpv.playlist_clear() self._mpv.play(url) self._mpv.pause = False self.state = State.playing self._current_media = media # TODO: we will emit a media object self.media_changed.emit(media) def prepare_media(self, song, done_cb=None): if song.meta.support_multi_quality: media, quality = song.select_media('hq<>') else: media = song.url media = Media(media) if media else None if done_cb is not None: done_cb(media) return media def play_song(self, song): """播放指定歌曲 如果目标歌曲与当前歌曲不相同,则修改播放列表当前歌曲, 播放列表会发出 song_changed 信号,player 监听到信号后调用 play 方法, 到那时才会真正的播放新的歌曲。如果和当前播放歌曲相同,则忽略。 .. note:: 调用方不应该直接调用 playlist.current_song = song 来切换歌曲 """ if song is not None and song == self.current_song: logger.warning('The song is already under playing.') else: self._playlist.current_song = song def play_songs(self, songs): """(alpha) play list of songs""" self.playlist.init_from(songs) self.play_next() def play_next(self): """播放下一首歌曲 .. note:: 这里不能使用 ``play_song(playlist.next_song)`` 方法来切换歌曲, ``play_song`` 和 ``playlist.current_song = song`` 是有区别的。 """ warnings.warn('use playlist.next instead, this will be removed on 3.4') self.playlist.next() def play_previous(self): warnings.warn('use playlist.previous instead, this will be removed on 3.4') self.playlist.previous() def replay(self): self.playlist.current_song = self.current_song def resume(self): self._mpv.pause = False self.state = State.playing def pause(self): self._mpv.pause = True self.state = State.paused def toggle(self): self._mpv.pause = not self._mpv.pause if self._mpv.pause: self.state = State.paused else: self.state = State.playing def stop(self): self._mpv.pause = True self.state = State.stopped self._current_media = None self._mpv.playlist_clear() logger.info('Player stopped.') @property def position(self): return self._position @position.setter def position(self, position): if self._current_media: self._mpv.seek(position, reference='absolute') self._position = position else: logger.warn("can't set position when current media is empty") @AbstractPlayer.volume.setter def volume(self, value): super(MpvPlayer, MpvPlayer).volume.__set__(self, value) self._mpv.volume = self.volume @property def video_format(self): return self._video_format @video_format.setter def video_format(self, vformat): self._video_format = vformat self.video_format_changed.emit(vformat) def _on_position_changed(self, position): self._position = position self.position_changed.emit(position) def _on_duration_changed(self, duration): """listening to mpv duration change event""" logger.debug('Player receive duration changed signal') self.duration = duration def _on_video_format_changed(self, vformat): self.video_format = vformat def _on_song_changed(self, song): """播放列表 current_song 发生变化后的回调 判断变化后的歌曲是否有效的,有效则播放,否则将它标记为无效歌曲。 如果变化后的歌曲是 None,则停止播放。 """ def prepare_callback(media): if media is not None: self.play(media) else: self._playlist.mark_as_bad(song) self.play_next() if song is not None: self.prepare_media(song, done_cb=prepare_callback) else: self.stop() def _on_event(self, event): if event['event_id'] == MpvEventID.END_FILE: reason = event['event']['reason'] logger.debug('Current song finished. reason: %d' % reason) if self.state != State.stopped and reason != MpvEventEndFile.ABORTED: self.song_finished.emit() def _on_song_finished(self): if self._playlist.playback_mode == PlaybackMode.one_loop: self.replay() else: self.play_next() def _set_http_headers(self, http_headers): if http_headers: headers = [] for key, value in http_headers.items(): headers.append("{}: {}".format(key, value)) headers_text = ','.join(headers) headers_bytes = bytes(headers_text, 'utf-8') logger.info('play media with headers: %s', headers_text) _mpv_set_option_string(self._mpv.handle, b'http-header-fields', headers_bytes) else: _mpv_set_option_string(self._mpv.handle, b'http-header-fields', b'') def __log_handler(self, loglevel, component, message): print('[{}] {}: {}'.format(loglevel, component, message))
class WotabagManager(object): def __init__(self, config_file): yaml = YAML(typ='safe') if isinstance(config_file, IOBase): config = yaml.load(config_file) else: with open(config_file) as f: config = yaml.load(f) logging.config.dictConfig(config.get('logging', {})) self.logger = logging.getLogger('wotabag') self.rpc_host = config.get('rpc_host', '127.0.0.1') self.rpc_port = config.get('rpc_port', 60715) self._load_playlist(config.get('playlist', [])) volume = int(config.get('volume', 0)) if volume > 100: volume = 100 elif volume < 0: volume = 0 self.volume = volume self.current_track = 0 self.status = WotabagStatus.IDLE self._status_lock = Semaphore() # init LED strip self.strip = init_strip() self.strip.begin() # init playback self.player = None self.song = None self._stopped = Event() self._stopped.set() self._playback_thread = None def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): if self.status == WotabagStatus.PLAYING: self._stopped.set() if self._playback_thread: self._playback_thread.join() if self.player: self.player.terminate() def _mpv_log(self, loglevel, component, message): f = { 'fatal': self.logger.critical, 'error': self.logger.error, 'warn': self.logger.warning, 'info': self.logger.info, 'verbose': self.logger.debug, 'debug': self.logger.debug, 'trace': self.logger.debug, } if loglevel in f: f[loglevel]('[MPV] {}: {}'.format(component, message)) def _load_file(self, yaml_file): yaml = YAML(typ='safe') with open(yaml_file) as f: song = yaml.load(f) self.logger.debug('loaded {} ({})'.format(song['title'], song['filename'])) self.song = song def _load_playlist(self, playlist): yaml = YAML(typ='safe') self.playlist = [] for yaml_file in playlist: with open(yaml_file) as f: song = yaml.load(f) self.playlist.append((yaml_file, song['title'])) def _play(self): self._stop() self._stopped.clear() self._status_lock.acquire() self.status = WotabagStatus.PLAYING self._status_lock.release() self._playback_thread = Thread(target=self._wota_playback) self.logger.debug('starting wota playback thread') self._playback_thread.start() def _stop(self): self._stopped.set() if self._playback_thread: self._playback_thread.join() self._playback_thread = None self.logger.debug('joined wota playback thread') if self.player: self.player.terminate() self.player = None self.song = None for i in range(self.strip.numPixels()): self.strip.setPixelColor(i, BladeColor.NONE.value) self.strip.show() self._status_lock.acquire() self.status = WotabagStatus.IDLE self._status_lock.release() def _wota_playback(self): while self.current_track < len(self.playlist): song, _ = self.playlist[self.current_track] self._load_file(song) if self.player: self.player.terminate() self.player = MPV(vid='no', hwdec='mmal', keep_open='yes', volume=self.volume, log_handler=self._mpv_log) self.player.play(self.song['filename']) # wait for mpv to actually start playing playback_lock = Semaphore(value=0) def observer(name, val): if val is not None: playback_lock.release() self.player.observe_property('time-pos', observer) playback_lock.acquire() self.player.unobserve_property('time-pos', observer) start = time.time() ticks = 0 # drift = 0 bpm = 120 cur_colors = { 'left': BladeColor.YOSHIKO, 'center': BladeColor.YOSHIKO, 'right': BladeColor.YOSHIKO, } initial_offset = self.song.get('initial_offset', 0) if initial_offset: time.sleep((start + initial_offset / 1000) - time.time()) last_tick = time.perf_counter() # play led patterns for pattern in self.song['patterns']: if 'bpm' in pattern: bpm = pattern['bpm'] for k in ['left', 'center', 'right']: if k in pattern: if isinstance(pattern[k], list): colors = [] for c in pattern[k]: color = c.upper() if color in BladeColor.__members__: colors.append(BladeColor[color]) cur_colors[k] = tuple(colors) else: color = pattern[k].upper() if color in BladeColor.__members__: cur_colors[k] = BladeColor[color] kwargs = pattern.get('kwargs', {}) kwargs.update(cur_colors) wota = WOTA_TYPE[pattern['type']](bpm=bpm, strip=self.strip, **kwargs) count = pattern.get('count', 1) for i in range(count): for _ in range(len(wota)): if self._stopped.is_set(): return next_tick = last_tick + wota.tick_s # if i == 0 or i == count - 1: # loop = False # else: # loop = True loop = True wota.tick(loop=loop) diff = next_tick - time.perf_counter() last_tick = next_tick # drift = 0 if diff > 0: time.sleep(diff) # elif diff < 0: # drift = diff ticks += 1 with self.player._playback_cond: # wait for mpv to reach the end of the audio file or 5 seconds, # whichever comes first self.player._playback_cond.wait(5) # end of song, setup next track self.player.terminate() self.player = None for i in range(self.strip.numPixels()): self.strip.setPixelColor(i, BladeColor.NONE.value) self.strip.show() self.current_track += 1 self.current_track = 0 self._status_lock.acquire() self.status = WotabagStatus.IDLE self._status_lock.release() @public def get_playlist(self): """Return this wotabag's playlist.""" self.logger.info('[RPC] wotabag.get_playlist') return [title for _, title in self.playlist] @public def get_status(self): """Return current status.""" self.logger.info('[RPC] wotabag.get_status') result = { 'status': self.status.name, 'volume': self.volume, } if self.status == WotabagStatus.IDLE: if self.playlist: _, title = self.playlist[self.current_track] result['next_track'] = title else: result['next_track'] = None elif self.status == WotabagStatus.PLAYING: _, title = self.playlist[self.current_track] result['current_track'] = title next_track = self.current_track + 1 if next_track < len(self.playlist): _, title = self.playlist[next_track] result['next_track'] = title else: result['next_track'] = None return result @public def get_volume(self): """Return current volume.""" self.logger.info('[RPC] wotabag.get_volume') return self.volume @public def set_volume(self, volume): """Set volume.""" self.logger.info('[RPC] wotabag.set_volume {}'.format(volume)) volume = int(volume) if volume > 100: volume = 100 elif volume < 0: volume = 0 self.volume = volume if self.player: self.player.volume = self.volume return self.volume @public def get_colors(self): """Return list of available colors.""" self.logger.info('[RPC] wotabag.get_colors') colors = ['None'] + \ [x.name.capitalize() for x in aqours] + list(aqours_units.keys()) + ['Aqours Rainbow'] + \ [x.name.capitalize() for x in saint_snow] + ['Saint Snow'] + \ [x.name.capitalize() for x in muse] + list(muse_units.keys()) return colors @public def set_color(self, color): """Set all LEDs to the specified color or color sequence.""" self.logger.info('[RPC] wotabag.set_color {}'.format(color)) if color == 'Aqours Rainbow': colors = aqours_rainbow elif color in aqours_units: colors = aqours_units[color] elif color == 'Saint Snow': colors = saint_snow elif color in muse_units: colors = muse_units[color] elif color.upper() in BladeColor.__members__: colors = (BladeColor[color.upper()],) else: raise BadRequestError('Unknown color') strip = self.strip if len(colors) == 1: for i in range(strip.numPixels()): strip.setPixelColor(i, colors[0].value) strip.show() elif len(colors) <= 3: if len(colors) == 2: colors = colors + (colors[0],) for x, color in (enumerate(colors)): for y in range(9): strip.setPixelColor(pixel_index(x, y), color.value) strip.show() elif len(colors) == 9: for x in range(3): for y, color in enumerate(colors): strip.setPixelColor(pixel_index(x, y), color.value) strip.show() @public def power_off(self): """Power off the device. Note: If 'shutdown -h' fails, the returncode will be returned. If shutdown succeeds, the connection will be dropped immediately (this will appear as a returned None to a tinyrpc client). """ self.logger.info('[RPC] wotabag.power_off') # clear led's before power off otherwise they will stay turned on until # the separate led battery source is manually switched off for i in range(self.strip.numPixels()): self.strip.setPixelColor(i, BladeColor.NONE.value) self.strip.show() proc = subprocess.run(['shutdown', '-h', 'now']) return proc.returncode @public def play(self): """Start playback of the next song.""" self.logger.info('[RPC] wotabag.play') self._play() @public def play_index(self, index): """Start playback of the specified song.""" self.logger.info('[RPC] wotabag.play_index {}'.format(index)) if index >= len(self.playlist) or index < 0: raise BadRequestError('Invalid song index') self.current_track = index self._play() @public def stop(self): """Stop playback.""" self.logger.info('[RPC] wotabag.stop') self._stop() @public def test_pattern(self): """Display test color wipe patterns.""" self.logger.info('[RPC] wotabag.test_pattern') gevent.spawn_later(0, test_wipe, self.strip, clear=True)
class MpvPlayer(AbstractPlayer): """ player will always play playlist current song. player will listening to playlist ``song_changed`` signal and change the current playback. TODO: make me singleton """ def __init__(self, audio_device=b'auto', *args, **kwargs): super(MpvPlayer, self).__init__() self._mpv = MPV(ytdl=False, input_default_bindings=True, input_vo_keyboard=True) _mpv_set_property_string(self._mpv.handle, b'audio-device', audio_device) self._playlist = Playlist() self._playlist.song_changed.connect(self._on_song_changed) def initialize(self): self._mpv.observe_property( 'time-pos', lambda name, position: self._on_position_changed(position)) self._mpv.observe_property( 'duration', lambda name, duration: self._on_duration_changed(duration)) # self._mpv.register_event_callback(lambda event: self._on_event(event)) self._mpv.event_callbacks.append(self._on_event) self.song_finished.connect(self.play_next) logger.info('Player initialize finished.') def shutdown(self): del self._mpv def play(self, url): # NOTE - API DESGIN: we should return None, see # QMediaPlayer API reference for more details. logger.debug("Player will play: '%s'", url) # Clear playlist before play next song, # otherwise, mpv will seek to the last position and play. self._mpv.playlist_clear() self._mpv.play(url) self._mpv.pause = False self.state = State.playing self.media_changed.emit(url) def play_song(self, song): if self.playlist.current_song is not None and \ self.playlist.current_song == song: logger.warning('the song to be played is same as current song') return url = song.url if url: self._playlist.current_song = song else: logger.warning("Invalid song: song url can't be None or ''") self._playlist.mark_as_bad(song) def play_next(self): self.playlist.current_song = self.playlist.next_song def play_previous(self): self.playlist.current_song = self.playlist.previous_song def resume(self): self._mpv.pause = False self.state = State.playing def pause(self): self._mpv.pause = True self.state = State.paused def toggle(self): self._mpv.pause = not self._mpv.pause if self._mpv.pause: self.state = State.paused else: self.state = State.playing def stop(self): logger.info('stop player...') self._mpv.pause = True self.state = State.stopped self._mpv.playlist_clear() @property def position(self): return self._position @position.setter def position(self, position): self._mpv.seek(position, reference='absolute') self._position = position @AbstractPlayer.volume.setter def volume(self, value): super(MpvPlayer, MpvPlayer).volume.__set__(self, value) self._mpv.volume = self.volume def _on_position_changed(self, position): self._position = position self.position_changed.emit(position) def _on_duration_changed(self, duration): """listening to mpv duration change event""" logger.info('player receive duration changed signal') self.duration = duration def _on_song_changed(self, song): logger.debug('player received song changed signal') if song is not None: logger.info('Will play song: %s' % self._playlist.current_song) self.play(song.url) else: self.stop() logger.info('playlist provide no song anymore.') def _on_event(self, event): if event['event_id'] == MpvEventID.END_FILE: reason = event['event']['reason'] logger.debug('Current song finished. reason: %d' % reason) if self.state != State.stopped and reason != MpvEventEndFile.ABORTED: self.song_finished.emit()
class MpvPlayer(AbstractPlayer): """ player will always play playlist current song. player will listening to playlist ``song_changed`` signal and change the current playback. todo: make me singleton """ def __init__(self, audio_device=b'auto', winid=None, *args, **kwargs): super(MpvPlayer, self).__init__(*args, **kwargs) # https://github.com/cosven/FeelUOwn/issues/246 locale.setlocale(locale.LC_NUMERIC, 'C') mpvkwargs = {} if winid is not None: mpvkwargs['wid'] = winid mpvkwargs['vo'] = 'opengl-cb' # set log_handler if you want to debug # mpvkwargs['log_handler'] = self.__log_handler # mpvkwargs['msg_level'] = 'all=v' logger.info('libmpv version %s', _mpv_client_api_version()) self._mpv = MPV(ytdl=False, input_default_bindings=True, input_vo_keyboard=True, **mpvkwargs) _mpv_set_property_string(self._mpv.handle, b'audio-device', audio_device) # old version libmpv(for example: (1, 20)) should set option by using # _mpv_set_option_string, while newer version can use _mpv_set_property_string _mpv_set_option_string(self._mpv.handle, b'user-agent', b'Mozilla/5.0 (Windows NT 10.0; Win64; x64)') #: if video_format changes to None, there is no video available self.video_format_changed = Signal() self._mpv.observe_property( 'time-pos', lambda name, position: self._on_position_changed(position)) self._mpv.observe_property( 'duration', lambda name, duration: self._on_duration_changed(duration)) self._mpv.observe_property( 'video-format', lambda name, vformat: self._on_video_format_changed(vformat)) # self._mpv.register_event_callback(lambda event: self._on_event(event)) self._mpv._event_callbacks.append(self._on_event) logger.debug('Player initialize finished.') def shutdown(self): self._mpv.terminate() def play(self, media, video=True): logger.debug("Player will play: '%s'", media) if isinstance(media, Media): media = media else: # media is a url media = Media(media) self._set_http_headers(media.http_headers) url = media.url # Clear playlist before play next song, # otherwise, mpv will seek to the last position and play. self.media_about_to_changed.emit(self._current_media, media) self._mpv.playlist_clear() self._mpv.play(url) self._current_media = media self.media_changed.emit(media) def set_play_range(self, start=None, end=None): start = str(start) if start is not None else 'none' end = str(end) if end is not None else 'none' _mpv_set_option_string(self._mpv.handle, b'start', bytes(start, 'utf-8')) _mpv_set_option_string(self._mpv.handle, b'end', bytes(end, 'utf-8')) def resume(self): self._mpv.pause = False self.state = State.playing def pause(self): self._mpv.pause = True self.state = State.paused def toggle(self): self._mpv.pause = not self._mpv.pause if self._mpv.pause: self.state = State.paused else: self.state = State.playing def stop(self): self._mpv.pause = True self.state = State.stopped self._current_media = None self._mpv.playlist_clear() logger.info('Player stopped.') @property def position(self): return self._position @position.setter def position(self, position): if self._current_media: self._mpv.seek(position, reference='absolute') self._position = position else: logger.warn("can't set position when current media is empty") @AbstractPlayer.volume.setter def volume(self, value): super(MpvPlayer, MpvPlayer).volume.__set__(self, value) self._mpv.volume = self.volume @property def video_format(self): return self._video_format @video_format.setter def video_format(self, vformat): self._video_format = vformat self.video_format_changed.emit(vformat) def _on_position_changed(self, position): self._position = position self.position_changed.emit(position) def _on_duration_changed(self, duration): """listening to mpv duration change event""" logger.debug('Player receive duration changed signal') self.duration = duration def _on_video_format_changed(self, vformat): self.video_format = vformat def _on_event(self, event): if event['event_id'] == MpvEventID.END_FILE: reason = event['event']['reason'] logger.debug('Current song finished. reason: %d' % reason) if self.state != State.stopped and reason != MpvEventEndFile.ABORTED: self.media_finished.emit() def _set_http_headers(self, http_headers): if http_headers: headers = [] for key, value in http_headers.items(): headers.append("{}: {}".format(key, value)) headers_text = ','.join(headers) headers_bytes = bytes(headers_text, 'utf-8') logger.info('play media with headers: %s', headers_text) _mpv_set_option_string(self._mpv.handle, b'http-header-fields', headers_bytes) else: _mpv_set_option_string(self._mpv.handle, b'http-header-fields', b'') def __log_handler(self, loglevel, component, message): print('[{}] {}: {}'.format(loglevel, component, message))