diff --git a/README.md b/README.md index 5ff8deefd..14676fb1c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,21 @@ Note: This requires you to use Ubuntu 16.04 or 18.04. Clone the [SDL Core repository](https://github.com/smartdevicelink/sdl_core) and follow the setup instructions for the project. After the project is built, run an instance of SDL Core in your terminal. +### Dependencies + +- nvm +- chromium-browser +- python3 and pip +- ffmpeg +- ffmpeg-python + +``` +sudo apt install chromium-browser ffmpeg python3 python3-pip -y +python3 -m pip install ffmpeg-python +``` + +Check out [nvm on github](https://github.com/nvm-sh/nvm#installing-and-updating) to learn how to install and use nvm! + ### Build and Run the HMI Once SDL Core is running, follow these steps to set up the Generic HMI. diff --git a/deploy_server.sh b/deploy_server.sh index cc076966e..6909a6751 100755 --- a/deploy_server.sh +++ b/deploy_server.sh @@ -39,7 +39,7 @@ DeployServer() { StartServer() { cp ${SOURCE_DIR}/${TARGET_SCRIPT} ${TARGET_DIR} - python3 ${TARGET_DIR}/${TARGET_SCRIPT} --host 127.0.0.1 --port 8081 + python3 ${TARGET_DIR}/${TARGET_SCRIPT} --host 127.0.0.1 --ws-port 8081 rm ${TARGET_DIR}/${TARGET_SCRIPT} } diff --git a/src/css/_config.scss b/src/css/_config.scss index 7ffa04f24..711906335 100644 --- a/src/css/_config.scss +++ b/src/css/_config.scss @@ -9,4 +9,9 @@ $accent-color: white; $secondary-accent-color: bright-blue; $font-primary-color: white; -$font-secondary-color: slate; \ No newline at end of file +$font-secondary-color: slate; + +:export { + masterWidth: $master-width; + masterHeight: $master-height; +} \ No newline at end of file diff --git a/src/css/base/_base.scss b/src/css/base/_base.scss index 3202226bf..dfb597976 100644 --- a/src/css/base/_base.scss +++ b/src/css/base/_base.scss @@ -61,7 +61,7 @@ body { .driver-distraction-button { margin-top: 22px; margin-left: 20px; - width: 210px; + width: 150px; border-radius: 2px; padding: 10px 0; float: left; @@ -70,6 +70,13 @@ body { } } +.resolution-selector { + margin-top: 18px; + margin-left: 20px; + border-radius: 2px; + float: left; +} + .webengine-app-container { iframe { position: absolute; diff --git a/src/index.js b/src/index.js index 95b467562..8daf4883c 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,7 @@ import * as serviceWorker from './serviceWorker'; // import css import './css/main.scss'; +import config from './css/_config.scss'; // import react and js import MediaPlayer from './js/MediaPlayer'; @@ -17,6 +18,7 @@ import TilesOnly from './js/Templates/TilesOnly/TilesOnly'; import TextWithGraphic from './js/Templates/TextWithGraphic/TextWithGraphic' import GraphicWithText from './js/Templates/GraphicWithText/GraphicWithText' import DoubleGraphicWithSoftbuttons from './js/Templates/DoubleGraphicWithSoftbuttons/DoubleGraphicWithSoftbuttons' +import NavFullscreenMap from './js/Templates/NavFullscreenMap/NavFullscreenMap' import HMIMenu from './js/HMIMenu'; import InAppMenu from './js/InAppMenu'; import InAppList from './js/InAppList'; @@ -50,12 +52,19 @@ class HMIApp extends React.Component { constructor(props) { super(props); this.state = { - dark: true + dark: true, + resolution: "960x600 Scale 1", + scale: 1 } this.sdl = new Controller(); this.handleClick = this.handleClick.bind(this); this.togglePTUWithModem = this.togglePTUWithModem.bind(this); this.handleDDToggle = this.handleDDToggle.bind(this); + this.pickResolution = this.pickResolution.bind(this); + this.onTouchBegin = this.onTouchBegin.bind(this); + this.onTouchMove = this.onTouchMove.bind(this); + this.onTouchEnd = this.onTouchEnd.bind(this); + this.onTouchEvent = this.onTouchEvent.bind(this); } handleClick() { var theme = !this.state.dark @@ -74,8 +83,82 @@ class HMIApp extends React.Component { store.dispatch(setDDState(newDDState)); uiController.onDriverDistraction((newDDState) ? "DD_ON" : "DD_OFF"); } + pickResolution(event) { + var match = event.target.value.match(/(\d+)x(\d+) Scale (\d+.?\d*)/); + var capability = { + systemCapabilityType: 'VIDEO_STREAMING', + videoStreamingCapability: { + scale: parseFloat(match[3]), + preferredResolution: { + resolutionWidth: parseInt(match[1]), + resolutionHeight: parseInt(match[2]) + } + } + } + this.setState({ resolution: event.target.value, scale: match[3] }); + + bcController.onSystemCapabilityUpdated(capability, this.props.activeAppId); + } + + onTouchEvent(type, event) { + if (!this.videoRect) { + var video = document.getElementById('navi_stream'); + this.videoRect = video.getBoundingClientRect(); + } + + if (event.pageX < this.videoRect.left + || event.pageX > this.videoRect.right + || event.pageY < this.videoRect.top + || event.pageY > this.videoRect.bottom) { + return; + } + + uiController.onTouchEvent(type, [{ + id: 0, + ts: [ parseInt(event.timeStamp) ], + c: [{ + x: event.pageX - this.videoRect.x, + y: event.pageY - this.videoRect.y + }] + }]); + } + + onTouchBegin(event) { + this.touchInProgress = true; + this.onTouchEvent('BEGIN', event); + } + + onTouchMove(event) { + if (this.touchInProgress) { + this.onTouchEvent('MOVE', event); + } + } + + onTouchEnd(event) { + if (this.touchInProgress) { + this.touchInProgress = false; + this.onTouchEvent('END', event); + } + } + render() { const themeClass = this.state.dark ? 'dark-theme' : 'light-theme'; + var videoStyle = { position: 'absolute', width: config.masterWidth, height: config.masterHeight - 75, top: 75, left: 0, backgroundColor: 'transparent', objectFit: 'fill' }; + if (!this.props.videoStreamVisible || !this.props.videoStreamUrl) { + videoStyle.display = 'none'; + } + + var resolutionSelector = undefined; + if (this.props.activeAppState && this.props.activeAppState.videoStreamingCapability.length) { + resolutionSelector = (
+
+ +
); + } + return(
@@ -94,7 +177,12 @@ class HMIApp extends React.Component {
+ { resolutionSelector }
+ { this.props.webEngineApps.map((app) => { let query = `?sdl-host=${flags.CoreHost}&sdl-port=${flags.CoreWebEngineAppPort}&sdl-transport-role=${app.transportType.toLowerCase()}-server`; @@ -176,7 +264,10 @@ const mapStateToProps = (state) => { webEngineApps: state.appStore.installedApps.filter(app => app.runningAppId), showWebView: state.appStore.webViewActive, activeAppId: state.activeApp, - dd: state.ddState + dd: state.ddState, + videoStreamUrl: state.system.videoStreamUrl, + videoStreamVisible: state.system.navigationActive && state.activeApp === state.system.videoStreamingApp, + activeAppState: state.ui[state.activeApp] } } HMIApp = connect(mapStateToProps)(HMIApp) @@ -199,6 +290,7 @@ ReactDOM.render(( + diff --git a/src/js/Controllers/BCController.js b/src/js/Controllers/BCController.js index 6577e4761..d7675ba8e 100644 --- a/src/js/Controllers/BCController.js +++ b/src/js/Controllers/BCController.js @@ -1,6 +1,6 @@ import RpcFactory from './RpcFactory' import store from '../store' -import { updateAppList, activateApp, deactivateApp, registerApplication, unregisterApplication, policyUpdate, onPutFile, updateColorScheme, setAppIsConnected, onSystemCapabilityUpdated, updateInstalledAppStoreApps, appStoreAppInstalled, appStoreAppUninstalled } from '../actions' +import { updateAppList, activateApp, deactivateApp, registerApplication, unregisterApplication, policyUpdate, onPutFile, updateColorScheme, setAppIsConnected, onSystemCapabilityUpdated, updateInstalledAppStoreApps, appStoreAppInstalled, appStoreAppUninstalled, setVideoStreamingCapability } from '../actions' import sdlController from './SDLController' import externalPolicies from './ExternalPoliciesController' import {flags} from '../Flags' @@ -48,6 +48,10 @@ class BCController { if (rpc.params.application.appType.includes("WEB_VIEW")) { store.dispatch(registerApplication(rpc.params.application.appID, "web-view")); this.listener.send(RpcFactory.OnSystemCapabilityDisplay("WEB_VIEW", rpc.params.application.appID)); + } else if (rpc.params.application.appType.includes("NAVIGATION") + || rpc.params.application.appType.includes("PROJECTION")) { + store.dispatch(registerApplication(rpc.params.application.appID, "nav-fullscreen-map")); + this.listener.send(RpcFactory.OnSystemCapabilityDisplay("NAV_FULLSCREEN_MAP", rpc.params.application.appID)); } else { var templates = rpc.params.application.isMediaApplication ? ["media","MEDIA"] : ["nonmedia","NON-MEDIA"]; store.dispatch(registerApplication(rpc.params.application.appID, templates[0])); @@ -98,6 +102,17 @@ class BCController { case "GetSystemTime": this.listener.send(RpcFactory.GetSystemTime(rpc.id)) return null + + case "OnAppCapabilityUpdated": + if (rpc.params.appCapability.appCapabilityType === 'VIDEO_STREAMING' + && rpc.params.appCapability.videoStreamingCapability) { + var vsc = rpc.params.appCapability.videoStreamingCapability; + if (!vsc.additionalVideoStreamingCapabilities) { + vsc.additionalVideoStreamingCapabilities = []; + } + store.dispatch(setVideoStreamingCapability(rpc.params.appID, vsc.additionalVideoStreamingCapabilities)); + } + return null; default: return false; } @@ -178,6 +193,9 @@ class BCController { getAppProperties(policyAppID){ this.listener.send(RpcFactory.GetAppProperties(policyAppID)) } + onSystemCapabilityUpdated(capability, appID) { + this.listener.send(RpcFactory.OnSystemCapabilityUpdated(capability, appID)); + } } let controller = new BCController() diff --git a/src/js/Controllers/Controller.js b/src/js/Controllers/Controller.js index 24290b6ff..02683178c 100644 --- a/src/js/Controllers/Controller.js +++ b/src/js/Controllers/Controller.js @@ -134,9 +134,11 @@ export default class Controller { this.subscribeToNotification("BasicCommunication.OnAppUnregistered") this.subscribeToNotification("BasicCommunication.OnPutFile") this.subscribeToNotification("Navigation.OnVideoDataStreaming") + this.subscribeToNotification("Navigation.OnAudioDataStreaming") this.subscribeToNotification("SDL.OnStatusUpdate") this.subscribeToNotification("BasicCommunication.OnSystemCapabilityUpdated") this.subscribeToNotification("AppService.OnAppServiceData") + this.subscribeToNotification("BasicCommunication.OnAppCapabilityUpdated") var onSystemTimeReady = { "jsonrpc": "2.0", diff --git a/src/js/Controllers/DisplayCapabilities.js b/src/js/Controllers/DisplayCapabilities.js index 3601f94b6..66c62ea2d 100644 --- a/src/js/Controllers/DisplayCapabilities.js +++ b/src/js/Controllers/DisplayCapabilities.js @@ -1,3 +1,8 @@ +import config from '../../css/_config.scss'; +const masterWidth = parseInt(config.masterWidth); +const masterHeight = parseInt(config.masterHeight); +const templateHeight = masterHeight - 75; + let softButtonCapability = { "shortPressAvailable": true, "longPressAvailable": true, @@ -15,16 +20,16 @@ let imageOnlySoftButtonCapability = { } let templatesAvailable = [ - "DEFAULT", "MEDIA", "NON-MEDIA", "LARGE_GRAPHIC_WITH_SOFTBUTTONS", - "LARGE_GRAPHIC_ONLY", "GRAPHIC_WITH_TEXTBUTTONS", "TEXTBUTTONS_WITH_GRAPHIC", - "TEXTBUTTONS_ONLY", "TEXT_WITH_GRAPHIC", "GRAPHIC_WITH_TEXT", - "DOUBLE_GRAPHIC_WITH_SOFTBUTTONS", "TILES_ONLY", "WEB_VIEW" + "DEFAULT", "MEDIA", "NON-MEDIA", "LARGE_GRAPHIC_WITH_SOFTBUTTONS", "LARGE_GRAPHIC_ONLY", + "GRAPHIC_WITH_TEXTBUTTONS", "TEXTBUTTONS_WITH_GRAPHIC", "TEXTBUTTONS_ONLY", + "TEXT_WITH_GRAPHIC", "GRAPHIC_WITH_TEXT", "DOUBLE_GRAPHIC_WITH_SOFTBUTTONS", "WEB_VIEW", + "NAV_FULLSCREEN_MAP", "TILES_ONLY" ] let screenParams = { "resolution": { - "resolutionWidth": 960, - "resolutionHeight": 675 + "resolutionWidth": masterWidth, + "resolutionHeight": masterHeight }, "touchEventAvailable": { "pressAvailable": true, @@ -571,6 +576,39 @@ let capabilities = { softButtonCapability ] }, + "NAV_FULLSCREEN_MAP": { + "displayCapabilities": { + "displayType": "SDL_GENERIC", + "displayName": "GENERIC_DISPLAY", + "textFields": [ + textField("templateTitle", 50), + textField("alertText1"), + textField("alertText2"), + textField("alertText3"), + textField("subtleAlertText1"), + textField("subtleAlertText2"), + textField("subtleAlertSoftButtonText"), + textField("menuName"), + textField("secondaryText"), + textField("tertiaryText") + ], + "imageFields": [ + imageField("choiceImage", 40), + imageField("menuIcon", 40), + imageField("cmdIcon", 150), + imageField("appIcon", 50), + imageField("alertIcon", 225), + imageField("subtleAlertIcon", 225) + ], + "mediaClockFormats": [], + "graphicSupported": false, + "templatesAvailable": templatesAvailable, + "screenParams": screenParams, + "imageCapabilities": ["DYNAMIC", "STATIC"], + "menuLayoutsAvailable": ["LIST", "TILES"] + }, + "softButtonCapabilities": [] + }, "COMMON": { "audioPassThruCapabilities": { "samplingRate": "44KHZ", @@ -595,7 +633,7 @@ let capabilities = { "hmiCapabilities": { "navigation": false, "phoneCall": false, - "videoStreaming": false + "videoStreaming": true }, "systemCapabilities": { "navigationCapability": { @@ -608,6 +646,65 @@ let capabilities = { "driverDistractionCapability": { "menuLength": 3, "subMenuDepth": 2 + }, + videoStreamingCapability: { + preferredResolution: { + resolutionWidth: masterWidth, + resolutionHeight: templateHeight + }, + maxBitrate: 400000, + supportedFormats: [ + { protocol: "RAW", codec: "H264" }, + { protocol: "RTP", codec: "H264" }, + { protocol: "RTSP", codec: "Theora" }, + { protocol: "RTMP", codec: "VP8" }, + { protocol: "WEBM", codec: "VP9" } + ], + hapticSpatialDataSupported: true, + diagonalScreenSize: 7, + pixelPerInch: 96, + scale: 1, + preferredFPS: 30, + additionalVideoStreamingCapabilities: [ + { + preferredResolution: { + resolutionWidth: masterWidth, + resolutionHeight: templateHeight + }, + maxBitrate: 400000, + supportedFormats: [ + { protocol: "RAW", codec: "H264" }, + { protocol: "RTP", codec: "H264" }, + { protocol: "RTSP", codec: "Theora" }, + { protocol: "RTMP", codec: "VP8" }, + { protocol: "WEBM", codec: "VP9" } + ], + hapticSpatialDataSupported: true, + diagonalScreenSize: 7, + pixelPerInch: 72, + scale: 1.5, + preferredFPS: 30 + }, + { + preferredResolution: { + resolutionWidth: masterWidth, + resolutionHeight: templateHeight + }, + maxBitrate: 400000, + supportedFormats: [ + { protocol: "RAW", codec: "H264" }, + { protocol: "RTP", codec: "H264" }, + { protocol: "RTSP", codec: "Theora" }, + { protocol: "RTMP", codec: "VP8" }, + { protocol: "WEBM", codec: "VP9" } + ], + hapticSpatialDataSupported: true, + diagonalScreenSize: 7, + pixelPerInch: 48, + scale: 2, + preferredFPS: 30 + } + ] } } } @@ -674,7 +771,8 @@ const getDisplayCapability = (template) => { var capability = { displayName: templateCapability.displayCapabilities.displayName, windowTypeSupported: [mainWindowTypeCapability], - windowCapabilities: [getWindowCapability(template)] + windowCapabilities: [getWindowCapability(template)], + screenParams: screenParams } return capability; diff --git a/src/js/Controllers/NavController.js b/src/js/Controllers/NavController.js index a0102dc76..127225083 100644 --- a/src/js/Controllers/NavController.js +++ b/src/js/Controllers/NavController.js @@ -1,4 +1,8 @@ import RpcFactory from './RpcFactory' +import FileSystemController from './FileSystemController' +import store from '../store'; +import { setVideoStreamUrl, setVideoStreamingApp } from '../actions' +import AudioPlayer from '../Utils/AudioPlayer' class NavController { constructor () { @@ -9,22 +13,79 @@ class NavController { } handleRPC(rpc) { let methodName = rpc.method.split(".")[1] - var message = ""; switch (methodName) { case "IsReady": - return {"rpc": RpcFactory.IsReadyResponse(rpc, false)} + return {"rpc": RpcFactory.IsReadyResponse(rpc, true)} + case "OnAudioDataStreaming": + if (rpc.params.available) { + FileSystemController.subscribeToEvent('StartAudioStream', (success, params) => { + if (!success || !params.endpoint) { + console.error('Error encountered while starting audio stream'); + return; + } + + AudioPlayer.play(params.endpoint); + }); + + FileSystemController.sendJSONMessage({ + method: 'StartAudioStream', + params: { + url: this.audioStreamUrl + } + }); + } else { + AudioPlayer.pause(); + } + return null + case "OnVideoDataStreaming": + if (rpc.params.available) { + FileSystemController.subscribeToEvent('StartVideoStream', (success, params) => { + if (!success) { + console.error('Error encountered while starting video stream'); + return; + } + + if (params.endpoint) { + store.dispatch(setVideoStreamUrl(params.endpoint)); + var video = document.getElementById('navi_stream'); + video.play(); + } + }); + + FileSystemController.sendJSONMessage({ + method: 'StartVideoStream', + params: { + url: this.videoStreamUrl, + config: this.videoConfig, + webm: this.webmSupport + } + }); + } else { + document.getElementById('navi_stream').pause(); + store.dispatch(setVideoStreamUrl(null)); + } + return null case "StartStream": - message = "This system does not support video streaming." - return {"rpc": RpcFactory.UnsupportedResourceResponse(rpc, message)}; + this.videoStreamUrl = rpc.params.url; + store.dispatch(setVideoStreamingApp(rpc.params.appID)); + return { "rpc": RpcFactory.StartStreamSuccess(rpc.id) }; + case "StopStream": + store.dispatch(setVideoStreamUrl(null)); + return { "rpc": RpcFactory.StopStreamSuccess(rpc.id) }; case "StartAudioStream": - message = "This system does not support audio streaming." - return {"rpc": RpcFactory.UnsupportedResourceResponse(rpc, message)}; + this.audioStreamUrl = rpc.params.url; + return {"rpc": RpcFactory.StartAudioStreamSuccess(rpc.id)}; case "SetVideoConfig": - message = "This system does not support video streaming." - return {"rpc": RpcFactory.UnsupportedResourceResponse(rpc, message)}; + this.videoConfig = rpc.params.config; + + if (this.webmSupport === undefined) { + var video = document.getElementById('navi_stream'); + this.webmSupport = 'probably' === video.canPlayType('video/webm; codecs="vp8"'); + } + + return { "rpc": RpcFactory.SetVideoConfigSuccess(rpc.id) }; default: - message = "This RPC is not supported." - return {"rpc": RpcFactory.UnsupportedResourceResponse(rpc, message)}; + return {"rpc": RpcFactory.UnsupportedResourceResponse(rpc, "This RPC is not supported.")}; } } } diff --git a/src/js/Controllers/RpcFactory.js b/src/js/Controllers/RpcFactory.js index 2c9b4c1de..a9244361c 100644 --- a/src/js/Controllers/RpcFactory.js +++ b/src/js/Controllers/RpcFactory.js @@ -710,21 +710,26 @@ class RpcFactory { } }) } - static OnSystemCapabilityDisplay(template, appID) { - var systemCapability = { - systemCapabilityType: "DISPLAYS", - displayCapabilities: [getDisplayCapability(template)] - } + + static OnSystemCapabilityUpdated(capability, appID) { return ({ "jsonrpc": "2.0", "method": "BasicCommunication.OnSystemCapabilityUpdated", "params": { - "systemCapability": systemCapability, + "systemCapability": capability, "appID": appID } }) } + static OnSystemCapabilityDisplay(template, appID) { + var systemCapability = { + systemCapabilityType: "DISPLAYS", + displayCapabilities: [getDisplayCapability(template)] + } + return this.OnSystemCapabilityUpdated(systemCapability, appID); + } + static OnUpdateFile(appID, fileName) { return ({ "jsonrpc": "2.0", @@ -782,6 +787,61 @@ class RpcFactory { } return message; } + + static OnTouchEvent(type, events) { + return ({ + 'jsonrpc': '2.0', + 'method': 'UI.OnTouchEvent', + 'params': { + 'type': type, + 'event': events + } + }) + } + + static StartStreamSuccess(id) { + return { + "jsonrpc": "2.0", + "id": id, + "result": { + "code": 0, + "method": "Navigation.StartStream" + } + }; + } + + static StartAudioStreamSuccess(id) { + return { + "jsonrpc": "2.0", + "id": id, + "result": { + "code": 0, + "method": "Navigation.StartAudioStream" + } + }; + } + + static StopStreamSuccess(id) { + return { + "jsonrpc": "2.0", + "id": id, + "result": { + "code": 0, + "method": "Navigation.StopStream" + } + }; + } + + static SetVideoConfigSuccess(id) { + return { + "jsonrpc": "2.0", + "id": id, + "result": { + "code": 0, + "method": "Navigation.SetVideoConfig" + } + }; + } } export default RpcFactory diff --git a/src/js/Controllers/UIController.js b/src/js/Controllers/UIController.js index 4a03cf901..b0337c67d 100644 --- a/src/js/Controllers/UIController.js +++ b/src/js/Controllers/UIController.js @@ -610,6 +610,10 @@ class UIController { onKeyboardInput(value, event) { this.listener.send(RpcFactory.OnKeyboardInput(value, event)) } + + onTouchEvent(type, events) { + this.listener.send(RpcFactory.OnTouchEvent(type, events)); + } } let controller = new UIController () diff --git a/src/js/Templates/NavFullscreenMap/NavFullscreenMap.js b/src/js/Templates/NavFullscreenMap/NavFullscreenMap.js new file mode 100644 index 000000000..b18820ba9 --- /dev/null +++ b/src/js/Templates/NavFullscreenMap/NavFullscreenMap.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { connect } from 'react-redux' +import AppHeader from '../../containers/Header'; +import store from '../../store'; +import { systemNavigationViewActive, systemNavigationViewInactive } from '../../actions'; + +class NavFullscreenMap extends React.Component { + componentWillMount() { + store.dispatch(systemNavigationViewActive()); + } + + componentWillUnmount() { + store.dispatch(systemNavigationViewInactive()); + } + + getColorScheme() { + var activeApp = this.props.activeApp + var colorScheme = null; + if (activeApp) { + if (this.props.theme === true) { //Dark Theme + if (this.props.ui[activeApp].nightColorScheme && this.props.ui[activeApp].nightColorScheme.backgroundColor) { + colorScheme = this.props.ui[activeApp].nightColorScheme.backgroundColor + } + } else { //Light Theme + if (this.props.ui[activeApp].dayColorScheme && this.props.ui[activeApp].dayColorScheme.backgroundColor) { + colorScheme = this.props.ui[activeApp].dayColorScheme.backgroundColor + } + } + } + + if (colorScheme) { + var redInt = colorScheme.red; + var blueInt = colorScheme.blue; + var greenInt = colorScheme.green; + var cssColorScheme = { + backgroundColor: `rgb(${redInt}, ${greenInt}, ${blueInt})` + } + return cssColorScheme; + } else { + return null; + } + } + + render() { + return ( +
+ +
+ ) + } +} + +const mapStateToProps = (state) => { + return { + activeApp: state.activeApp, + theme: state.theme, + ui: state.ui + }; +}; + +export default connect(mapStateToProps)(NavFullscreenMap); \ No newline at end of file diff --git a/src/js/Templates/NonMedia/NonMedia.js b/src/js/Templates/NonMedia/NonMedia.js index d815f4106..df54e250d 100644 --- a/src/js/Templates/NonMedia/NonMedia.js +++ b/src/js/Templates/NonMedia/NonMedia.js @@ -48,7 +48,7 @@ const mapStateToProps = (state) => { return { activeApp: state.activeApp, theme: state.theme, - ui: state.ui + ui: state.ui }; }; diff --git a/src/js/Utils/AudioPlayer.js b/src/js/Utils/AudioPlayer.js new file mode 100644 index 000000000..953ef3922 --- /dev/null +++ b/src/js/Utils/AudioPlayer.js @@ -0,0 +1,24 @@ +class AudioPlayer { + constructor() { + this.audio = new Audio(); + } + + play(url) { + this.audio.src = url; + this.audio.play().then(_ => + console.log('audio streaming starting @ ', url) + ).catch(err => + console.error('failed to stream audio: ', err) + ) + } + + pause() { + if (this.audio.src !== '') { + this.audio.pause(); + this.audio.src = ''; + } + } +} + +const audio = new AudioPlayer(); +export default audio; \ No newline at end of file diff --git a/src/js/actions.js b/src/js/actions.js index 27c08203a..312e6b0ae 100644 --- a/src/js/actions.js +++ b/src/js/actions.js @@ -43,7 +43,11 @@ export const Actions = { WEBENGINE_APP_LAUNCH: "WEBENGINE_APP_LAUNCH", APPSTORE_BEGIN_INSTALL: "APPSTORE_BEGIN_INSTALL", WEB_VIEW_ACTIVE: "WEB_VIEW_ACTIVE", - SET_DD_STATE: "SET_DD_STATE" + SET_DD_STATE: "SET_DD_STATE", + SET_VIDEO_STREAM_URL: "SET_VIDEO_STREAM_URL", + SET_VIDEO_STREAM_APP: "SET_VIDEO_STREAM_APP", + NAVIGATION_VIEW_ACTIVE: "NAVIGATION_VIEW_ACTIVE", + SET_VIDEO_STREAM_CAPABILITY: "SET_VIDEO_STREAM_CAPABILITY" } export const updateAppList = (applications) => { @@ -432,3 +436,39 @@ export const setDDState = (ddState) => { dd: ddState } } + +export const setVideoStreamUrl = (url) => { + return { + type: Actions.SET_VIDEO_STREAM_URL, + url: url + } +} + +export const setVideoStreamingApp = (appID) => { + return { + type: Actions.SET_VIDEO_STREAM_APP, + appID: appID + } +} + +export const systemNavigationViewActive = () => { + return { + type: Actions.NAVIGATION_VIEW_ACTIVE, + active: true + } +} + +export const systemNavigationViewInactive = () => { + return { + type: Actions.NAVIGATION_VIEW_ACTIVE, + active: false + } +} + +export const setVideoStreamingCapability = (appID, capability) => { + return { + type: Actions.SET_VIDEO_STREAM_CAPABILITY, + appID: appID, + capability: capability + } +} diff --git a/src/js/reducers.js b/src/js/reducers.js index c25945061..38846f450 100644 --- a/src/js/reducers.js +++ b/src/js/reducers.js @@ -50,7 +50,63 @@ function newAppState () { msgID: null }, dayColorScheme: null, - nightColorScheme: null + nightColorScheme: null, + videoStreamingCapability: [ + { + preferredResolution: { + resolutionWidth: 960, + resolutionHeight: 600 + }, + maxBitrate: 400000, + supportedFormats: [ + { protocol: "RAW", codec: "H264" }, + { protocol: "RTP", codec: "H264" }, + { protocol: "RTSP", codec: "Theora" }, + { protocol: "RTMP", codec: "VP8" }, + { protocol: "WEBM", codec: "VP9" } + ], + hapticSpatialDataSupported: true, + diagonalScreenSize: 7, + pixelPerInch: 96, + scale: 1 + }, + { + preferredResolution: { + resolutionWidth: 960, + resolutionHeight: 600 + }, + maxBitrate: 400000, + supportedFormats: [ + { protocol: "RAW", codec: "H264" }, + { protocol: "RTP", codec: "H264" }, + { protocol: "RTSP", codec: "Theora" }, + { protocol: "RTMP", codec: "VP8" }, + { protocol: "WEBM", codec: "VP9" } + ], + hapticSpatialDataSupported: true, + diagonalScreenSize: 7, + pixelPerInch: 48, + scale: 2 + }, + { + preferredResolution: { + resolutionWidth: 960, + resolutionHeight: 600 + }, + maxBitrate: 400000, + supportedFormats: [ + { protocol: "RAW", codec: "H264" }, + { protocol: "RTP", codec: "H264" }, + { protocol: "RTSP", codec: "Theora" }, + { protocol: "RTMP", codec: "VP8" }, + { protocol: "WEBM", codec: "VP9" } + ], + hapticSpatialDataSupported: true, + diagonalScreenSize: 7, + pixelPerInch: 72, + scale: 1.5 + } + ] } } @@ -538,7 +594,10 @@ function ui(state = {}, action) { case "DOUBLE_GRAPHIC_WITH_SOFTBUTTONS": app.displayLayout = "double-graphic-with-softbuttons" break - default: + case "NAV_FULLSCREEN_MAP": + app.displayLayout = "nav-fullscreen-map" + break + default: break } if (action.dayColorScheme) { @@ -613,6 +672,9 @@ function ui(state = {}, action) { app.keyboardProperties, action.keyboardProperties); } return newState + case Actions.SET_VIDEO_STREAM_CAPABILITY: + app.videoStreamingCapability = action.capability + return newState case Actions.DEACTIVATE_APP: app.backSeekIndicator = {type: "TRACK", seekTime: null} app.forwardSeekIndicator = {type: "TRACK", seekTime: null} @@ -636,6 +698,18 @@ function system(state = {}, action) { case Actions.SET_PTU_WITH_MODEM: newState.ptuWithModemEnabled = action.enabled return newState + case Actions.SET_VIDEO_STREAM_URL: + if (state.videoStreamUrl === action.url) { + return state; + } + newState.videoStreamUrl = action.url + return newState + case Actions.SET_VIDEO_STREAM_APP: + newState.videoStreamingApp = action.appID; + return newState; + case Actions.NAVIGATION_VIEW_ACTIVE: + newState.navigationActive = action.active + return newState default: return state diff --git a/tools/start_server.py b/tools/start_server.py index 5177e394a..c152d227d 100644 --- a/tools/start_server.py +++ b/tools/start_server.py @@ -43,16 +43,20 @@ from http.server import SimpleHTTPRequestHandler import threading import argparse +import ffmpeg +import pexpect.fdpexpect class Flags(): """Used to define global properties""" FILE_SERVER_HOST = '127.0.0.1' FILE_SERVER_PORT = 4000 FILE_SERVER_URI = 'http://127.0.0.1:4000' + VIDEO_SERVER_PORT = 8085 + AUDIO_SERVER_PORT = 8086 class WebengineFileServer(): """Used to handle routing file server requests for webengine apps. - + Routes are added/removed via the add_app_mapping/remove_app_mapping functions """ def __init__(self, _host, _port, _uri=None): @@ -92,13 +96,13 @@ class Handler(SimpleHTTPRequestHandler): def end_headers(self): self.send_header('Access-Control-Allow-Origin', '*') super().end_headers() - + def do_GET(self): path_parts = self.path.split('/') key = path_parts[1] - file_path = '/'.join(path_parts[2:]) + file_path = '/'.join(path_parts[2:]) - # Check if the secret key is valid + # Check if the secret key is valid if key not in app_dir_mapping: super().send_error(403, "Using invalid key %s" % key) return @@ -118,17 +122,17 @@ def do_GET(self): class WSServer(): """Used to create a Websocket Connection with the HMI. - + Has a SampleRPCService class to handle incoming and outgoing messages. """ def __init__(self, _host, _port, _service_class=None): self.HOST = _host self.PORT = _port self.service_class = _service_class if _service_class != None else WSServer.SampleRPCService - + async def serve(self, _stop_func): async with websockets.serve(self.on_connect, self.HOST, self.PORT): - await _stop_func + await _stop_func def start_server(self): loop = asyncio.get_event_loop() @@ -144,22 +148,22 @@ def start_server(self): async def on_connect(self, _websocket, _path): print('\033[1;2mClient %s connected\033[0m' % str(_websocket.remote_address)) rpc_service = self.service_class(_websocket, _path) - + async for message in _websocket: await rpc_service.on_receive(message) - + class SampleRPCService(): def __init__(self, _websocket, _path): print('\033[1;2mCreated RPC service\033[0m') self.websocket = _websocket self.path = _path - + async def on_receive(self, _msg): print('\033[1;2mMessage received: %s\033[0m' % _msg) class RPCService(WSServer.SampleRPCService): """Used to handle receiving RPC requests and send RPC responses. - + An implementation of the SampleRPCService class. RPC requests are handled in the `handle_*` functions. """ webengine_manager = None @@ -174,12 +178,14 @@ def __init__(self, _websocket, _path): "InstallApp": RPCService.webengine_manager.handle_install_app, "GetInstalledApps": RPCService.webengine_manager.handle_get_installed_apps, "UninstallApp": RPCService.webengine_manager.handle_uninstall_app, + "StartVideoStream": self.handle_start_video_stream, + "StartAudioStream": self.handle_start_audio_stream, } async def send(self, _msg): print('\033[1;2m***Sending message: %s\033[0m' % _msg) await self.websocket.send(_msg) - + async def send_error(self, _error_msg): err = self.gen_error_msg(_error_msg) print(json.dumps(err)) @@ -217,7 +223,7 @@ def handle_request(self, _method_name, _params): return self.rpc_mapping[_method_name](_method_name, _params) def handle_get_pts_file_content(self, _method_name, _params): - + if 'fileName' not in _params: return self.gen_error_msg('Missing mandatory param \'fileName\'') @@ -245,14 +251,14 @@ def handle_save_ptu_to_file(self, _method_name, _params): # Validate file path def isFileNameValid(_file_name): - path = os.path.abspath(os.path.normpath(_file_name)) + path = os.path.abspath(os.path.normpath(_file_name)) if os.path.commonpath([path, os.getcwd()]) != os.getcwd(): # Trying to save outside the working directory return False return True - + if not isFileNameValid(file_name): return self.gen_error_msg('Invalid file name: %s. Cannot save PTU' % file_name) - + try: json_file = open(file_name, 'w') json.dump(content, json_file) @@ -263,6 +269,54 @@ def isFileNameValid(_file_name): "success": True } + def handle_start_video_stream(self, _method, _params): + if 'url' not in _params: + return self.gen_error_msg('Missing mandatory param \'url\'') + + if 'config' not in _params: + return self.gen_error_msg('Missing mandatory param \'config\'') + + if 'webm' not in _params: + return self.gen_error_msg('Missing mandatory param \'webm\'') + + if _params['config'].get('protocol') == 'RTP': + print('\033[33mSDL does not support RTP video in browser\033[0m') + if _params['config'].get('codec') == 'H264': + print('\033[1mYou may view your video with gstreamer:\033[0m') + print('gst-launch-1.0 souphttpsrc location=' + _params['url'] + ' ! "application/x-rtp-stream" ! rtpstreamdepay ! "application/x-rtp,media=(string)video,clock-rate=90000,encoding-name=(string)H264" ! rtph264depay ! "video/x-h264, stream-format=(string)avc, alignment=(string)au" ! avdec_h264 ! videoconvert ! ximagesink sync=false') + + elif not _params['webm']: + print('\033[33mYour browser does not support WEBM video\033[0m') + if _params['config'].get('protocol') == 'RAW' and _params['config'].get('codec') == 'H264': + print('\033[1mYou may view your video with gstreamer:\033[0m') + print('gst-launch-1.0 souphttpsrc location=' + _params['url'] + ' ! decodebin ! videoconvert ! xvimagesink sync=false') + + else: + server_endpoint = 'http://' + Flags.FILE_SERVER_HOST + ':' + str(Flags.VIDEO_SERVER_PORT) + ffmpeg_process = ffmpeg.input(_params['url']).output(server_endpoint, vcodec='vp8', format='webm', listen=1, multiple_requests=1).run_async(pipe_stderr=True) + o = pexpect.fdpexpect.fdspawn(ffmpeg_process.stderr.fileno(), logfile=sys.stdout.buffer) + index = o.expect(["Input", pexpect.EOF, pexpect.TIMEOUT]) + + if index != 0: + return self.gen_error_msg('Streaming data not available from SDL') + return { 'success': True, 'params': { 'endpoint': server_endpoint } } + + return { 'success': True } + + def handle_start_audio_stream(self, _method, _params): + if 'url' not in _params: + return self.gen_error_msg('Missing mandatory param \'url\'') + + server_endpoint = 'http://' + Flags.FILE_SERVER_HOST + ':' + str(Flags.AUDIO_SERVER_PORT) + ffmpeg_process = ffmpeg.input(_params['url'], ar='16000', ac='1', f='s16le').output(server_endpoint, format="wav", listen=1, multiple_requests=1).run_async(pipe_stderr=True) + o = pexpect.fdpexpect.fdspawn(ffmpeg_process.stderr.fileno(), logfile=sys.stdout.buffer) + index = o.expect(["Input", pexpect.EOF, pexpect.TIMEOUT]) + + if index != 0: + return self.gen_error_msg('Streaming data not available from SDL') + + return { 'success': True, 'params': { 'endpoint': server_endpoint } } + @staticmethod def gen_error_msg(_error_msg): return {'success': False, 'info': _error_msg} @@ -270,7 +324,7 @@ def gen_error_msg(_error_msg): class WebEngineManager(): """Used to specifically handle WebEngine RPCs. - All the `handle_*` functions parse the RPC requests and the non `handle_*` functions implement + All the `handle_*` functions parse the RPC requests and the non `handle_*` functions implement the actual webengine operations(downloading the app zip, creating directories, etc.). """ def __init__(self): @@ -291,7 +345,7 @@ def handle_install_app(self, _method_name, _params): return RPCService.gen_error_msg('Missing manadatory param \'policyAppID\'') if 'packageUrl' not in _params: return RPCService.gen_error_msg('Missing manadatory param \'packageUrl\'') - + resp = self.install_app(_params['policyAppID'], _params['packageUrl']) return resp @@ -344,7 +398,7 @@ def add_app_to_file_server(self, _app_storage_folder): print('\033[2mSecret key is %s\033[0m' % (secret_key)) self.file_server.add_app_mapping(secret_key, _app_storage_folder) return { - "key": secret_key, + "key": secret_key, "url": '%s/%s/' % (self.file_server.URI, secret_key) } @@ -366,10 +420,10 @@ def get_installed_apps(self): "appKey": file_server_info['key'] } apps_info.append({ - "policyAppID": app_id, + "policyAppID": app_id, "appUrl": self.webengine_apps[app_id]['appURL'] }) - + return { "success": True, "params":{ @@ -408,7 +462,9 @@ def uninstall_app(self, _app_id): def main(): parser = argparse.ArgumentParser(description="Handle backend operations for the hmi") parser.add_argument('--host', type=str, required=True, help="Backend server hostname") - parser.add_argument('--port', type=int, required=True, help="Backend server port number") + parser.add_argument('--ws-port', type=int, required=True, help="Backend server port number") + parser.add_argument('--video-port', type=int, default=8085, help="Video streaming server port number") + parser.add_argument('--audio-port', type=int, default=8086, help="Audio streaming server port number") parser.add_argument('--fs-port', type=int, default=4000, help="File server port number") parser.add_argument('--fs-uri', type=str, help="File server's URI (to be sent back to the client hmi)") @@ -416,8 +472,10 @@ def main(): Flags.FILE_SERVER_HOST = args.host Flags.FILE_SERVER_PORT = args.fs_port Flags.FILE_SERVER_URI = args.fs_uri if args.fs_uri else 'http://%s:%s' % (Flags.FILE_SERVER_HOST, Flags.FILE_SERVER_PORT) + Flags.VIDEO_SERVER_PORT = args.video_port + Flags.AUDIO_SERVER_PORT = args.audio_port - backend_server = WSServer(args.host, args.port, RPCService) + backend_server = WSServer(args.host, args.ws_port, RPCService) print('Starting server') backend_server.start_server()