Browse Source

feat(tile-view): initial implementation for tile view (#3317)

* feat(tile-view): initial implementation for tile view

- Modify the classname on the app root so layout can adjust
  depending on the desired layout mode--vertical filmstrip,
  horizontal filmstrip, and tile view.
- Create a button for toggling tile view.
- Add a StateListenerRegistry to automatically update the
  selected participant and max receiver frame height on tile
  view toggle.
- Rezise thumbnails when switching in and out of tile view.
- Move the local video when switching in and out of tile view.
- Update reactified pieces of thumbnails when switching in and
  out of tile view.
- Cap the max receiver video quality in tile view based on tile
  size.
- Use CSS to hide UI components that should not display in tile
  view.
- Signal follow me changes.

* change local video id for tests

* change approach: leverage more css

* squash: fix some formatting

* squash: prevent pinning, hide pin border in tile view

* squash: change logic for maxReceiverQuality due to sidestepping resizing logic

* squash: fix typo, columns configurable, remove unused constants

* squash: resize with js again

* squash: use yana's math for calculating tile size
efficient_tiling
virtuacoplenny 6 years ago
parent
commit
c353e9377f
32 changed files with 876 additions and 81 deletions
  1. 3
    8
      css/filmstrip/_small_video.scss
  2. 113
    0
      css/filmstrip/_tile_view.scss
  3. 47
    0
      css/filmstrip/_tile_view_overrides.scss
  4. 2
    0
      css/main.scss
  5. 8
    1
      interface_config.js
  6. 2
    0
      lang/main.json
  7. 66
    1
      modules/FollowMe.js
  8. 6
    2
      modules/UI/shared_video/SharedVideoThumb.js
  9. 99
    11
      modules/UI/videolayout/Filmstrip.js
  10. 56
    13
      modules/UI/videolayout/LocalVideo.js
  11. 19
    4
      modules/UI/videolayout/RemoteVideo.js
  12. 51
    4
      modules/UI/videolayout/SmallVideo.js
  13. 33
    3
      modules/UI/videolayout/VideoLayout.js
  14. 34
    1
      react/features/base/conference/functions.js
  15. 46
    23
      react/features/conference/components/Conference.web.js
  16. 2
    2
      react/features/filmstrip/components/web/Filmstrip.js
  17. 8
    4
      react/features/large-video/actions.js
  18. 10
    2
      react/features/large-video/components/AbstractLabels.js
  19. 2
    1
      react/features/large-video/components/Labels.web.js
  20. 1
    1
      react/features/large-video/components/LargeVideo.web.js
  21. 3
    0
      react/features/toolbox/components/web/Toolbox.js
  22. 10
    0
      react/features/video-layout/actionTypes.js
  23. 20
    0
      react/features/video-layout/actions.js
  24. 90
    0
      react/features/video-layout/components/TileViewButton.js
  25. 1
    0
      react/features/video-layout/components/index.js
  26. 10
    0
      react/features/video-layout/constants.js
  27. 78
    0
      react/features/video-layout/functions.js
  28. 8
    0
      react/features/video-layout/index.js
  29. 6
    0
      react/features/video-layout/middleware.web.js
  30. 17
    0
      react/features/video-layout/reducer.js
  31. 24
    0
      react/features/video-layout/subscriber.js
  32. 1
    0
      service/UI/UIEvents.js

+ 3
- 8
css/filmstrip/_small_video.scss View File

14
      * Focused video thumbnail.
14
      * Focused video thumbnail.
15
      */
15
      */
16
     &.videoContainerFocused {
16
     &.videoContainerFocused {
17
-        transition-duration: 0.5s;
18
-        -webkit-transition-duration: 0.5s;
19
-        -webkit-animation-name: greyPulse;
20
-        -webkit-animation-duration: 2s;
21
-        -webkit-animation-iteration-count: 1;
22
-        border: $thumbnailVideoBorder solid $videoThumbnailSelected !important;
17
+        border: $thumbnailVideoBorder solid $videoThumbnailSelected;
23
         box-shadow: inset 0 0 3px $videoThumbnailSelected,
18
         box-shadow: inset 0 0 3px $videoThumbnailSelected,
24
-        0 0 3px $videoThumbnailSelected !important;
19
+        0 0 3px $videoThumbnailSelected;
25
     }
20
     }
26
 
21
 
27
     .remotevideomenu > .icon-menu {
22
     .remotevideomenu > .icon-menu {
31
     /**
26
     /**
32
      * Hovered video thumbnail.
27
      * Hovered video thumbnail.
33
      */
28
      */
34
-    &:hover {
29
+    &:hover:not(.videoContainerFocused):not(.active-speaker) {
35
         cursor: hand;
30
         cursor: hand;
36
         border: $thumbnailVideoBorder solid $videoThumbnailHovered;
31
         border: $thumbnailVideoBorder solid $videoThumbnailHovered;
37
         box-shadow: inset 0 0 3px $videoThumbnailHovered,
32
         box-shadow: inset 0 0 3px $videoThumbnailHovered,

+ 113
- 0
css/filmstrip/_tile_view.scss View File

1
+/**
2
+ * CSS styles that are specific to the filmstrip that shows the thumbnail tiles.
3
+ */
4
+.tile-view {
5
+    /**
6
+     * Add a border around the active speaker to make the thumbnail easier to
7
+     * see.
8
+     */
9
+    .active-speaker {
10
+        border: $thumbnailVideoBorder solid $videoThumbnailSelected;
11
+        box-shadow: inset 0 0 3px $videoThumbnailSelected,
12
+        0 0 3px $videoThumbnailSelected;
13
+    }
14
+
15
+    #filmstripRemoteVideos {
16
+        align-items: center;
17
+        box-sizing: border-box;
18
+        display: flex;
19
+        flex-direction: column;
20
+        height: 100vh;
21
+        width: 100vw;
22
+    }
23
+
24
+    .filmstrip__videos .videocontainer {
25
+        &:not(.active-speaker),
26
+        &:hover:not(.active-speaker) {
27
+            border: none;
28
+            box-shadow: none;
29
+        }
30
+    }
31
+
32
+    #remoteVideos {
33
+        /**
34
+         * Height is modified with an inline style in horizontal filmstrip mode
35
+         * so !important is used to override that.
36
+         */
37
+        height: 100% !important;
38
+        width: 100%;
39
+    }
40
+
41
+    .filmstrip {
42
+        align-items: center;
43
+        display: flex;
44
+        height: 100%;
45
+        justify-content: center;
46
+        left: 0;
47
+        position: fixed;
48
+        top: 0;
49
+        width: 100%;
50
+        z-index: $filmstripVideosZ
51
+    }
52
+
53
+    /**
54
+     * Regardless of the user setting, do not let the filmstrip be in a hidden
55
+     * state.
56
+     */
57
+    .filmstrip__videos.hidden {
58
+        display: block;
59
+    }
60
+
61
+    #filmstripRemoteVideos {
62
+        box-sizing: border-box;
63
+
64
+        /**
65
+         * Allow scrolling of the thumbnails.
66
+         */
67
+        overflow: auto;
68
+    }
69
+
70
+    /**
71
+     * The size of the thumbnails should be set with javascript, based on
72
+     * desired column count and window width. The rows are created using flex
73
+     * and allowing the thumbnails to wrap.
74
+     */
75
+    #filmstripRemoteVideosContainer {
76
+        align-content: center;
77
+        align-items: center;
78
+        box-sizing: border-box;
79
+        display: flex;
80
+        flex-wrap: wrap;
81
+        height: 100vh;
82
+        justify-content: center;
83
+        padding: 100px 0;
84
+
85
+        .videocontainer {
86
+            box-sizing: border-box;
87
+            display: block;
88
+            margin: 5px;
89
+        }
90
+
91
+        video {
92
+            object-fit: contain;
93
+        }
94
+    }
95
+
96
+    .has-overflow#filmstripRemoteVideosContainer {
97
+        align-content: baseline;
98
+    }
99
+
100
+    .has-overflow .videocontainer {
101
+        align-self: baseline;
102
+    }
103
+
104
+    /**
105
+     * Firefox flex acts a little differently. To make sure the bottom row of
106
+     * thumbnails is not overlapped by the horizontal toolbar, margin is added
107
+     * to the local thumbnail to keep it from the bottom of the screen. It is
108
+     * assumed the local thumbnail will always be on the bottom row.
109
+     */
110
+    .has-overflow #localVideoContainer {
111
+        margin-bottom: 100px !important;
112
+    }
113
+}

+ 47
- 0
css/filmstrip/_tile_view_overrides.scss View File

1
+/**
2
+ * Various overrides outside of the filmstrip to style the app to support a
3
+ * tiled thumbnail experience.
4
+ */
5
+.tile-view {
6
+    /**
7
+     * Let the avatar grow with the tile.
8
+     */
9
+    .userAvatar {
10
+        max-height: initial;
11
+        max-width: initial;
12
+    }
13
+
14
+    /**
15
+     * Hide various features that should not be displayed while in tile view.
16
+     */
17
+    #dominantSpeaker,
18
+    #filmstripLocalVideoThumbnail,
19
+    #largeVideoElementsContainer,
20
+    #sharedVideo,
21
+    .filmstrip__toolbar {
22
+        display: none;
23
+    }
24
+
25
+    #localConnectionMessage,
26
+    #remoteConnectionMessage,
27
+    .watermark {
28
+        z-index: $filmstripVideosZ + 1;
29
+    }
30
+
31
+    /**
32
+     * The follow styling uses !important to override inline styles set with
33
+     * javascript.
34
+     *
35
+     * TODO: These overrides should be more easy to remove and should be removed
36
+     * when the components are in react so their rendering done declaratively,
37
+     * making conditional styling easier to apply.
38
+     */
39
+    #largeVideoElementsContainer,
40
+    #remoteConnectionMessage,
41
+    #remotePresenceMessage {
42
+        display: none !important;
43
+    }
44
+    #largeVideoContainer {
45
+        background-color: $defaultBackground !important;
46
+    }
47
+}

