You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1049 lines
28 KiB
1049 lines
28 KiB
<!--
|
|
- @copyright Copyright (c) 2020 Marco Ambrosini <marcoambrosini@icloud.com>
|
|
-
|
|
- @author Marco Ambrosini <marcoambrosini@icloud.com>
|
|
-
|
|
- @license AGPL-3.0-or-later
|
|
-
|
|
- This program is free software: you can redistribute it and/or modify
|
|
- it under the terms of the GNU Affero General Public License as
|
|
- published by the Free Software Foundation, either version 3 of the
|
|
- License, or (at your option) any later version.
|
|
-
|
|
- This program is distributed in the hope that it will be useful,
|
|
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
- GNU Affero General Public License for more details.
|
|
-
|
|
- You should have received a copy of the GNU Affero General Public License
|
|
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
-->
|
|
|
|
<template>
|
|
<div class="grid-main-wrapper" :class="{'is-grid': !isStripe, 'transparent': isLessThanTwoVideos}">
|
|
<NcButton v-if="isStripe && !isRecording"
|
|
class="stripe--collapse"
|
|
type="tertiary-no-background"
|
|
:aria-label="stripeButtonTooltip"
|
|
@click="handleClickStripeCollapse">
|
|
<template #icon>
|
|
<ChevronDown v-if="stripeOpen"
|
|
fill-color="#ffffff"
|
|
:size="20" />
|
|
<ChevronUp v-else
|
|
fill-color="#ffffff"
|
|
:size="20" />
|
|
</template>
|
|
</NcButton>
|
|
<TransitionWrapper :name="isStripe ? 'slide-down' : undefined">
|
|
<div v-if="!isStripe || stripeOpen" class="wrapper" :style="wrapperStyle">
|
|
<div :class="[isStripe ? 'stripe-wrapper' : 'grid-wrapper']">
|
|
<NcButton v-if="hasPreviousPage && gridWidth > 0"
|
|
type="tertiary-no-background"
|
|
class="grid-navigation grid-navigation__previous"
|
|
:aria-label="t('spreed', 'Previous page of videos')"
|
|
@click="handleClickPrevious">
|
|
<template #icon>
|
|
<ChevronLeft fill-color="#ffffff"
|
|
:size="20" />
|
|
</template>
|
|
</NcButton>
|
|
<div ref="grid"
|
|
class="grid"
|
|
:class="{stripe: isStripe}"
|
|
:style="gridStyle"
|
|
@mousemove="handleMovement"
|
|
@keydown="handleMovement">
|
|
<template v-if="!devMode && (!isLessThanTwoVideos || !isStripe)">
|
|
<EmptyCallView v-if="videos.length === 0 && !isStripe" class="video" :is-grid="true" />
|
|
<template v-for="callParticipantModel in displayedVideos">
|
|
<VideoVue :key="callParticipantModel.attributes.peerId"
|
|
:class="{'video': !isStripe}"
|
|
:show-video-overlay="showVideoOverlay"
|
|
:token="token"
|
|
:model="callParticipantModel"
|
|
:is-grid="true"
|
|
:show-talking-highlight="!isStripe"
|
|
:is-stripe="isStripe"
|
|
:is-promoted="sharedDatas[callParticipantModel.attributes.peerId].promoted"
|
|
:is-selected="isSelected(callParticipantModel)"
|
|
:shared-data="sharedDatas[callParticipantModel.attributes.peerId]"
|
|
@click-video="handleClickVideo($event, callParticipantModel.attributes.peerId)" />
|
|
</template>
|
|
</template>
|
|
<!-- Grid developer mode -->
|
|
<template v-if="devMode">
|
|
<div v-for="(video, key) in displayedVideos"
|
|
:key="video"
|
|
class="dev-mode-video video"
|
|
:class="{'dev-mode-screenshot': screenshotMode}">
|
|
<img :src="placeholderImage(key)">
|
|
<VideoBottomBar :has-shadow="false"
|
|
:model="placeholderModel(key)"
|
|
:shared-data="placeholderSharedData(key)"
|
|
:token="token"
|
|
:participant-name="placeholderName(key)" />
|
|
</div>
|
|
<h1 v-if="!screenshotMode" class="dev-mode__title">
|
|
Dev mode on ;-)
|
|
</h1>
|
|
<div v-else
|
|
class="dev-mode-video--self video"
|
|
:style="{'background': 'url(' + placeholderImage(8) + ')'}" />
|
|
</template>
|
|
<LocalVideo v-if="!isStripe && !isRecording && !screenshotMode"
|
|
ref="localVideo"
|
|
class="video"
|
|
:is-grid="true"
|
|
:fit-video="isStripe"
|
|
:token="token"
|
|
:local-media-model="localMediaModel"
|
|
:local-call-participant-model="localCallParticipantModel"
|
|
@click-video="handleClickLocalVideo" />
|
|
</div>
|
|
<NcButton v-if="hasNextPage && gridWidth > 0"
|
|
type="tertiary-no-background"
|
|
class="grid-navigation grid-navigation__next"
|
|
:aria-label="t('spreed', 'Next page of videos')"
|
|
@click="handleClickNext">
|
|
<template #icon>
|
|
<ChevronRight fill-color="#ffffff"
|
|
:size="20" />
|
|
</template>
|
|
</NcButton>
|
|
</div>
|
|
<LocalVideo v-if="isStripe && !isRecording && !screenshotMode"
|
|
ref="localVideo"
|
|
class="video"
|
|
:is-stripe="true"
|
|
:show-controls="false"
|
|
:token="token"
|
|
:local-media-model="localMediaModel"
|
|
:local-call-participant-model="localCallParticipantModel"
|
|
@click-video="handleClickLocalVideo" />
|
|
<!-- page indicator (disabled) -->
|
|
<div v-if="numberOfPages !== 0 && hasPagination && false"
|
|
class="pages-indicator">
|
|
<div v-for="(page, index) in numberOfPages"
|
|
:key="index"
|
|
class="pages-indicator__dot"
|
|
:class="{'pages-indicator__dot--active': index === currentPage }" />
|
|
</div>
|
|
<div v-if="devMode && !screenshotMode" class="dev-mode__data">
|
|
<p>GRID INFO</p>
|
|
<p>Videos (total): {{ videosCount }}</p>
|
|
<p>Displayed videos n: {{ displayedVideos.length }}</p>
|
|
<p>Max per page: ~{{ videosCap }}</p>
|
|
<p>Grid width: {{ gridWidth }}</p>
|
|
<p>Grid height: {{ gridHeight }}</p>
|
|
<p>Min video width: {{ minWidth }} </p>
|
|
<p>Min video Height: {{ minHeight }} </p>
|
|
<p>Grid aspect ratio: {{ gridAspectRatio }}</p>
|
|
<p>Number of pages: {{ numberOfPages }}</p>
|
|
<p>Current page: {{ currentPage }}</p>
|
|
</div>
|
|
</div>
|
|
</TransitionWrapper>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import debounce from 'debounce'
|
|
|
|
import ChevronDown from 'vue-material-design-icons/ChevronDown.vue'
|
|
import ChevronLeft from 'vue-material-design-icons/ChevronLeft.vue'
|
|
import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
|
|
import ChevronUp from 'vue-material-design-icons/ChevronUp.vue'
|
|
|
|
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
|
|
import { loadState } from '@nextcloud/initial-state'
|
|
import { generateFilePath } from '@nextcloud/router'
|
|
|
|
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
|
|
import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip.js'
|
|
|
|
import TransitionWrapper from '../../UIShared/TransitionWrapper.vue'
|
|
import EmptyCallView from '../shared/EmptyCallView.vue'
|
|
import LocalVideo from '../shared/LocalVideo.vue'
|
|
import VideoBottomBar from '../shared/VideoBottomBar.vue'
|
|
import VideoVue from '../shared/VideoVue.vue'
|
|
|
|
// Max number of videos per page. `0`, the default value, means no cap
|
|
const videosCap = parseInt(loadState('spreed', 'grid_videos_limit'), 10) || 0
|
|
const videosCapEnforced = loadState('spreed', 'grid_videos_limit_enforced') || false
|
|
|
|
export default {
|
|
name: 'Grid',
|
|
|
|
components: {
|
|
VideoVue,
|
|
LocalVideo,
|
|
EmptyCallView,
|
|
NcButton,
|
|
TransitionWrapper,
|
|
VideoBottomBar,
|
|
ChevronRight,
|
|
ChevronLeft,
|
|
ChevronUp,
|
|
ChevronDown,
|
|
},
|
|
|
|
directives: {
|
|
Tooltip,
|
|
},
|
|
|
|
props: {
|
|
/**
|
|
* Developer mode: If enabled it allows to debug the grid using dummy
|
|
* videos
|
|
*/
|
|
devMode: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
screenshotMode: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
/**
|
|
* The number of dummy videos in dev mode
|
|
*/
|
|
dummies: {
|
|
type: Number,
|
|
default: 8,
|
|
},
|
|
/**
|
|
* Display the overflow of videos in separate pages;
|
|
*/
|
|
hasPagination: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
/**
|
|
* To be set to true when the grid is in the promoted view.
|
|
*/
|
|
isStripe: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
isSidebar: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
isRecording: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
callParticipantModels: {
|
|
type: Array,
|
|
required: true,
|
|
},
|
|
localMediaModel: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
localCallParticipantModel: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
token: {
|
|
type: String,
|
|
required: true,
|
|
},
|
|
sharedDatas: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
isLocalVideoSelectable: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
screens: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
},
|
|
|
|
emits: ['select-video', 'click-local-video'],
|
|
|
|
setup() {
|
|
return {
|
|
videosCap,
|
|
videosCapEnforced,
|
|
}
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
gridWidth: 0,
|
|
gridHeight: 0,
|
|
// Columns of the grid at any given moment
|
|
columns: 0,
|
|
// Rows of the grid at any given moment
|
|
rows: 0,
|
|
// The current page
|
|
currentPage: 0,
|
|
// Videos controls and name
|
|
showVideoOverlay: true,
|
|
// Timer for the videos bottom bar
|
|
showVideoOverlayTimer: null,
|
|
debounceMakeGrid: () => {},
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
stripeButtonTooltip() {
|
|
if (this.stripeOpen) {
|
|
return t('spreed', 'Collapse stripe')
|
|
} else {
|
|
return t('spreed', 'Expand stripe')
|
|
}
|
|
},
|
|
|
|
// The videos array. This is the total number of grid elements.
|
|
// Depending on `gridWidth`, `gridHeight`, `minWidth`, `minHeight` and
|
|
// `videosCap`, these videos are shown in one or more grid 'pages'.
|
|
videos() {
|
|
if (this.devMode) {
|
|
return Array.from(Array(this.dummies).keys())
|
|
} else {
|
|
return this.callParticipantModels
|
|
}
|
|
},
|
|
|
|
// Number of video components (it does not include the local video)
|
|
videosCount() {
|
|
if (!this.isStripe && this.videos.length === 0) {
|
|
// Count the emptycontent as a grid element
|
|
return 1
|
|
}
|
|
|
|
return this.videos.length
|
|
},
|
|
videoWidth() {
|
|
return this.gridWidth / this.columns
|
|
},
|
|
videoHeight() {
|
|
return this.gridHeight / this.rows
|
|
},
|
|
|
|
// Array of videos that are being displayed in the grid at any given
|
|
// moment
|
|
displayedVideos() {
|
|
if (!this.slots) {
|
|
return []
|
|
}
|
|
|
|
const slots = (this.videosCap && this.videosCapEnforced) ? Math.min(this.videosCap, this.slots) : this.slots
|
|
|
|
// Slice the `videos` array to display the current page of videos
|
|
if (((this.currentPage + 1) * slots) >= this.videos.length) {
|
|
return this.videos.slice(this.currentPage * slots)
|
|
}
|
|
|
|
return this.videos.slice(this.currentPage * slots, (this.currentPage + 1) * slots)
|
|
},
|
|
|
|
isLessThanTwoVideos() {
|
|
// without screen share, we don't want to duplicate videos if we were to show them in the stripe
|
|
// however, if a screen share is in progress, it means the video of the presenting user is not visible,
|
|
// so we can show it in the stripe
|
|
return this.videos.length <= 1 && !this.screens.length
|
|
},
|
|
|
|
dpiFactor() {
|
|
if (this.isStripe) {
|
|
// On the stripe we only ever want 1 row, so we ignore the DPR
|
|
// as the height of the grid is the height of the video elements then.
|
|
return 1.0
|
|
}
|
|
|
|
const devicePixelRatio = window.devicePixelRatio
|
|
|
|
// Some sanity check to not screw up the math.
|
|
if (devicePixelRatio < 0.5) {
|
|
return 0.5
|
|
}
|
|
|
|
if (devicePixelRatio > 2.0) {
|
|
return 2.0
|
|
}
|
|
|
|
return devicePixelRatio
|
|
},
|
|
|
|
/**
|
|
* Minimum width of the video components
|
|
*/
|
|
minWidth() {
|
|
return (this.isStripe || this.isSidebar) ? 200 : 320
|
|
},
|
|
/**
|
|
* Minimum height of the video components
|
|
*/
|
|
minHeight() {
|
|
return (this.isStripe || this.isSidebar) ? 150 : 240
|
|
},
|
|
|
|
dpiAwareMinWidth() {
|
|
return this.minWidth / this.dpiFactor
|
|
},
|
|
|
|
dpiAwareMinHeight() {
|
|
return this.minHeight / this.dpiFactor
|
|
},
|
|
|
|
// The aspect ratio of the grid (in terms of px)
|
|
gridAspectRatio() {
|
|
return (this.gridWidth / this.gridHeight).toPrecision([2])
|
|
},
|
|
|
|
targetAspectRatio() {
|
|
return this.isStripe ? 1 : 1.5
|
|
},
|
|
|
|
// Max number of columns possible
|
|
columnsMax() {
|
|
// Max amount of columns that fits on screen, including gaps and paddings (8px)
|
|
const calculatedApproxColumnsMax = Math.floor((this.gridWidth - 8 * this.columns) / this.dpiAwareMinWidth)
|
|
// Max amount of columns that fits on screen (with one more gap, as if we try to fit one more column)
|
|
const calculatedHypotheticalColumnsMax = Math.floor((this.gridWidth - 8 * (this.columns + 1)) / this.dpiAwareMinWidth)
|
|
// If we about to change current columns amount, check if one more column could fit the screen
|
|
// This helps to avoid flickering, when resize within 8px from minimal gridWidth for current amount of columns
|
|
const calculatedColumnsMax = calculatedApproxColumnsMax === this.columns ? calculatedApproxColumnsMax : calculatedHypotheticalColumnsMax
|
|
// Return at least 1 column
|
|
return calculatedColumnsMax <= 1 ? 1 : calculatedColumnsMax
|
|
},
|
|
|
|
// Max number of rows possible
|
|
rowsMax() {
|
|
if (Math.floor(this.gridHeight / this.dpiAwareMinHeight) < 1) {
|
|
// Return at least 1 row
|
|
return 1
|
|
} else {
|
|
return Math.floor(this.gridHeight / this.dpiAwareMinHeight)
|
|
}
|
|
},
|
|
|
|
// Number of grid slots at any given moment
|
|
// The local video always takes one slot if the grid view is not shown
|
|
// as a stripe.
|
|
slots() {
|
|
return this.isStripe ? this.rows * this.columns : this.rows * this.columns - 1
|
|
},
|
|
|
|
// Grid pages at any given moment
|
|
numberOfPages() {
|
|
return Math.ceil(this.videosCount / this.slots)
|
|
},
|
|
|
|
// Hides or displays the `grid-navigation next` button
|
|
hasNextPage() {
|
|
if (this.displayedVideos.length !== 0 && this.hasPagination) {
|
|
return this.displayedVideos.at(-1) !== this.videos.at(-1)
|
|
} else {
|
|
return false
|
|
}
|
|
},
|
|
|
|
// Hides or displays the `grid-navigation previous` button
|
|
hasPreviousPage() {
|
|
if (this.displayedVideos.length !== 0 && this.hasPagination) {
|
|
return this.displayedVideos[0] !== this.videos[0]
|
|
} else {
|
|
return false
|
|
}
|
|
},
|
|
|
|
// TODO: rebuild the grid to have optimal for last page
|
|
// isLastPage() {
|
|
// return !this.hasNextPage
|
|
// },
|
|
|
|
// Computed css to reactively style the grid
|
|
gridStyle() {
|
|
let columns = this.columns
|
|
let rows = this.rows
|
|
|
|
// If there are no other videos the empty call view is shown above
|
|
// the local video.
|
|
if (this.videos.length === 0 && !this.isStripe) {
|
|
columns = 1
|
|
rows = 2
|
|
}
|
|
|
|
return {
|
|
gridTemplateColumns: `repeat(${columns}, minmax(${this.dpiAwareMinWidth}px, 1fr))`,
|
|
gridTemplateRows: `repeat(${rows}, minmax(${this.dpiAwareMinHeight}px, 1fr))`,
|
|
}
|
|
},
|
|
|
|
// Check if there's an overflow of videos (videos that don't fit in the grid)
|
|
hasVideoOverflow() {
|
|
return this.videosCount > this.slots
|
|
},
|
|
|
|
sidebarStatus() {
|
|
return this.$store.getters.getSidebarStatus
|
|
},
|
|
|
|
wrapperStyle() {
|
|
if (this.isStripe) {
|
|
return 'height: 250px'
|
|
} else {
|
|
return 'height: 100%'
|
|
}
|
|
},
|
|
|
|
stripeOpen() {
|
|
return this.$store.getters.isStripeOpen && !this.isRecording
|
|
},
|
|
},
|
|
|
|
watch: {
|
|
// If the video array size changes, rebuild the grid
|
|
'videos.length'() {
|
|
this.makeGrid()
|
|
},
|
|
// TODO: rebuild the grid to have optimal for last page
|
|
// Exception for when navigating in and away from the last page of the
|
|
// grid
|
|
/**
|
|
isLastPage(newValue, oldValue) {
|
|
if (this.hasPagination) {
|
|
// If navigating into last page, make grid for last page
|
|
if (newValue && this.currentPage !== 0) {
|
|
this.makeGridForLastPage()
|
|
} else if (!newValue) {
|
|
// TODO: make a proper grid for when navigating away from last page
|
|
this.makeGrid()
|
|
}
|
|
}
|
|
},
|
|
*/
|
|
isStripe() {
|
|
this.rebuildGrid()
|
|
|
|
// Reset current page when switching between stripe and full grid,
|
|
// as the previous page is meaningless in the new mode.
|
|
this.currentPage = 0
|
|
},
|
|
|
|
stripeOpen() {
|
|
this.rebuildGrid()
|
|
},
|
|
|
|
sidebarStatus() {
|
|
// Handle the resize after the sidebar animation has completed
|
|
setTimeout(this.handleResize, 500)
|
|
},
|
|
|
|
numberOfPages() {
|
|
if (this.currentPage >= this.numberOfPages) {
|
|
this.currentPage = Math.max(0, this.numberOfPages - 1)
|
|
}
|
|
},
|
|
},
|
|
|
|
// bind event handlers to the `handleResize` method
|
|
mounted() {
|
|
this.debounceMakeGrid = debounce(this.makeGrid, 200)
|
|
window.addEventListener('resize', this.handleResize)
|
|
subscribe('navigation-toggled', this.handleResize)
|
|
this.makeGrid()
|
|
|
|
window.OCA.Talk.gridDebugInformation = this.gridDebugInformation
|
|
},
|
|
beforeDestroy() {
|
|
this.debounceMakeGrid.clear?.()
|
|
window.OCA.Talk.gridDebugInformation = () => console.debug('Not in a call')
|
|
|
|
window.removeEventListener('resize', this.handleResize)
|
|
unsubscribe('navigation-toggled', this.handleResize)
|
|
},
|
|
|
|
methods: {
|
|
gridDebugInformation() {
|
|
console.debug('Grid debug information')
|
|
console.debug({
|
|
minWidth: this.minWidth,
|
|
minHeight: this.minHeight,
|
|
videosCap: this.videosCap,
|
|
videosCapEnforced: this.videosCapEnforced,
|
|
targetAspectRatio: this.targetAspectRatio,
|
|
videosCount: this.videosCount,
|
|
videoWidth: this.videoWidth,
|
|
videoHeight: this.videoHeight,
|
|
devicePixelRatio: window.devicePixelRatio,
|
|
dpiFactor: this.dpiFactor,
|
|
dpiAwareMinWidth: this.dpiAwareMinWidth,
|
|
dpiAwareMinHeight: this.dpiAwareMinHeight,
|
|
gridAspectRatio: this.gridAspectRatio,
|
|
columnsMax: this.columnsMax,
|
|
rowsMax: this.rowsMax,
|
|
numberOfPages: this.numberOfPages,
|
|
bodyWidth: document.body.clientWidth,
|
|
bodyHeight: document.body.clientHeight,
|
|
gridWidth: this.$refs.grid.clientWidth,
|
|
gridHeight: this.$refs.grid.clientHeight,
|
|
})
|
|
},
|
|
|
|
rebuildGrid() {
|
|
console.debug('isStripe: ', this.isStripe)
|
|
console.debug('stripeOpen: ', this.stripeOpen)
|
|
console.debug('previousGridWidth: ', this.gridWidth, 'previousGridHeight: ', this.gridHeight)
|
|
console.debug('newGridWidth: ', this.gridWidth, 'newGridHeight: ', this.gridHeight)
|
|
if (!this.isStripe || this.stripeOpen) {
|
|
this.$nextTick(this.makeGrid)
|
|
}
|
|
},
|
|
|
|
placeholderImage(i) {
|
|
return generateFilePath('spreed', 'docs', 'screenshotplaceholders/placeholder-' + i + '.jpeg')
|
|
},
|
|
|
|
placeholderName(i) {
|
|
switch (i) {
|
|
case 0:
|
|
return 'Sandra McKinney'
|
|
case 1:
|
|
return 'Chris Wurst'
|
|
case 2:
|
|
return 'Edeltraut Bobb'
|
|
case 3:
|
|
return 'Arthur Blitz'
|
|
case 4:
|
|
return 'Roeland Douma'
|
|
case 5:
|
|
return 'Vanessa Steg'
|
|
case 6:
|
|
return 'Emily Grant'
|
|
case 7:
|
|
return 'Tobias Kaminsky'
|
|
case 8:
|
|
return 'Adrian Ada'
|
|
}
|
|
},
|
|
|
|
placeholderModel(i) {
|
|
return {
|
|
attributes: {
|
|
audioAvailable: i === 1 || i === 2 || i === 4 || i === 5 || i === 6 || i === 7 || i === 8,
|
|
audioEnabled: i === 8,
|
|
videoAvailable: true,
|
|
screen: false,
|
|
currentVolume: 0.75,
|
|
volumeThreshold: 0.75,
|
|
localScreen: false,
|
|
raisedHand: {
|
|
state: i === 0 || i === 1 || i === 6,
|
|
},
|
|
},
|
|
forceMute: () => {},
|
|
on: () => {},
|
|
off: () => {},
|
|
getWebRtc: () => {
|
|
return {
|
|
connection: {
|
|
getSendVideoIfAvailable: () => {},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
},
|
|
|
|
placeholderSharedData() {
|
|
return {
|
|
videoEnabled: {
|
|
isVideoEnabled() {
|
|
return true
|
|
},
|
|
},
|
|
remoteVideoBlocker: {
|
|
isVideoEnabled() {
|
|
return true
|
|
},
|
|
},
|
|
screenVisible: false,
|
|
}
|
|
},
|
|
|
|
// whenever the document is resized, re-set the 'clientWidth' variable
|
|
handleResize(event) {
|
|
// TODO: properly handle resizes when not on first page:
|
|
// currently if the user is not on the 'first page', upon resize the
|
|
// current position in the videos array is lost (first element
|
|
// in the grid goes back to be first video)
|
|
this.debounceMakeGrid()
|
|
},
|
|
|
|
// Find the right size if the grid in rows and columns (we already know
|
|
// the size in px).
|
|
makeGrid() {
|
|
if (!this.$refs.grid) {
|
|
return
|
|
}
|
|
this.gridWidth = this.$refs.grid.clientWidth
|
|
this.gridHeight = this.$refs.grid.clientHeight
|
|
// prevent making grid if no videos
|
|
if (this.videos.length === 0) {
|
|
this.columns = 0
|
|
this.rows = 0
|
|
return
|
|
}
|
|
|
|
if (this.devMode) {
|
|
console.debug('Recreating grid: videos: ', this.videos.length, 'columns: ', this.columnsMax + ', rows: ' + this.rowsMax)
|
|
}
|
|
|
|
// We start by assigning the max possible value to our rows and columns
|
|
// variables. These variables are kept in the data and represent how the
|
|
// grid looks at any given moment. We do this based on `gridWidth`,
|
|
// `gridHeight`, `minWidth` and `minHeight`. If the video is used in the
|
|
// context of the promoted view, we se 1 row directly, and we remove 1 column
|
|
// (one of the participants will be in the promoted video slot)
|
|
this.columns = this.columnsMax
|
|
this.rows = this.rowsMax
|
|
// This values would already work if the grid is entirely populated with
|
|
// video elements. However, if we'd have only a couple of videos to display
|
|
// and a very big screen, we'd now have a lot of columns and rows, and our
|
|
// video components would occupy only the first 2 slots and be too small.
|
|
// To solve this, we shrink this 'max grid' we've just created to fit the
|
|
// number of videos that we have.
|
|
if (this.videosCap !== 0 && this.videosCount > this.videosCap) {
|
|
this.shrinkGrid(this.videosCap)
|
|
} else {
|
|
this.shrinkGrid(this.videosCount)
|
|
}
|
|
},
|
|
|
|
// Fine tune the number of rows and columns of the grid
|
|
async shrinkGrid(numberOfVideos) {
|
|
if (this.devMode) {
|
|
console.debug('Shrinking grid: columns', this.columns + ', rows: ' + this.rows)
|
|
}
|
|
|
|
// No need to shrink more if 1 row and 1 column
|
|
if (this.rows === 1 && this.columns === 1) {
|
|
return
|
|
}
|
|
|
|
let currentColumns = this.columns
|
|
let currentRows = this.rows
|
|
let currentSlots = this.isStripe ? currentColumns * currentRows : currentColumns * currentRows - 1
|
|
|
|
// Run this code only if we don't have an 'overflow' of videos. If the
|
|
// videos are populating the grid, there's no point in shrinking it.
|
|
while (numberOfVideos < currentSlots) {
|
|
const previousColumns = currentColumns
|
|
const previousRows = currentRows
|
|
|
|
// Current video dimensions
|
|
const videoWidth = this.gridWidth / currentColumns
|
|
const videoHeight = this.gridHeight / currentRows
|
|
|
|
// Hypothetical width/height with one column/row less than current
|
|
const videoWidthWithOneColumnLess = this.gridWidth / (currentColumns - 1)
|
|
const videoHeightWithOneRowLess = this.gridHeight / (currentRows - 1)
|
|
|
|
// Hypothetical aspect ratio with one column/row less than current
|
|
const aspectRatioWithOneColumnLess = videoWidthWithOneColumnLess / videoHeight
|
|
const aspectRatioWithOneRowLess = videoWidth / videoHeightWithOneRowLess
|
|
|
|
// Deltas with target aspect ratio
|
|
const deltaAspectRatioWithOneColumnLess = Math.abs(aspectRatioWithOneColumnLess - this.targetAspectRatio)
|
|
const deltaAspectRatioWithOneRowLess = Math.abs(aspectRatioWithOneRowLess - this.targetAspectRatio)
|
|
|
|
if (this.devMode) {
|
|
console.debug('deltaAspectRatioWithOneColumnLess: ', deltaAspectRatioWithOneColumnLess, 'deltaAspectRatioWithOneRowLess: ', deltaAspectRatioWithOneRowLess)
|
|
}
|
|
// Compare the deltas to find out whether we need to remove a column or a row
|
|
if (deltaAspectRatioWithOneColumnLess <= deltaAspectRatioWithOneRowLess) {
|
|
if (currentColumns >= 2) {
|
|
currentColumns--
|
|
}
|
|
|
|
currentSlots = this.isStripe ? currentColumns * currentRows : currentColumns * currentRows - 1
|
|
|
|
// Check that there are still enough slots available
|
|
if (numberOfVideos > currentSlots) {
|
|
// If not, revert the changes and break the loop
|
|
currentColumns++
|
|
break
|
|
}
|
|
} else {
|
|
if (currentRows >= 2) {
|
|
currentRows--
|
|
}
|
|
|
|
currentSlots = this.isStripe ? currentColumns * currentRows : currentColumns * currentRows - 1
|
|
|
|
// Check that there are still enough slots available
|
|
if (numberOfVideos > currentSlots) {
|
|
// If not, revert the changes and break the loop
|
|
currentRows++
|
|
break
|
|
}
|
|
}
|
|
|
|
if (previousColumns === currentColumns && previousRows === currentRows) {
|
|
break
|
|
}
|
|
}
|
|
|
|
this.columns = currentColumns
|
|
this.rows = currentRows
|
|
},
|
|
|
|
// The last grid page is very likely not to have the same number of
|
|
// elements as the previous pages so the grid needs to be tweaked
|
|
// accordingly
|
|
// makeGridForLastPage() {
|
|
// this.columns = this.columnsMax
|
|
// this.rows = this.rowsMax
|
|
// // The displayed videos for the last page have already been set
|
|
// // in `handleClickNext`
|
|
// this.shrinkGrid(this.displayedVideos.length)
|
|
// },
|
|
|
|
handleClickNext() {
|
|
this.currentPage++
|
|
console.debug('handleclicknext, ', 'currentPage ', this.currentPage, 'slots ', this.slot, 'videos.length ', this.videos.length)
|
|
},
|
|
handleClickPrevious() {
|
|
this.currentPage--
|
|
console.debug('handleclickprevious, ', 'currentPage ', this.currentPage, 'slots ', this.slots, 'videos.length ', this.videos.length)
|
|
},
|
|
|
|
handleClickStripeCollapse() {
|
|
this.$store.dispatch('setCallViewMode', { isStripeOpen: !this.stripeOpen })
|
|
},
|
|
|
|
handleMovement() {
|
|
// TODO: debounce this
|
|
this.setTimerForUiControls()
|
|
},
|
|
setTimerForUiControls() {
|
|
if (this.showVideoOverlayTimer !== null) {
|
|
clearTimeout(this.showVideoOverlayTimer)
|
|
}
|
|
this.showVideoOverlay = true
|
|
this.showVideoOverlayTimer = setTimeout(() => {
|
|
this.showVideoOverlay = false
|
|
}, 5000)
|
|
},
|
|
|
|
handleClickVideo(event, peerId) {
|
|
console.debug('selected-video peer id', peerId)
|
|
this.$emit('select-video', peerId)
|
|
},
|
|
|
|
handleClickLocalVideo() {
|
|
this.$emit('click-local-video')
|
|
},
|
|
|
|
isSelected(callParticipantModel) {
|
|
return callParticipantModel.attributes.peerId === this.$store.getters.selectedVideoPeerId
|
|
},
|
|
|
|
},
|
|
}
|
|
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.grid-main-wrapper {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.grid-main-wrapper.transparent {
|
|
background: transparent;
|
|
}
|
|
|
|
.grid-main-wrapper.is-grid {
|
|
height: 100%;
|
|
}
|
|
|
|
.wrapper {
|
|
width: 100%;
|
|
display: flex;
|
|
position: relative;
|
|
bottom: 0;
|
|
left: 0;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
height: 100%;
|
|
width: 100%;
|
|
|
|
grid-row-gap: 8px;
|
|
grid-column-gap: 8px;
|
|
|
|
&.stripe {
|
|
padding: 8px 8px 0 0;
|
|
}
|
|
}
|
|
|
|
.empty-call-view {
|
|
position: relative;
|
|
padding: 16px;
|
|
}
|
|
|
|
.grid-wrapper {
|
|
width: 100%;
|
|
min-width: 0;
|
|
position: relative;
|
|
flex: 1 0 auto;
|
|
}
|
|
|
|
.stripe-wrapper {
|
|
width: 100%;
|
|
min-width: 0;
|
|
position: relative;
|
|
}
|
|
|
|
.dev-mode-video {
|
|
&:not(.dev-mode-screenshot) {
|
|
border: 1px solid #00FF41;
|
|
color: #00FF41;
|
|
}
|
|
|
|
position: relative;
|
|
|
|
&--self {
|
|
background-size: cover !important;
|
|
border-radius: calc(var(--default-clickable-area) / 2);
|
|
}
|
|
|
|
img {
|
|
object-fit: cover;
|
|
height: 100%;
|
|
width: 100%;
|
|
border-radius: calc(var(--default-clickable-area) / 2);
|
|
}
|
|
|
|
.wrapper {
|
|
position: absolute;
|
|
}
|
|
}
|
|
|
|
.dev-mode__title {
|
|
position: absolute;
|
|
left: 44px;
|
|
color: #00FF41;
|
|
z-index: 100;
|
|
line-height: 120px;
|
|
font-weight: 900;
|
|
font-size: 100px !important;
|
|
top: 88px;
|
|
opacity: 25%;
|
|
}
|
|
|
|
.dev-mode__data {
|
|
font-family: monospace;
|
|
position: fixed;
|
|
color: #00FF41;
|
|
left: 20px;
|
|
bottom: 50%;
|
|
padding: 20px;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
border: 1px solid #00FF41;
|
|
width: 212px;
|
|
font-size: 12px;
|
|
z-index: 999999999999999;
|
|
|
|
& p {
|
|
text-overflow: ellipsis;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
}
|
|
}
|
|
|
|
.video:last-child {
|
|
grid-column-end: -1;
|
|
}
|
|
|
|
.grid-navigation {
|
|
.grid-wrapper & {
|
|
position: absolute;
|
|
top: calc(50% - var(--default-clickable-area) / 2);
|
|
|
|
&__previous {
|
|
left: 8px;
|
|
}
|
|
|
|
&__next {
|
|
right: 8px;
|
|
}
|
|
}
|
|
|
|
.stripe-wrapper & {
|
|
position: absolute;
|
|
top: 16px;
|
|
|
|
&__previous {
|
|
left: 8px;
|
|
}
|
|
|
|
&__next {
|
|
right: 16px;
|
|
}
|
|
}
|
|
}
|
|
|
|
.pages-indicator {
|
|
position: absolute;
|
|
right: 50%;
|
|
top: 4px;
|
|
display: flex;
|
|
background-color: var(--color-background-hover);
|
|
height: 44px;
|
|
padding: 0 22px;
|
|
border-radius: 22px;
|
|
|
|
&__dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
margin: auto 4px;
|
|
border-radius: 4px;
|
|
background-color: white;
|
|
opacity: 80%;
|
|
box-shadow: 0 0 4px black;
|
|
|
|
&--active {
|
|
opacity: 100%;
|
|
}
|
|
}
|
|
}
|
|
|
|
.stripe--collapse {
|
|
position: absolute !important;
|
|
top: calc(-1 * var(--default-clickable-area));
|
|
right: 0;
|
|
}
|
|
|
|
.stripe--collapse,
|
|
.grid-navigation {
|
|
z-index: 2;
|
|
opacity: .7;
|
|
|
|
#call-container:hover & {
|
|
background-color: rgba(0, 0, 0, 0.1) !important;
|
|
|
|
&:hover,
|
|
&:focus {
|
|
opacity: 1;
|
|
background-color: rgba(0, 0, 0, 0.2) !important;
|
|
}
|
|
}
|
|
|
|
&:active {
|
|
/* needed again to override default active button style */
|
|
background: none;
|
|
}
|
|
}
|
|
|
|
</style>
|