spreed/src/components/CallView/Grid/Grid.vue
Dorra Jaouad d19a764143 fix: RTL support in call view
Signed-off-by: Dorra Jaouad <dorra.jaoued7@gmail.com>
Signed-off-by: Maksim Sukharev <antreesy.web@gmail.com>
2025-01-30 16:42:06 +01:00

1142 lines
32 KiB
Vue

<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="grid-main-wrapper" :class="{'is-grid': !isStripe, 'transparent': isLessThanTwoVideos}">
<NcButton v-if="isStripe && !isRecording"
class="stripe--collapse"
type="tertiary-no-background"
:title="stripeButtonTitle"
:aria-label="stripeButtonTitle"
@click="handleClickStripeCollapse">
<template #icon>
<IconChevronDown v-if="stripeOpen"
fill-color="#ffffff"
:size="20" />
<IconChevronUp 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>
<IconChevronLeft class="bidirectional-icon"
fill-color="#ffffff"
:size="20" />
</template>
</NcButton>
<div ref="grid"
class="grid"
:class="{stripe: isStripe}"
:style="gridStyle"
@mousemove="handleMovement"
@wheel="debounceHandleWheelEvent"
@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="key in displayedVideos"
:key="key"
class="dev-mode-video video"
:class="{'dev-mode-screenshot': screenshotMode}">
<img :alt="placeholderName(key)" :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>
</template>
<LocalVideo v-if="!isStripe && !isRecording"
ref="localVideo"
class="video"
is-grid
:fit-video="false"
: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>
<IconChevronRight class="bidirectional-icon"
fill-color="#ffffff"
:size="20" />
</template>
</NcButton>
</div>
<LocalVideo v-if="isStripe && !isRecording"
ref="localVideo"
class="video"
:is-stripe="true"
:show-controls="false"
:token="token"
:local-media-model="localMediaModel"
:local-call-participant-model="localCallParticipantModel"
@click-video="handleClickLocalVideo" />
<template v-if="devMode">
<NcButton type="tertiary"
class="dev-mode__toggle"
aria-label="Toggle screenshot mode"
@click="screenshotMode = !screenshotMode">
<template #icon>
<IconChevronLeft v-if="!screenshotMode"
class="bidirectional-icon"
fill-color="#00FF41"
:size="20" />
</template>
</NcButton>
<div v-if="!screenshotMode" class="dev-mode__data">
<span>GRID INFO</span>
<NcButton small @click="disableDevMode">
Disable
</NcButton>
<span>Videos (total):</span><span>{{ videosCount }}</span>
<span>Displayed videos:</span><span>{{ displayedVideos.length }}</span>
<span>Max per page:</span><span>~{{ videosCap }}</span>
<span>Grid width:</span><span>{{ gridWidth }}px</span>
<span>Grid height:</span><span>{{ gridHeight }}px</span>
<span>Min video width:</span><span>{{ minWidth }}px</span>
<span>Min video Height:</span><span>{{ minHeight }}px</span>
<span>Grid aspect ratio:</span><span>{{ gridAspectRatio }}</span>
<span>Number of pages:</span><span>{{ numberOfPages }}</span>
<span>Current page:</span><span>{{ currentPage }}</span>
<span>Dummies:</span><input v-model.number="dummies" type="number">
<span>Stripe mode:</span><input v-model="devStripe" type="checkbox">
<span>Screenshot mode:</span><input v-model="screenshotMode" type="checkbox">
</div>
</template>
</div>
</TransitionWrapper>
</div>
</template>
<script>
import debounce from 'debounce'
import { inject, ref } from 'vue'
import IconChevronDown from 'vue-material-design-icons/ChevronDown.vue'
import IconChevronLeft from 'vue-material-design-icons/ChevronLeft.vue'
import IconChevronRight from 'vue-material-design-icons/ChevronRight.vue'
import IconChevronUp from 'vue-material-design-icons/ChevronUp.vue'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.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'
import { placeholderImage, placeholderModel, placeholderName, placeholderSharedData } from './gridPlaceholders.ts'
import { PARTICIPANT, ATTENDEE } from '../../../constants.ts'
import { useCallViewStore } from '../../../stores/callView.js'
import { useSidebarStore } from '../../../stores/sidebar.ts'
// 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,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronUp,
},
props: {
/**
* 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() {
// Developer mode: If enabled it allows to debug the grid using dummy videos
const devMode = inject('CallView:devModeEnabled', ref(false))
const screenshotMode = inject('CallView:screenshotModeEnabled', ref(false))
// The number of dummy videos in dev mode
const dummies = ref(4)
return {
devMode,
dummies,
screenshotMode,
videosCap,
videosCapEnforced,
callViewStore: useCallViewStore(),
sidebarStore: useSidebarStore(),
}
},
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: () => {},
debounceHandleWheelEvent: () => {},
tempPromotedModels: [],
unpromoteSpeakerTimer: {},
promotedHistoryMask: [],
}
},
computed: {
stripeButtonTitle() {
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.orderedVideos.length) {
return this.orderedVideos.slice(this.currentPage * slots)
}
return this.orderedVideos.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.orderedVideos.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.orderedVideos[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.sidebarStore.show
},
wrapperStyle() {
if (this.isStripe) {
return 'height: 250px'
} else {
return 'height: 100%'
}
},
stripeOpen() {
return this.callViewStore.isStripeOpen && !this.isRecording
},
participantsInitialised() {
return this.$store.getters.participantsInitialised(this.token)
},
isGuestNonModerator() {
return this.$store.getters.getActorType() === ATTENDEE.ACTOR_TYPE.GUESTS
&& this.$store.getters.conversation(this.token).participantType !== PARTICIPANT.TYPE.GUEST_MODERATOR
},
orderedVideos() {
// Dynamic ordering is not possible for guests because
// participants store is not initialized
if (this.isGuestNonModerator || this.devMode) {
return this.videos
}
if (!this.participantsInitialised) {
return []
}
const objectMap = {
modelsWithScreenshare: [],
modelsTempPromoted: [],
modelsWithVideoEnabled: [],
modelsWithAudioOnly: [],
modelsWithNoPermissions: [],
}
const screensSet = new Set(this.screens)
const tempPromotedModelsSet = new Set(this.tempPromotedModels.map(model => model.attributes.nextcloudSessionId))
const videoTilesMap = new Map()
const audioTilesMap = new Map()
this.callParticipantModels.forEach((model) => {
if (screensSet.has(model.attributes.peerId)) {
objectMap.modelsWithScreenshare.push(model)
} else if (tempPromotedModelsSet.has(model.attributes.nextcloudSessionId)) {
objectMap.modelsTempPromoted.push(model)
} else if (this.isModelWithVideo(model)) {
videoTilesMap.set(model.attributes.nextcloudSessionId, model)
} else if (this.isModelWithAudio(model)) {
audioTilesMap.set(model.attributes.nextcloudSessionId, model)
} else {
objectMap.modelsWithNoPermissions.push(model)
}
})
objectMap.modelsWithVideoEnabled = this.getOrderedTiles(videoTilesMap, this.promotedHistoryMask)
objectMap.modelsWithAudioOnly = this.getOrderedTiles(audioTilesMap, this.promotedHistoryMask)
return [...objectMap.modelsWithScreenshare,
...objectMap.modelsTempPromoted,
...objectMap.modelsWithVideoEnabled,
...objectMap.modelsWithAudioOnly,
...objectMap.modelsWithNoPermissions]
},
speakers() {
return this.callParticipantModels.filter(model => model.attributes.speaking)
},
speakersWithAudioOff() {
return this.tempPromotedModels.filter(model => !model.attributes.audioAvailable)
},
devStripe: {
get() {
return this.isStripe
},
set(value) {
this.callViewStore.setCallViewMode({ token: this.token, isGrid: !value, clearLast: false })
},
},
},
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)
}
},
speakers(models) {
models.forEach(model => {
this.promoteSpeaker(model)
clearTimeout(this.unpromoteSpeakerTimer[model.attributes.nextcloudSessionId])
})
},
speakersWithAudioOff(newModels, oldModels) {
newModels.forEach(speaker => {
if (oldModels.includes(speaker)) {
return
}
this.unpromoteSpeakerTimer[speaker.attributes.nextcloudSessionId] = setTimeout(() => {
this.unpromoteSpeaker(speaker)
}, 10000)
})
},
},
// bind event handlers to the `handleResize` method
mounted() {
this.debounceMakeGrid = debounce(this.makeGrid, 200)
this.debounceHandleWheelEvent = debounce(this.handleWheelEvent, 50)
window.addEventListener('resize', this.handleResize)
subscribe('navigation-toggled', this.handleResize)
this.makeGrid()
window.OCA.Talk.gridDebugInformation = this.gridDebugInformation
},
beforeDestroy() {
this.debounceMakeGrid.clear?.()
this.debounceHandleWheelEvent.clear?.()
window.OCA.Talk.gridDebugInformation = () => console.debug('Not in a call')
window.removeEventListener('resize', this.handleResize)
unsubscribe('navigation-toggled', this.handleResize)
},
methods: {
t,
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)
}
},
// Placeholder data for devMode and screenshotMode
placeholderImage,
placeholderName,
placeholderModel,
placeholderSharedData,
disableDevMode() {
this.screenshotMode = false
this.devMode = 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)
// },
handleWheelEvent(event) {
if (this.gridWidth <= 0) {
return
}
if (event.deltaY < 0 && this.hasPreviousPage) {
this.handleClickPrevious()
} else if (event.deltaY > 0 && this.hasNextPage) {
this.handleClickNext()
}
},
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.callViewStore.setCallViewMode({ token: this.token, isStripeOpen: !this.stripeOpen, clearLast: false })
},
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.callViewStore.selectedVideoPeerId
},
isModelWithVideo(callParticipantModel) {
return callParticipantModel.attributes.videoAvailable
&& (typeof callParticipantModel.attributes.stream === 'object')
},
isModelWithAudio(callParticipantModel) {
const participant = this.$store.getters.getParticipantBySessionId(this.token, callParticipantModel.attributes.nextcloudSessionId)
if (!participant) {
return false
}
return participant?.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_AUDIO
},
unpromoteSpeaker(model) {
// remove model from the temp promoted speakers
const index = this.tempPromotedModels.indexOf(model)
if (index === -1) {
return
}
this.tempPromotedModels.splice(index, 1)
},
promoteSpeaker(model) {
const id = model.attributes.nextcloudSessionId
// if model is already in the first page, do nothing
if (this.orderedVideos.slice(0, this.slots).find(video => video.attributes.nextcloudSessionId === id)) {
return
}
if (this.screens.includes(model.attributes.peerId)) {
// tiles with screenshare have a better priority position already
// do nothing
return
}
// add the model
if (!this.tempPromotedModels.includes(model)) {
// remove model from the order history if it exists
const modelIndex = this.promotedHistoryMask.indexOf(id)
if (modelIndex !== -1) {
this.promotedHistoryMask.splice(modelIndex, 1)
}
this.tempPromotedModels.unshift(model)
// add model to the beginning of the orderedVideos in its category
this.promotedHistoryMask.unshift(id)
}
},
getOrderedTiles(tilesMap, orderMask) {
const orderedTiles = []
const rest = []
// Get the ordered tiles
orderMask.forEach(id => {
if (tilesMap.has(id)) {
orderedTiles.push(tilesMap.get(id))
}
})
// Add remaining tiles not in orderMask to rest
tilesMap.forEach((tile, id) => {
if (!orderMask.includes(id)) {
rest.push(tile)
}
})
return [...orderedTiles, ...rest]
},
},
}
</script>
<style lang="scss" scoped>
.grid-main-wrapper {
--navigation-position: calc(var(--default-grid-baseline) * 2);
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;
inset-inline-start: 0;
}
.grid {
display: grid;
height: 100%;
width: 100%;
grid-row-gap: var(--grid-gap);
grid-column-gap: var(--grid-gap);
&.stripe {
padding: var(--grid-gap) var(--grid-gap) 0 0;
}
}
.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) {
outline: 1px solid #00FF41;
color: #00FF41;
}
position: relative;
img {
object-fit: cover;
height: 100%;
width: 100%;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
}
.wrapper {
position: absolute;
}
}
.dev-mode__title {
position: absolute;
/* stylelint-disable-next-line csstools/use-logical */
left: var(--default-clickable-area);
color: #00FF41;
z-index: 100;
line-height: 120px;
font-weight: 900;
font-size: 100px !important;
top: 88px;
opacity: 25%;
}
.dev-mode__toggle {
position: fixed !important;
/* stylelint-disable-next-line csstools/use-logical */
left: 20px;
top: calc(2 * var(--header-height));
}
.dev-mode__data {
direction: ltr;
font-family: monospace;
position: fixed;
color: #00FF41;
/* stylelint-disable-next-line csstools/use-logical */
left: 20px;
top: calc(2 * var(--header-height) + 40px);
padding: 5px;
background: rgba(0, 0, 0, 0.8);
border: 1px solid #00FF41;
display: grid;
grid-template-columns: 165px 75px;
align-items: center;
justify-content: flex-start;
z-index: 1;
& span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
& input {
max-width: 65px;
height: 22.5px !important;
min-height: unset;
margin: 0;
}
}
.video:last-child {
grid-column-end: -1;
}
.grid-navigation {
.grid-wrapper & {
position: absolute;
top: calc(50% - var(--default-clickable-area) / 2);
&__previous {
inset-inline-start: calc(var(--default-grid-baseline) * 2);
}
&__next {
inset-inline-end: calc(var(--default-grid-baseline) * 2);
}
}
.stripe-wrapper & {
position: absolute;
top: calc(var(--navigation-position) + var(--grid-gap));
&__previous {
inset-inline-start: var(--navigation-position);
}
&__next {
inset-inline-end: calc(var(--navigation-position) + var(--grid-gap));
}
}
}
.stripe--collapse {
position: absolute !important;
top: calc(-1 * (var(--default-clickable-area) + var(--navigation-position) / 2));
inset-inline-end: calc(var(--navigation-position) / 2) ;
}
.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>