+ 2
- 0
css/main.scss View File

72
 @import 'filmstrip/filmstrip_toolbar';
72
 @import 'filmstrip/filmstrip_toolbar';
73
 @import 'filmstrip/horizontal_filmstrip';
73
 @import 'filmstrip/horizontal_filmstrip';
74
 @import 'filmstrip/small_video';
74
 @import 'filmstrip/small_video';
75
+@import 'filmstrip/tile_view';
76
+@import 'filmstrip/tile_view_overrides';
75
 @import 'filmstrip/vertical_filmstrip';
77
 @import 'filmstrip/vertical_filmstrip';
76
 @import 'filmstrip/vertical_filmstrip_overrides';
78
 @import 'filmstrip/vertical_filmstrip_overrides';
77
 @import 'unsupported-browser/main';
79
 @import 'unsupported-browser/main';

+ 8
- 1
interface_config.js View File

48
         'microphone', 'camera', 'closedcaptions', 'desktop', 'fullscreen',
48
         'microphone', 'camera', 'closedcaptions', 'desktop', 'fullscreen',
49
         'fodeviceselection', 'hangup', 'profile', 'info', 'chat', 'recording',
49
         'fodeviceselection', 'hangup', 'profile', 'info', 'chat', 'recording',
50
         'livestreaming', 'etherpad', 'sharedvideo', 'settings', 'raisehand',
50
         'livestreaming', 'etherpad', 'sharedvideo', 'settings', 'raisehand',
51
-        'videoquality', 'filmstrip', 'invite', 'feedback', 'stats', 'shortcuts'
51
+        'videoquality', 'filmstrip', 'invite', 'feedback', 'stats', 'shortcuts',
52
+        'tileview'
52
     ],
53
     ],
53
 
54
 
54
     SETTINGS_SECTIONS: [ 'devices', 'language', 'moderator', 'profile' ],
55
     SETTINGS_SECTIONS: [ 'devices', 'language', 'moderator', 'profile' ],
172
      */
173
      */
173
     RECENT_LIST_ENABLED: true
174
     RECENT_LIST_ENABLED: true
174
 
175
 
176
+    /**
177
+     * How many columns the tile view can expand to. The respected range is
178
+     * between 1 and 5.
179
+     */
180
+    // TILE_VIEW_MAX_COLUMNS: 5,
181
+
175
     /**
182
     /**
176
      * Specify custom URL for downloading android mobile app.
183
      * Specify custom URL for downloading android mobile app.
177
      */
184
      */

+ 2
- 0
lang/main.json View File

102
             "shortcuts": "Toggle shortcuts",
102
             "shortcuts": "Toggle shortcuts",
103
             "speakerStats": "Toggle speaker statistics",
103
             "speakerStats": "Toggle speaker statistics",
104
             "toggleCamera": "Toggle camera",
104
             "toggleCamera": "Toggle camera",
105
+            "tileView": "Toggle tile view",
105
             "videomute": "Toggle mute video"
106
             "videomute": "Toggle mute video"
106
         },
107
         },
107
         "addPeople": "Add people to your call",
108
         "addPeople": "Add people to your call",
144
         "raiseHand": "Raise / Lower your hand",
145
         "raiseHand": "Raise / Lower your hand",
145
         "shortcuts": "View shortcuts",
146
         "shortcuts": "View shortcuts",
146
         "speakerStats": "Speaker stats",
147
         "speakerStats": "Speaker stats",
148
+        "tileViewToggle": "Toggle tile view",
147
         "invite": "Invite people"
149
         "invite": "Invite people"
148
     },
150
     },
149
     "chat":{
151
     "chat":{

+ 66
- 1
modules/FollowMe.js View File

21
     getPinnedParticipant,
21
     getPinnedParticipant,
22
     pinParticipant
22
     pinParticipant
23
 } from '../react/features/base/participants';
23
 } from '../react/features/base/participants';
24
+import { setTileView } from '../react/features/video-layout';
24
 import UIEvents from '../service/UI/UIEvents';
25
 import UIEvents from '../service/UI/UIEvents';
25
 import VideoLayout from './UI/videolayout/VideoLayout';
26
 import VideoLayout from './UI/videolayout/VideoLayout';
26
 
27
 
117
         }
118
         }
118
     }
119
     }
119
 
120
 
121
+    /**
122
+     * A getter for this object instance to know the state of tile view.
123
+     *
124
+     * @returns {boolean} True if tile view is enabled.
125
+     */
126
+    get tileViewEnabled() {
127
+        return this._tileViewEnabled;
128
+    }
129
+
130
+    /**
131
+     * A setter for {@link tileViewEnabled}. Fires a property change event for
132
+     * other participants to follow.
133
+     *
134
+     * @param {boolean} b - Whether or not tile view is enabled.
135
+     * @returns {void}
136
+     */
137
+    set tileViewEnabled(b) {
138
+        const oldValue = this._tileViewEnabled;
139
+
140
+        if (oldValue !== b) {
141
+            this._tileViewEnabled = b;
142
+            this._firePropertyChange('tileViewEnabled', oldValue, b);
143
+        }
144
+    }
145
+
120
     /**
146
     /**
121
      * Invokes {_propertyChangeCallback} to notify it that {property} had its
147
      * Invokes {_propertyChangeCallback} to notify it that {property} had its
122
      * value changed from {oldValue} to {newValue}.
148
      * value changed from {oldValue} to {newValue}.
189
             this._sharedDocumentToggled
215
             this._sharedDocumentToggled
190
                 .bind(this, this._UI.getSharedDocumentManager().isVisible());
216
                 .bind(this, this._UI.getSharedDocumentManager().isVisible());
191
         }
217
         }
218
+
219
+        this._tileViewToggled.bind(
220
+            this,
221
+            APP.store.getState()['features/video-layout'].tileViewEnabled);
192
     }
222
     }
193
 
223
 
194
     /**
224
     /**
214
         this.sharedDocEventHandler = this._sharedDocumentToggled.bind(this);
244
         this.sharedDocEventHandler = this._sharedDocumentToggled.bind(this);
215
         this._UI.addListener(UIEvents.TOGGLED_SHARED_DOCUMENT,
245
         this._UI.addListener(UIEvents.TOGGLED_SHARED_DOCUMENT,
216
                             this.sharedDocEventHandler);
246
                             this.sharedDocEventHandler);
247
+
248
+        this.tileViewEventHandler = this._tileViewToggled.bind(this);
249
+        this._UI.addListener(UIEvents.TOGGLED_TILE_VIEW,
250
+              this.tileViewEventHandler);
217
     }
251
     }
218
 
252
 
219
     /**
253
     /**
227
                                 this.sharedDocEventHandler);
261
                                 this.sharedDocEventHandler);
228
         this._UI.removeListener(UIEvents.PINNED_ENDPOINT,
262
         this._UI.removeListener(UIEvents.PINNED_ENDPOINT,
229
                                 this.pinnedEndpointEventHandler);
263
                                 this.pinnedEndpointEventHandler);
264
+        this._UI.removeListener(UIEvents.TOGGLED_TILE_VIEW,
265
+                                this.tileViewEventHandler);
230
     }
266
     }
231
 
267
 
232
     /**
268
     /**
266
         this._local.sharedDocumentVisible = sharedDocumentVisible;
302
         this._local.sharedDocumentVisible = sharedDocumentVisible;
267
     }
303
     }
268
 
304
 
305
+    /**
306
+     * Notifies this instance that the tile view mode has been enabled or
307
+     * disabled.
308
+     *
309
+     * @param {boolean} enabled - True if tile view has been enabled, false
310
+     * if has been disabled.
311
+     * @returns {void}
312
+     */
313
+    _tileViewToggled(enabled) {
314
+        this._local.tileViewEnabled = enabled;
315
+    }
316
+
269
     /**
317
     /**
270
      * Changes the nextOnStage property value.
318
      * Changes the nextOnStage property value.
271
      *
319
      *
316
                     attributes: {
364
                     attributes: {
317
                         filmstripVisible: local.filmstripVisible,
365
                         filmstripVisible: local.filmstripVisible,
318
                         nextOnStage: local.nextOnStage,
366
                         nextOnStage: local.nextOnStage,
319
-                        sharedDocumentVisible: local.sharedDocumentVisible
367
+                        sharedDocumentVisible: local.sharedDocumentVisible,
368
+                        tileViewEnabled: local.tileViewEnabled
320
                     }
369
                     }
321
                 });
370
                 });
322
     }
371
     }
355
         this._onFilmstripVisible(attributes.filmstripVisible);
404
         this._onFilmstripVisible(attributes.filmstripVisible);
356
         this._onNextOnStage(attributes.nextOnStage);
405
         this._onNextOnStage(attributes.nextOnStage);
357
         this._onSharedDocumentVisible(attributes.sharedDocumentVisible);
406
         this._onSharedDocumentVisible(attributes.sharedDocumentVisible);
407
+        this._onTileViewEnabled(attributes.tileViewEnabled);
358
     }
408
     }
359
 
409
 
360
     /**
410
     /**
434
         }
484
         }
435
     }
485
     }
436
 
486
 
487
+    /**
488
+     * Process a tile view enabled / disabled event received from FOLLOW-ME.
489
+     *
490
+     * @param {boolean} enabled - Whether or not tile view should be shown.
491
+     * @private
492
+     * @returns {void}
493
+     */
494
+    _onTileViewEnabled(enabled) {
495
+        if (typeof enabled === 'undefined') {
496
+            return;
497
+        }
498
+
499
+        APP.store.dispatch(setTileView(enabled === 'true'));
500
+    }
501
+
437
     /**
502
     /**
438
      * Pins / unpins the video thumbnail given by clickId.
503
      * Pins / unpins the video thumbnail given by clickId.
439
      *
504
      *

+ 6
- 2
modules/UI/shared_video/SharedVideoThumb.js View File

1
-/* global $ */
1
+/* global $, APP */
2
+import { shouldDisplayTileView } from '../../../react/features/video-layout';
3
+
2
 import SmallVideo from '../videolayout/SmallVideo';
