From f86eeef5495f2a78a2a5f9272e5c333e481c57fa Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 27 Jun 2019 23:52:40 +0200 Subject: [PATCH] New media webplugin WIP --- .../js/plugins/media/handlers/youtube.js | 2 +- .../http/static/js/plugins/media/index.js | 54 ++-- .../js/plugins/media/players/browser.js | 15 +- .../js/plugins/media/players/chromecast.js | 30 +- platypush/plugins/media/chromecast.py | 272 ++++++++++++------ platypush/plugins/media/webtorrent.py | 2 +- 6 files changed, 251 insertions(+), 124 deletions(-) diff --git a/platypush/backend/http/static/js/plugins/media/handlers/youtube.js b/platypush/backend/http/static/js/plugins/media/handlers/youtube.js index 6ea9e07c..bbf0c0c5 100644 --- a/platypush/backend/http/static/js/plugins/media/handlers/youtube.js +++ b/platypush/backend/http/static/js/plugins/media/handlers/youtube.js @@ -38,7 +38,7 @@ MediaHandlers.youtube = MediaHandlers.base.extend({ methods: { matchesUrl: function(url) { - return !!(url.match('^https?://(www\.)?youtube.com/') || url.match('^https?://youtu.be/')); + return !!(url.match('^https?://(www\.)?youtube.com/') || url.match('^https?://youtu.be/') || url.match('^https?://.*googlevideo.com/')); }, getMetadata: function(url) { diff --git a/platypush/backend/http/static/js/plugins/media/index.js b/platypush/backend/http/static/js/plugins/media/index.js index f83bd90e..a4d79133 100644 --- a/platypush/backend/http/static/js/plugins/media/index.js +++ b/platypush/backend/http/static/js/plugins/media/index.js @@ -48,6 +48,7 @@ Vue.component('media', { results: [], status: {}, selectedDevice: {}, + deviceHandlers: {}, loading: { results: false, @@ -175,19 +176,13 @@ Vue.component('media', { subtitles: item.subtitles, }); - const hostRegex = /^(https?:\/\/[^:/]+(:[0-9]+)?\/?)/; - const baseURL = window.location.href.match(hostRegex)[1]; - - ret.url = ret.url.replace(hostRegex, baseURL); - if (ret.subtitles_url) - ret.subtitles_url = ret.subtitles_url.replace(hostRegex, baseURL); - this.bus.$emit('streaming-started', { url: ret.url, resource: item.url, + subtitles_url: ret.subtiles_url, }); - return ret; + return {...item, ...ret}; }, searchSubs: function(item) { @@ -209,6 +204,9 @@ Vue.component('media', { }, syncPosition: function(status) { + if (!status) + return; + status._syncTime = { timestamp: new Date(), position: status.position, @@ -227,10 +225,36 @@ Vue.component('media', { if (!this.status[dev.type]) Vue.set(this.status, dev.type, {}); Vue.set(this.status[dev.type], dev.name, status); + + if (!this.deviceHandlers[dev.type]) + Vue.set(this.deviceHandlers, dev.type, {}); + Vue.set(this.deviceHandlers[dev.type], dev.name, dev); }, onMediaEvent: async function(event) { - let status = await request(event.plugin + '.status'); + var type, player; + const plugin = event.plugin.replace(/^media\./, ''); + + if (this.status[event.player] && this.status[event.player][plugin]) { + type = event.player; + player = plugin; + } else if (this.status[plugin] && this.status[plugin][event.player]) { + type = plugin; + player = event.player; + } + + var handler; + if (this.deviceHandlers[event.player] && this.deviceHandlers[event.player][plugin]) { + handler = this.deviceHandlers[event.player][plugin]; + } else if (this.deviceHandlers[plugin] && this.deviceHandlers[plugin][event.player]) { + handler = this.deviceHandlers[plugin][event.player]; + } else { + // No handlers + console.warn('No handlers found for device type '.concat(event.plugin, ' and player ', event.player)); + return; + } + + let status = await handler.status(event.player); this.syncPosition(status); if (event.resource) { @@ -238,18 +262,6 @@ Vue.component('media', { delete event.resource; } - if (event.plugin.startsWith('media.')) - event.plugin = event.plugin.substr(6); - - var type, player; - if (this.status[event.player] && this.status[event.player][event.plugin]) { - type = event.player; - player = event.plugin; - } else if (this.status[event.plugin] && this.status[event.plugin][event.player]) { - type = event.plugin; - player = event.player; - } - if (status.state !== 'stop') { status.title = status.title || this.status[type][player].title; } diff --git a/platypush/backend/http/static/js/plugins/media/players/browser.js b/platypush/backend/http/static/js/plugins/media/players/browser.js index e516adb1..140e1ea9 100644 --- a/platypush/backend/http/static/js/plugins/media/players/browser.js +++ b/platypush/backend/http/static/js/plugins/media/players/browser.js @@ -40,16 +40,17 @@ MediaPlayers.browser = Vue.extend({ play: async function(item, subtitles) { let url = item.url; - if (item.source && item.source.startsWith('file://')) - url += '?webplayer' - let playerWindow = window.open(url, '_blank'); - console.log(playerWindow); + if (item.source && !item.source.match('https?://')) { + // Non-HTTP resource streamed over HTTP + const hostRegex = /^(https?:\/\/[^:/]+(:[0-9]+)?\/?)/; + const baseURL = window.location.href.match(hostRegex)[1]; + url = url.replace(hostRegex, baseURL) + '?webplayer'; + } + + window.open(url, '_blank'); return {}; }, - - stop: async function() { - }, }, }); diff --git a/platypush/backend/http/static/js/plugins/media/players/chromecast.js b/platypush/backend/http/static/js/plugins/media/players/chromecast.js index 5834cd33..c53d3807 100644 --- a/platypush/backend/http/static/js/plugins/media/players/chromecast.js +++ b/platypush/backend/http/static/js/plugins/media/players/chromecast.js @@ -48,14 +48,40 @@ MediaPlayers.chromecast = Vue.extend({ return await request('media.chromecast.get_chromecasts'); }, - status: async function() { - return {}; + status: async function(device) { + return await request('media.chromecast.status', {chromecast: device || this.device.name}); }, play: async function(item) { + return await request('media.chromecast.play', { + resource: item.url, + chromecast: this.device.name, + title: item.title || item.url, + subtitles: item.subtitles_url, + content_type: item.mime_type, + }); + }, + + pause: async function() { + return await request('media.chromecast.pause', {chromecast: this.device.name}); }, stop: async function() { + return await request('media.chromecast.stop', {chromecast: this.device.name}); + }, + + seek: async function(position) { + return await request('media.chromecast.set_position', { + position: position, + chromecast: this.device.name, + }); + }, + + setVolume: async function(volume) { + return await request('media.chromecast.set_volume', { + volume: volume, + chromecast: this.device.name, + }); }, }, }); diff --git a/platypush/plugins/media/chromecast.py b/platypush/plugins/media/chromecast.py index fb6788db..38007340 100644 --- a/platypush/plugins/media/chromecast.py +++ b/platypush/plugins/media/chromecast.py @@ -1,15 +1,56 @@ import datetime import re import pychromecast +import time from pychromecast.controllers.youtube import YouTubeController from platypush.context import get_plugin, get_bus -from platypush.plugins import Plugin, action +from platypush.plugins import action from platypush.plugins.media import MediaPlugin from platypush.utils import get_mime_type from platypush.message.event.media import MediaPlayEvent, MediaPlayRequestEvent, \ - MediaStopEvent, MediaPauseEvent, NewPlayingMediaEvent + MediaStopEvent, MediaPauseEvent, NewPlayingMediaEvent, MediaVolumeChangedEvent, MediaSeekEvent + + +def convert_status(status): + attrs = [a for a in dir(status) if not a.startswith('_') + and not callable(getattr(status, a))] + + renamed_attrs = { + 'current_time': 'position', + 'player_state': 'state', + 'supports_seek': 'seekable', + 'volume_level': 'volume', + 'volume_muted': 'mute', + 'content_id': 'url', + } + + ret = {} + for attr in attrs: + value = getattr(status, attr) + if attr == 'volume_level': + value *= 100 + if attr == 'player_state': + value = value.lower() + if value == 'paused': + value = 'pause' + if value == 'playing': + value = 'play' + if isinstance(value, datetime.datetime): + value = value.isoformat() + + if attr in renamed_attrs: + ret[renamed_attrs[attr]] = value + else: + ret[attr] = value + + return ret + + +def post_event(evt_type, **evt): + bus = get_bus() + bus.post(evt_type(player=evt.get('device'), plugin='media.chromecast', **evt)) class MediaChromecastPlugin(MediaPlugin): @@ -31,6 +72,47 @@ class MediaChromecastPlugin(MediaPlugin): STREAM_TYPE_BUFFERED = "BUFFERED" STREAM_TYPE_LIVE = "LIVE" + class MediaListener: + def __init__(self, name, cast): + self.name = name + self.cast = cast + self.status = convert_status(cast.media_controller.status) + self.last_status_timestamp = time.time() + + def new_media_status(self, status): + status = convert_status(status) + + if status.get('url') and status.get('url') != self.status.get('url'): + post_event(NewPlayingMediaEvent, resource=status['url'], + title=status.get('title'), device=self.name) + if status.get('state') != self.status.get('state'): + if status.get('state') == 'play': + post_event(MediaPlayEvent, resource=status['url'], device=self.name) + elif status.get('state') == 'pause': + post_event(MediaPauseEvent, resource=status['url'], device=self.name) + elif status.get('state') in ['stop', 'idle']: + post_event(MediaStopEvent, device=self.name) + if status.get('volume') != self.status.get('volume'): + post_event(MediaVolumeChangedEvent, volume=status.get('volume'), device=self.name) + if abs(status.get('position') - self.status.get('position')) > time.time() - self.last_status_timestamp + 5: + post_event(MediaSeekEvent, position=status.get('position'), device=self.name) + + self.last_status_timestamp = time.time() + self.status = status + + class SubtitlesAsyncHandler: + def __init__(self, mc, subtitle_id): + self.mc = mc + self.subtitle_id = subtitle_id + self.initialized = False + + # pylint: disable=unused-argument + def new_media_status(self, status): + if self.subtitle_id and not self.initialized: + self.mc.update_status() + self.mc.enable_subtitle(self.subtitle_id) + self.initialized = True + def __init__(self, chromecast=None, *args, **kwargs): """ :param chromecast: Default Chromecast to cast to if no name is specified @@ -42,7 +124,7 @@ class MediaChromecastPlugin(MediaPlugin): self._is_local = False self.chromecast = chromecast self.chromecasts = {} - + self._media_listeners = {} @action def get_chromecasts(self, tries=2, retry_wait=10, timeout=60, @@ -76,7 +158,10 @@ class MediaChromecastPlugin(MediaPlugin): callback=callback) }) - return [ { + for name, cast in self.chromecasts.items(): + self._update_listeners(name, cast) + + return [{ 'type': cc.cast_type, 'name': cc.name, 'manufacturer': cc.device.manufacturer, @@ -99,8 +184,13 @@ class MediaChromecastPlugin(MediaPlugin): 'volume': round(100*cc.status.volume_level, 2), 'muted': cc.status.volume_muted, } if cc.status else {}), - } for cc in self.chromecasts.values() ] + } for cc in self.chromecasts.values()] + def _update_listeners(self, name, cast): + if name not in self._media_listeners: + cast.start() + self._media_listeners[name] = self.MediaListener(name=name, cast=cast) + cast.media_controller.register_status_listener(self._media_listeners[name]) def get_chromecast(self, chromecast=None, n_tries=2): if isinstance(chromecast, pychromecast.Chromecast): @@ -111,7 +201,6 @@ class MediaChromecastPlugin(MediaPlugin): raise RuntimeError('No Chromecast specified nor default Chromecast configured') chromecast = self.chromecast - if chromecast not in self.chromecasts: casts = {} while n_tries > 0: @@ -128,15 +217,16 @@ class MediaChromecastPlugin(MediaPlugin): if chromecast not in self.chromecasts: raise RuntimeError('Device {} not found'.format(chromecast)) - return self.chromecasts[chromecast] - + cast = self.chromecasts[chromecast] + cast.wait() + return cast @action def play(self, resource, content_type=None, chromecast=None, title=None, - image_url=None, autoplay=True, current_time=0, - stream_type=STREAM_TYPE_BUFFERED, subtitles=None, - subtitles_lang='en-US', subtitles_mime='text/vtt', - subtitle_id=1): + image_url=None, autoplay=True, current_time=0, + stream_type=STREAM_TYPE_BUFFERED, subtitles=None, + subtitles_lang='en-US', subtitles_mime='text/vtt', + subtitle_id=1): """ Cast media to a visible Chromecast @@ -146,7 +236,8 @@ class MediaChromecastPlugin(MediaPlugin): :param content_type: Content type as a MIME type string :type content_type: str - :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast + will be used. :type chromecast: str :param title: Optional title @@ -180,11 +271,8 @@ class MediaChromecastPlugin(MediaPlugin): if not chromecast: chromecast = self.chromecast - get_bus().post(MediaPlayRequestEvent(resource=resource, - device=chromecast)) - + post_event(MediaPlayRequestEvent, resource=resource, device=chromecast) cast = self.get_chromecast(chromecast) - cast.wait() mc = cast.media_controller yt = self._get_youtube_url(resource) @@ -201,7 +289,7 @@ class MediaChromecastPlugin(MediaPlugin): resource = self._get_resource(resource) if resource.startswith('magnet:?'): - player_args = { 'chromecast': cast } + player_args = {'chromecast': chromecast} return get_plugin('media.webtorrent').play(resource, player='chromecast', **player_args) @@ -226,11 +314,11 @@ class MediaChromecastPlugin(MediaPlugin): subtitles_lang=subtitles_lang, subtitles_mime=subtitles_mime, subtitle_id=subtitle_id) + if subtitles: + mc.register_status_listener(self.SubtitlesAsyncHandler(mc, subtitle_id)) + mc.block_until_active() - get_bus().post(MediaPlayEvent(resource=resource, - device=chromecast)) - - + return self.status(chromecast=chromecast) @classmethod def _get_youtube_url(cls, url): @@ -250,32 +338,40 @@ class MediaChromecastPlugin(MediaPlugin): @action def pause(self, chromecast=None): - cast = self.get_chromecast(chromecast or self.chromecast) - if cast.media_controller.is_paused: - ret = cast.media_controller.play() - get_bus().post(MediaPlayEvent(device=chromecast or self.chromecast)) - return ret - elif cast.media_controller.is_playing: - ret = cast.media_controller.pause() - get_bus().post(MediaPauseEvent(device=chromecast or self.chromecast)) - return ret + chromecast = chromecast or self.chromecast + cast = self.get_chromecast(chromecast) + if cast.media_controller.is_paused: + cast.media_controller.play() + elif cast.media_controller.is_playing: + cast.media_controller.pause() + + cast.wait() + return self.status(chromecast=chromecast) @action def stop(self, chromecast=None): - ret = self.get_chromecast(chromecast or self.chromecast).media_controller.stop() - get_bus().post(MediaStopEvent(device=chromecast or self.chromecast)) - return ret + chromecast = chromecast or self.chromecast + cast = self.get_chromecast(chromecast) + cast.media_controller.stop() + cast.wait() + return self.status(chromecast=chromecast) @action def rewind(self, chromecast=None): - return self.get_chromecast(chromecast or self.chromecast).media_controller.rewind() - + chromecast = chromecast or self.chromecast + cast = self.get_chromecast(chromecast) + cast.media_controller.rewind() + cast.wait() + return self.status(chromecast=chromecast) @action def set_position(self, position, chromecast=None): - return self.get_chromecast(chromecast or self.chromecast).media_controller.seek(position) + cast = self.get_chromecast(chromecast or self.chromecast) + cast.media_controller.seek(position) + cast.wait() + return self.status(chromecast=chromecast) @action def seek(self, position, chromecast=None): @@ -283,39 +379,41 @@ class MediaChromecastPlugin(MediaPlugin): @action def back(self, chromecast=None, offset=60): - mc = self.get_chromecast(chromecast or self.chromecast).media_controller + cast = self.get_chromecast(chromecast or self.chromecast) + mc = cast.media_controller if mc.status.current_time: - return mc.seek(mc.status.current_time-offset) + mc.seek(mc.status.current_time-offset) + cast.wait() + return self.status(chromecast=chromecast) @action def forward(self, chromecast=None, offset=60): - mc = self.get_chromecast(chromecast or self.chromecast).media_controller + cast = self.get_chromecast(chromecast or self.chromecast) + mc = cast.media_controller if mc.status.current_time: - return mc.seek(mc.status.current_time+offset) + mc.seek(mc.status.current_time+offset) + cast.wait() + return self.status(chromecast=chromecast) @action def is_playing(self, chromecast=None): return self.get_chromecast(chromecast or self.chromecast).media_controller.is_playing - @action def is_paused(self, chromecast=None): return self.get_chromecast(chromecast or self.chromecast).media_controller.is_paused - @action def is_idle(self, chromecast=None): return self.get_chromecast(chromecast or self.chromecast).media_controller.is_idle - @action def list_subtitles(self, chromecast=None): return self.get_chromecast(chromecast or self.chromecast) \ .media_controller.subtitle_tracks - @action def enable_subtitles(self, chromecast=None, track_id=None): mc = self.get_chromecast(chromecast or self.chromecast).media_controller @@ -324,7 +422,6 @@ class MediaChromecastPlugin(MediaPlugin): elif mc.subtitle_tracks: return mc.enable_subtitle(mc.subtitle_tracks[0].get('trackId')) - @action def disable_subtitles(self, chromecast=None, track_id=None): mc = self.get_chromecast(chromecast or self.chromecast).media_controller @@ -344,51 +441,26 @@ class MediaChromecastPlugin(MediaPlugin): else: return self.enable_subtitles(chromecast, all_subs[0].get('trackId')) - @action def status(self, chromecast=None): - status = self.get_chromecast(chromecast or self.chromecast) \ - .media_controller.status - attrs = [a for a in dir(status) if not a.startswith('_') - and not callable(getattr(status, a))] - renamed_attrs = { - 'player_state': 'state', - 'volume_level': 'volume', - 'volume_muted': 'muted', - } - - ret = {} - for attr in attrs: - value = getattr(status, attr) - if attr == 'volume_level': - value *= 100 - if attr == 'player_state': - value = value.lower() - if value == 'paused': value = 'pause' - if value == 'playing': value = 'play' - if isinstance(value, datetime.datetime): - value = value.isoformat() - - if attr in renamed_attrs: - ret[renamed_attrs[attr]] = value - else: - ret[attr] = value - - return ret - + cast = self.get_chromecast(chromecast or self.chromecast) + status = cast.media_controller.status + return convert_status(status) @action def disconnect(self, chromecast=None, timeout=None, blocking=True): """ Disconnect a Chromecast and wait for it to terminate - :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast + will be used. :type chromecast: str :param timeout: Number of seconds to wait for disconnection (default: None: block until termination) :type timeout: float - :param blocking: If set (default), then the code will wait until disconnection, otherwise it will return immediately. + :param blocking: If set (default), then the code will wait until disconnection, otherwise it will return + immediately. :type blocking: bool """ @@ -396,29 +468,28 @@ class MediaChromecastPlugin(MediaPlugin): cast.disconnect(timeout=timeout, blocking=blocking) @action - def join(self, chromecast=None, timeout=None, blocking=True): + def join(self, chromecast=None, timeout=None): """ Blocks the thread until the Chromecast connection is terminated. - :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast + will be used. :type chromecast: str :param timeout: Number of seconds to wait for disconnection (default: None: block until termination) :type timeout: float - - :param blocking: If set (default), then the code will wait until disconnection, otherwise it will return immediately. - :type blocking: bool """ cast = self.get_chromecast(chromecast) - cast.join(timeout=timeout, blocking=blocking) + cast.join(timeout=timeout) @action def quit(self, chromecast=None): """ Exits the current app on the Chromecast - :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast + will be used. :type chromecast: str """ @@ -430,7 +501,8 @@ class MediaChromecastPlugin(MediaPlugin): """ Reboots the Chromecast - :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast + will be used. :type chromecast: str """ @@ -445,58 +517,74 @@ class MediaChromecastPlugin(MediaPlugin): :param volume: Volume to be set, between 0 and 100 :type volume: float - :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast + will be used. :type chromecast: str """ + chromecast = chromecast or self.chromecast cast = self.get_chromecast(chromecast) cast.set_volume(volume/100) + cast.wait() + status = self.status(chromecast=chromecast) + status.output['volume'] = volume + return status @action def volup(self, chromecast=None, step=10): """ Turn up the Chromecast volume by 10% or step. - :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast + will be used. :type chromecast: str :param step: Volume increment between 0 and 100 (default: 100%) :type step: float """ + chromecast = chromecast or self.chromecast cast = self.get_chromecast(chromecast) step /= 100 cast.volume_up(min(step, 1)) - + cast.wait() + return self.status(chromecast=chromecast) @action def voldown(self, chromecast=None, step=10): """ Turn down the Chromecast volume by 10% or step. - :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast + will be used. :type chromecast: str :param step: Volume decrement between 0 and 100 (default: 100%) :type step: float """ + chromecast = chromecast or self.chromecast cast = self.get_chromecast(chromecast) step /= 100 cast.volume_down(max(step, 0)) - + cast.wait() + return self.status(chromecast=chromecast) @action def mute(self, chromecast=None): """ Toggle the mute status on the Chromecast - :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast will be used. + :param chromecast: Chromecast to cast to. If none is specified, then the default configured Chromecast + will be used. :type chromecast: str """ + chromecast = chromecast or self.chromecast cast = self.get_chromecast(chromecast) cast.set_volume_muted(not cast.status.volume_muted) + cast.wait() + return self.status(chromecast=chromecast) # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/media/webtorrent.py b/platypush/plugins/media/webtorrent.py index 6c92e0c8..12243d77 100644 --- a/platypush/plugins/media/webtorrent.py +++ b/platypush/plugins/media/webtorrent.py @@ -366,7 +366,7 @@ class MediaWebtorrentPlugin(MediaPlugin): return {'resource': resource, 'url': stream_url} @action - def download(self, resource): + def download(self, resource, **kwargs): return self.play(resource, download_only=True) @action