Browse Source

feat(call): add bottom bar in call and move controls there

Signed-off-by: Dorra Jaouad <dorra.jaoued7@gmail.com>
pull/15583/head
Dorra Jaouad 4 months ago
parent
commit
3ebf2c4c73
  1. 181
      src/components/CallView/BottomBar.vue
  2. 13
      src/components/CallView/CallView.vue
  3. 31
      src/components/TopBar/TopBar.vue
  4. 140
      src/components/TopBar/TopBarMenu.vue
  5. 3
      src/env.d.ts
  6. 2
      src/utils/webrtc/models/LocalMediaModel.js

181
src/components/CallView/BottomBar.vue

@ -0,0 +1,181 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { computed, watch } from 'vue'
import { useStore } from 'vuex'
import NcButton from '@nextcloud/vue/components/NcButton'
import IconHandBackLeft from 'vue-material-design-icons/HandBackLeft.vue'
import CallButton from '../TopBar/CallButton.vue'
import ReactionMenu from '../TopBar/ReactionMenu.vue'
import TopBarMediaControls from '../TopBar/TopBarMediaControls.vue'
import { useGetToken } from '../../composables/useGetToken.ts'
import { CONVERSATION, PARTICIPANT } from '../../constants.ts'
import { getTalkConfig } from '../../services/CapabilitiesManager.ts'
import { useActorStore } from '../../stores/actor.ts'
import { useBreakoutRoomsStore } from '../../stores/breakoutRooms.ts'
import { localCallParticipantModel, localMediaModel } from '../../utils/webrtc/index.js'
const { isSidebar = false } = defineProps<{
isSidebar: boolean
}>()
const AUTO_LOWER_HAND_THRESHOLD = 3000
const disableKeyboardShortcuts = OCP.Accessibility.disableKeyboardShortcuts()
const store = useStore()
const token = useGetToken()
const actorStore = useActorStore()
const breakoutRoomsStore = useBreakoutRoomsStore()
const conversation = computed(() => {
return store.getters.conversation(token.value) || store.getters.dummyConversation
})
const supportedReactions = computed(() => getTalkConfig(token.value, 'call', 'supported-reactions') || [])
const hasReactionSupport = computed(() => supportedReactions.value && supportedReactions.value.length > 0)
const canModerate = computed(() => [PARTICIPANT.TYPE.OWNER, PARTICIPANT.TYPE.MODERATOR, PARTICIPANT.TYPE.GUEST_MODERATOR]
.includes(conversation.value.participantType))
const isHandRaised = computed(() => localMediaModel.attributes.raisedHand.state === true)
const raiseHandButtonLabel = computed(() => {
if (!isHandRaised.value) {
return disableKeyboardShortcuts
? t('spreed', 'Raise hand')
: t('spreed', 'Raise hand (R)')
}
return disableKeyboardShortcuts
? t('spreed', 'Lower hand')
: t('spreed', 'Lower hand (R)')
})
const userIsInBreakoutRoomAndInCall = computed(() => conversation.value.objectType === CONVERSATION.OBJECT_TYPE.BREAKOUT_ROOM)
let lowerHandDelay = AUTO_LOWER_HAND_THRESHOLD
let speakingTimestamp: number | null = null
let lowerHandTimeout: ReturnType<typeof setTimeout> | null = null
// Hand raising functionality
/**
*
*/
function toggleHandRaised() {
const newState = !isHandRaised.value
localMediaModel.toggleHandRaised(newState)
store.dispatch('setParticipantHandRaised', {
sessionId: actorStore.sessionId,
raisedHand: localMediaModel.attributes.raisedHand,
})
// Handle breakout room assistance requests
if (userIsInBreakoutRoomAndInCall.value && !canModerate.value) {
const hasRaisedHands = Object.keys(store.getters.participantRaisedHandList)
.filter((sessionId) => sessionId !== actorStore.sessionId)
.length !== 0
if (hasRaisedHands) {
return // Assistance is already requested by someone in the room
}
const hasAssistanceRequested = conversation.value.breakoutRoomStatus === CONVERSATION.BREAKOUT_ROOM_STATUS.STATUS_ASSISTANCE_REQUESTED
if (newState && !hasAssistanceRequested) {
breakoutRoomsStore.requestAssistance(token.value)
} else if (!newState && hasAssistanceRequested) {
breakoutRoomsStore.dismissRequestAssistance(token.value)
}
}
}
// Auto-lower hand when speaking
watch(() => localMediaModel.attributes.speaking, (speaking) => {
if (lowerHandTimeout !== null && !speaking) {
lowerHandDelay = Math.max(0, lowerHandDelay - (Date.now() - speakingTimestamp!))
clearTimeout(lowerHandTimeout)
lowerHandTimeout = null
return
}
// User is not speaking OR timeout is already running OR hand is not raised
if (!speaking || lowerHandTimeout !== null || !isHandRaised.value) {
return
}
speakingTimestamp = Date.now()
lowerHandTimeout = setTimeout(() => {
lowerHandTimeout = null
speakingTimestamp = null
lowerHandDelay = AUTO_LOWER_HAND_THRESHOLD
if (isHandRaised.value) {
toggleHandRaised()
}
}, lowerHandDelay)
})
// Keyboard shortcuts
useHotKey('r', toggleHandRaised)
</script>
<template>
<div class="bottom-bar" data-theme-dark>
<!-- fullscreen and grid view buttons -->
<div class="bottom-bar-call-controls">
<!-- Local media controls -->
<TopBarMediaControls
:token="token"
:model="localMediaModel"
:is-sidebar="isSidebar"
:local-call-participant-model="localCallParticipantModel" />
<!-- Reactions menu -->
<ReactionMenu v-if="hasReactionSupport"
:token="token"
:supported-reactions="supportedReactions"
:local-call-participant-model="localCallParticipantModel" />
<NcButton
:title="raiseHandButtonLabel"
:aria-label="raiseHandButtonLabel"
:variant="isHandRaised ? 'secondary' : 'tertiary'"
@click="toggleHandRaised">
<!-- The following icon is much bigger than all the others
so we reduce its size -->
<template #icon>
<IconHandBackLeft :size="16" />
</template>
</NcButton>
</div>
<CallButton shrink-on-mobile
:hide-text="isSidebar"
:is-screensharing="!!localMediaModel.attributes.localScreen" />
</div>
</template>
<style lang="scss" scoped>
.bottom-bar {
position: absolute;
bottom: 0;
inset-inline: 0;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 calc(var(--default-grid-baseline) * 2);
z-index: 10;
}
.bottom-bar-call-controls {
display: flex;
align-items: center;
flex-direction: row;
gap: var(--default-grid-baseline);
}
</style>