4
 import SmallVideo from '../videolayout/SmallVideo';
3
 
5
 
4
 const logger = require('jitsi-meet-logger').getLogger(__filename);
6
 const logger = require('jitsi-meet-logger').getLogger(__filename);
64
  * The thumb click handler.
66
  * The thumb click handler.
65
  */
67
  */
66
 SharedVideoThumb.prototype.videoClick = function() {
68
 SharedVideoThumb.prototype.videoClick = function() {
67
-    this._togglePin();
69
+    if (!shouldDisplayTileView(APP.store.getState())) {
70
+        this._togglePin();
71
+    }
68
 };
72
 };
69
 
73
 
70
 /**
74
 /**

+ 99
- 11
modules/UI/videolayout/Filmstrip.js View File

1
 /* global $, APP, interfaceConfig */
1
 /* global $, APP, interfaceConfig */
2
 
2
 
3
 import { setFilmstripVisible } from '../../../react/features/filmstrip';
3
 import { setFilmstripVisible } from '../../../react/features/filmstrip';
4
+import {
5
+    LAYOUTS,
6
+    getCurrentLayout,
7
+    getMaxColumnCount,
8
+    getTileViewGridDimensions,
9
+    shouldDisplayTileView
10
+} from '../../../react/features/video-layout';
4
 
11
 
5
 import UIEvents from '../../../service/UI/UIEvents';
12
 import UIEvents from '../../../service/UI/UIEvents';
6
 import UIUtil from '../util/UIUtil';
13
 import UIUtil from '../util/UIUtil';
233
      * @returns {*|{localVideo, remoteVideo}}
240
      * @returns {*|{localVideo, remoteVideo}}
234
      */
241
      */
