From d15b21ddfa6d38abc90abcd6ea9ca912d1be9bf9 Mon Sep 17 00:00:00 2001
From: Fabio Manganiello <blacklight86@gmail.com>
Date: Wed, 6 Feb 2019 09:46:57 +0100
Subject: [PATCH] Added support for remote cast on the web media panel

---
 platypush/backend/http/static/css/media.css   |  48 ++++++
 .../backend/http/static/js/application.js     |   7 +
 .../http/static/js/assistant.google.js        |   2 -
 platypush/backend/http/static/js/media.js     | 145 +++++++++++++++---
 .../backend/http/templates/plugins/media.html |  24 ++-
 platypush/plugins/media/bin/localstream       |  71 +++++++++
 platypush/plugins/media/chromecast.py         |   1 +
 7 files changed, 271 insertions(+), 27 deletions(-)
 create mode 100755 platypush/plugins/media/bin/localstream

diff --git a/platypush/backend/http/static/css/media.css b/platypush/backend/http/static/css/media.css
index 52e902746..a2260dabf 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 a8ab4768f..dc1d58a26 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 09fef6bce..a14b32c3e 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 793bd882e..9effdc585 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 = $('<div></div>').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 = $('<i></i>').addClass('fa fa-' + icon)
+                        .addClass('cast-device-icon');
+                    var $castName = $('<span></span>').addClass('cast-device-name')
+                        .text(cast.name);
+
+                    var $iconContainer = $('<div></div>').addClass('two columns');
+                    var $nameContainer = $('<div></div>').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 351d98367..7286a2313 100644
--- a/platypush/backend/http/templates/plugins/media.html
+++ b/platypush/backend/http/templates/plugins/media.html
@@ -10,13 +10,27 @@
         </div>
 
         <div class="row">
-            <div class="eleven columns">
+            <div class="eleven columns" id="media-search-bar-container">
                 <input type="text" name="video-search-text" placeholder="Search query or video URL">
             </div>
-            <div class="one column">
+            <div class="one columns" id="media-btns-container">
                 <button type="submit">
                     <i class="fa fa-search"></i>
                 </button>
+                <button data-toggle="#media-devices-panel">
+                    <i class="fa fa-tv"></i>
+                </button>
+            </div>
+        </div>
+
+        <div class="row" id="media-devices-panel">
+            <div class="row cast-device cast-device-local selected" data-local="local">
+                <div class="two columns">
+                    <i class="fa fa-microchip cast-device-icon"></i>
+                </div>
+                <div class="ten columns">
+                    <span class="cast-device-name">This device</span>
+                </div>
             </div>
         </div>
     </form>
@@ -36,14 +50,10 @@
                     <i class="fa fa-step-backward"></i>
                 </button>
 
-                <button data-action="backward">
+                <button data-action="back">
                     <i class="fa fa-backward"></i>
                 </button>
 
-                <button data-action="play">
-                    <i class="fa fa-play"></i>
-                </button>
-
                 <button data-action="pause">
                     <i class="fa fa-pause"></i>
                 </button>
diff --git a/platypush/plugins/media/bin/localstream b/platypush/plugins/media/bin/localstream
new file mode 100755
index 000000000..39dd79e1d
--- /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]} <media_file> [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 62e0a8746..e40fce327 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,