From a9243400897d98b5dbeba28028a974b5cf5231ca Mon Sep 17 00:00:00 2001
From: Eric Rosenbaum <eric.rosenbaum@gmail.com>
Date: Wed, 26 Sep 2018 17:59:30 -0400
Subject: [PATCH] Add an indicator to show that the microphone is listening
 (#3205)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* Add mic indicator

* Fix mic indicator position and margin

* Don’t always show indicator, don’t sneaky-enable extension

* Position mic indicator in RTL

* Actually, don’t move indicator in RTL

* Update event name for MIC_LISTENING

* Move mic indicator state into redux

* Move stageSizeToTransform into screen-utils

* Position mic indicator and question at bottom of stage

* Fix pointer events

* JSDOC for stageSizeToTransform

* Pass micIndicator into StageComponent via …props
---
 .../mic-indicator/mic-indicator.css           |  10 ++++++
 .../mic-indicator/mic-indicator.jsx           |  29 ++++++++++++++++++
 .../mic-indicator/mic-indicator.svg           | Bin 0 -> 3029 bytes
 src/components/monitor-list/monitor-list.jsx  |  12 +-------
 src/components/question/question.css          |   7 -----
 src/components/question/question.jsx          |   4 ++-
 src/components/stage/stage.css                |  27 +++++++++++++---
 src/components/stage/stage.jsx                |  29 ++++++++++++------
 src/containers/stage.jsx                      |   4 ++-
 src/css/z-index.css                           |   1 +
 src/lib/screen-utils.js                       |  24 ++++++++++++++-
 src/lib/vm-listener-hoc.jsx                   |   8 +++++
 src/reducers/gui.js                           |   3 ++
 src/reducers/mic-indicator.js                 |  26 ++++++++++++++++
 14 files changed, 149 insertions(+), 35 deletions(-)
 create mode 100644 src/components/mic-indicator/mic-indicator.css
 create mode 100644 src/components/mic-indicator/mic-indicator.jsx
 create mode 100644 src/components/mic-indicator/mic-indicator.svg
 create mode 100644 src/reducers/mic-indicator.js

diff --git a/src/components/mic-indicator/mic-indicator.css b/src/components/mic-indicator/mic-indicator.css
new file mode 100644
index 000000000..9fb397029
--- /dev/null
+++ b/src/components/mic-indicator/mic-indicator.css
@@ -0,0 +1,10 @@
+@keyframes popIn {
+    from {transform: scale(0.5)}
+    to {transform: scale(1)}
+}
+
+.mic-img {
+    margin: 10px;
+    transform-origin: center;
+    animation: popIn 0.1s ease-in-out;
+}
diff --git a/src/components/mic-indicator/mic-indicator.jsx b/src/components/mic-indicator/mic-indicator.jsx
new file mode 100644
index 000000000..fe0eb602b
--- /dev/null
+++ b/src/components/mic-indicator/mic-indicator.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import styles from './mic-indicator.css';
+import micIcon from './mic-indicator.svg';
+import {stageSizeToTransform} from '../../lib/screen-utils';
+
+const MicIndicatorComponent = props => (
+    <div
+        className={props.className}
+        style={stageSizeToTransform(props.stageSize)}
+    >
+        <img
+            className={styles.micImg}
+            src={micIcon}
+        />
+    </div>
+);
+
+MicIndicatorComponent.propTypes = {
+    className: PropTypes.string,
+    stageSize: PropTypes.shape({
+        width: PropTypes.number,
+        height: PropTypes.number,
+        widthDefault: PropTypes.number,
+        heightDefault: PropTypes.number
+    }).isRequired
+};
+
+export default MicIndicatorComponent;
diff --git a/src/components/mic-indicator/mic-indicator.svg b/src/components/mic-indicator/mic-indicator.svg
new file mode 100644
index 0000000000000000000000000000000000000000..78726389d13b811a8b7f31bbfa32a98df118a932
GIT binary patch
literal 3029
zcma)8$!^<75Is9z(V;Ow0+_Ad7e{gy1<oZw4oNNva-gM_#IZ~oBxTtM=HKTv7m4F|
zfXIf}e5`&|^{T3=>FfKOEx8L-otMQd;glsL6pM117wcK_?dxUglGhi{ruE&L?DJ)_
znI%@--X~-e^7W<}?(Xuie^=hm5=I!Y0{_XFlqUpkih7o8n&$R=GTHC<bT4UHttWyp
zHUWKaa(=(fi|eNhx%Yn3K20v35qzf4)0BJ&MX0i-tj@`o>(DGVL~|<13vIPhFG)&9
z9<RzxxXH6VJ-R4wCUv(bO?%2FZ?@s$CSRm^vCJ2dcsl98&gU}Ji;H;`vL-B{NVDnP
z(&?mq)LGTd!#0rS;Wo^YCfqlZMP2t%pG=?sn7@Df_3e+=^yE#t4_DWDlU|31Rh8X@
zn%wTTHTcP|&f<jq71RAinkp;mRaxDflX{VD!wW|J%UA#OR-8q<C#?9vrZ**T{@)jA
zojt)a@)e_LKds9rY>ytv;PSNGHK7uRoQ}z)WtFe<;+#lxd*6P&>N6sPG+B|~WDPp>
z{Gbh^x+Wp30}7Rbo8-kRFLJ1sZQ-bZeg~S-_LGxA+&?E6a`^ePZ<0w{-u`A8R&}?G
zl1!_xKrG8ya+@`qG&Vk_3e)h=UHTbP?l?hlUBODI<YGFB`$u%EJZ2L~QlTmPt0fwi
zJsH=meKL&XaNq6y22n70<<;L{epl|$*2NmLnzT`NHmT^FvnwmoScT84RUI0#j6<D}
zCE~{=#~JaZ+*OP4c6+<Y5O}C}TO0%RCR>*KZqMgfX&#dqSdIy`8m+>IY**Kq#CO|W
zMe1hxKHTMPfoF;8ygWhp$XVPwb~Wbw&EdS<mer@Msq%Yrmu+_elEkyB`y+=HLx2N{
zb-$#VAo*^b(TE@!2l+UVk*{MM5Th++hs48TXq)M}t;B!MoCKm&<uy)^qAbF2mktF_
z2*|d>!?;eX-4@p2E)?Z*IjZJ#kyi_xMX@Ej5$ran<!!den^;z=2fowGOJf{TU7+gH
z;ELz*TD2Ecv|}5k^_U(V-^Medw|~aZV#oZ(--(kKIORuKwQaPkL$AZtHd|ajcHBjR
zR-7ffYWw1}(?1n2F_ZBfbH1s<YL=Y#g9_&$JZARD#X*`U)czyB>12H*pGKzyCHW)`
z_1;LS+?i6;8X*~1<l`82R5+)E<YYdEJ!Oo0r4`Ohs<`!>*)!#-c1#+tiPY3GC9!pk
z&ZUAw=8WNE7^vbxDG|W~?-4x4@Ns0?1}vO5h>TIgw6{8@A-s25N+K+k!f3C|nc~!O
zE0HO|L58Jb)|^T3YULf71Ju$ptN59;6hz)h)tV~96bEXklin()i7-?%$)vRr)rKps
z!C)yjj7cRUDx72vN&@xV3FAd;>lteq9Od2^Cct2+P!LK(Ug4>-(mM+o7!^hcV+@gk
zD(0m#kXImHqa+79lJ|(tCFJAMLW;35viI6DuOT{!l{S!tC`nOnZy;KXf?1SBkU3z@
zj8jatAuG?cFeJ)qq!+wRL|~<vh$tYh(LPZMuDOt4fS49FK<R;6&%D>30Cf(+GT1_5
z<xl||QNtxPYKy@P=PWWP%9&S8qAfWXl=WH?#O?&+&a}-cymid^*nUs6@OSd5mB>f4
z$nC7vI9<>xNSiZbC89gRl2h)GD~A{;weZ3jNyez6k_qR19FL<h3f%tIvkJ^37;j6k
zp(n}6pO9#7W?fgkF0=zSkKM=_v_Cswv0BmyWDsA!J>dvEI{$b?F(E^w^AS4A>fmTB
lKy0Yr)cW4Nr}ITwj85rGeByB#kCEYxg5zZxe@I_E`xi=f46^_L

literal 0
HcmV?d00001

diff --git a/src/components/monitor-list/monitor-list.jsx b/src/components/monitor-list/monitor-list.jsx
index 5cb114241..f7b02080f 100644
--- a/src/components/monitor-list/monitor-list.jsx
+++ b/src/components/monitor-list/monitor-list.jsx
@@ -4,20 +4,10 @@ import Box from '../box/box.jsx';
 import Monitor from '../../containers/monitor.jsx';
 import PropTypes from 'prop-types';
 import {OrderedMap} from 'immutable';
+import {stageSizeToTransform} from '../../lib/screen-utils';
 
 import styles from './monitor-list.css';
 
-const stageSizeToTransform = ({width, height, widthDefault, heightDefault}) => {
-    const scaleX = width / widthDefault;
-    const scaleY = height / heightDefault;
-    if (scaleX === 1 && scaleY === 1) {
-        // Do not set a transform if the scale is 1 because
-        // it messes up `position: fixed` elements like the context menu.
-        return;
-    }
-    return {transform: `scale(${scaleX},${scaleY})`};
-};
-
 const MonitorList = props => (
     <Box
         // Use static `monitor-overlay` class for bounds of draggables
diff --git a/src/components/question/question.css b/src/components/question/question.css
index 4550df75c..a0f8116ed 100644
--- a/src/components/question/question.css
+++ b/src/components/question/question.css
@@ -1,13 +1,6 @@
 @import "../../css/units.css";
 @import "../../css/colors.css";
 
-.question-wrapper {
-    position: absolute;
-    bottom: 0;
-    left: 0;
-    width: 100%;
-}
-
 .question-container {
     margin: $space;
     border: 1px solid $ui-black-transparent;
diff --git a/src/components/question/question.jsx b/src/components/question/question.jsx
index c57fb085d..373f7cb91 100644
--- a/src/components/question/question.jsx
+++ b/src/components/question/question.jsx
@@ -7,13 +7,14 @@ import enterIcon from './icon--enter.svg';
 const QuestionComponent = props => {
     const {
         answer,
+        className,
         question,
         onChange,
         onClick,
         onKeyPress
     } = props;
     return (
-        <div className={styles.questionWrapper}>
+        <div className={className}>
             <div className={styles.questionContainer}>
                 {question ? (
                     <div className={styles.questionLabel}>{question}</div>
@@ -43,6 +44,7 @@ const QuestionComponent = props => {
 
 QuestionComponent.propTypes = {
     answer: PropTypes.string,
+    className: PropTypes.string,
     onChange: PropTypes.func.isRequired,
     onClick: PropTypes.func.isRequired,
     onKeyPress: PropTypes.func.isRequired,
diff --git a/src/components/stage/stage.css b/src/components/stage/stage.css
index 7ce4d55d9..98a0c42a7 100644
--- a/src/components/stage/stage.css
+++ b/src/components/stage/stage.css
@@ -74,10 +74,6 @@
     border: none;
 }
 
-.question-wrapper {
-    position: absolute;
-}
-
 /* adjust monitors when stage is standard size:
 shift them down and right to compensate for the stage's border */
 .stage-wrapper .monitor-wrapper {
@@ -93,7 +89,7 @@ to adjust for the border using a different method */
     padding-bottom: calc($stage-full-screen-stage-padding + $stage-full-screen-border-width);
 }
 
-.monitor-wrapper, .color-picker-wrapper, .queston-wrapper {
+.monitor-wrapper, .color-picker-wrapper {
     position: absolute;
     top: 0;
     left: 0;
@@ -110,3 +106,24 @@ to adjust for the border using a different method */
     z-index: $z-index-dragging-sprite;
     filter: drop-shadow(5px 5px 5px $ui-black-transparent);
  }
+
+.stage-bottom-wrapper {
+    position: absolute;
+    display: flex;
+    flex-direction: column;
+    justify-content: flex-end;
+    top: 0;
+    overflow: hidden;
+    pointer-events: none;
+}
+
+.mic-indicator {
+    transform-origin: bottom right;
+    z-index: $z-index-stage-indicator;
+    pointer-events: none;
+    align-self: flex-end;
+}
+
+.question-wrapper {
+    pointer-events: auto;
+}
diff --git a/src/components/stage/stage.jsx b/src/components/stage/stage.jsx
index d8c0d0774..9e1f3454d 100644
--- a/src/components/stage/stage.jsx
+++ b/src/components/stage/stage.jsx
@@ -7,6 +7,7 @@ import DOMElementRenderer from '../../containers/dom-element-renderer.jsx';
 import Loupe from '../loupe/loupe.jsx';
 import MonitorList from '../../containers/monitor-list.jsx';
 import Question from '../../containers/question.jsx';
+import MicIndicator from '../mic-indicator/mic-indicator.jsx';
 import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants.js';
 import {getStageDimensions} from '../../lib/screen-utils.js';
 import styles from './stage.css';
@@ -18,6 +19,7 @@ const StageComponent = props => {
         isColorPicking,
         isFullScreen,
         colorInfo,
+        micIndicator,
         question,
         stageSize,
         useEditorDragStyle,
@@ -66,13 +68,22 @@ const StageComponent = props => {
                         <Loupe colorInfo={colorInfo} />
                     </Box>
                 ) : null}
-                {question === null ? null : (
-                    <div
-                        className={classNames(
-                            styles.stageOverlayContent,
-                            styles.stageOverlayContentBorderOverride
-                        )}
-                    >
+                <div
+                    className={styles.stageBottomWrapper}
+                    style={{
+                        width: stageDimensions.width,
+                        height: stageDimensions.height,
+                        left: '50%',
+                        marginLeft: stageDimensions.width * -0.5
+                    }}
+                >
+                    {micIndicator ? (
+                        <MicIndicator
+                            className={styles.micIndicator}
+                            stageSize={stageDimensions}
+                        />
+                    ) : null}
+                    {question === null ? null : (
                         <div
                             className={styles.questionWrapper}
                             style={{width: stageDimensions.width}}
@@ -82,8 +93,8 @@ const StageComponent = props => {
                                 onQuestionAnswered={onQuestionAnswered}
                             />
                         </div>
-                    </div>
-                )}
+                    )}
+                </div>
                 <canvas
                     className={styles.draggingSprite}
                     height={0}
diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx
index 0aa3f2a4b..dc2b87043 100644
--- a/src/containers/stage.jsx
+++ b/src/containers/stage.jsx
@@ -75,7 +75,8 @@ class Stage extends React.Component {
             this.props.isColorPicking !== nextProps.isColorPicking ||
             this.state.colorInfo !== nextState.colorInfo ||
             this.props.isFullScreen !== nextProps.isFullScreen ||
-            this.state.question !== nextState.question;
+            this.state.question !== nextState.question ||
+            this.props.micIndicator !== nextProps.micIndicator;
     }
     componentDidUpdate (prevProps) {
         if (this.props.isColorPicking && !prevProps.isColorPicking) {
@@ -402,6 +403,7 @@ Stage.defaultProps = {
 const mapStateToProps = state => ({
     isColorPicking: state.scratchGui.colorPicker.active,
     isFullScreen: state.scratchGui.mode.isFullScreen,
+    micIndicator: state.scratchGui.micIndicator,
     // Do not use editor drag style in fullscreen or player mode.
     useEditorDragStyle: !(state.scratchGui.mode.isFullScreen || state.scratchGui.mode.isPlayerOnly)
 });
diff --git a/src/css/z-index.css b/src/css/z-index.css
index 87cc2cd01..06e201565 100644
--- a/src/css/z-index.css
+++ b/src/css/z-index.css
@@ -8,6 +8,7 @@ $z-index-extension-button: 50; /* Force extension button above the ScratchBlocks
 $z-index-menu-bar: 50; /* blocklyToolboxDiv is 40 */
 
 $z-index-monitor: 100;
+$z-index-stage-indicator: 110;
 $z-index-add-button: 120;
 $z-index-tooltip: 130; /* tooltips should go over add buttons if they overlap */
 
diff --git a/src/lib/screen-utils.js b/src/lib/screen-utils.js
index afef90d84..d61201784 100644
--- a/src/lib/screen-utils.js
+++ b/src/lib/screen-utils.js
@@ -72,7 +72,29 @@ const getStageDimensions = (stageSize, isFullScreen) => {
     return stageDimensions;
 };
 
+/**
+ * Take a pair of sizes for the stage (a target height and width and a default height and width),
+ * calculate the ratio between them, and return a CSS transform to scale to that ratio.
+ * @param {object} sizeInfo An object containing dimensions of the target and default stage sizes.
+ * @param {number} sizeInfo.width The target width
+ * @param {number} sizeInfo.height The target height
+ * @param {number} sizeInfo.widthDefault The default width
+ * @param {number} sizeInfo.heightDefault The default height
+ * @returns {object} the CSS transform
+ */
+const stageSizeToTransform = ({width, height, widthDefault, heightDefault}) => {
+    const scaleX = width / widthDefault;
+    const scaleY = height / heightDefault;
+    if (scaleX === 1 && scaleY === 1) {
+        // Do not set a transform if the scale is 1 because
+        // it messes up `position: fixed` elements like the context menu.
+        return;
+    }
+    return {transform: `scale(${scaleX},${scaleY})`};
+};
+
 export {
     getStageDimensions,
-    resolveStageSize
+    resolveStageSize,
+    stageSizeToTransform
 };
diff --git a/src/lib/vm-listener-hoc.jsx b/src/lib/vm-listener-hoc.jsx
index 68b2753ab..6b8d17777 100644
--- a/src/lib/vm-listener-hoc.jsx
+++ b/src/lib/vm-listener-hoc.jsx
@@ -10,6 +10,7 @@ import {updateBlockDrag} from '../reducers/block-drag';
 import {updateMonitors} from '../reducers/monitors';
 import {setRunningState, setTurboState} from '../reducers/vm-status';
 import {showAlert} from '../reducers/alerts';
+import {updateMicIndicator} from '../reducers/mic-indicator';
 
 /*
  * Higher Order Component to manage events emitted by the VM
@@ -38,6 +39,8 @@ const vmListenerHOC = function (WrappedComponent) {
             this.props.vm.on('PROJECT_RUN_START', this.props.onProjectRunStart);
             this.props.vm.on('PROJECT_RUN_STOP', this.props.onProjectRunStop);
             this.props.vm.on('PERIPHERAL_ERROR', this.props.onShowAlert);
+            this.props.vm.on('MIC_LISTENING', this.props.onMicListeningUpdate);
+
         }
         componentDidMount () {
             if (this.props.attachKeyboardEvents) {
@@ -89,6 +92,7 @@ const vmListenerHOC = function (WrappedComponent) {
                 onBlockDragUpdate,
                 onKeyDown,
                 onKeyUp,
+                onMicListeningUpdate,
                 onMonitorsUpdate,
                 onTargetsUpdate,
                 onProjectRunStart,
@@ -107,6 +111,7 @@ const vmListenerHOC = function (WrappedComponent) {
         onBlockDragUpdate: PropTypes.func.isRequired,
         onKeyDown: PropTypes.func,
         onKeyUp: PropTypes.func,
+        onMicListeningUpdate: PropTypes.func.isRequired,
         onMonitorsUpdate: PropTypes.func.isRequired,
         onProjectRunStart: PropTypes.func.isRequired,
         onProjectRunStop: PropTypes.func.isRequired,
@@ -141,6 +146,9 @@ const vmListenerHOC = function (WrappedComponent) {
         onTurboModeOff: () => dispatch(setTurboState(false)),
         onShowAlert: data => {
             dispatch(showAlert(data));
+        },
+        onMicListeningUpdate: listening => {
+            dispatch(updateMicIndicator(listening));
         }
     });
     return connect(
diff --git a/src/reducers/gui.js b/src/reducers/gui.js
index 4d6cea9c0..bce112a1c 100644
--- a/src/reducers/gui.js
+++ b/src/reducers/gui.js
@@ -8,6 +8,7 @@ import blockDragReducer, {blockDragInitialState} from './block-drag';
 import editorTabReducer, {editorTabInitialState} from './editor-tab';
 import hoveredTargetReducer, {hoveredTargetInitialState} from './hovered-target';
 import menuReducer, {menuInitialState} from './menus';
+import micIndicatorReducer, {micIndicatorInitialState} from './mic-indicator';
 import modalReducer, {modalsInitialState} from './modals';
 import modeReducer, {modeInitialState} from './mode';
 import monitorReducer, {monitorsInitialState} from './monitors';
@@ -38,6 +39,7 @@ const guiInitialState = {
     hoveredTarget: hoveredTargetInitialState,
     stageSize: stageSizeInitialState,
     menus: menuInitialState,
+    micIndicator: micIndicatorInitialState,
     modals: modalsInitialState,
     monitors: monitorsInitialState,
     monitorLayout: monitorLayoutInitialState,
@@ -104,6 +106,7 @@ const guiReducer = combineReducers({
     hoveredTarget: hoveredTargetReducer,
     stageSize: stageSizeReducer,
     menus: menuReducer,
+    micIndicator: micIndicatorReducer,
     modals: modalReducer,
     monitors: monitorReducer,
     monitorLayout: monitorLayoutReducer,
diff --git a/src/reducers/mic-indicator.js b/src/reducers/mic-indicator.js
new file mode 100644
index 000000000..2548abbb4
--- /dev/null
+++ b/src/reducers/mic-indicator.js
@@ -0,0 +1,26 @@
+const UPDATE = 'scratch-gui/mic-indicator/UPDATE';
+
+const initialState = false;
+
+const reducer = function (state, action) {
+    if (typeof state === 'undefined') state = initialState;
+    switch (action.type) {
+    case UPDATE:
+        return action.visible;
+    default:
+        return state;
+    }
+};
+
+const updateMicIndicator = function (visible) {
+    return {
+        type: UPDATE,
+        visible: visible
+    };
+};
+
+export {
+    reducer as default,
+    initialState as micIndicatorInitialState,
+    updateMicIndicator
+};
-- 
GitLab