235
     calculateThumbnailSize() {
242
     calculateThumbnailSize() {
243
+        if (shouldDisplayTileView(APP.store.getState())) {
244
+            return this._calculateThumbnailSizeForTileView();
245
+        }
246
+
236
         const availableSizes = this.calculateAvailableSize();
247
         const availableSizes = this.calculateAvailableSize();
237
         const width = availableSizes.availableWidth;
248
         const width = availableSizes.availableWidth;
238
         const height = availableSizes.availableHeight;
249
         const height = availableSizes.availableHeight;
247
      * @returns {{availableWidth: number, availableHeight: number}}
258
      * @returns {{availableWidth: number, availableHeight: number}}
248
      */
259
      */
249
     calculateAvailableSize() {
260
     calculateAvailableSize() {
250
-        let availableHeight = interfaceConfig.FILM_STRIP_MAX_HEIGHT;
251
-        const thumbs = this.getThumbs(true);
252
-        const numvids = thumbs.remoteThumbs.length;
253
-
254
-        const localVideoContainer = $('#localVideoContainer');
261
+        const state = APP.store.getState();
262
+        const currentLayout = getCurrentLayout(state);
263
+        const isHorizontalFilmstripView
264
+            = currentLayout === LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW;
255
 
265
 
256
         /**
266
         /**
257
          * If the videoAreaAvailableWidth is set we use this one to calculate
267
          * If the videoAreaAvailableWidth is set we use this one to calculate
268
             - UIUtil.parseCssInt(this.filmstrip.css('borderRightWidth'), 10)
278
             - UIUtil.parseCssInt(this.filmstrip.css('borderRightWidth'), 10)
269
             - 5;
279
             - 5;
270
 
280
 
281
+        let availableHeight = interfaceConfig.FILM_STRIP_MAX_HEIGHT;
271
         let availableWidth = videoAreaAvailableWidth;
282
         let availableWidth = videoAreaAvailableWidth;
272
 
283
 
284
+        const thumbs = this.getThumbs(true);
285
+
273
         // If local thumb is not hidden
286
         // If local thumb is not hidden
274
         if (thumbs.localThumb) {
287
         if (thumbs.localThumb) {
288
+            const localVideoContainer = $('#localVideoContainer');
289
+
275
             availableWidth = Math.floor(
290
             availableWidth = Math.floor(
276
                 videoAreaAvailableWidth - (
291
                 videoAreaAvailableWidth - (
277
                     UIUtil.parseCssInt(
292
                     UIUtil.parseCssInt(
289
             );
304
             );
290
         }
305
         }
291
 
306
 
292
-        // If the number of videos is 0 or undefined or we're in vertical
307
+        // If the number of videos is 0 or undefined or we're not in horizontal
293
         // filmstrip mode we don't need to calculate further any adjustments
308
         // filmstrip mode we don't need to calculate further any adjustments
294
         // to width based on the number of videos present.
309
         // to width based on the number of videos present.
295
-        if (numvids && !interfaceConfig.VERTICAL_FILMSTRIP) {
310
+        const numvids = thumbs.remoteThumbs.length;
311
+
312
+        if (numvids && isHorizontalFilmstripView) {
296
             const remoteVideoContainer = thumbs.remoteThumbs.eq(0);
313
             const remoteVideoContainer = thumbs.remoteThumbs.eq(0);
297
 
314
 
298
             availableWidth = Math.floor(
315
             availableWidth = Math.floor(
322
         availableHeight
339
         availableHeight
323
             = Math.min(maxHeight, window.innerHeight - 18);
340
             = Math.min(maxHeight, window.innerHeight - 18);
324
 
341
 
325
-        return { availableWidth,
326
-            availableHeight };
342
+        return {
343
+            availableHeight,
344
+            availableWidth
345
+        };
327
     },
346
     },
328
 
347
 
329
     /**
348
     /**
434
         };
453
         };
435
     },
454
     },
436
 
455
 
456
+    /**
457
+     * Calculates the size for thumbnails when in tile view layout.
458
+     *
459
+     * @returns {{localVideo, remoteVideo}}
460
+     */
461
+    _calculateThumbnailSizeForTileView() {
462
+        const tileAspectRatio = 16 / 9;
463
+
464
+        // The distance from the top and bottom of the screen, as set by CSS, to
465
+        // avoid overlapping UI elements.
466
+        const topBottomPadding = 200;
467
+
468
+        // Minimum space to keep between the sides of the tiles and the sides
469
+        // of the window.
470
+        const sideMargins = 30 * 2;
471
+
472
+        const state = APP.store.getState();
473
+
474
+        const viewWidth = document.body.clientWidth - sideMargins;
475
+        const viewHeight = document.body.clientHeight - topBottomPadding;
476
+
477
+        const {
478
+            columns,
479
+            visibleRows
480
+        } = getTileViewGridDimensions(state, getMaxColumnCount());
481
+        const initialWidth = viewWidth / columns;
482
+        const aspectRatioHeight = initialWidth / tileAspectRatio;
483
+
484
+        const heightOfEach = Math.min(
485
+            aspectRatioHeight,
486
+            viewHeight / visibleRows);
487
+        const widthOfEach = tileAspectRatio * heightOfEach;
488
+
489
+        return {
490
+            localVideo: {
491
+                thumbWidth: widthOfEach,
492
+                thumbHeight: heightOfEach
493
+            },
494
+            remoteVideo: {
495
+                thumbWidth: widthOfEach,
496
+                thumbHeight: heightOfEach
497
+            }
498
+        };
499
+    },
500
+
437
     /**
501
     /**
438
      * Resizes thumbnails
502
      * Resizes thumbnails
439
      * @param local
503
      * @param local
443
      */
507
      */
444
     // eslint-disable-next-line max-params
508
     // eslint-disable-next-line max-params
445
     resizeThumbnails(local, remote, forceUpdate = false) {
509
     resizeThumbnails(local, remote, forceUpdate = false) {
510
+        const state = APP.store.getState();
511
+
512
+        if (shouldDisplayTileView(state)) {
513
+            // The size of the side margins for each tile as set in CSS.
514
+            const sideMargins = 10 * 2;
515
+            const {
516
+                columns,
517
+                rows
518
+            } = getTileViewGridDimensions(state, getMaxColumnCount());
519
+            const hasOverflow = rows > columns;
520
+
521
+            // Width is set so that the flex layout can automatically wrap
522
+            // tiles onto new rows.
523
+            this.filmstripRemoteVideos.css({
524
+                width: (local.thumbWidth * columns) + (columns * sideMargins)
525
+            });
526
+
527
+            this.filmstripRemoteVideos.toggleClass('has-overflow', hasOverflow);
528
+        } else {
529
+            this.filmstripRemoteVideos.css('width', '');
530
+        }
531
+
446
         const thumbs = this.getThumbs(!forceUpdate);
532
         const thumbs = this.getThumbs(!forceUpdate);
447
 
533
 
448
         if (thumbs.localThumb) {
534
         if (thumbs.localThumb) {
466
             });
552
             });
467
         }
553
         }
468
 
554
 
555
+        const currentLayout = getCurrentLayout(APP.store.getState());
556
+
469
         // Let CSS take care of height in vertical filmstrip mode.
557
         // Let CSS take care of height in vertical filmstrip mode.
470
-        if (interfaceConfig.VERTICAL_FILMSTRIP) {
558
+        if (currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW) {
471
             $('#filmstripLocalVideo').css({
559
             $('#filmstripLocalVideo').css({
472
                 // adds 4 px because of small video 2px border
560
                 // adds 4 px because of small video 2px border
473
                 width: `${local.thumbWidth + 4}px`
561
                 width: `${local.thumbWidth + 4}px`
474
             });
562
             });
475
-        } else {
563
+        } else if (currentLayout === LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW) {
476
             this.filmstrip.css({
564
             this.filmstrip.css({
477
                 // adds 4 px because of small video 2px border
565
                 // adds 4 px because of small video 2px border
478
                 height: `${remote.thumbHeight + 4}px`
566
                 height: `${remote.thumbHeight + 4}px`

+ 56
- 13
modules/UI/videolayout/LocalVideo.js View File

11
     getAvatarURLByParticipantId
11
     getAvatarURLByParticipantId
12
 } from '../../../react/features/base/participants';
12
 } from '../../../react/features/base/participants';
13
 import { updateSettings } from '../../../react/features/base/settings';
13
 import { updateSettings } from '../../../react/features/base/settings';
14
+import { shouldDisplayTileView } from '../../../react/features/video-layout';
14
 /* eslint-enable no-unused-vars */
15
 /* eslint-enable no-unused-vars */
15
 
16
 
16
 const logger = require('jitsi-meet-logger').getLogger(__filename);
17
 const logger = require('jitsi-meet-logger').getLogger(__filename);
26
     this.streamEndedCallback = streamEndedCallback;
27
     this.streamEndedCallback = streamEndedCallback;
27
     this.container = this.createContainer();
28
     this.container = this.createContainer();
28
     this.$container = $(this.container);
29
     this.$container = $(this.container);
29
-    $('#filmstripLocalVideoThumbnail').append(this.container);
30
+    this.updateDOMLocation();
30
 
31
 
31
     this.localVideoId = null;
32
     this.localVideoId = null;
32
     this.bindHoverHandler();
33
     this.bindHoverHandler();
109
 
110
 
110
     this.localVideoId = `localVideo_${stream.getId()}`;
111
     this.localVideoId = `localVideo_${stream.getId()}`;
111
 
112
 
112
-    const localVideoContainer = document.getElementById('localVideoWrapper');
113
-
114
-    ReactDOM.render(
115
-        <Provider store = { APP.store }>
116
-            <VideoTrack
117
-                id = { this.localVideoId }
118
-                videoTrack = {{ jitsiTrack: stream }} />
119
-        </Provider>,
120
-        localVideoContainer
121
-    );
113
+    this._updateVideoElement();
122
 
114
 
123
     // eslint-disable-next-line eqeqeq
115
     // eslint-disable-next-line eqeqeq
124
     const isVideo = stream.videoType != 'desktop';
116
     const isVideo = stream.videoType != 'desktop';
128
     this.setFlipX(isVideo ? settings.localFlipX : false);
120
     this.setFlipX(isVideo ? settings.localFlipX : false);
129
 
121
 
130
     const endedHandler = () => {
122
     const endedHandler = () => {
123
+        const localVideoContainer
124
+            = document.getElementById('localVideoWrapper');
131
 
125
 
132
         // Only remove if there is no video and not a transition state.
126
         // Only remove if there is no video and not a transition state.
133
         // Previous non-react logic created a new video element with each track
127
         // Previous non-react logic created a new video element with each track
134
         // removal whereas react reuses the video component so it could be the
128
         // removal whereas react reuses the video component so it could be the
135
         // stream ended but a new one is being used.
129
         // stream ended but a new one is being used.
136
-        if (this.videoStream.isEnded()) {
130
+        if (localVideoContainer && this.videoStream.isEnded()) {
137
             ReactDOM.unmountComponentAtNode(localVideoContainer);
131
             ReactDOM.unmountComponentAtNode(localVideoContainer);
138
         }
132
         }
139
 
133
 
235
     }
229
     }
236
 };
230
 };
237
 
231
 
232
+/**
233
+ * Places the {@code LocalVideo} in the DOM based on the current video layout.
234
+ *
235
+ * @returns {void}
236
+ */
237
+LocalVideo.prototype.updateDOMLocation = function() {
238
+    if (!this.container) {
239
+        return;
240
+    }
241
+
242
+    if (this.container.parentElement) {
243
+        this.container.parentElement.removeChild(this.container);
244
+    }
245
+
246
+    const appendTarget = shouldDisplayTileView(APP.store.getState())
247
+        ? document.getElementById('localVideoTileViewContainer')
248
+        : document.getElementById('filmstripLocalVideoThumbnail');
249
+
250
+    appendTarget && appendTarget.appendChild(this.container);
251
+
252
+    this._updateVideoElement();
253
+};
254
+
238
 /**
255
 /**
239
  * Callback invoked when the thumbnail is clicked. Will directly call
256
  * Callback invoked when the thumbnail is clicked. Will directly call
240
  * VideoLayout to handle thumbnail click if certain elements have not been
257
  * VideoLayout to handle thumbnail click if certain elements have not been
258
         = $source.parents('.displayNameContainer').length > 0;
275
         = $source.parents('.displayNameContainer').length > 0;
259
     const clickedOnPopover = $source.parents('.popover').length > 0
276
     const clickedOnPopover = $source.parents('.popover').length > 0
260
             || classList.contains('popover');
277
             || classList.contains('popover');
261
-    const ignoreClick = clickedOnDisplayName || clickedOnPopover;
278
+    const ignoreClick = clickedOnDisplayName
279
+        || clickedOnPopover
280
+        || shouldDisplayTileView(APP.store.getState());
262
 
281
 
263
     if (event.stopPropagation && !ignoreClick) {
282
     if (event.stopPropagation && !ignoreClick) {
264
         event.stopPropagation();
283
         event.stopPropagation();
269
     }
288
     }
270
 };
289
 };
271
 
290
 
291
+/**
292
+ * Renders the React Element for displaying video in {@code LocalVideo}.
293
+ *
294
+ */
295
+LocalVideo.prototype._updateVideoElement = function() {
296
+    const localVideoContainer = document.getElementById('localVideoWrapper');
297
+
298
+    ReactDOM.render(
299
+        <Provider store = { APP.store }>
300
+            <VideoTrack
301
+                id = 'localVideo_container'
302
+                videoTrack = {{ jitsiTrack: this.videoStream }} />
303
+        </Provider>,
304
+        localVideoContainer
305
+    );
306
+
307
+    // Ensure the video gets play() called on it. This may be necessary in the
308
+    // case where the local video container was moved and re-attached, in which
309
+    // case video does not autoplay.
310
+    const video = this.container.querySelector('video');
311
+
312
+    video && video.play();
313
+};
314
+
272
 export default LocalVideo;
315
 export default LocalVideo;

+ 19
- 4
modules/UI/videolayout/RemoteVideo.js View File

20
     REMOTE_CONTROL_MENU_STATES,
20
     REMOTE_CONTROL_MENU_STATES,
21
     RemoteVideoMenuTriggerButton
21
     RemoteVideoMenuTriggerButton
22
 } from '../../../react/features/remote-video-menu';
22
 } from '../../../react/features/remote-video-menu';
23
+import {
24
+    LAYOUTS,
25
+    getCurrentLayout,
26
+    shouldDisplayTileView
27
+} from '../../../react/features/video-layout';
23
 /* eslint-enable no-unused-vars */
28
 /* eslint-enable no-unused-vars */
24
 
29
 
25
 const logger = require('jitsi-meet-logger').getLogger(__filename);
30
 const logger = require('jitsi-meet-logger').getLogger(__filename);
163
     const onVolumeChange = this._setAudioVolume;
168
     const onVolumeChange = this._setAudioVolume;
164
     const { isModerator } = APP.conference;
169
     const { isModerator } = APP.conference;
165
     const participantID = this.id;
170
     const participantID = this.id;
166
-    const menuPosition = interfaceConfig.VERTICAL_FILMSTRIP
167
-        ? 'left bottom' : 'top center';
171
+
172
+    const currentLayout = getCurrentLayout(APP.store.getState());
173
+    let remoteMenuPosition;
174
+
175
+    if (currentLayout === LAYOUTS.TILE_VIEW) {
176
+        remoteMenuPosition = 'left top';
177
+    } else if (currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW) {
178
+        remoteMenuPosition = 'left bottom';
179
+    } else {
180
+        remoteMenuPosition = 'top center';
181
+    }
168
 
182
 
169
     ReactDOM.render(
183
     ReactDOM.render(
170
         <Provider store = { APP.store }>
184
         <Provider store = { APP.store }>
174
                         initialVolumeValue = { initialVolumeValue }
188
                         initialVolumeValue = { initialVolumeValue }
175
                         isAudioMuted = { this.isAudioMuted }
189
                         isAudioMuted = { this.isAudioMuted }
176
                         isModerator = { isModerator }
190
                         isModerator = { isModerator }
177
-                        menuPosition = { menuPosition }
191
+                        menuPosition = { remoteMenuPosition }
178
                         onMenuDisplay
192
                         onMenuDisplay
179
                             = {this._onRemoteVideoMenuDisplay.bind(this)}
193
                             = {this._onRemoteVideoMenuDisplay.bind(this)}
180
                         onRemoteControlToggle = { onRemoteControlToggle }
194
                         onRemoteControlToggle = { onRemoteControlToggle }
613
     const { classList } = event.target;
627
     const { classList } = event.target;
614
 
628
 
615
     const ignoreClick = $source.parents('.popover').length > 0
629
     const ignoreClick = $source.parents('.popover').length > 0
616
-            || classList.contains('popover');
630
+            || classList.contains('popover')
631
+            || shouldDisplayTileView(APP.store.getState());
617
 
632
 
618
     if (!ignoreClick) {
633
     if (!ignoreClick) {
619
         this._togglePin();
634
         this._togglePin();

+ 51
- 4
modules/UI/videolayout/SmallVideo.js View File

27
     RaisedHandIndicator,
27
     RaisedHandIndicator,
28
     VideoMutedIndicator
28
     VideoMutedIndicator
29
 } from '../../../react/features/filmstrip';
29
 } from '../../../react/features/filmstrip';
30
+import {
31
+    LAYOUTS,
32
+    getCurrentLayout,
33
+    shouldDisplayTileView
34
+} from '../../../react/features/video-layout';
30
 /* eslint-enable no-unused-vars */
35
 /* eslint-enable no-unused-vars */
31
 
36
 
32
 const logger = require('jitsi-meet-logger').getLogger(__filename);
37
 const logger = require('jitsi-meet-logger').getLogger(__filename);
328
 SmallVideo.prototype.updateStatusBar = function() {
333
 SmallVideo.prototype.updateStatusBar = function() {
329
     const statusBarContainer
334
     const statusBarContainer
330
         = this.container.querySelector('.videocontainer__toolbar');
335
         = this.container.querySelector('.videocontainer__toolbar');
331
-    const tooltipPosition = interfaceConfig.VERTICAL_FILMSTRIP ? 'left' : 'top';
336
+
337
+    if (!statusBarContainer) {
338
+        return;
339
+    }
340
+
341
+    const currentLayout = getCurrentLayout(APP.store.getState());
342
+    let tooltipPosition;
343
+
344
+    if (currentLayout === LAYOUTS.TILE_VIEW) {
345
+        tooltipPosition = 'right';
346
+    } else if (currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW) {
347
+        tooltipPosition = 'left';
348
+    } else {
349
+        tooltipPosition = 'top';
350
+    }
332
 
351
 
333
     ReactDOM.render(
352
     ReactDOM.render(
334
         <I18nextProvider i18n = { i18next }>
353
         <I18nextProvider i18n = { i18next }>
547
  */
566
  */
548
 SmallVideo.prototype.selectDisplayMode = function() {
567
 SmallVideo.prototype.selectDisplayMode = function() {
549
     // Display name is always and only displayed when user is on the stage
568
     // Display name is always and only displayed when user is on the stage
550
-    if (this.isCurrentlyOnLargeVideo()) {
569
+    if (this.isCurrentlyOnLargeVideo()
570
+        && !shouldDisplayTileView(APP.store.getState())) {
551
         return this.isVideoPlayable() && !APP.conference.isAudioOnly()
571
         return this.isVideoPlayable() && !APP.conference.isAudioOnly()
552
             ? DISPLAY_BLACKNESS_WITH_NAME : DISPLAY_AVATAR_WITH_NAME;
572
             ? DISPLAY_BLACKNESS_WITH_NAME : DISPLAY_AVATAR_WITH_NAME;
553
     } else if (this.isVideoPlayable()
573
     } else if (this.isVideoPlayable()
685
 
705
 
686
     this._showDominantSpeaker = show;
706
     this._showDominantSpeaker = show;
687
 
707
 
708
+    this.$container.toggleClass('active-speaker', this._showDominantSpeaker);
709
+
688
     this.updateIndicators();
710
     this.updateIndicators();
711
+    this.updateView();
689
 };
712
 };
690
 
713
 
691
 /**
714
 /**
765
     }
788
     }
766
 };
789
 };
767
 
790
 
791
+/**
792
+ * Helper function for re-rendering multiple react components of the small
793
+ * video.
794
+ *
795
+ * @returns {void}
796
+ */
797
+SmallVideo.prototype.rerender = function() {
798
+    this.updateIndicators();
799
+    this.updateStatusBar();
800
+    this.updateView();
801
+};
802
+
768
 /**
803
 /**
769
  * Updates the React element responsible for showing connection status, dominant
804
  * Updates the React element responsible for showing connection status, dominant
770
  * speaker, and raised hand icons. Uses instance variables to get the necessary
805
  * speaker, and raised hand icons. Uses instance variables to get the necessary
784
     const iconSize = UIUtil.getIndicatorFontSize();
819
     const iconSize = UIUtil.getIndicatorFontSize();
785
     const showConnectionIndicator = this.videoIsHovered
820
     const showConnectionIndicator = this.videoIsHovered
786
         || !interfaceConfig.CONNECTION_INDICATOR_AUTO_HIDE_ENABLED;
821
         || !interfaceConfig.CONNECTION_INDICATOR_AUTO_HIDE_ENABLED;
787
-    const tooltipPosition = interfaceConfig.VERTICAL_FILMSTRIP ? 'left' : 'top';
822
+    const currentLayout = getCurrentLayout(APP.store.getState());
823
+    let statsPopoverPosition, tooltipPosition;
824
+
825
+    if (currentLayout === LAYOUTS.TILE_VIEW) {
826
+        statsPopoverPosition = 'right top';
827
+        tooltipPosition = 'right';
828
+    } else if (currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW) {
829
+        statsPopoverPosition = this.statsPopoverLocation;
830
+        tooltipPosition = 'left';
831
+    } else {
832
+        statsPopoverPosition = this.statsPopoverLocation;
833
+        tooltipPosition = 'top';
834
+    }
788
 
835
 
789
     ReactDOM.render(
836
     ReactDOM.render(
790
             <I18nextProvider i18n = { i18next }>
837
             <I18nextProvider i18n = { i18next }>
799
                                 enableStatsDisplay
846
                                 enableStatsDisplay
800
                                     = { !interfaceConfig.filmStripOnly }
847
                                     = { !interfaceConfig.filmStripOnly }
801
                                 statsPopoverPosition
848
                                 statsPopoverPosition
802
-                                    = { this.statsPopoverLocation }
849
+                                    = { statsPopoverPosition }
803
                                 userID = { this.id } />
850
                                 userID = { this.id } />
804
                             : null }
851
                             : null }
805
                         { this._showRaisedHand
852
                         { this._showRaisedHand

+ 33
- 3
modules/UI/videolayout/VideoLayout.js View File

1
 /* global APP, $, interfaceConfig  */
1
 /* global APP, $, interfaceConfig  */
2
 const logger = require('jitsi-meet-logger').getLogger(__filename);
2
 const logger = require('jitsi-meet-logger').getLogger(__filename);
3
 
3
 
4
+import {
5
+    getNearestReceiverVideoQualityLevel,
6
+    setMaxReceiverVideoQuality
7
+} from '../../../react/features/base/conference';
4
 import {
8
 import {
5
     JitsiParticipantConnectionStatus
9
     JitsiParticipantConnectionStatus
6
 } from '../../../react/features/base/lib-jitsi-meet';
10
 } from '../../../react/features/base/lib-jitsi-meet';
9
     getPinnedParticipant,
13
     getPinnedParticipant,
10
     pinParticipant
14
     pinParticipant
11
 } from '../../../react/features/base/participants';
15
 } from '../../../react/features/base/participants';
16
+import {
17
+    shouldDisplayTileView
18
+} from '../../../react/features/video-layout';
12
 import { SHARED_VIDEO_CONTAINER_TYPE } from '../shared_video/SharedVideo';
19
 import { SHARED_VIDEO_CONTAINER_TYPE } from '../shared_video/SharedVideo';
13
 import SharedVideoThumb from '../shared_video/SharedVideoThumb';
20
 import SharedVideoThumb from '../shared_video/SharedVideoThumb';
14
 
21
 
594
 
601
 
595
         Filmstrip.resizeThumbnails(localVideo, remoteVideo, forceUpdate);
602
         Filmstrip.resizeThumbnails(localVideo, remoteVideo, forceUpdate);
596
 
603
 
604
+        if (shouldDisplayTileView(APP.store.getState())) {
605
+            const height
606
+                = (localVideo && localVideo.thumbHeight)
607
+                || (remoteVideo && remoteVideo.thumbnHeight)
608
+                || 0;
609
+            const qualityLevel = getNearestReceiverVideoQualityLevel(height);
610
+
611
+            APP.store.dispatch(setMaxReceiverVideoQuality(qualityLevel));
612
+        }
613
+
597
         if (onComplete && typeof onComplete === 'function') {
614
         if (onComplete && typeof onComplete === 'function') {
598
             onComplete();
615
             onComplete();
599
         }
616
         }
600
-
601
-        return { localVideo,
602
-            remoteVideo };
603
     },
617
     },
604
 
618
 
605
     /**
619
     /**
1142
         );
1156
         );
1143
     },
1157
     },
1144
 
1158
 
1159
+    /**
1160
+     * Helper method to invoke when the video layout has changed and elements
1161
+     * have to be re-arranged and resized.
1162
+     *
1163
+     * @returns {void}
1164
+     */
1165
+    refreshLayout() {
1166
+        localVideoThumbnail && localVideoThumbnail.updateDOMLocation();
1167
+        VideoLayout.resizeVideoArea();
1168
+
1169
+        localVideoThumbnail && localVideoThumbnail.rerender();
1170
+        Object.values(remoteVideos).forEach(
1171
+            remoteVideo => remoteVideo.rerender()
1172
+        );
1173
+    },
1174
+
1145
     /**
1175
     /**
1146
      * Triggers an update of large video if the passed in participant is
1176
      * Triggers an update of large video if the passed in participant is
1147
      * currently displayed on large video.
1177
      * currently displayed on large video.

+ 34
- 1
react/features/base/conference/functions.js View File

8
     AVATAR_ID_COMMAND,
8
     AVATAR_ID_COMMAND,
9
     AVATAR_URL_COMMAND,
9
     AVATAR_URL_COMMAND,
10
     EMAIL_COMMAND,
10
     EMAIL_COMMAND,
11
-    JITSI_CONFERENCE_URL_KEY
11
+    JITSI_CONFERENCE_URL_KEY,
12
+    VIDEO_QUALITY_LEVELS
12
 } from './constants';
13
 } from './constants';
13
 
14
 
14
 const logger = require('jitsi-meet-logger').getLogger(__filename);
15
 const logger = require('jitsi-meet-logger').getLogger(__filename);
102
             : joining);
103
             : joining);
103
 }
104
 }
104
 
105
 
106
+/**
107
+ * Finds the nearest match for the passed in {@link availableHeight} to am
108
+ * enumerated value in {@code VIDEO_QUALITY_LEVELS}.
109
+ *
110
+ * @param {number} availableHeight - The height to which a matching video
111
+ * quality level should be found.
112
+ * @returns {number} The closest matching value from
113
+ * {@code VIDEO_QUALITY_LEVELS}.
114
+ */
115
+export function getNearestReceiverVideoQualityLevel(availableHeight: number) {
116
+    const qualityLevels = [
117
+        VIDEO_QUALITY_LEVELS.HIGH,
118
+        VIDEO_QUALITY_LEVELS.STANDARD,
119
+        VIDEO_QUALITY_LEVELS.LOW
120
+    ];
121
+
122
+    let selectedLevel = qualityLevels[0];
123
+
124
+    for (let i = 1; i < qualityLevels.length; i++) {
125
+        const previousValue = qualityLevels[i - 1];
126
+        const currentValue = qualityLevels[i];
127
+        const diffWithCurrent = Math.abs(availableHeight - currentValue);
128
+        const diffWithPrevious = Math.abs(availableHeight - previousValue);
129
+
130
+        if (diffWithCurrent < diffWithPrevious) {
131
+            selectedLevel = currentValue;
132
+        }
133
+    }
134
+
135
+    return selectedLevel;
136
+}
137
+
105
 /**
138
 /**
106
  * Handle an error thrown by the backend (i.e. lib-jitsi-meet) while
139
  * Handle an error thrown by the backend (i.e. lib-jitsi-meet) while
107
  * manipulating a conference participant (e.g. pin or select participant).
140
  * manipulating a conference participant (e.g. pin or select participant).

+ 46
- 23
react/features/conference/components/Conference.web.js View File

4
 import React, { Component } from 'react';
4
 import React, { Component } from 'react';
5
 import { connect as reactReduxConnect } from 'react-redux';
5
 import { connect as reactReduxConnect } from 'react-redux';
6
 
6
 
7
+import VideoLayout from '../../../../modules/UI/videolayout/VideoLayout';
8
+
7
 import { obtainConfig } from '../../base/config';
9
 import { obtainConfig } from '../../base/config';
8
 import { connect, disconnect } from '../../base/connection';
10
 import { connect, disconnect } from '../../base/connection';
9
 import { DialogContainer } from '../../base/dialog';
11
 import { DialogContainer } from '../../base/dialog';
13
 import { LargeVideo } from '../../large-video';
15
 import { LargeVideo } from '../../large-video';
14
 import { NotificationsContainer } from '../../notifications';
16
 import { NotificationsContainer } from '../../notifications';
15
 import { SidePanel } from '../../side-panel';
17
 import { SidePanel } from '../../side-panel';
18
+import {
19
+    LAYOUTS,
20
+    getCurrentLayout,
21
+    shouldDisplayTileView
22
+} from '../../video-layout';
23
+
16
 import { default as Notice } from './Notice';
24
 import { default as Notice } from './Notice';
17
 import {
25
 import {
18
     Toolbox,
26
     Toolbox,
49
  * @private
57
  * @private
50
  * @type {Object}
58
  * @type {Object}
51
  */
59
  */
52
-const LAYOUT_CLASSES = {
53
-    HORIZONTAL_FILMSTRIP: 'horizontal-filmstrip',
54
-    VERTICAL_FILMSTRIP: 'vertical-filmstrip'
60
+const LAYOUT_CLASSNAMES = {
61
+    [LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW]: 'horizontal-filmstrip',
62
+    [LAYOUTS.TILE_VIEW]: 'tile-view',
63
+    [LAYOUTS.VERTICAL_FILMSTRIP_VIEW]: 'vertical-filmstrip'
55
 };
64
 };
56
 
65
 
57
 /**
66
 /**
68
      * The CSS class to apply to the root of {@link Conference} to modify the
77
      * The CSS class to apply to the root of {@link Conference} to modify the
69
      * application layout.
78
      * application layout.
70
      */
79
      */
71
-    _layoutModeClassName: string,
80
+    _layoutClassName: string,
72
 
81
 
73
     /**
82
     /**
74
      * Conference room name.
83
      * Conference room name.
75
      */
84
      */
76
     _room: string,
85
     _room: string,
77
 
86
 
87
+    /**
88
+     * Whether or not the current UI layout should be in tile view.
89
+     */
90
+    _shouldDisplayTileView: boolean,
91
+
78
     dispatch: Function,
92
     dispatch: Function,
79
     t: Function
93
     t: Function
80
 }
94
 }
143
         }
157
         }
144
     }
158
     }