13
src/components/CallView/CallView.vue

@ -127,6 +127,8 @@
:is-sidebar="isSidebar"
@click-video="handleClickLocalVideo" />
</div>
<BottomBar :is-sidebar="isSidebar" />
</template>
</div>
</template>
@ -138,6 +140,7 @@ import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import debounce from 'debounce'
import { provide, ref } from 'vue'
import BottomBar from './BottomBar.vue'
import Grid from './Grid/Grid.vue'
import EmptyCallView from './shared/EmptyCallView.vue'
import LocalVideo from './shared/LocalVideo.vue'
@ -167,6 +170,7 @@ export default {
name: 'CallView',
components: {
BottomBar,
EmptyCallView,
Grid,
LocalVideo,
@ -833,6 +837,8 @@ export default {
// Default value has changed since v29.0.4: 'blur(25px)' => 'none'
backdrop-filter: var(--filter-background-blur);
--grid-gap: calc(var(--default-grid-baseline) * 2);
--top-bar-height: 51px;
--bottom-bar-height: 56px;
&.call-container__blurred {
backdrop-filter: blur(25px);
@ -845,8 +851,9 @@ export default {
#videos {
position: absolute;
width: 100%;
height: calc(100% - 51px);
top: 51px; // TopBar height
height: calc(100% - (var(--top-bar-height) + var(--bottom-bar-height)));
top: var(--top-bar-height);
bottom: var(--bottom-bar-height);
overflow: hidden;
display: flex;
justify-content: space-around;
@ -894,6 +901,8 @@ export default {
&--sidebar {
width: 150px;
height: 100px;
bottom: var(--bottom-bar-height);
margin: var(--default-grid-baseline);
}
}

31
src/components/TopBar/TopBar.vue

@ -142,27 +142,13 @@
<ExtendOneToOneDialog v-else-if="!isSidebar && canExtendOneToOneConversation"
:token="token" />
<!-- Reactions menu -->
<ReactionMenu v-if="isInCall && hasReactionSupport"
:token="token"
:supported-reactions="supportedReactions"
:local-call-participant-model="localCallParticipantModel" />
<!-- Local media controls -->
<TopBarMediaControls v-if="isInCall"
:token="token"
:model="localMediaModel"
:is-sidebar="isSidebar"
:local-call-participant-model="localCallParticipantModel" />
<!-- TopBar menu -->
<TopBarMenu :token="token"
:show-actions="!isSidebar"
:is-sidebar="isSidebar"
:model="localMediaModel"
@open-breakout-rooms-editor="showBreakoutRoomsEditor = true" />
<CallButton shrink-on-mobile :hide-text="isSidebar" :is-screensharing="!!localMediaModel.attributes.localScreen" />
<CallButton v-if="!isInCall" shrink-on-mobile />
<!-- Breakout rooms editor -->
<BreakoutRoomsEditor v-if="showBreakoutRoomsEditor"
@ -194,9 +180,7 @@ import ConversationIcon from '../ConversationIcon.vue'
import ExtendOneToOneDialog from '../ExtendOneToOneDialog.vue'
import CallButton from './CallButton.vue'
import CallTime from './CallTime.vue'
import ReactionMenu from './ReactionMenu.vue'
import TasksCounter from './TasksCounter.vue'
import TopBarMediaControls from './TopBarMediaControls.vue'
import TopBarMenu from './TopBarMenu.vue'
import { useGetThreadId } from '../../composables/useGetThreadId.ts'
import { useGetToken } from '../../composables/useGetToken.ts'
@ -209,7 +193,6 @@ import { useSidebarStore } from '../../stores/sidebar.ts'
import { getDisplayNameWithFallback } from '../../utils/getDisplayName.ts'
import { parseToSimpleMessage } from '../../utils/textParse.ts'
import { getStatusMessage } from '../../utils/userStatus.ts'
import { localCallParticipantModel, localMediaModel } from '../../utils/webrtc/index.js'
const canStartConversations = getTalkConfig('local', 'conversations', 'can-create')
const supportConversationCreationAll = hasTalkFeature('local', 'conversation-creation-all')
@ -240,7 +223,6 @@ export default {
CallTime,
ConversationIcon,
ExtendOneToOneDialog,
TopBarMediaControls,
NcActionButton,
NcActions,
NcButton,
@ -248,7 +230,6 @@ export default {
NcRichText,
TopBarMenu,
TasksCounter,
ReactionMenu,
// Icons
IconAccountMultipleOutline,
IconAccountMultiplePlusOutline,
@ -275,8 +256,6 @@ export default {
return {
AVATAR,
PARTICIPANT,
localCallParticipantModel,
localMediaModel,
groupwareStore: useGroupwareStore(),
sidebarStore: useSidebarStore(),
actorStore: useActorStore(),
@ -371,14 +350,6 @@ export default {
return n('spreed', '%n participant in call', '%n participants in call', this.$store.getters.participantsInCall(this.token))
},
supportedReactions() {
return getTalkConfig(this.token, 'call', 'supported-reactions')
},
hasReactionSupport() {
return this.isInCall && this.supportedReactions?.length > 0
},
showCalendarEvents() {
return this.getUserId && !this.isInCall && !this.isSidebar
&& this.conversation.type !== CONVERSATION.TYPE.NOTE_TO_SELF

140
src/components/TopBar/TopBarMenu.vue

@ -5,20 +5,8 @@
<template>
<div class="top-bar-menu">
<TransitionExpand v-if="isInCall" :show="isHandRaised" direction="horizontal">
<NcButton :title="raiseHandButtonLabel"
:aria-label="raiseHandButtonLabel"
variant="tertiary"
@click.stop="toggleHandRaised">
<template #icon>
<!-- The following icon is much bigger than all the others
so we reduce its size -->
<IconHandBackLeft :size="18" />
</template>
</NcButton>
</TransitionExpand>
<NcActions v-if="!isSidebar"
force-menu
:title="t('spreed', 'Conversation actions')"
:aria-label="t('spreed', 'Conversation actions')"
variant="tertiary">
@ -28,17 +16,6 @@
</template>
<template v-if="showActions && isInCall">
<!-- Raise hand -->
<NcActionButton close-after-click
@click="toggleHandRaised">
<!-- The following icon is much bigger than all the others
so we reduce its size -->
<template #icon>
<IconHandBackLeft :size="16" />
</template>
{{ raiseHandButtonLabel }}
</NcActionButton>
<!-- Moderator actions -->
<template v-if="!isOneToOneConversation && canFullModerate">
<NcActionButton close-after-click
@ -159,7 +136,6 @@ import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionLink from '@nextcloud/vue/components/NcActionLink'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconCog from 'vue-material-design-icons/Cog.vue'
@ -168,14 +144,12 @@ import IconDotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue'
import IconFile from 'vue-material-design-icons/File.vue'
import IconFullscreen from 'vue-material-design-icons/Fullscreen.vue'
import IconFullscreenExit from 'vue-material-design-icons/FullscreenExit.vue'
import IconHandBackLeft from 'vue-material-design-icons/HandBackLeft.vue'
import IconMicrophoneOff from 'vue-material-design-icons/MicrophoneOff.vue'
import IconRecordCircle from 'vue-material-design-icons/RecordCircle.vue'
import IconStop from 'vue-material-design-icons/Stop.vue'
import IconVideo from 'vue-material-design-icons/Video.vue'
import IconViewGallery from 'vue-material-design-icons/ViewGallery.vue'
import IconViewGrid from 'vue-material-design-icons/ViewGrid.vue'
import TransitionExpand from '../MediaSettings/TransitionExpand.vue'
import IconFileDownload from '../../../img/material-icons/file-download.svg?raw'
import {
disableFullscreen,
@ -185,25 +159,18 @@ import {
import { useIsInCall } from '../../composables/useIsInCall.js'
import { CALL, CONVERSATION, PARTICIPANT } from '../../constants.ts'
import { getTalkConfig, hasTalkFeature } from '../../services/CapabilitiesManager.ts'
import { useActorStore } from '../../stores/actor.ts'
import { useBreakoutRoomsStore } from '../../stores/breakoutRooms.ts'
import { useCallViewStore } from '../../stores/callView.ts'
import { generateAbsoluteUrl } from '../../utils/handleUrl.ts'
import { callParticipantCollection } from '../../utils/webrtc/index.js'
const AUTO_LOWER_HAND_THRESHOLD = 3000
const disableKeyboardShortcuts = OCP.Accessibility.disableKeyboardShortcuts()
export default {
name: 'TopBarMenu',
components: {
TransitionExpand,
NcActionButton,
NcActionLink,
NcActionSeparator,
NcActions,
NcButton,
NcLoadingIcon,
NcIconSvgWrapper,
// Icons
@ -213,7 +180,6 @@ export default {
IconFile,
IconFullscreen,
IconFullscreenExit,
IconHandBackLeft,
IconMicrophoneOff,
IconRecordCircle,
IconStop,
@ -231,14 +197,6 @@ export default {
required: true,
},
/**
* The local media model
*/
model: {
type: Object,
required: true,
},
showActions: {
type: Boolean,
default: true,
@ -260,18 +218,13 @@ export default {
IconFileDownload,
isInCall: useIsInCall(),
isFullscreen: useDocumentFullscreen(),
breakoutRoomsStore: useBreakoutRoomsStore(),
callViewStore: useCallViewStore(),
actorStore: useActorStore(),
}
},
data() {
return {
boundaryElement: document.querySelector('.main-view'),
lowerHandTimeout: null,
speakingTimestamp: null,
lowerHandDelay: AUTO_LOWER_HAND_THRESHOLD,
}
},
@ -311,29 +264,6 @@ export default {
return this.callViewStore.isGrid
},
isVirtualBackgroundAvailable() {
return this.model.attributes.virtualBackgroundAvailable
},
isVirtualBackgroundEnabled() {
return this.model.attributes.virtualBackgroundEnabled
},
isHandRaised() {
return this.model.attributes.raisedHand?.state === true
},
raiseHandButtonLabel() {
if (!this.isHandRaised) {
return disableKeyboardShortcuts
? t('spreed', 'Raise hand')
: t('spreed', 'Raise hand (R)')
}
return disableKeyboardShortcuts
? t('spreed', 'Lower hand')
: t('spreed', 'Lower hand (R)')
},
participantType() {
return this.conversation.participantType
},
@ -373,13 +303,6 @@ export default {
|| this.conversation.callRecording === CALL.RECORDING.AUDIO
},
// True if current conversation is a breakout room and the breakout room has started
// And a call is in progress
userIsInBreakoutRoomAndInCall() {
return this.conversation.objectType === CONVERSATION.OBJECT_TYPE.BREAKOUT_ROOM
&& this.isInCall
},
showCallLayoutSwitch() {
return !this.callViewStore.isEmptyCallView
},
@ -393,37 +316,7 @@ export default {
},
},
watch: {
'model.attributes.speaking'(speaking) {
// user stops speaking in lowerHandTimeout
if (this.lowerHandTimeout !== null && !speaking) {
this.lowerHandDelay = Math.max(0, this.lowerHandDelay - (Date.now() - this.speakingTimestamp))
clearTimeout(this.lowerHandTimeout)
this.lowerHandTimeout = null
return
}
// user is not speaking OR timeout is already running OR hand is not raised
if (!speaking || this.lowerHandTimeout !== null || !this.isHandRaised) {
return
}
this.speakingTimestamp = Date.now()
this.lowerHandTimeout = setTimeout(() => {
this.lowerHandTimeout = null
this.speakingTimestamp = null
this.lowerHandDelay = AUTO_LOWER_HAND_THRESHOLD
if (this.isHandRaised) {
this.toggleHandRaised()
}
}, this.lowerHandDelay)
},
},
created() {
useHotKey('r', this.toggleHandRaised)
useHotKey('f', this.toggleFullscreen)
},
@ -467,37 +360,6 @@ export default {
emit('talk:media-settings:show')
},
toggleHandRaised() {
if (!this.isInCall) {
return
}
const newState = !this.isHandRaised
this.model.toggleHandRaised(newState)
this.$store.dispatch(
'setParticipantHandRaised',
{
sessionId: this.actorStore.sessionId,
raisedHand: this.model.attributes.raisedHand,
},
)
// If the current conversation is a break-out room and the user is not a moderator,
// also send request for assistance to the moderators.
if (this.userIsInBreakoutRoomAndInCall && !this.canModerate) {
const hasRaisedHands = Object.keys(this.$store.getters.participantRaisedHandList)
.filter((sessionId) => sessionId !== this.actorStore.sessionId)
.length !== 0
if (hasRaisedHands) {
return // Assistance is already requested by someone in the room
}
const hasAssistanceRequested = this.conversation.breakoutRoomStatus === CONVERSATION.BREAKOUT_ROOM_STATUS.STATUS_ASSISTANCE_REQUESTED
if (newState && !hasAssistanceRequested) {
this.breakoutRoomsStore.requestAssistance(this.token)
} else if (!newState && hasAssistanceRequested) {
this.breakoutRoomsStore.dismissRequestAssistance(this.token)
}
}
},
openConversationSettings() {
emit('show-conversation-settings', { token: this.token })
},

3
src/env.d.ts

@ -20,6 +20,9 @@ declare global {
AppConfig: {
setValue: (app: string, key: string, value: string | number | boolean, options?: { success?: () => void, error?: () => void }) => void
}
Accessibility: {
disableKeyboardShortcuts: () => boolean
}
}
const OC: {

2
src/utils/webrtc/models/LocalMediaModel.js

@ -36,7 +36,7 @@ export default function LocalMediaModel() {
virtualBackgroundUrl: null,
localScreen: null,
token: '',
raisedHand: false,
raisedHand: { state: false, timestamp: Date.now() },
})
this._handleLocalStreamRequestedBound = this._handleLocalStreamRequested.bind(this)

Loading…
Cancel
Save