diff --git a/platypush/backend/http/static/css/media.css b/platypush/backend/http/static/css/media.css index 52e90274..a2260dab 100644 --- a/platypush/backend/http/static/css/media.css +++ b/platypush/backend/http/static/css/media.css @@ -77,3 +77,51 @@ form#video-ctrl { 100% { background: #d4ffe3; } } +button[data-toggle="#media-devices-panel"] { + display: none; +} + + button.remote[data-toggle="#media-devices-panel"] { + color: #34b868; + } + + button.selected[data-toggle="#media-devices-panel"] { + box-shadow: inset 0 2px 0 rgba(0,0,0,0.1), + inset 3px 0 0 rgba(0,0,0,0.1), + inset 0 0 2px rgba(0,0,0,0.1), + inset 0 0 0 1px rgba(0,0,0,0.1); + } + +#media-devices-panel { + display: none; + position: absolute; + padding: 1rem; + background: #f0f0f0; + z-index: 10; + border: 1px solid #d0d0d0; + border-radius: 5px; +} + + #media-devices-panel .cast-device { + padding: 0.5rem; + cursor: pointer; + } + + #media-devices-panel .cast-device-local { + border-bottom: 1px solid #ddd; + margin-bottom: 0.25rem; + } + + #media-devices-panel .cast-device.selected { + font-weight: bold; + color: #34b868; + } + + #media-devices-panel .cast-device:hover { + background-color: #daf8e2 !important; + } + + #media-devices-panel * > .cast-device-icon { + color: #666; + } + diff --git a/platypush/backend/http/static/js/application.js b/platypush/backend/http/static/js/application.js index a8ab4768..dc1d58a2 100644 --- a/platypush/backend/http/static/js/application.js +++ b/platypush/backend/http/static/js/application.js @@ -32,6 +32,7 @@ $(document).ready(function() { onWebsocketTimeout(websocket), websocketReconnectMsecs); websocket.onmessage = function(event) { + console.debug(event); for (var listener of eventListeners) { data = event.data; if (typeof event.data === 'string') { @@ -218,6 +219,12 @@ function execute(request, onSuccess, onError, onComplete) { } }, success: function(response, status, xhr) { + if ('errors' in response && response.errors.length) { + if (onError) { + onError(xhr, '500', response.errors); + } + } + if (onSuccess) { onSuccess(response, status, xhr); } diff --git a/platypush/backend/http/static/js/assistant.google.js b/platypush/backend/http/static/js/assistant.google.js index 09fef6bc..a14b32c3 100644 --- a/platypush/backend/http/static/js/assistant.google.js +++ b/platypush/backend/http/static/js/assistant.google.js @@ -1,7 +1,5 @@ $(document).ready(function() { var onEvent = function(event) { - console.log(event); - switch (event.args.type) { case 'platypush.message.event.assistant.ConversationStartEvent': createNotification({ diff --git a/platypush/backend/http/static/js/media.js b/platypush/backend/http/static/js/media.js index 793bd882..9effdc58 100644 --- a/platypush/backend/http/static/js/media.js +++ b/platypush/backend/http/static/js/media.js @@ -4,6 +4,10 @@ $(document).ready(function() { $videoResults = $('#video-results'), $volumeCtrl = $('#video-volume-ctrl'), $ctrlForm = $('#video-ctrl'), + $devsPanel = $('#media-devices-panel'), + $devsBtn = $('button[data-toggle="#media-devices-panel"]'), + $searchBarContainer = $('#media-search-bar-container'), + $mediaBtnsContainer = $('#media-btns-container'), prevVolume = undefined; var updateVideoResults = function(videos) { @@ -38,6 +42,19 @@ $(document).ready(function() { return $iconContainer; }; + var getSelectedDevice = function() { + var device = { isRemote: false, name: undefined }; + var $remoteDevice = $devsPanel.find('.cast-device.selected') + .filter((i, dev) => !$(dev).data('local') && $(dev).data('name')); + + if ($remoteDevice.length) { + device.isRemote = true; + device.name = $remoteDevice.data('name'); + } + + return device; + }; + var initBindings = function() { $searchForm.on('submit', function(event) { var $input = $(this).find('input[name=video-search-text]'); @@ -84,12 +101,18 @@ $(document).ready(function() { var action = $(this).data('action'); var $btn = $(this); - execute( - { - type: 'request', - action: 'media.' + action, - } - ); + var requestArgs = { + type: 'request', + action: 'media.' + action, + }; + + var selectedDevice = getSelectedDevice(); + if (selectedDevice.isRemote) { + requestArgs.action = 'media.chromecast.' + action; + requestArgs.args = { 'chromecast': selectedDevice.name }; + } + + execute(requestArgs); }); $volumeCtrl.on('mousedown touchstart', function(event) { @@ -97,13 +120,19 @@ $(document).ready(function() { }); $volumeCtrl.on('mouseup touchend', function(event) { - execute( - { - type: 'request', - action: 'media.set_volume', - args: { volume: $(this).val() } - }, + var requestArgs = { + type: 'request', + action: 'media.set_volume', + args: { volume: $(this).val() }, + }; + var selectedDevice = getSelectedDevice(); + if (selectedDevice.isRemote) { + requestArgs.action = 'media.chromecast.set_volume', + requestArgs.args.chromecast = selectedDevice.name; + } + + execute(requestArgs, onSuccess=undefined, onError = function() { $volumeCtrl.val(prevVolume); @@ -120,14 +149,21 @@ $(document).ready(function() { return false; } + var requestArgs = { + type: 'request', + action: 'media.play', + args: { resource: $item.data('url') }, + }; + + var selectedDevice = getSelectedDevice(); + if (selectedDevice.isRemote) { + requestArgs.action = 'media.chromecast.play'; + requestArgs.args.chromecast = selectedDevice.name; + } + $videoResults.text('Loading video...'); execute( - { - type: 'request', - action: 'media.play', - args: { resource: $item.data('url') }, - }, - + requestArgs, function() { $videoResults.html(results); $item.siblings().removeClass('active'); @@ -139,9 +175,82 @@ $(document).ready(function() { } ); }); + + $devsBtn.on('click touch', function() { + $(this).toggleClass('selected'); + $devsPanel.css('top', ($(this).position().top + $(this).outerHeight()) + 'px'); + $devsPanel.css('left', ($(this).position().left) + 'px'); + $devsPanel.toggle(); + return false; + }); + + $devsPanel.on('mouseup touchend', '.cast-device', function() { + var $devices = $devsPanel.find('.cast-device'); + var $curSelected = $devices.filter((i, d) => $(d).hasClass('selected')); + + if ($curSelected.data('name') !== $(this).data('name')) { + $curSelected.removeClass('selected'); + $(this).addClass('selected'); + + if (!$(this).data('local')) { + $devsBtn.addClass('remote'); + } else { + $devsBtn.removeClass('remote'); + } + } + + $devsPanel.hide(); + $devsBtn.removeClass('selected'); + }); + }; + + var initRemoteDevices = function() { + execute( + { + type: 'request', + action: 'media.chromecast.get_chromecasts', + }, + + function(results) { + if (!results || results.response.errors.length) { + return; + } + + $searchBarContainer.removeClass('eleven').addClass('nine'); + $mediaBtnsContainer.removeClass('one').addClass('three'); + $devsBtn.show(); + + results = results.response.output; + for (var cast of results) { + var $cast = $('
').addClass('row cast-device') + .addClass('cast-device-' + cast.type).data('name', cast.name); + + var icon = 'question'; + switch (cast.type) { + case 'cast': icon = 'tv'; break; + case 'audio': icon = 'volume-up'; break; + } + + var $castIcon = $('').addClass('fa fa-' + icon) + .addClass('cast-device-icon'); + var $castName = $('').addClass('cast-device-name') + .text(cast.name); + + var $iconContainer = $('
').addClass('two columns'); + var $nameContainer = $('
').addClass('ten columns'); + $castIcon.appendTo($iconContainer); + $castName.appendTo($nameContainer); + + $iconContainer.appendTo($cast); + $nameContainer.appendTo($cast); + $cast.appendTo($devsPanel); + } + } + ); }; var init = function() { + initRemoteDevices(); initBindings(); }; diff --git a/platypush/backend/http/templates/plugins/media.html b/platypush/backend/http/templates/plugins/media.html index 351d9836..7286a231 100644 --- a/platypush/backend/http/templates/plugins/media.html +++ b/platypush/backend/http/templates/plugins/media.html @@ -10,13 +10,27 @@
-
+
-
+
+ +
+
+ +
+
+
+ +
+
+ This device +
@@ -36,14 +50,10 @@ - - - diff --git a/platypush/plugins/media/bin/localstream b/platypush/plugins/media/bin/localstream new file mode 100755 index 00000000..39dd79e1 --- /dev/null +++ b/platypush/plugins/media/bin/localstream @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +// Requires: +// - express (`npm install express`) +// - mime-types (`npm install mime-types`) + +const express = require('express') +const fs = require('fs') +const path = require('path') +const process = require('process') +const mime = require('mime-types') +const app = express() + +function parseArgv() { + let file = undefined + let port = 8989 + + if (process.argv.length < 3) { + throw Error(`Usage: ${process.argv[0]} ${process.argv[1]} [port=${port}]`) + } + + file = process.argv[2] + + if (process.argv.length > 3) { + port = parseInt(process.argv[3]) + } + + return { file: file, port: port } +} + +let args = parseArgv() + +app.get('/video', function(req, res) { + const path = args.file + const ext = args.file.split('.').pop() + const stat = fs.statSync(path) + const fileSize = stat.size + const range = req.headers.range + const mimeType = mime.lookup(ext) + + if (range) { + const parts = range.replace(/bytes=/, "").split("-") + const start = parseInt(parts[0], 10) + const end = parts[1] + ? parseInt(parts[1], 10) + : fileSize-1 + + const chunksize = (end-start)+1 + const file = fs.createReadStream(path, {start, end}) + const head = { + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize, + 'Content-Type': mimeType, + } + + res.writeHead(206, head) + file.pipe(res) + } else { + const head = { + 'Content-Length': fileSize, + 'Content-Type': mimeType, + } + res.writeHead(200, head) + fs.createReadStream(path).pipe(res) + } +}) + +app.listen(args.port, function () { + console.log(`Listening on port ${args.port}`) +}) diff --git a/platypush/plugins/media/chromecast.py b/platypush/plugins/media/chromecast.py index 62e0a874..e40fce32 100644 --- a/platypush/plugins/media/chromecast.py +++ b/platypush/plugins/media/chromecast.py @@ -67,6 +67,7 @@ class MediaChromecastPlugin(MediaPlugin): 'name': cc.app_display_name, }, + 'media': self.status(cc.name).output, 'is_active_input': cc.status.is_active_input, 'is_stand_by': cc.status.is_stand_by, 'is_idle': cc.is_idle,