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

  1. <!--
  2. - @copyright Copyright (c) 2020 Marco Ambrosini <marcoambrosini@icloud.com>
  3. -
  4. - @author Marco Ambrosini <marcoambrosini@icloud.com>
  5. -
  6. - @license AGPL-3.0-or-later
  7. -
  8. - This program is free software: you can redistribute it and/or modify
  9. - it under the terms of the GNU Affero General Public License as
  10. - published by the Free Software Foundation, either version 3 of the
  11. - License, or (at your option) any later version.
  12. -
  13. - This program is distributed in the hope that it will be useful,
  14. - but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. - GNU Affero General Public License for more details.
  17. -
  18. - You should have received a copy of the GNU Affero General Public License
  19. - along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. -->
  21. <template>
  22. <div class="grid-main-wrapper" :class="{'is-grid': !isStripe, 'transparent': isLessThanTwoVideos}">
  23. <NcButton v-if="isStripe && !isRecording"
  24. class="stripe--collapse"
  25. type="tertiary-no-background"
  26. :aria-label="stripeButtonTooltip"
  27. @click="handleClickStripeCollapse">
  28. <template #icon>
  29. <ChevronDown v-if="stripeOpen"
  30. fill-color="#ffffff"
  31. :size="20" />
  32. <ChevronUp v-else
  33. fill-color="#ffffff"
  34. :size="20" />
  35. </template>
  36. </NcButton>
  37. <TransitionWrapper :name="isStripe ? 'slide-down' : undefined">
  38. <div v-if="!isStripe || stripeOpen" class="wrapper" :style="wrapperStyle">
  39. <div :class="[isStripe ? 'stripe-wrapper' : 'grid-wrapper']">
  40. <NcButton v-if="hasPreviousPage && gridWidth > 0"
  41. type="tertiary-no-background"
  42. class="grid-navigation grid-navigation__previous"
  43. :aria-label="t('spreed', 'Previous page of videos')"
  44. @click="handleClickPrevious">
  45. <template #icon>
  46. <ChevronLeft fill-color="#ffffff"
  47. :size="20" />
  48. </template>
  49. </NcButton>
  50. <div ref="grid"
  51. class="grid"
  52. :class="{stripe: isStripe}"
  53. :style="gridStyle"
  54. @mousemove="handleMovement"
  55. @keydown="handleMovement">
  56. <template v-if="!devMode && (!isLessThanTwoVideos || !isStripe)">
  57. <EmptyCallView v-if="videos.length === 0 && !isStripe" class="video" :is-grid="true" />
  58. <template v-for="callParticipantModel in displayedVideos">
  59. <VideoVue :key="callParticipantModel.attributes.peerId"
  60. :class="{'video': !isStripe}"
  61. :show-video-overlay="showVideoOverlay"
  62. :token="token"
  63. :model="callParticipantModel"
  64. :is-grid="true"
  65. :show-talking-highlight="!isStripe"
  66. :is-stripe="isStripe"
  67. :is-promoted="sharedDatas[callParticipantModel.attributes.peerId].promoted"
  68. :is-selected="isSelected(callParticipantModel)"
  69. :shared-data="sharedDatas[callParticipantModel.attributes.peerId]"
  70. @click-video="handleClickVideo($event, callParticipantModel.attributes.peerId)" />
  71. </template>
  72. </template>
  73. <!-- Grid developer mode -->
  74. <template v-if="devMode">
  75. <div v-for="(video, key) in displayedVideos"
  76. :key="video"
  77. class="dev-mode-video video"
  78. :class="{'dev-mode-screenshot': screenshotMode}">
  79. <img :src="placeholderImage(key)">
  80. <VideoBottomBar :has-shadow="false"
  81. :model="placeholderModel(key)"
  82. :shared-data="placeholderSharedData(key)"
  83. :token="token"
  84. :participant-name="placeholderName(key)" />
  85. </div>
  86. <h1 v-if="!screenshotMode" class="dev-mode__title">
  87. Dev mode on ;-)
  88. </h1>
  89. <div v-else
  90. class="dev-mode-video--self video"
  91. :style="{'background': 'url(' + placeholderImage(8) + ')'}" />
  92. </template>
  93. <LocalVideo v-if="!isStripe && !isRecording && !screenshotMode"
  94. ref="localVideo"
  95. class="video"
  96. :is-grid="true"
  97. :fit-video="isStripe"
  98. :token="token"
  99. :local-media-model="localMediaModel"
  100. :local-call-participant-model="localCallParticipantModel"
  101. @click-video="handleClickLocalVideo" />
  102. </div>
  103. <NcButton v-if="hasNextPage && gridWidth > 0"
  104. type="tertiary-no-background"
  105. class="grid-navigation grid-navigation__next"
  106. :aria-label="t('spreed', 'Next page of videos')"
  107. @click="handleClickNext">
  108. <template #icon>
  109. <ChevronRight fill-color="#ffffff"
  110. :size="20" />
  111. </template>
  112. </NcButton>
  113. </div>
  114. <LocalVideo v-if="isStripe && !isRecording && !screenshotMode"
  115. ref="localVideo"
  116. class="video"
  117. :is-stripe="true"
  118. :show-controls="false"
  119. :token="token"
  120. :local-media-model="localMediaModel"
  121. :local-call-participant-model="localCallParticipantModel"
  122. @click-video="handleClickLocalVideo" />
  123. <!-- page indicator (disabled) -->
  124. <div v-if="numberOfPages !== 0 && hasPagination && false"
  125. class="pages-indicator">
  126. <div v-for="(page, index) in numberOfPages"
  127. :key="index"
  128. class="pages-indicator__dot"
  129. :class="{'pages-indicator__dot--active': index === currentPage }" />
  130. </div>
  131. <div v-if="devMode && !screenshotMode" class="dev-mode__data">
  132. <p>GRID INFO</p>
  133. <p>Videos (total): {{ videosCount }}</p>
  134. <p>Displayed videos n: {{ displayedVideos.length }}</p>
  135. <p>Max per page: ~{{ videosCap }}</p>
  136. <p>Grid width: {{ gridWidth }}</p>
  137. <p>Grid height: {{ gridHeight }}</p>
  138. <p>Min video width: {{ minWidth }} </p>
  139. <p>Min video Height: {{ minHeight }} </p>
  140. <p>Grid aspect ratio: {{ gridAspectRatio }}</p>
  141. <p>Number of pages: {{ numberOfPages }}</p>
  142. <p>Current page: {{ currentPage }}</p>
  143. </div>
  144. </div>
  145. </TransitionWrapper>
  146. </div>
  147. </template>
  148. <script>
  149. import debounce from 'debounce'
  150. import ChevronDown from 'vue-material-design-icons/ChevronDown.vue'
  151. import ChevronLeft from 'vue-material-design-icons/ChevronLeft.vue'
  152. import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
  153. import ChevronUp from 'vue-material-design-icons/ChevronUp.vue'
  154. import { subscribe, unsubscribe } from '@nextcloud/event-bus'
  155. import { loadState } from '@nextcloud/initial-state'
  156. import { generateFilePath } from '@nextcloud/router'
  157. import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
  158. import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip.js'
  159. import TransitionWrapper from '../../UIShared/TransitionWrapper.vue'
  160. import EmptyCallView from '../shared/EmptyCallView.vue'
  161. import LocalVideo from '../shared/LocalVideo.vue'
  162. import VideoBottomBar from '../shared/VideoBottomBar.vue'
  163. import VideoVue from '../shared/VideoVue.vue'
  164. // Max number of videos per page. `0`, the default value, means no cap
  165. const videosCap = parseInt(loadState('spreed', 'grid_videos_limit'), 10) || 0
  166. const videosCapEnforced = loadState('spreed', 'grid_videos_limit_enforced') || false
  167. export default {
  168. name: 'Grid',
  169. components: {
  170. VideoVue,
  171. LocalVideo,
  172. EmptyCallView,
  173. NcButton,
  174. TransitionWrapper,
  175. VideoBottomBar,
  176. ChevronRight,
  177. ChevronLeft,
  178. ChevronUp,
  179. ChevronDown,
  180. },
  181. directives: {
  182. Tooltip,
  183. },
  184. props: {
  185. /**
  186. * Developer mode: If enabled it allows to debug the grid using dummy
  187. * videos
  188. */
  189. devMode: {
  190. type: Boolean,
  191. default: false,
  192. },
  193. screenshotMode: {
  194. type: Boolean,
  195. default: false,
  196. },
  197. /**
  198. * The number of dummy videos in dev mode
  199. */
  200. dummies: {
  201. type: Number,
  202. default: 8,
  203. },
  204. /**
  205. * Display the overflow of videos in separate pages;
  206. */
  207. hasPagination: {
  208. type: Boolean,
  209. default: false,
  210. },
  211. /**
  212. * To be set to true when the grid is in the promoted view.
  213. */
  214. isStripe: {
  215. type: Boolean,
  216. default: false,
  217. },
  218. isSidebar: {
  219. type: Boolean,
  220. default: false,
  221. },
  222. isRecording: {
  223. type: Boolean,
  224. default: false,
  225. },
  226. callParticipantModels: {
  227. type: Array,
  228. required: true,
  229. },
  230. localMediaModel: {
  231. type: Object,
  232. required: true,
  233. },
  234. localCallParticipantModel: {
  235. type: Object,
  236. required: true,
  237. },
  238. token: {
  239. type: String,
  240. required: true,
  241. },
  242. sharedDatas: {
  243. type: Object,
  244. required: true,
  245. },
  246. isLocalVideoSelectable: {
  247. type: Boolean,
  248. default: false,
  249. },
  250. screens: {
  251. type: Array,
  252. default: () => [],
  253. },
  254. },
  255. emits: ['select-video', 'click-local-video'],
  256. setup() {
  257. return {
  258. videosCap,
  259. videosCapEnforced,
  260. }
  261. },
  262. data() {
  263. return {
  264. gridWidth: 0,
  265. gridHeight: 0,
  266. // Columns of the grid at any given moment
  267. columns: 0,
  268. // Rows of the grid at any given moment
  269. rows: 0,
  270. // The current page
  271. currentPage: 0,
  272. // Videos controls and name
  273. showVideoOverlay: true,
  274. // Timer for the videos bottom bar
  275. showVideoOverlayTimer: null,
  276. debounceMakeGrid: () => {},
  277. }
  278. },
  279. computed: {
  280. stripeButtonTooltip() {
  281. if (this.stripeOpen) {
  282. return t('spreed', 'Collapse stripe')
  283. } else {
  284. return t('spreed', 'Expand stripe')
  285. }
  286. },
  287. // The videos array. This is the total number of grid elements.
  288. // Depending on `gridWidth`, `gridHeight`, `minWidth`, `minHeight` and
  289. // `videosCap`, these videos are shown in one or more grid 'pages'.
  290. videos() {
  291. if (this.devMode) {
  292. return Array.from(Array(this.dummies).keys())
  293. } else {
  294. return this.callParticipantModels
  295. }
  296. },
  297. // Number of video components (it does not include the local video)
  298. videosCount() {
  299. if (!this.isStripe && this.videos.length === 0) {
  300. // Count the emptycontent as a grid element
  301. return 1
  302. }
  303. return this.videos.length
  304. },
  305. videoWidth() {
  306. return this.gridWidth / this.columns
  307. },
  308. videoHeight() {
  309. return this.gridHeight / this.rows
  310. },
  311. // Array of videos that are being displayed in the grid at any given
  312. // moment
  313. displayedVideos() {
  314. if (!this.slots) {
  315. return []
  316. }
  317. const slots = (this.videosCap && this.videosCapEnforced) ? Math.min(this.videosCap, this.slots) : this.slots
  318. // Slice the `videos` array to display the current page of videos
  319. if (((this.currentPage + 1) * slots) >= this.videos.length) {
  320. return this.videos.slice(this.currentPage * slots)
  321. }
  322. return this.videos.slice(this.currentPage * slots, (this.currentPage + 1) * slots)
  323. },
  324. isLessThanTwoVideos() {
  325. // without screen share, we don't want to duplicate videos if we were to show them in the stripe
  326. // however, if a screen share is in progress, it means the video of the presenting user is not visible,
  327. // so we can show it in the stripe
  328. return this.videos.length <= 1 && !this.screens.length
  329. },
  330. dpiFactor() {
  331. if (this.isStripe) {
  332. // On the stripe we only ever want 1 row, so we ignore the DPR
  333. // as the height of the grid is the height of the video elements then.
  334. return 1.0
  335. }
  336. const devicePixelRatio = window.devicePixelRatio
  337. // Some sanity check to not screw up the math.
  338. if (devicePixelRatio < 0.5) {
  339. return 0.5
  340. }
  341. if (devicePixelRatio > 2.0) {
  342. return 2.0
  343. }
  344. return devicePixelRatio
  345. },
  346. /**
  347. * Minimum width of the video components
  348. */
  349. minWidth() {
  350. return (this.isStripe || this.isSidebar) ? 200 : 320
  351. },
  352. /**
  353. * Minimum height of the video components
  354. */
  355. minHeight() {
  356. return (this.isStripe || this.isSidebar) ? 150 : 240
  357. },
  358. dpiAwareMinWidth() {
  359. return this.minWidth / this.dpiFactor
  360. },
  361. dpiAwareMinHeight() {
  362. return this.minHeight / this.dpiFactor
  363. },
  364. // The aspect ratio of the grid (in terms of px)
  365. gridAspectRatio() {
  366. return (this.gridWidth / this.gridHeight).toPrecision([2])
  367. },
  368. targetAspectRatio() {
  369. return this.isStripe ? 1 : 1.5
  370. },
  371. // Max number of columns possible
  372. columnsMax() {
  373. // Max amount of columns that fits on screen, including gaps and paddings (8px)
  374. const calculatedApproxColumnsMax = Math.floor((this.gridWidth - 8 * this.columns) / this.dpiAwareMinWidth)
  375. // Max amount of columns that fits on screen (with one more gap, as if we try to fit one more column)
  376. const calculatedHypotheticalColumnsMax = Math.floor((this.gridWidth - 8 * (this.columns + 1)) / this.dpiAwareMinWidth)
  377. // If we about to change current columns amount, check if one more column could fit the screen
  378. // This helps to avoid flickering, when resize within 8px from minimal gridWidth for current amount of columns
  379. const calculatedColumnsMax = calculatedApproxColumnsMax === this.columns ? calculatedApproxColumnsMax : calculatedHypotheticalColumnsMax
  380. // Return at least 1 column
  381. return calculatedColumnsMax <= 1 ? 1 : calculatedColumnsMax
  382. },
  383. // Max number of rows possible
  384. rowsMax() {
  385. if (Math.floor(this.gridHeight / this.dpiAwareMinHeight) < 1) {
  386. // Return at least 1 row
  387. return 1
  388. } else {
  389. return Math.floor(this.gridHeight / this.dpiAwareMinHeight)
  390. }
  391. },
  392. // Number of grid slots at any given moment
  393. // The local video always takes one slot if the grid view is not shown
  394. // as a stripe.
  395. slots() {
  396. return this.isStripe ? this.rows * this.columns : this.rows * this.columns - 1
  397. },
  398. // Grid pages at any given moment
  399. numberOfPages() {
  400. return Math.ceil(this.videosCount / this.slots)
  401. },
  402. // Hides or displays the `grid-navigation next` button
  403. hasNextPage() {
  404. if (this.displayedVideos.length !== 0 && this.hasPagination) {
  405. return this.displayedVideos.at(-1) !== this.videos.at(-1)
  406. } else {
  407. return false
  408. }
  409. },
  410. // Hides or displays the `grid-navigation previous` button
  411. hasPreviousPage() {
  412. if (this.displayedVideos.length !== 0 && this.hasPagination) {
  413. return this.displayedVideos[0] !== this.videos[0]
  414. } else {
  415. return false
  416. }
  417. },
  418. // TODO: rebuild the grid to have optimal for last page
  419. // isLastPage() {
  420. // return !this.hasNextPage
  421. // },
  422. // Computed css to reactively style the grid
  423. gridStyle() {
  424. let columns = this.columns
  425. let rows = this.rows
  426. // If there are no other videos the empty call view is shown above
  427. // the local video.
  428. if (this.videos.length === 0 && !this.isStripe) {
  429. columns = 1
  430. rows = 2
  431. }
  432. return {
  433. gridTemplateColumns: `repeat(${columns}, minmax(${this.dpiAwareMinWidth}px, 1fr))`,
  434. gridTemplateRows: `repeat(${rows}, minmax(${this.dpiAwareMinHeight}px, 1fr))`,
  435. }
  436. },
  437. // Check if there's an overflow of videos (videos that don't fit in the grid)
  438. hasVideoOverflow() {
  439. return this.videosCount > this.slots
  440. },
  441. sidebarStatus() {
  442. return this.$store.getters.getSidebarStatus
  443. },
  444. wrapperStyle() {
  445. if (this.isStripe) {
  446. return 'height: 250px'
  447. } else {
  448. return 'height: 100%'
  449. }
  450. },
  451. stripeOpen() {
  452. return this.$store.getters.isStripeOpen && !this.isRecording
  453. },
  454. },
  455. watch: {
  456. // If the video array size changes, rebuild the grid
  457. 'videos.length'() {
  458. this.makeGrid()
  459. },
  460. // TODO: rebuild the grid to have optimal for last page
  461. // Exception for when navigating in and away from the last page of the
  462. // grid
  463. /**
  464. isLastPage(newValue, oldValue) {
  465. if (this.hasPagination) {
  466. // If navigating into last page, make grid for last page
  467. if (newValue && this.currentPage !== 0) {
  468. this.makeGridForLastPage()
  469. } else if (!newValue) {
  470. // TODO: make a proper grid for when navigating away from last page
  471. this.makeGrid()
  472. }
  473. }
  474. },
  475. */
  476. isStripe() {
  477. this.rebuildGrid()
  478. // Reset current page when switching between stripe and full grid,
  479. // as the previous page is meaningless in the new mode.
  480. this.currentPage = 0
  481. },
  482. stripeOpen() {
  483. this.rebuildGrid()
  484. },
  485. sidebarStatus() {
  486. // Handle the resize after the sidebar animation has completed
  487. setTimeout(this.handleResize, 500)
  488. },
  489. numberOfPages() {
  490. if (this.currentPage >= this.numberOfPages) {
  491. this.currentPage = Math.max(0, this.numberOfPages - 1)
  492. }
  493. },
  494. },
  495. // bind event handlers to the `handleResize` method
  496. mounted() {
  497. this.debounceMakeGrid = debounce(this.makeGrid, 200)
  498. window.addEventListener('resize', this.handleResize)
  499. subscribe('navigation-toggled', this.handleResize)
  500. this.makeGrid()
  501. window.OCA.Talk.gridDebugInformation = this.gridDebugInformation
  502. },
  503. beforeDestroy() {
  504. this.debounceMakeGrid.clear?.()
  505. window.OCA.Talk.gridDebugInformation = () => console.debug('Not in a call')
  506. window.removeEventListener('resize', this.handleResize)
  507. unsubscribe('navigation-toggled', this.handleResize)
  508. },
  509. methods: {
  510. gridDebugInformation() {
  511. console.debug('Grid debug information')
  512. console.debug({
  513. minWidth: this.minWidth,
  514. minHeight: this.minHeight,
  515. videosCap: this.videosCap,
  516. videosCapEnforced: this.videosCapEnforced,
  517. targetAspectRatio: this.targetAspectRatio,
  518. videosCount: this.videosCount,
  519. videoWidth: this.videoWidth,
  520. videoHeight: this.videoHeight,
  521. devicePixelRatio: window.devicePixelRatio,
  522. dpiFactor: this.dpiFactor,
  523. dpiAwareMinWidth: this.dpiAwareMinWidth,
  524. dpiAwareMinHeight: this.dpiAwareMinHeight,
  525. gridAspectRatio: this.gridAspectRatio,
  526. columnsMax: this.columnsMax,
  527. rowsMax: this.rowsMax,
  528. numberOfPages: this.numberOfPages,
  529. bodyWidth: document.body.clientWidth,
  530. bodyHeight: document.body.clientHeight,
  531. gridWidth: this.$refs.grid.clientWidth,
  532. gridHeight: this.$refs.grid.clientHeight,
  533. })
  534. },
  535. rebuildGrid() {
  536. console.debug('isStripe: ', this.isStripe)
  537. console.debug('stripeOpen: ', this.stripeOpen)
  538. console.debug('previousGridWidth: ', this.gridWidth, 'previousGridHeight: ', this.gridHeight)
  539. console.debug('newGridWidth: ', this.gridWidth, 'newGridHeight: ', this.gridHeight)
  540. if (!this.isStripe || this.stripeOpen) {
  541. this.$nextTick(this.makeGrid)
  542. }
  543. },
  544. placeholderImage(i) {
  545. return generateFilePath('spreed', 'docs', 'screenshotplaceholders/placeholder-' + i + '.jpeg')
  546. },
  547. placeholderName(i) {
  548. switch (i) {
  549. case 0:
  550. return 'Sandra McKinney'
  551. case 1:
  552. return 'Chris Wurst'
  553. case 2:
  554. return 'Edeltraut Bobb'
  555. case 3:
  556. return 'Arthur Blitz'
  557. case 4:
  558. return 'Roeland Douma'
  559. case 5:
  560. return 'Vanessa Steg'
  561. case 6:
  562. return 'Emily Grant'
  563. case 7:
  564. return 'Tobias Kaminsky'
  565. case 8:
  566. return 'Adrian Ada'
  567. }
  568. },
  569. placeholderModel(i) {
  570. return {
  571. attributes: {
  572. audioAvailable: i === 1 || i === 2 || i === 4 || i === 5 || i === 6 || i === 7 || i === 8,
  573. audioEnabled: i === 8,
  574. videoAvailable: true,
  575. screen: false,
  576. currentVolume: 0.75,
  577. volumeThreshold: 0.75,
  578. localScreen: false,
  579. raisedHand: {
  580. state: i === 0 || i === 1 || i === 6,
  581. },
  582. },
  583. forceMute: () => {},
  584. on: () => {},
  585. off: () => {},
  586. getWebRtc: () => {
  587. return {
  588. connection: {
  589. getSendVideoIfAvailable: () => {},
  590. },
  591. }
  592. },
  593. }
  594. },
  595. placeholderSharedData() {
  596. return {
  597. videoEnabled: {
  598. isVideoEnabled() {
  599. return true
  600. },
  601. },
  602. remoteVideoBlocker: {
  603. isVideoEnabled() {
  604. return true
  605. },
  606. },
  607. screenVisible: false,
  608. }
  609. },
  610. // whenever the document is resized, re-set the 'clientWidth' variable
  611. handleResize(event) {
  612. // TODO: properly handle resizes when not on first page:
  613. // currently if the user is not on the 'first page', upon resize the
  614. // current position in the videos array is lost (first element
  615. // in the grid goes back to be first video)
  616. this.debounceMakeGrid()
  617. },
  618. // Find the right size if the grid in rows and columns (we already know
  619. // the size in px).
  620. makeGrid() {
  621. if (!this.$refs.grid) {
  622. return
  623. }
  624. this.gridWidth = this.$refs.grid.clientWidth
  625. this.gridHeight = this.$refs.grid.clientHeight
  626. // prevent making grid if no videos
  627. if (this.videos.length === 0) {
  628. this.columns = 0
  629. this.rows = 0
  630. return
  631. }
  632. if (this.devMode) {
  633. console.debug('Recreating grid: videos: ', this.videos.length, 'columns: ', this.columnsMax + ', rows: ' + this.rowsMax)
  634. }
  635. // We start by assigning the max possible value to our rows and columns
  636. // variables. These variables are kept in the data and represent how the
  637. // grid looks at any given moment. We do this based on `gridWidth`,
  638. // `gridHeight`, `minWidth` and `minHeight`. If the video is used in the
  639. // context of the promoted view, we se 1 row directly, and we remove 1 column
  640. // (one of the participants will be in the promoted video slot)
  641. this.columns = this.columnsMax
  642. this.rows = this.rowsMax
  643. // This values would already work if the grid is entirely populated with
  644. // video elements. However, if we'd have only a couple of videos to display
  645. // and a very big screen, we'd now have a lot of columns and rows, and our
  646. // video components would occupy only the first 2 slots and be too small.
  647. // To solve this, we shrink this 'max grid' we've just created to fit the
  648. // number of videos that we have.
  649. if (this.videosCap !== 0 && this.videosCount > this.videosCap) {
  650. this.shrinkGrid(this.videosCap)
  651. } else {
  652. this.shrinkGrid(this.videosCount)
  653. }
  654. },
  655. // Fine tune the number of rows and columns of the grid
  656. async shrinkGrid(numberOfVideos) {
  657. if (this.devMode) {
  658. console.debug('Shrinking grid: columns', this.columns + ', rows: ' + this.rows)
  659. }
  660. // No need to shrink more if 1 row and 1 column
  661. if (this.rows === 1 && this.columns === 1) {
  662. return
  663. }
  664. let currentColumns = this.columns
  665. let currentRows = this.rows
  666. let currentSlots = this.isStripe ? currentColumns * currentRows : currentColumns * currentRows - 1
  667. // Run this code only if we don't have an 'overflow' of videos. If the
  668. // videos are populating the grid, there's no point in shrinking it.
  669. while (numberOfVideos < currentSlots) {
  670. const previousColumns = currentColumns
  671. const previousRows = currentRows
  672. // Current video dimensions
  673. const videoWidth = this.gridWidth / currentColumns
  674. const videoHeight = this.gridHeight / currentRows
  675. // Hypothetical width/height with one column/row less than current
  676. const videoWidthWithOneColumnLess = this.gridWidth / (currentColumns - 1)
  677. const videoHeightWithOneRowLess = this.gridHeight / (currentRows - 1)
  678. // Hypothetical aspect ratio with one column/row less than current
  679. const aspectRatioWithOneColumnLess = videoWidthWithOneColumnLess / videoHeight
  680. const aspectRatioWithOneRowLess = videoWidth / videoHeightWithOneRowLess
  681. // Deltas with target aspect ratio
  682. const deltaAspectRatioWithOneColumnLess = Math.abs(aspectRatioWithOneColumnLess - this.targetAspectRatio)
  683. const deltaAspectRatioWithOneRowLess = Math.abs(aspectRatioWithOneRowLess - this.targetAspectRatio)
  684. if (this.devMode) {
  685. console.debug('deltaAspectRatioWithOneColumnLess: ', deltaAspectRatioWithOneColumnLess, 'deltaAspectRatioWithOneRowLess: ', deltaAspectRatioWithOneRowLess)
  686. }
  687. // Compare the deltas to find out whether we need to remove a column or a row
  688. if (deltaAspectRatioWithOneColumnLess <= deltaAspectRatioWithOneRowLess) {
  689. if (currentColumns >= 2) {
  690. currentColumns--
  691. }
  692. currentSlots = this.isStripe ? currentColumns * currentRows : currentColumns * currentRows - 1
  693. // Check that there are still enough slots available
  694. if (numberOfVideos > currentSlots) {
  695. // If not, revert the changes and break the loop
  696. currentColumns++
  697. break
  698. }
  699. } else {
  700. if (currentRows >= 2) {
  701. currentRows--
  702. }
  703. currentSlots = this.isStripe ? currentColumns * currentRows : currentColumns * currentRows - 1
  704. // Check that there are still enough slots available
  705. if (numberOfVideos > currentSlots) {
  706. // If not, revert the changes and break the loop
  707. currentRows++
  708. break
  709. }
  710. }
  711. if (previousColumns === currentColumns && previousRows === currentRows) {
  712. break
  713. }
  714. }
  715. this.columns = currentColumns
  716. this.rows = currentRows
  717. },
  718. // The last grid page is very likely not to have the same number of
  719. // elements as the previous pages so the grid needs to be tweaked
  720. // accordingly
  721. // makeGridForLastPage() {
  722. // this.columns = this.columnsMax
  723. // this.rows = this.rowsMax
  724. // // The displayed videos for the last page have already been set
  725. // // in `handleClickNext`
  726. // this.shrinkGrid(this.displayedVideos.length)
  727. // },
  728. handleClickNext() {
  729. this.currentPage++
  730. console.debug('handleclicknext, ', 'currentPage ', this.currentPage, 'slots ', this.slot, 'videos.length ', this.videos.length)
  731. },
  732. handleClickPrevious() {
  733. this.currentPage--
  734. console.debug('handleclickprevious, ', 'currentPage ', this.currentPage, 'slots ', this.slots, 'videos.length ', this.videos.length)
  735. },
  736. handleClickStripeCollapse() {
  737. this.$store.dispatch('setCallViewMode', { isStripeOpen: !this.stripeOpen })
  738. },
  739. handleMovement() {
  740. // TODO: debounce this
  741. this.setTimerForUiControls()
  742. },
  743. setTimerForUiControls() {
  744. if (this.showVideoOverlayTimer !== null) {
  745. clearTimeout(this.showVideoOverlayTimer)
  746. }
  747. this.showVideoOverlay = true
  748. this.showVideoOverlayTimer = setTimeout(() => {
  749. this.showVideoOverlay = false
  750. }, 5000)
  751. },
  752. handleClickVideo(event, peerId) {
  753. console.debug('selected-video peer id', peerId)
  754. this.$emit('select-video', peerId)
  755. },
  756. handleClickLocalVideo() {
  757. this.$emit('click-local-video')
  758. },
  759. isSelected(callParticipantModel) {
  760. return callParticipantModel.attributes.peerId === this.$store.getters.selectedVideoPeerId
  761. },
  762. },
  763. }
  764. </script>
  765. <style lang="scss" scoped>
  766. .grid-main-wrapper {
  767. position: relative;
  768. width: 100%;
  769. }
  770. .grid-main-wrapper.transparent {
  771. background: transparent;
  772. }
  773. .grid-main-wrapper.is-grid {
  774. height: 100%;
  775. }
  776. .wrapper {
  777. width: 100%;
  778. display: flex;
  779. position: relative;
  780. bottom: 0;
  781. left: 0;
  782. }
  783. .grid {
  784. display: grid;
  785. height: 100%;
  786. width: 100%;
  787. grid-row-gap: 8px;
  788. grid-column-gap: 8px;
  789. &.stripe {
  790. padding: 8px 8px 0 0;
  791. }
  792. }
  793. .empty-call-view {
  794. position: relative;
  795. padding: 16px;
  796. }
  797. .grid-wrapper {
  798. width: 100%;
  799. min-width: 0;
  800. position: relative;
  801. flex: 1 0 auto;
  802. }
  803. .stripe-wrapper {
  804. width: 100%;
  805. min-width: 0;
  806. position: relative;
  807. }
  808. .dev-mode-video {
  809. &:not(.dev-mode-screenshot) {
  810. border: 1px solid #00FF41;
  811. color: #00FF41;
  812. }
  813. position: relative;
  814. &--self {
  815. background-size: cover !important;
  816. border-radius: calc(var(--default-clickable-area) / 2);
  817. }
  818. img {
  819. object-fit: cover;
  820. height: 100%;
  821. width: 100%;
  822. border-radius: calc(var(--default-clickable-area) / 2);
  823. }
  824. .wrapper {
  825. position: absolute;
  826. }
  827. }
  828. .dev-mode__title {
  829. position: absolute;
  830. left: 44px;
  831. color: #00FF41;
  832. z-index: 100;
  833. line-height: 120px;
  834. font-weight: 900;
  835. font-size: 100px !important;
  836. top: 88px;
  837. opacity: 25%;
  838. }
  839. .dev-mode__data {
  840. font-family: monospace;
  841. position: fixed;
  842. color: #00FF41;
  843. left: 20px;
  844. bottom: 50%;
  845. padding: 20px;
  846. background: rgba(0, 0, 0, 0.8);
  847. border: 1px solid #00FF41;
  848. width: 212px;
  849. font-size: 12px;
  850. z-index: 999999999999999;
  851. & p {
  852. text-overflow: ellipsis;
  853. overflow: hidden;
  854. white-space: nowrap;
  855. }
  856. }
  857. .video:last-child {
  858. grid-column-end: -1;
  859. }
  860. .grid-navigation {
  861. .grid-wrapper & {
  862. position: absolute;
  863. top: calc(50% - var(--default-clickable-area) / 2);
  864. &__previous {
  865. left: 8px;
  866. }
  867. &__next {
  868. right: 8px;
  869. }
  870. }
  871. .stripe-wrapper & {
  872. position: absolute;
  873. top: 16px;
  874. &__previous {
  875. left: 8px;
  876. }
  877. &__next {
  878. right: 16px;
  879. }
  880. }
  881. }
  882. .pages-indicator {
  883. position: absolute;
  884. right: 50%;
  885. top: 4px;
  886. display: flex;
  887. background-color: var(--color-background-hover);
  888. height: 44px;
  889. padding: 0 22px;
  890. border-radius: 22px;
  891. &__dot {
  892. width: 8px;
  893. height: 8px;
  894. margin: auto 4px;
  895. border-radius: 4px;
  896. background-color: white;
  897. opacity: 80%;
  898. box-shadow: 0 0 4px black;
  899. &--active {
  900. opacity: 100%;
  901. }
  902. }
  903. }
  904. .stripe--collapse {
  905. position: absolute !important;
  906. top: calc(-1 * var(--default-clickable-area));
  907. right: 0;
  908. }
  909. .stripe--collapse,
  910. .grid-navigation {
  911. z-index: 2;
  912. opacity: .7;
  913. #call-container:hover & {
  914. background-color: rgba(0, 0, 0, 0.1) !important;
  915. &:hover,
  916. &:focus {
  917. opacity: 1;
  918. background-color: rgba(0, 0, 0, 0.2) !important;
  919. }
  920. }
  921. &:active {
  922. /* needed again to override default active button style */
  923. background: none;
  924. }
  925. }
  926. </style>