145
 
159
 
160
+    /**
161
+     * Calls into legacy UI to update the application layout, if necessary.
162
+     *
163
+     * @inheritdoc
164
+     * returns {void}
165
+     */
166
+    componentDidUpdate(prevProps) {
167
+        if (this.props._shouldDisplayTileView
168
+            === prevProps._shouldDisplayTileView) {
169
+            return;
170
+        }
171
+
172
+        // TODO: For now VideoLayout is being called as LargeVideo and Filmstrip
173
+        // sizing logic is still handled outside of React. Once all components
174
+        // are in react they should calculate size on their own as much as
175
+        // possible and pass down sizings.
176
+        VideoLayout.refreshLayout();
177
+    }
178
+
146
     /**
179
     /**
147
      * Disconnect from the conference when component will be
180
      * Disconnect from the conference when component will be
148
      * unmounted.
181
      * unmounted.
180
 
213
 
181
         return (
214
         return (
182
             <div
215
             <div
183
-                className = { this.props._layoutModeClassName }
216
+                className = { this.props._layoutClassName }
184
                 id = 'videoconference_page'
217
                 id = 'videoconference_page'
185
                 onMouseMove = { this._onShowToolbar }>
218
                 onMouseMove = { this._onShowToolbar }>
186
                 <Notice />
219
                 <Notice />
257
  * @private
290
  * @private
258
  * @returns {{
291
  * @returns {{
259
  *     _iAmRecorder: boolean,
292
  *     _iAmRecorder: boolean,
260
- *     _room: ?string
293
+ *     _layoutClassName: string,
294
+ *     _room: ?string,
295
+ *     _shouldDisplayTileView: boolean
261
  * }}
296
  * }}
262
  */
297
  */
263
 function _mapStateToProps(state) {
298
 function _mapStateToProps(state) {
264
-    const { room } = state['features/base/conference'];
265
-    const { iAmRecorder } = state['features/base/config'];
299
+    const currentLayout = getCurrentLayout(state);
266
 
300
 
267
     return {
301
     return {
268
-        /**
269
-         * Whether the local participant is recording the conference.
270
-         *
271
-         * @private
272
-         */
273
-        _iAmRecorder: iAmRecorder,
274
-
275
-        _layoutModeClassName: interfaceConfig.VERTICAL_FILMSTRIP
276
-            ? LAYOUT_CLASSES.VERTICAL_FILMSTRIP
277
-            : LAYOUT_CLASSES.HORIZONTAL_FILMSTRIP,
278
-
279
-        /**
280
-         * Conference room name.
281
-         */
282
-        _room: room
302
+        _iAmRecorder: state['features/base/config'].iAmRecorder,
303
+        _layoutClassName: LAYOUT_CLASSNAMES[currentLayout],
304
+        _room: state['features/base/conference'].room,
305
+        _shouldDisplayTileView: shouldDisplayTileView(state)
283
     };
306
     };
284
 }
307
 }
285
 
308
 

+ 2
- 2
react/features/filmstrip/components/web/Filmstrip.js View File

8
 
8
 
9
 import { setFilmstripHovered } from '../../actions';
9
 import { setFilmstripHovered } from '../../actions';
10
 import { shouldRemoteVideosBeVisible } from '../../functions';
10
 import { shouldRemoteVideosBeVisible } from '../../functions';
11
+
11
 import Toolbar from './Toolbar';
12
 import Toolbar from './Toolbar';
12
 
13
 
13
 declare var interfaceConfig: Object;
14
 declare var interfaceConfig: Object;
185
         && state['features/toolbox'].visible
186
         && state['features/toolbox'].visible
186
         && interfaceConfig.TOOLBAR_BUTTONS.length;
187
         && interfaceConfig.TOOLBAR_BUTTONS.length;
187
     const remoteVideosVisible = shouldRemoteVideosBeVisible(state);
188
     const remoteVideosVisible = shouldRemoteVideosBeVisible(state);
188
-
189
     const className = `${remoteVideosVisible ? '' : 'hide-videos'} ${
189
     const className = `${remoteVideosVisible ? '' : 'hide-videos'} ${
190
-        reduceHeight ? 'reduce-height' : ''}`;
190
+        reduceHeight ? 'reduce-height' : ''}`.trim();
191
 
191
 
192
     return {
192
     return {
193
         _className: className,
193
         _className: className,

+ 8
- 4
react/features/large-video/actions.js View File

6
 } from '../analytics';
6
 } from '../analytics';
7
 import { _handleParticipantError } from '../base/conference';
7
 import { _handleParticipantError } from '../base/conference';
8
 import { MEDIA_TYPE } from '../base/media';
8
 import { MEDIA_TYPE } from '../base/media';
9
+import { getParticipants } from '../base/participants';
9
 import { reportError } from '../base/util';
10
 import { reportError } from '../base/util';
11
+import { shouldDisplayTileView } from '../video-layout';
10
 
12
 
11
 import {
13
 import {
12
     SELECT_LARGE_VIDEO_PARTICIPANT,
14
     SELECT_LARGE_VIDEO_PARTICIPANT,
26
         const { conference } = state['features/base/conference'];
28
         const { conference } = state['features/base/conference'];
27
 
29
 
28
         if (conference) {
30
         if (conference) {
29
-            const largeVideo = state['features/large-video'];
30
-            const id = largeVideo.participantId;
31
+            const ids = shouldDisplayTileView(state)
32
+                ? getParticipants(state).map(participant => participant.id)
33
+                : [ state['features/large-video'].participantId ];
31
 
34
 
32
             try {
35
             try {
33
-                conference.selectParticipant(id);
36
+                conference.selectParticipants(ids);
34
             } catch (err) {
37
             } catch (err) {
35
                 _handleParticipantError(err);
38
                 _handleParticipantError(err);
36
 
39
 
37
                 sendAnalytics(createSelectParticipantFailedEvent(err));
40
                 sendAnalytics(createSelectParticipantFailedEvent(err));
38
 
41
 
39
-                reportError(err, `Failed to select participant ${id}`);
42
+                reportError(
43
+                    err, `Failed to select participants ${ids.toString()}`);
40
             }
44
             }
41
         }
45
         }
42
     };
46
     };

+ 10
- 2
react/features/large-video/components/AbstractLabels.js View File

4
 
4
 
5
 import { isFilmstripVisible } from '../../filmstrip';
5
 import { isFilmstripVisible } from '../../filmstrip';
6
 import { RecordingLabel } from '../../recording';
6
 import { RecordingLabel } from '../../recording';
7
+import { shouldDisplayTileView } from '../../video-layout';
7
 import { VideoQualityLabel } from '../../video-quality';
8
 import { VideoQualityLabel } from '../../video-quality';
8
 import { TranscribingLabel } from '../../transcribing/';
9
 import { TranscribingLabel } from '../../transcribing/';
9
 
10
 
17
     * determine display classes to set.
18
     * determine display classes to set.
18
     */
19
     */
19
     _filmstripVisible: boolean,
20
     _filmstripVisible: boolean,
21
+
22
+    /**
23
+     * Whether or not the video quality label should be displayed.
24
+     */
25
+    _showVideoQualityLabel: boolean
20
 };
26
 };
21
 
27
 
22
 /**
28
 /**
72
  * @param {Object} state - The Redux state.
78
  * @param {Object} state - The Redux state.
73
  * @private
79
  * @private
74
  * @returns {{
80
  * @returns {{
75
- *     _filmstripVisible: boolean
81
+ *     _filmstripVisible: boolean,
82
+ *     _showVideoQualityLabel: boolean
76
  * }}
83
  * }}
77
  */
84
  */
78
 export function _abstractMapStateToProps(state: Object) {
85
 export function _abstractMapStateToProps(state: Object) {
79
     return {
86
     return {
80
-        _filmstripVisible: isFilmstripVisible(state)
87
+        _filmstripVisible: isFilmstripVisible(state),
88
+        _showVideoQualityLabel: !shouldDisplayTileView(state)
81
     };
89
     };
82
 }
90
 }

+ 2
- 1
react/features/large-video/components/Labels.web.js View File

89
                     this._renderTranscribingLabel()
89
                     this._renderTranscribingLabel()
90
                 }
90
                 }
91
                 {
91
                 {
92
-                    this._renderVideoQualityLabel()
92
+                    this.props._showVideoQualityLabel
93
+                        && this._renderVideoQualityLabel()
93
                 }
94
                 }
94
             </div>
95
             </div>
95
         );
96
         );

+ 1
- 1
react/features/large-video/components/LargeVideo.web.js View File

50
                 </div>
50
                 </div>
51
                 <div id = 'remotePresenceMessage' />
51
                 <div id = 'remotePresenceMessage' />
52
                 <span id = 'remoteConnectionMessage' />
52
                 <span id = 'remoteConnectionMessage' />
53
-                <div>
53
+                <div id = 'largeVideoElementsContainer'>
54
                     <div id = 'largeVideoBackgroundContainer' />
54
                     <div id = 'largeVideoBackgroundContainer' />
55
                     {
55
                     {
56
 
56
 

+ 3
- 0
react/features/toolbox/components/web/Toolbox.js View File

40
 import { toggleSharedVideo } from '../../../shared-video';
40
 import { toggleSharedVideo } from '../../../shared-video';
41
 import { toggleChat } from '../../../side-panel';
41
 import { toggleChat } from '../../../side-panel';
42
 import { SpeakerStats } from '../../../speaker-stats';
42
 import { SpeakerStats } from '../../../speaker-stats';
43
+import { TileViewButton } from '../../../video-layout';
43
 import {
44
 import {
44
     OverflowMenuVideoQualityItem,
45
     OverflowMenuVideoQualityItem,
45
     VideoQualityDialog
46
     VideoQualityDialog
369
                         visible = { this._shouldShowButton('camera') } />
370
                         visible = { this._shouldShowButton('camera') } />
370
                 </div>
371
                 </div>
371
                 <div className = 'button-group-right'>
372
                 <div className = 'button-group-right'>
373
+                    { this._shouldShowButton('tileview')
374
+                        && <TileViewButton /> }
372
                     { this._shouldShowButton('invite')
375
                     { this._shouldShowButton('invite')
373
                         && !_hideInviteButton
376
                         && !_hideInviteButton
374
                         && <ToolbarButton
377
                         && <ToolbarButton

+ 10
- 0
react/features/video-layout/actionTypes.js View File

1
+/**
2
+ * The type of the action which enables or disables the feature for showing
3
+ * video thumbnails in a two-axis tile view.
4
+ *
5
+ * @returns {{
6
+ *     type: SET_TILE_VIEW,
7
+ *     enabled: boolean
8
+ * }}
9
+ */
10
+export const SET_TILE_VIEW = Symbol('SET_TILE_VIEW');

+ 20
- 0
react/features/video-layout/actions.js View File

1
+// @flow
2
+
3
+import { SET_TILE_VIEW } from './actionTypes';
4
+
5
+/**
6
+ * Creates a (redux) action which signals to set the UI layout to be tiled view
7
+ * or not.
8
+ *
9
+ * @param {boolean} enabled - Whether or not tile view should be shown.
10
+ * @returns {{
11
+ *     type: SET_TILE_VIEW,
12
+ *     enabled: boolean
13
+ * }}
14
+ */
15
+export function setTileView(enabled: boolean) {
16
+    return {
17
+        type: SET_TILE_VIEW,
18
+        enabled
19
+    };
20
+}

+ 90
- 0
react/features/video-layout/components/TileViewButton.js View File

1
+// @flow
2
+
3
+import { connect } from 'react-redux';
4
+
5
+import {
6
+    createToolbarEvent,
7
+    sendAnalytics
8
+} from '../../analytics';
9
+import { translate } from '../../base/i18n';
10
+import {
11
+    AbstractButton,
12
+    type AbstractButtonProps
13
+} from '../../base/toolbox';
14
+
15
+import { setTileView } from '../actions';
16
+
17
+/**
18
+ * The type of the React {@code Component} props of {@link TileViewButton}.
19
+ */
20
+type Props = AbstractButtonProps & {
21
+
22
+    /**
23
+     * Whether or not tile view layout has been enabled as the user preference.
24
+     */
25
+    _tileViewEnabled: boolean,
26
+
27
+    /**
28
+     * Used to dispatch actions from the buttons.
29
+     */
30
+    dispatch: Dispatch<*>
31
+};
32
+
33
+/**
34
+ * Component that renders a toolbar button for toggling the tile layout view.
35
+ *
36
+ * @extends AbstractButton
37
+ */
38
+class TileViewButton<P: Props> extends AbstractButton<P, *> {
39
+    accessibilityLabel = 'toolbar.accessibilityLabel.tileView';
40
+    iconName = 'icon-tiles-many';
41
+    toggledIconName = 'icon-tiles-many toggled';
42
+    tooltip = 'toolbar.tileViewToggle';
43
+
44
+    /**
45
+     * Handles clicking / pressing the button.
46
+     *
47
+     * @override
48
+     * @protected
49
+     * @returns {void}
50
+     */
51
+    _handleClick() {
52
+        const { _tileViewEnabled, dispatch } = this.props;
53
+
54
+        sendAnalytics(createToolbarEvent(
55
+            'tileview.button',
56
+            {
57
+                'is_enabled': _tileViewEnabled
58
+            }));
59
+
60
+        dispatch(setTileView(!_tileViewEnabled));
61
+    }
62
+
63
+    /**
64
+     * Indicates whether this button is in toggled state or not.
65
+     *
66
+     * @override
67
+     * @protected
68
+     * @returns {boolean}
69
+     */
70
+    _isToggled() {
71
+        return this.props._tileViewEnabled;
72
+    }
73
+}
74
+
75
+/**
76
+ * Maps (parts of) the redux state to the associated props for the
77
+ * {@code TileViewButton} component.
78
+ *
79
+ * @param {Object} state - The Redux state.
80
+ * @returns {{
81
+ *     _tileViewEnabled: boolean
82
+ * }}
83
+ */
84
+function _mapStateToProps(state) {
85
+    return {
86
+        _tileViewEnabled: state['features/video-layout'].tileViewEnabled
87
+    };
88
+}
89
+
90
+export default translate(connect(_mapStateToProps)(TileViewButton));

+ 1
- 0
react/features/video-layout/components/index.js View File

1
+export { default as TileViewButton } from './TileViewButton';

+ 10
- 0
react/features/video-layout/constants.js View File

1
+/**
2
+ * An enumeration of the different display layouts supported by the application.
3
+ *
4
+ * @type {Object}
5
+ */
6
+export const LAYOUTS = {
7
+    HORIZONTAL_FILMSTRIP_VIEW: 'horizontal-filmstrip-view',
8
+    TILE_VIEW: 'tile-view',
9
+    VERTICAL_FILMSTRIP_VIEW: 'vertical-filmstrip-view'
10
+};

+ 78
- 0
react/features/video-layout/functions.js View File

1
+// @flow
2
+
3
+import { LAYOUTS } from './constants';
4
+
5
+declare var interfaceConfig: Object;
6
+
7
+/**
8
+ * Returns the {@code LAYOUTS} constant associated with the layout
9
+ * the application should currently be in.
10
+ *
11
+ * @param {Object} state - The redux state.
12
+ * @returns {string}
13
+ */
14
+export function getCurrentLayout(state: Object) {
15
+    if (shouldDisplayTileView(state)) {
16
+        return LAYOUTS.TILE_VIEW;
17
+    } else if (interfaceConfig.VERTICAL_FILMSTRIP) {
18
+        return LAYOUTS.VERTICAL_FILMSTRIP_VIEW;
19
+    }
20
+
21
+    return LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW;
22
+}
23
+
24
+/**
25
+ * Returns how many columns should be displayed in tile view. The number
26
+ * returned will be between 1 and 5, inclusive.
27
+ *
28
+ * @returns {number}
29
+ */
30
+export function getMaxColumnCount() {
31
+    const configuredMax = interfaceConfig.TILE_VIEW_MAX_COLUMNS || 5;
32
+
33
+    return Math.max(Math.min(configuredMax, 1), 5);
34
+}
35
+
36
+/**
37
+ * Returns the cell count dimensions for tile view. Tile view tries to uphold
38
+ * equal count of tiles for height and width, until maxColumn is reached in
39
+ * which rows will be added but no more columns.
40
+ *
41
+ * @param {Object} state - The redux state.
42
+ * @param {number} maxColumns - The maximum number of columns that can be
43
+ * displayed.
44
+ * @returns {Object} An object is return with the desired number of columns,
45
+ * rows, and visible rows (the rest should overflow) for the tile view layout.
46
+ */
47
+export function getTileViewGridDimensions(state: Object, maxColumns: number) {
48
+    // Purposefully include all participants, which includes fake participants
49
+    // that should show a thumbnail.
50
+    const potentialThumbnails = state['features/base/participants'].length;
51
+
52
+    const columnsToMaintainASquare = Math.ceil(Math.sqrt(potentialThumbnails));
53
+    const columns = Math.min(columnsToMaintainASquare, maxColumns);
54
+    const rows = Math.ceil(potentialThumbnails / columns);
55
+    const visibleRows = Math.min(maxColumns, rows);
56
+
57
+    return {
58
+        columns,
59
+        rows,
60
+        visibleRows
61
+    };
62
+}
63
+
64
+/**
65
+ * Selector for determining if the UI layout should be in tile view. Tile view
66
+ * is determined by more than just having the tile view setting enabled, as
67
+ * one-on-one calls should not be in tile view, as well as etherpad editing.
68
+ *
69
+ * @param {Object} state - The redux state.
70
+ * @returns {boolean} True if tile view should be displayed.
71
+ */
72
+export function shouldDisplayTileView(state: Object = {}) {
73
+    return Boolean(
74
+        state['features/video-layout']
75
+            && state['features/video-layout'].tileViewEnabled
76
+            && !state['features/etherpad'].editing
77
+    );
78
+}

+ 8
- 0
react/features/video-layout/index.js View File

1
+export * from './actions';
2
+export * from './actionTypes';
3
+export * from './components';
4
+export * from './constants';
5
+export * from './functions';
6
+
1
 import './middleware';
7
 import './middleware';
8
+import './reducer';
9
+import './subscriber';

+ 6
- 0
react/features/video-layout/middleware.web.js View File

15
 import { MiddlewareRegistry } from '../base/redux';
15
 import { MiddlewareRegistry } from '../base/redux';
16
 import { TRACK_ADDED } from '../base/tracks';
16
 import { TRACK_ADDED } from '../base/tracks';
17
 
17
 
18
+import { SET_TILE_VIEW } from './actionTypes';
19
+
18
 declare var APP: Object;
20
 declare var APP: Object;
19
 
21
 
20
 /**
22
 /**
71
             Boolean(action.participant.id));
73
             Boolean(action.participant.id));
72
         break;
74
         break;
73
 
75
 
76
+    case SET_TILE_VIEW:
77
+        APP.UI.emitEvent(UIEvents.TOGGLED_TILE_VIEW, action.enabled);
78
+        break;
79
+
74
     case TRACK_ADDED:
80
     case TRACK_ADDED:
75
         if (!action.track.local) {
81
         if (!action.track.local) {
76
             VideoLayout.onRemoteStreamAdded(action.track.jitsiTrack);
82
             VideoLayout.onRemoteStreamAdded(action.track.jitsiTrack);

+ 17
- 0
react/features/video-layout/reducer.js View File

1
+// @flow
2
+
3
+import { ReducerRegistry } from '../base/redux';
4
+
5
+import { SET_TILE_VIEW } from './actionTypes';
6
+
7
+ReducerRegistry.register('features/video-layout', (state = {}, action) => {
8
+    switch (action.type) {
9
+    case SET_TILE_VIEW:
10
+        return {
11
+            ...state,
12
+            tileViewEnabled: action.enabled
13
+        };
14
+    }
15
+
16
+    return state;
17
+});

+ 24
- 0
react/features/video-layout/subscriber.js View File

1
+// @flow
2
+
3
+import {
4
+    VIDEO_QUALITY_LEVELS,
5
+    setMaxReceiverVideoQuality
6
+} from '../base/conference';
7
+import { StateListenerRegistry } from '../base/redux';
8
+import { selectParticipant } from '../large-video';
9
+import { shouldDisplayTileView } from './functions';
10
+
11
+/**
12
+ * StateListenerRegistry provides a reliable way of detecting changes to
13
+ * preferred layout state and dispatching additional actions.
14
+ */
15
+StateListenerRegistry.register(
16
+    /* selector */ state => shouldDisplayTileView(state),
17
+    /* listener */ (displayTileView, { dispatch }) => {
18
+        dispatch(selectParticipant());
19
+
20
+        if (!displayTileView) {
21
+            dispatch(setMaxReceiverVideoQuality(VIDEO_QUALITY_LEVELS.HIGH));
22
+        }
23
+    }
24
+);

+ 1
- 0
service/UI/UIEvents.js View File

59
     TOGGLED_FILMSTRIP: 'UI.toggled_filmstrip',
59
     TOGGLED_FILMSTRIP: 'UI.toggled_filmstrip',
60
     TOGGLE_SCREENSHARING: 'UI.toggle_screensharing',
60
     TOGGLE_SCREENSHARING: 'UI.toggle_screensharing',
61
     TOGGLED_SHARED_DOCUMENT: 'UI.toggled_shared_document',
61
     TOGGLED_SHARED_DOCUMENT: 'UI.toggled_shared_document',
62
+    TOGGLED_TILE_VIEW: 'UI.toggled_tile_view',
62
     HANGUP: 'UI.hangup',
63
     HANGUP: 'UI.hangup',
63
     LOGOUT: 'UI.logout',
64
     LOGOUT: 'UI.logout',
64
     VIDEO_DEVICE_CHANGED: 'UI.video_device_changed',
65
     VIDEO_DEVICE_CHANGED: 'UI.video_device_changed',

Loading…
Cancel
Save