UPSTREAM BASELINE: nextcloud/spreed v22.0.12 (без изменений)
Type checking / changes (push) Has been cancelled
Type checking / test (push) Has been cancelled
Type checking / typescript-summary (push) Has been cancelled
Node tests / changes (push) Has been cancelled
Node tests / test (push) Has been cancelled
Node tests / test-summary (push) Has been cancelled

Источник: https://github.com/nextcloud/spreed/archive/refs/tags/v22.0.12.tar.gz
С этого коммита ветка официального Nextcloud Talk отрезана (решение владельца 2026-07-06).
Все дальнейшие изменения — только наши; версии релизов: 22.0.12-f7.N.
This commit is contained in:
2026-07-06 14:07:50 +00:00
commit 01acfa3b40
1716 changed files with 613013 additions and 0 deletions
+482
View File
@@ -0,0 +1,482 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import {
showError,
} from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
import { useResizeObserver } from '@vueuse/core'
import debounce from 'debounce'
import { computed, onMounted, onUnmounted, ref, toValue, useTemplateRef, watch } from 'vue'
import { useStore } from 'vuex'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
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' // Filled for better indication
import IconHandBackLeftOutline from 'vue-material-design-icons/HandBackLeftOutline.vue'
import IconSubtitles from 'vue-material-design-icons/Subtitles.vue'
import IconSubtitlesOutline from 'vue-material-design-icons/SubtitlesOutline.vue'
import IconViewGalleryOutline from 'vue-material-design-icons/ViewGalleryOutline.vue'
import IconViewGridOutline from 'vue-material-design-icons/ViewGridOutline.vue'
import CallButton from '../TopBar/CallButton.vue'
import ReactionMenu from '../TopBar/ReactionMenu.vue'
import TopBarMediaControls from '../TopBar/TopBarMediaControls.vue'
import {
toggleFullscreen,
useDocumentFullscreen,
} from '../../composables/useDocumentFullscreen.ts'
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 { useCallViewStore } from '../../stores/callView.ts'
import { useLiveTranscriptionStore } from '../../stores/liveTranscription.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 isFullscreen = !isSidebar && useDocumentFullscreen()
const callViewStore = useCallViewStore()
const liveTranscriptionStore = useLiveTranscriptionStore()
const isLiveTranscriptionLoading = ref(false)
const bottomBar = useTemplateRef('bottomBar')
const callButtonWithActions = useTemplateRef('callButtonWithActions')
const isMobile = useIsMobile()
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 isLiveTranscriptionSupported = computed(() => getTalkConfig(token.value, 'call', 'live-transcription') || false)
const liveTranscriptionButtonLabel = computed(() => {
if (!callViewStore.isLiveTranscriptionEnabled) {
return t('spreed', 'Enable live transcription')
}
return t('spreed', 'Disable live transcription')
})
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 fullscreenLabel = computed(() => {
return toValue(isFullscreen)
? t('spreed', 'Exit full screen (F)')
: t('spreed', 'Full screen (F)')
})
const changeViewLabel = computed(() => {
return isGrid.value
? t('spreed', 'Speaker view')
: t('spreed', 'Grid view')
})
const showCallLayoutSwitch = computed(() => !callViewStore.isEmptyCallView)
const isGrid = computed(() => callViewStore.isGrid)
const userIsInBreakoutRoomAndInCall = computed(() => conversation.value.objectType === CONVERSATION.OBJECT_TYPE.BREAKOUT_ROOM)
const COLLAPSIBLE_BUTTONS = ['virtualBackground', 'liveTranscription', 'raiseHand', 'callLayout', 'fullscreen'] as const
type CollapsibleButtons = Record<typeof COLLAPSIBLE_BUTTONS[number], boolean>
const isActionAvailableMask = computed<CollapsibleButtons>(() => ({
fullscreen: !isSidebar,
callLayout: showCallLayoutSwitch.value,
raiseHand: true,
liveTranscription: isLiveTranscriptionSupported.value,
virtualBackground: !isSidebar,
}))
const hidingList = ref<CollapsibleButtons>({ ...isActionAvailableMask.value })
const hasHiddenItems = computed(() => Object.values(hidingList.value).some(Boolean))
const BUTTON_WITH_GAP_WIDTH = 38 // var(--default-clickable-area) + var--default-grid-baseline)
const MINIMAL_MEDIA_CONTROLS_WIDTH = 236 // Minimal width to show media controls properly
/**
* Adjust the layout of the bottom bar based on the available width.
*
*/
function adjustLayout() {
if (!bottomBar.value) {
return
}
// 20px is for side paddings of the bottom bar, 8px is for the gap between the call button and the options
const availableWidth = bottomBar.value.clientWidth - callButtonWithActions.value!.clientWidth - 28
if (availableWidth <= MINIMAL_MEDIA_CONTROLS_WIDTH) {
// Not enough space to show anything, hide all buttons
COLLAPSIBLE_BUTTONS.forEach((button) => {
hidingList.value[button as keyof typeof hidingList.value] = true
})
return
}
const buttonsToRender = Math.floor((availableWidth - MINIMAL_MEDIA_CONTROLS_WIDTH) / BUTTON_WITH_GAP_WIDTH)
// make the first n buttons visible, hide the rest
const buttonsToCollapse = COLLAPSIBLE_BUTTONS.filter((button) => isActionAvailableMask.value[button])
buttonsToCollapse.forEach((button, index) => {
hidingList.value[button] = index >= buttonsToRender
})
}
const debounceAdjustLayout = debounce(adjustLayout, 200)
useResizeObserver(bottomBar, () => {
debounceAdjustLayout()
})
onMounted(() => {
adjustLayout()
})
onUnmounted(() => {
debounceAdjustLayout.clear?.()
})
/**
* Toggle live transcriptions.
*/
async function toggleLiveTranscription() {
if (isLiveTranscriptionLoading.value) {
return
}
isLiveTranscriptionLoading.value = true
if (!callViewStore.isLiveTranscriptionEnabled) {
await enableLiveTranscription()
} else {
await disableLiveTranscription()
}
isLiveTranscriptionLoading.value = false
}
/**
* Enable live transcriptions.
*/
async function enableLiveTranscription() {
// Strictly speaking it would be the responsibility of the components using
// the language metadata to ensure that it is loaded before using it, but
// for simplicity it is done here and enabling the live transcription is
// tied to having said metadata.
try {
await liveTranscriptionStore.loadLiveTranscriptionLanguages()
} catch (exception) {
showError(t('spreed', 'Error when trying to load the available live transcription languages'))
return
}
try {
await callViewStore.enableLiveTranscription(token.value)
} catch (error) {
showError(t('spreed', 'Failed to enable live transcription'))
}
}
/**
* Disable live transcriptions.
*/
async function disableLiveTranscription() {
try {
await callViewStore.disableLiveTranscription(token.value)
} catch (error) {
// Not being able to disable the live transcription is not really
// relevant for the user, as the transcript will be no longer visible in
// the UI anyway, so no error is shown in that case.
}
}
let lowerHandDelay = AUTO_LOWER_HAND_THRESHOLD
let speakingTimestamp: number | null = null
let lowerHandTimeout: ReturnType<typeof setTimeout> | null = null
// Hand raising functionality
/**
* Toggle the hand raised state for the local media model and update the store.
* If the user is in a breakout room, it also handles the request for assistance.
*/
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)
})
/**
* Switches the call view mode between grid and speaker view.
*/
function changeView() {
callViewStore.setCallViewMode({ token: token.value, isGrid: !isGrid.value, clearLast: false })
callViewStore.setSelectedVideoPeerId(null)
}
// Keyboard shortcuts
useHotKey('r', toggleHandRaised)
</script>
<template>
<div ref="bottomBar" class="bottom-bar" data-theme-dark>
<div v-if="!isSidebar" class="bottom-bar-call-controls">
<!-- Fullscreen -->
<NcButton
v-if="!hidingList.fullscreen"
:aria-label="fullscreenLabel"
:variant="isFullscreen ? 'secondary' : 'tertiary'"
:title="fullscreenLabel"
@click="toggleFullscreen">
<template #icon>
<IconFullscreen v-if="!isFullscreen" :size="20" />
<IconFullscreenExit v-else :size="20" />
</template>
</NcButton>
<!-- Call layout switcher -->
<NcButton
v-if="showCallLayoutSwitch && !hidingList.callLayout"
variant="tertiary"
:aria-label="changeViewLabel"
:title="changeViewLabel"
@click="changeView">
<template #icon>
<IconViewGridOutline v-if="!isGrid" :size="20" />
<IconViewGalleryOutline v-else :size="20" />
</template>
</NcButton>
</div>
<div class="bottom-bar-call-controls">
<!-- Local media controls -->
<TopBarMediaControls
:token="token"
:model="localMediaModel"
:isSidebar="isSidebar"
:hideVirtualBackgroundShortcut="hidingList.virtualBackground"
:localCallParticipantModel="localCallParticipantModel" />
<!-- Reactions menu -->
<ReactionMenu
v-if="hasReactionSupport"
:token="token"
:supportedReactions="supportedReactions"
:localCallParticipantModel="localCallParticipantModel" />
<NcButton
v-if="isLiveTranscriptionSupported && !hidingList.liveTranscription"
:title="liveTranscriptionButtonLabel"
:aria-label="liveTranscriptionButtonLabel"
:variant="callViewStore.isLiveTranscriptionEnabled ? 'secondary' : 'tertiary'"
:disabled="isLiveTranscriptionLoading"
@click="toggleLiveTranscription">
<template #icon>
<NcLoadingIcon
v-if="isLiveTranscriptionLoading"
:size="20" />
<IconSubtitles
v-else-if="callViewStore.isLiveTranscriptionEnabled"
:size="20" />
<IconSubtitlesOutline
v-else
:size="20" />
</template>
</NcButton>
<NcButton
v-if="!isSidebar && !hidingList.raiseHand"
: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 v-if="isHandRaised" :size="18" />
<IconHandBackLeftOutline v-else :size="18" />
</template>
</NcButton>
</div>
<div ref="callButtonWithActions" class="bottom-bar-options call-options">
<!-- Collapsed actions -->
<NcActions v-if="hasHiddenItems" forceMenu>
<!-- Fullscreen -->
<NcActionButton
v-if="!isSidebar && hidingList.fullscreen"
:aria-label="fullscreenLabel"
:variant="isFullscreen ? 'secondary' : 'tertiary'"
:title="fullscreenLabel"
@click="toggleFullscreen">
<template #icon>
<IconFullscreen v-if="!isFullscreen" :size="20" />
<IconFullscreenExit v-else :size="20" />
</template>
{{ fullscreenLabel }}
</NcActionButton>
<!-- Call layout switcher -->
<NcActionButton
v-if="hidingList.callLayout && showCallLayoutSwitch"
variant="tertiary"
:aria-label="changeViewLabel"
:title="changeViewLabel"
@click="changeView">
<template #icon>
<IconViewGridOutline v-if="!isGrid" :size="20" />
<IconViewGalleryOutline v-else :size="20" />
</template>
{{ changeViewLabel }}
</NcActionButton>
<NcActionButton
v-if="isLiveTranscriptionSupported && hidingList.liveTranscription"
:title="liveTranscriptionButtonLabel"
:aria-label="liveTranscriptionButtonLabel"
:variant="callViewStore.isLiveTranscriptionEnabled ? 'secondary' : 'tertiary'"
:disabled="isLiveTranscriptionLoading"
@click="toggleLiveTranscription">
<template #icon>
<NcLoadingIcon
v-if="isLiveTranscriptionLoading"
:size="20" />
<IconSubtitles
v-else-if="callViewStore.isLiveTranscriptionEnabled"
:size="20" />
<IconSubtitlesOutline
v-else
:size="20" />
</template>
{{ liveTranscriptionButtonLabel }}
</NcActionButton>
<NcActionButton
v-if="!isSidebar && hidingList.raiseHand"
: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 v-if="isHandRaised" :size="18" />
<IconHandBackLeftOutline v-else :size="18" />
</template>
{{ raiseHandButtonLabel }}
</NcActionButton>
</NcActions>
<CallButton
class="call-button"
:hideText="isSidebar || isMobile"
:isScreensharing="!!localMediaModel.attributes.localScreen" />
</div>
</div>
</template>
<style lang="scss" scoped>
.bottom-bar {
position: absolute;
bottom: 0;
inset-inline: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--wrapper-padding);
z-index: 10;
overflow: hidden;
:deep(.button-vue--tertiary) {
background-color: var(--color-primary-light);
}
}
.bottom-bar-call-controls {
display: flex;
align-items: center;
flex-direction: row;
gap: var(--default-grid-baseline);
}
.bottom-bar-call-controls:not(:has(*)) {
display: none
}
.call-options {
display: flex;
align-items: center;
flex-direction: row;
gap: 4px;
}
</style>
@@ -0,0 +1,88 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import { useStore } from 'vuex'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcModal from '@nextcloud/vue/components/NcModal'
import IconAlertOctagonOutline from 'vue-material-design-icons/AlertOctagonOutline.vue'
import IconRefresh from 'vue-material-design-icons/Refresh.vue'
import { messagePleaseTryToReload } from '../../utils/talkDesktopUtils.ts'
const props = defineProps({
token: {
type: String,
required: true,
},
})
const store = useStore()
const STATUS_ERRORS = {
400: t('spreed', 'Recording consent is required'),
403: t('spreed', 'This conversation is read-only'),
404: t('spreed', 'Conversation not found or not joined'),
412: t('spreed', "Lobby is still active and you're not a moderator"),
} as const
const connectionFailed = computed(() => store.getters.connectionFailed(props.token))
const connectionFailedDialogId = `connection-failed-${props.token}`
const message = computed(() => {
if (!connectionFailed.value) {
return ''
}
const statusCode: keyof typeof STATUS_ERRORS | undefined = connectionFailed.value.meta?.statuscode
if (statusCode && STATUS_ERRORS[statusCode]) {
return STATUS_ERRORS[statusCode]
}
if (connectionFailed.value?.data?.error) {
return connectionFailed.value.data.error
}
return messagePleaseTryToReload
})
/**
* Reset error status in the store
*/
function clearConnectionFailedError() {
store.dispatch('clearConnectionFailed', props.token)
}
/**
* Reload the page to get a valid room object and HPB settings
*/
function reloadApp() {
window.location.reload()
}
</script>
<template>
<NcModal
:labelId="connectionFailedDialogId"
@close="clearConnectionFailedError">
<NcEmptyContent
:name="t('spreed', 'Connection failed')"
:description="message">
<template #icon>
<IconAlertOctagonOutline />
</template>
<template #action>
<NcButton
variant="primary"
@click="reloadApp">
<template #icon>
<IconRefresh />
</template>
{{ t('spreed', 'Reload') }}
</NcButton>
</template>
</NcEmptyContent>
</NcModal>
</template>
+978
View File
@@ -0,0 +1,978 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div id="call-container" :class="callContainerClass">
<ViewerOverlayCallView
v-if="isViewerOverlay"
:token="token"
:model="promotedParticipantModel"
:sharedData="promotedParticipantModel && sharedDatas[promotedParticipantModel.attributes.peerId]"
:screens="screens"
:localSharedData="localSharedData" />
<template v-else>
<EmptyCallView v-if="showEmptyCallView" :isSidebar="isSidebar" />
<div id="videos" :class="{ 'is-sidebar': isSidebar }">
<div
v-if="devMode ? !isGrid : (!isGrid || !callParticipantModels.length)"
class="video__promoted"
:class="{ 'full-page': showFullPage }">
<!-- Selected video override mode -->
<VideoVue
v-if="showSelectedVideo && selectedCallParticipantModel"
:key="`promoted-${selectedVideoPeerId}`"
:token="token"
:model="selectedCallParticipantModel"
:sharedData="sharedDatas[selectedVideoPeerId]"
:showTalkingHighlight="false"
:isOneToOne="isOneToOne"
isGrid
isBig
fitVideo />
<!-- Local Video Override mode (following own video) -->
<LocalVideo
v-else-if="showLocalVideo"
ref="localVideo"
:token="token"
:localMediaModel="localMediaModel"
:localCallParticipantModel="localCallParticipantModel"
:isStripe="false"
:showControls="false"
:isSidebar="false"
isBig
fitVideo />
<!-- Screens -->
<!-- Local screen -->
<ScreenShare
v-else-if="showLocalScreen"
key="screen-local"
:token="token"
:localMediaModel="localMediaModel"
:sharedData="localSharedData"
isBig />
<!-- Remote or selected screen -->
<ScreenShare
v-else-if="(showRemoteScreen || showSelectedScreen) && shownRemoteScreenCallParticipantModel"
:key="`screen-${shownRemoteScreenPeerId}`"
:token="token"
:callParticipantModel="shownRemoteScreenCallParticipantModel"
:sharedData="sharedDatas[shownRemoteScreenPeerId]"
isBig />
<!-- Promoted "autopilot" mode -->
<VideoVue
v-else-if="promotedParticipantModel"
:key="`autopilot-${promotedParticipantModel.attributes.peerId}`"
:token="token"
:model="promotedParticipantModel"
:sharedData="sharedDatas[promotedParticipantModel.attributes.peerId]"
:showTalkingHighlight="false"
isGrid
fitVideo
isBig
:isOneToOne="isOneToOne"
:isSidebar="isSidebar"
@forcePromoteVideo="forcePromotedModel = $event" />
<!-- presenter overlay -->
<PresenterOverlay
v-if="shouldShowPresenterOverlay"
:token="token"
:model="presenterModel"
:sharedData="presenterSharedData"
:isLocalPresenter="showLocalScreen"
:localMediaModel="localMediaModel"
:isCollapsed="!showPresenterOverlay"
@click="toggleShowPresenterOverlay" />
<div
v-else-if="devMode && !isGrid"
class="dev-mode-video--promoted">
<img :alt="placeholderName(6)" :src="placeholderImage(6)">
<VideoBottomBar
:hasShadow="false"
:model="placeholderModel(6)"
:sharedData="placeholderSharedData(6)"
:token="token"
:participantName="placeholderName(6)"
isBig />
</div>
</div>
<!-- Stripe or fullscreen grid depending on `isGrid` -->
<VideosGrid
v-if="!isSidebar"
:isStripe="devMode ? !isGrid : (!isGrid || !callParticipantModels.length)"
:isRecording="isRecording"
:token="token"
:hasPagination="true"
:isOverlap="showFullPage"
:callParticipantModels="callParticipantModels"
:screens="screens"
:localMediaModel="localMediaModel"
:localCallParticipantModel="localCallParticipantModel"
:sharedDatas="sharedDatas"
v-bind="$attrs"
@selectVideo="handleSelectVideo"
@clickLocalVideo="handleClickLocalVideo" />
<ReactionToaster
v-if="supportedReactions?.length"
:token="token"
:supportedReactions="supportedReactions"
:callParticipantModels="callParticipantModels" />
<LiveTranscriptionRenderer
v-if="isLiveTranscriptionEnabled"
:token="token"
:callParticipantModels="callParticipantModels" />
<!-- Local video if sidebar -->
<LocalVideo
v-if="isSidebar && !showLocalVideo"
ref="localVideo"
class="local-video"
:class="{ 'local-video--sidebar': isSidebar }"
:showControls="false"
:fitVideo="true"
:isStripe="true"
:token="token"
:localMediaModel="localMediaModel"
:localCallParticipantModel="localCallParticipantModel"
:isSidebar="isSidebar"
@clickVideo="handleClickLocalVideo" />
</div>
<BottomBar v-if="!isRecording" :isSidebar="isSidebar" />
</template>
</div>
</template>
<script>
import { showMessage } from '@nextcloud/dialogs'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
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 VideosGrid from './Grid/VideosGrid.vue'
import EmptyCallView from './shared/EmptyCallView.vue'
import LiveTranscriptionRenderer from './shared/LiveTranscriptionRenderer.vue'
import LocalVideo from './shared/LocalVideo.vue'
import PresenterOverlay from './shared/PresenterOverlay.vue'
import ReactionToaster from './shared/ReactionToaster.vue'
import ScreenShare from './shared/ScreenShare.vue'
import VideoBottomBar from './shared/VideoBottomBar.vue'
import VideoVue from './shared/VideoVue.vue'
import ViewerOverlayCallView from './shared/ViewerOverlayCallView.vue'
import { SIMULCAST } from '../../constants.ts'
import BrowserStorage from '../../services/BrowserStorage.js'
import { fetchPeers } from '../../services/callsService.ts'
import { getTalkConfig } from '../../services/CapabilitiesManager.ts'
import { EventBus } from '../../services/EventBus.ts'
import { useCallViewStore } from '../../stores/callView.ts'
import { useSettingsStore } from '../../stores/settings.ts'
import { satisfyVersion } from '../../utils/satisfyVersion.ts'
import { callParticipantCollection, localCallParticipantModel, localMediaModel } from '../../utils/webrtc/index.js'
import RemoteVideoBlocker from '../../utils/webrtc/RemoteVideoBlocker.js'
import { placeholderImage, placeholderModel, placeholderName, placeholderSharedData } from './Grid/gridPlaceholders.ts'
import { useWakeLock } from './useWakeLock.ts'
const serverVersion = loadState('core', 'config', {}).version ?? '29.0.0.0'
const serverSupportsBackgroundBlurred = satisfyVersion(serverVersion, '29.0.4.0')
export default {
name: 'CallView',
components: {
BottomBar,
EmptyCallView,
VideosGrid,
LiveTranscriptionRenderer,
LocalVideo,
PresenterOverlay,
ReactionToaster,
ScreenShare,
VideoBottomBar,
VideoVue,
ViewerOverlayCallView,
},
props: {
token: {
type: String,
required: true,
},
// Determines whether this component is used in the sidebar
isSidebar: {
type: Boolean,
default: false,
},
// Determines whether this component is used in the recording view
isRecording: {
type: Boolean,
default: false,
},
},
setup() {
// Prevent the screen from turning off
useWakeLock()
// For debug and screenshot purposes. Set to true to enable
const devMode = ref(false)
provide('CallView:devModeEnabled', devMode)
const screenshotMode = ref(false)
provide('CallView:screenshotModeEnabled', screenshotMode)
const settingsStore = useSettingsStore()
// If media settings was not used, we check the global config of default devices state here
if (!settingsStore.showMediaSettings && settingsStore.startWithoutMedia) {
localMediaModel.disableAudio()
localMediaModel.disableVideo()
}
// Fallback ref for versions before v29.0.4
const isBackgroundBlurred = ref(BrowserStorage.getItem('background-blurred') !== 'false')
return {
localMediaModel,
localCallParticipantModel,
callParticipantCollection,
devMode,
callViewStore: useCallViewStore(),
isBackgroundBlurred,
}
},
data() {
return {
screens: [],
sharedDatas: {},
raisedHandUnwatchers: {},
speakingUnwatchers: {},
screenUnwatchers: {},
speakers: [],
localSharedData: {
screenVisible: true,
},
showPresenterOverlay: true,
debounceFetchPeers: () => {},
forcePromotedModel: null,
}
},
computed: {
promotedParticipantModel() {
// Ensure at least one participant is always promoted to show in autopilot
return this.forcePromotedModel
?? this.callParticipantModels.find((callParticipantModel) => this.sharedDatas[callParticipantModel.attributes.peerId].promoted)
?? this.callParticipantModels[0]
},
callParticipantModels() {
return callParticipantCollection.callParticipantModels.filter((callParticipantModel) => !callParticipantModel.attributes.internal || callParticipantModel.attributes.videoAvailable)
},
callParticipantModelsWithScreen() {
return this.callParticipantModels.filter((callParticipantModel) => callParticipantModel.attributes.screen)
},
localScreen() {
return localMediaModel.attributes.localScreen
},
screenSharingActive() {
return this.screens.length > 0
},
isViewerOverlay() {
return this.callViewStore.isViewerOverlay
},
isGrid() {
return this.callViewStore.isGrid && !this.isSidebar
},
selectedVideoPeerId() {
return this.callViewStore.selectedVideoPeerId
},
selectedCallParticipantModel() {
if (!this.showSelectedVideo || !this.selectedVideoPeerId) {
return null
}
return this.callParticipantModels.find((callParticipantModel) => {
return callParticipantModel.attributes.peerId === this.selectedVideoPeerId
})
},
hasSelectedScreen() {
return this.selectedVideoPeerId !== null && this.screens.includes(this.selectedVideoPeerId)
},
hasSelectedVideo() {
return this.selectedVideoPeerId !== null && !this.screens.includes(this.selectedVideoPeerId)
},
isOneToOne() {
return this.callParticipantModels.length === 1
},
showFullPage() {
return this.isOneToOne && !(this.showLocalScreen || this.showRemoteScreen || this.showSelectedScreen)
},
hasLocalVideo() {
return this.localMediaModel.attributes.videoEnabled
},
hasLocalScreen() {
return !!this.localMediaModel.attributes.localScreen
},
hasRemoteScreen() {
return this.callParticipantModelsWithScreen.length > 0
},
// The following conditions determine what to show in the "Big container"
// of the promoted view
// Show selected video (other than local)
showSelectedVideo() {
return this.hasSelectedVideo && !this.showLocalVideo
},
showSelectedScreen() {
return this.hasSelectedScreen && !this.showLocalVideo
},
// Shows the local video if selected
showLocalVideo() {
return this.hasLocalVideo && this.selectedVideoPeerId === 'local'
},
// Show local screen
showLocalScreen() {
return this.hasLocalScreen && this.selectedVideoPeerId === null && this.screens[0] === localCallParticipantModel.attributes.peerId
},
// Show somebody else's screen. This will show the screen of the last
// person that shared it.
showRemoteScreen() {
return this.shownRemoteScreenPeerId !== null && !this.showSelectedVideo && !this.showSelectedScreen
},
shownRemoteScreenPeerId() {
if (!this.screenSharingActive || !this.hasRemoteScreen) {
return null
}
if (this.screens.includes(this.selectedVideoPeerId)) {
return this.selectedVideoPeerId
}
if (!this.hasSelectedScreen) {
return this.screens[0]
}
return null
},
shownRemoteScreenCallParticipantModel() {
if (!this.shownRemoteScreenPeerId) {
return null
}
return this.callParticipantModels.find((callParticipantModel) => {
return callParticipantModel.attributes.peerId === this.shownRemoteScreenPeerId
})
},
shouldShowPresenterOverlay() {
return (this.showLocalScreen && this.hasLocalVideo)
|| ((this.showRemoteScreen || this.showSelectedScreen)
&& (this.shownRemoteScreenCallParticipantModel?.attributes.videoAvailable || this.isModelWithVideo(this.shownRemoteScreenCallParticipantModel)))
},
presenterModel() {
// Prioritize local screen over remote screen, if both are available (as in DOM order)
return this.showLocalScreen ? this.localCallParticipantModel : this.shownRemoteScreenCallParticipantModel
},
presenterSharedData() {
return this.showLocalScreen ? this.localSharedData : this.sharedDatas[this.shownRemoteScreenPeerId]
},
presenterVideoBlockerEnabled() {
return this.sharedDatas[this.shownRemoteScreenPeerId]?.remoteVideoBlocker?.isVideoEnabled()
},
showEmptyCallView() {
return !this.callParticipantModels.length && !this.screenSharingActive && !this.devMode
},
supportedReactions() {
return getTalkConfig(this.token, 'call', 'supported-reactions')
},
/**
* Fallback style for versions before v29.0.4
*/
callContainerClass() {
if (serverSupportsBackgroundBlurred) {
return
}
return this.isBackgroundBlurred ? 'call-container__blurred' : 'call-container__non-blurred'
},
isLiveTranscriptionEnabled() {
return this.callViewStore.isLiveTranscriptionEnabled
},
},
watch: {
'localCallParticipantModel.attributes.peerId': function(newValue, previousValue) {
const index = this.screens.indexOf(previousValue)
if (index !== -1) {
this.screens[index] = newValue
}
},
localScreen(localScreen) {
this._setScreenAvailable(localCallParticipantModel.attributes.peerId, localScreen)
},
callParticipantModels(models) {
this.updateDataFromCallParticipantModels(models)
},
isGrid() {
this.adjustSimulcastQuality()
},
selectedVideoPeerId() {
this.adjustSimulcastQuality()
},
speakers: {
deep: true,
handler() {
this._setPromotedParticipant()
},
},
shownRemoteScreenPeerId(value) {
if (value) {
this._setPromotedParticipant()
}
},
screens: {
deep: true,
handler() {
this._setScreenVisible()
},
},
callParticipantModelsWithScreen(newValue, previousValue) {
// Everytime a new screen is shared, switch to promoted view
if (newValue.length > previousValue.length) {
this.callViewStore.startPresentation(this.token)
} else if (newValue.length === 0 && previousValue.length > 0 && !this.hasLocalScreen && !this.selectedVideoPeerId) {
// last screen share stopped and no selected video, restoring previous state
this.callViewStore.stopPresentation(this.token)
}
},
showLocalScreen(showLocalScreen) {
// Everytime the local screen is shared, switch to promoted view
if (showLocalScreen) {
this.callViewStore.startPresentation(this.token)
} else if (this.callParticipantModelsWithScreen.length === 0 && !this.selectedVideoPeerId) {
// last screen share stopped and no selected video, restoring previous state
this.callViewStore.stopPresentation(this.token)
}
},
hasLocalVideo(newValue) {
if (this.selectedVideoPeerId === 'local') {
if (!newValue) {
this.callViewStore.setSelectedVideoPeerId(null)
}
}
},
presenterVideoBlockerEnabled(value) {
this.showPresenterOverlay = value
},
showEmptyCallView: {
immediate: true,
handler(value) {
this.callViewStore.setIsEmptyCallView(value)
},
},
},
created() {
// Ensure that data is properly initialized before mounting the
// subviews.
this.updateDataFromCallParticipantModels(this.callParticipantModels)
},
mounted() {
this.debounceFetchPeers = debounce(this.fetchPeers, 1500)
EventBus.on('refresh-peer-list', this.debounceFetchPeers)
callParticipantCollection.on('remove', this._lowerHandWhenParticipantLeaves)
subscribe('switch-screen-to-id', this._switchScreenToId)
subscribe('set-background-blurred', this.setBackgroundBlurred)
},
beforeUnmount() {
this.debounceFetchPeers.clear?.()
this.callViewStore.setIsEmptyCallView(true)
EventBus.off('refresh-peer-list', this.debounceFetchPeers)
callParticipantCollection.off('remove', this._lowerHandWhenParticipantLeaves)
unsubscribe('switch-screen-to-id', this._switchScreenToId)
unsubscribe('set-background-blurred', this.setBackgroundBlurred)
},
methods: {
t,
// Placeholder data for devMode and screenshotMode
placeholderImage,
placeholderName,
placeholderModel,
placeholderSharedData,
/**
* Updates data properties that depend on the CallParticipantModels.
*
* The data contains some properties that can not be dynamically
* computed but that depend on the current CallParticipantModels, so
* this function adds and removes elements and watchers as needed based
* on the given CallParticipantModels.
*
* @param {Array} models the array of CallParticipantModels
*/
updateDataFromCallParticipantModels(models) {
const addedModels = models.filter((model) => !this.sharedDatas[model.attributes.peerId])
const removedModelIds = Object.keys(this.sharedDatas).filter((sharedDataId) => models.find((model) => model.attributes.peerId === sharedDataId) === undefined)
removedModelIds.forEach((removedModelId) => {
this.sharedDatas[removedModelId].remoteVideoBlocker.destroy()
delete this.sharedDatas[removedModelId]
this.speakingUnwatchers[removedModelId]()
// Not reactive, but not a problem
delete this.speakingUnwatchers[removedModelId]
this.screenUnwatchers[removedModelId]()
// Not reactive, but not a problem
delete this.screenUnwatchers[removedModelId]
this.raisedHandUnwatchers[removedModelId]()
// Not reactive, but not a problem
delete this.raisedHandUnwatchers[removedModelId]
const index = this.speakers.findIndex((speaker) => speaker.id === removedModelId)
this.speakers.splice(index, 1)
this._setScreenAvailable(removedModelId, false)
})
addedModels.forEach((addedModel) => {
const sharedData = {
promoted: false,
remoteVideoBlocker: new RemoteVideoBlocker(addedModel),
screenVisible: false,
}
this.sharedDatas[addedModel.attributes.peerId] = sharedData
// Not reactive, but not a problem
this.speakingUnwatchers[addedModel.attributes.peerId] = this.$watch(function() {
return addedModel.attributes.speaking
}, function(speaking) {
this._setSpeaking(addedModel.attributes.peerId, speaking)
})
this.speakers.push({
id: addedModel.attributes.peerId,
active: false,
})
// Not reactive, but not a problem
this.screenUnwatchers[addedModel.attributes.peerId] = this.$watch(function() {
return addedModel.attributes.screen
}, function(screen) {
this._setScreenAvailable(addedModel.attributes.peerId, screen)
})
// Not reactive, but not a problem
this.raisedHandUnwatchers[addedModel.attributes.peerId] = this.$watch(function() {
return addedModel.attributes.raisedHand
}, function(raisedHand) {
this._handleParticipantRaisedHand(addedModel, raisedHand)
})
this.adjustSimulcastQualityForParticipant(addedModel)
})
},
_setSpeaking(peerId, speaking) {
if (speaking) {
// Move the speaker to the first element of the list
const index = this.speakers.findIndex((speaker) => speaker.id === peerId)
const speaker = this.speakers[index]
speaker.active = true
this.speakers.splice(index, 1)
this.speakers.unshift(speaker)
return
}
// Set the speaker as not speaking
const index = this.speakers.findIndex((speaker) => speaker.id === peerId)
const speaker = this.speakers[index]
speaker.active = false
// Move the speaker after all the active speakers
if (index === 0) {
this.speakers.shift()
const firstInactiveSpeakerIndex = this.speakers.findIndex((speaker) => !speaker.active)
if (firstInactiveSpeakerIndex === -1) {
this.speakers.push(speaker)
} else {
this.speakers.splice(firstInactiveSpeakerIndex, 0, speaker)
}
}
},
_handleParticipantRaisedHand(callParticipantModel, raisedHand) {
const nickName = callParticipantModel.attributes.name || callParticipantModel.attributes.userId
// sometimes the nick name is not available yet...
if (nickName) {
if (raisedHand?.state) {
showMessage(t('spreed', '{nickName} raised their hand.', { nickName }))
}
} else {
if (raisedHand?.state) {
showMessage(t('spreed', 'A participant raised their hand.'))
}
}
// update in callViewStore
this.$store.dispatch('setParticipantHandRaised', {
sessionId: callParticipantModel.attributes.nextcloudSessionId,
raisedHand,
})
},
_lowerHandWhenParticipantLeaves(callParticipantCollection, callParticipantModel) {
this.$store.dispatch('setParticipantHandRaised', {
sessionId: callParticipantModel.attributes.nextcloudSessionId,
raisedHand: false,
})
},
_setScreenAvailable(id, screen) {
if (screen) {
this.screens.unshift(id)
return
}
const index = this.screens.indexOf(id)
if (index !== -1) {
this.screens.splice(index, 1)
}
},
_setPromotedParticipant() {
let promotedPeerId = null
if (!this.screenSharingActive && this.speakers.length) {
promotedPeerId = this.speakers[0].id
} else if (this.shownRemoteScreenPeerId && this.sharedDatas[this.shownRemoteScreenPeerId]) {
promotedPeerId = this.shownRemoteScreenPeerId
}
// Ensure at least one participant is always promoted to show in autopilot
if (promotedPeerId && this.sharedDatas[promotedPeerId]) {
Object.keys(this.sharedDatas).forEach((peerId) => {
this.sharedDatas[peerId].promoted = false
})
this.sharedDatas[promotedPeerId].promoted = true
}
this.adjustSimulcastQuality()
},
_switchScreenToId(id) {
const index = this.screens.indexOf(id)
if (index === -1) {
return
}
if (this.callViewStore.presentationStarted) {
this.callViewStore.setCallViewMode({
token: this.token,
isGrid: false,
isStripeOpen: false,
clearLast: false,
})
} else {
this.callViewStore.startPresentation(this.token)
}
this.callViewStore.setSelectedVideoPeerId(null)
this.screens.splice(index, 1)
this.screens.unshift(id)
},
_setScreenVisible() {
this.localSharedData.screenVisible = false
Object.values(this.sharedDatas).forEach((sharedData) => {
sharedData.screenVisible = false
})
if (!this.screens.length) {
return
}
if (this.screens[0] === this.localCallParticipantModel.attributes.peerId) {
this.localSharedData.screenVisible = true
return
}
this.sharedDatas[this.screens[0]].screenVisible = true
},
handleSelectVideo(peerId) {
if (this.isSidebar) {
return
}
this.callViewStore.setSelectedVideoPeerId(peerId)
this.callViewStore.setCallViewMode({
token: this.token,
isGrid: false,
isStripeOpen: false,
clearLast: false,
})
},
handleClickLocalVideo() {
// DO nothing if no video
if (!this.hasLocalVideo || this.isSidebar) {
return
}
// Deselect possible selected video
this.callViewStore.setSelectedVideoPeerId('local')
this.callViewStore.setCallViewMode({
token: this.token,
isGrid: false,
isStripeOpen: false,
clearLast: false,
})
},
async fetchPeers() {
// The recording participant does not have a Nextcloud session, so
// it can not fetch the peers. This should not be a problem, as all
// the needed data for the recording should be (eventually)
// available in the signaling data.
if (this.isRecording) {
return
}
const token = this.token
try {
const response = await fetchPeers(token)
this.$store.dispatch('purgePeersStore')
response.data.ocs.data.forEach((peer) => {
this.$store.dispatch('addPeer', {
token,
peer,
})
})
} catch (exception) {
// Just means guests have no name, so don't error …
console.error(exception)
}
},
adjustSimulcastQuality() {
this.callParticipantModels.forEach((callParticipantModel) => {
this.adjustSimulcastQualityForParticipant(callParticipantModel)
})
},
adjustSimulcastQualityForParticipant(callParticipantModel) {
// Always use the high temporal layer, as a low frame rate can look
// bad specially with a low number of participants.
if (this.isGrid) {
callParticipantModel.setSimulcastVideoQuality(SIMULCAST.MEDIUM, SIMULCAST.HIGH)
} else if (this.sharedDatas[callParticipantModel.attributes.peerId].promoted || this.selectedVideoPeerId === callParticipantModel.attributes.peerId) {
callParticipantModel.setSimulcastVideoQuality(SIMULCAST.HIGH, SIMULCAST.HIGH)
} else {
callParticipantModel.setSimulcastVideoQuality(SIMULCAST.LOW, SIMULCAST.HIGH)
}
},
/**
* Fallback method for versions before v29.0.4
*
* @param {boolean} value whether background should be blurred
*/
setBackgroundBlurred(value) {
this.isBackgroundBlurred = value
},
isModelWithVideo(callParticipantModel) {
if (!callParticipantModel) {
return false
}
return callParticipantModel.attributes.videoAvailable
&& this.sharedDatas[callParticipantModel.attributes.peerId].remoteVideoBlocker.isVideoEnabled()
&& (typeof callParticipantModel.attributes.stream === 'object')
},
toggleShowPresenterOverlay() {
if (!this.showLocalScreen && !this.presenterVideoBlockerEnabled) {
this.sharedDatas[this.shownRemoteScreenPeerId].remoteVideoBlocker.setVideoEnabled(true)
} else {
this.showPresenterOverlay = !this.showPresenterOverlay
}
},
},
}
</script>
<style lang="scss" scoped>
@use '../../assets/variables' as *;
#call-container {
width: 100%;
height: 100%;
background-color: $color-call-background;
// 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;
--wrapper-padding: calc(var(--default-grid-baseline) * 2.5);
--bottom-bar-height: calc(var(--default-clickable-area) + var(--wrapper-padding) * 2);
&.call-container__blurred {
backdrop-filter: blur(25px);
}
&.call-container__non-blurred {
backdrop-filter: none;
}
}
#videos {
position: absolute;
width: 100%;
height: calc(100% - (var(--top-bar-height) + var(--bottom-bar-height)));
top: var(--top-bar-height);
overflow: hidden;
display: flex;
justify-content: space-around;
align-items: flex-end;
flex-direction: column;
padding-inline: var(--wrapper-padding);
&.is-sidebar {
width: 100%;
height: 100%;
inset: 0;
padding: 0;
:deep(.video-container-big) {
width: 100%;
height: 100%;
padding: 0;
border-radius: 0;
}
}
}
.video__promoted {
position: relative;
height: 100%;
width: 100%;
&.full-page {
// force the promoted remote or local video to cover the whole call view
// doesn't affect screen shares, as it's a different MediaStream
position: static;
}
.dev-mode-video--promoted {
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
}
.dev-mode-video--promoted img {
position: absolute;
height: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
}
}
.local-video {
position: absolute;
inset-inline-end: 0;
bottom: 0;
width: 300px;
height: 250px;
&--sidebar {
width: 150px;
height: 100px;
bottom: var(--bottom-bar-height);
margin: var(--default-grid-baseline);
}
}
#videos.hidden {
display: none;
}
:deep(video) {
z-index: 0;
/* default filter for slightly better look */
/* Disabled for now as it causes a huuuuge performance drop.
CPU usage is more than halved without this.
-webkit-filter: contrast(1.1) saturate(1.1) sepia(.1);
filter: contrast(1.1) saturate(1.1) sepia(.1);
*/
vertical-align: top; /* fix white line below video */
}
#videos :deep(video) {
padding: 0;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { generateFilePath } from '@nextcloud/router'
/**
* Mock participant image for placeholders
*
* @param i index
*/
export function placeholderImage(i: number) {
return generateFilePath('spreed', 'docs', 'screenshotplaceholders/placeholder-' + (i % 9) + '.jpeg')
}
/**
* Mock participant name for placeholders
*
* @param i index
* @param showKey show key next to the name
*/
export function placeholderName(i: number, showKey: boolean = false): string {
switch (i % 9) {
case 0:
return 'Sandra McKinney' + (showKey ? ` | ${i}` : '')
case 1:
return 'Chris Wurst' + (showKey ? ` | ${i}` : '')
case 2:
return 'Edeltraut Bobb' + (showKey ? ` | ${i}` : '')
case 3:
return 'Arthur Blitz' + (showKey ? ` | ${i}` : '')
case 4:
return 'Roeland Douma' + (showKey ? ` | ${i}` : '')
case 5:
return 'Vanessa Steg' + (showKey ? ` | ${i}` : '')
case 6:
return 'Emily Grant' + (showKey ? ` | ${i}` : '')
case 7:
return 'Tobias Kaminsky' + (showKey ? ` | ${i}` : '')
case 8:
default:
return 'Adrian Ada' + (showKey ? ` | ${i}` : '')
}
}
/**
* Mock participant model for placeholders
*
* @param i index
*/
export function placeholderModel(i: number) {
return {
attributes: {
audioAvailable: [1, 2, 4, 5, 7, 8].includes(i % 9),
audioEnabled: (i % 9) === 8,
videoAvailable: true,
screen: false,
currentVolume: 0.75,
volumeThreshold: 0.75,
localScreen: false,
raisedHand: {
state: [0, 1, 6].includes(i % 9),
},
},
forceMute: () => {},
on: () => {},
off: () => {},
getWebRtc: () => {
return {
connection: {
getSendVideoIfAvailable: () => {},
},
}
},
}
}
/**
* Mock shared data for placeholders
*/
export function placeholderSharedData() {
return {
videoEnabled: {
isVideoEnabled: () => true,
},
remoteVideoBlocker: {
isVideoEnabled: () => true,
},
screenVisible: false,
}
}
@@ -0,0 +1,260 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
class="empty-call-view"
:class="{
'empty-call-view--sidebar': isSidebar,
'empty-call-view--small': isSmall,
}"
data-theme-dark>
<component :is="emptyCallViewIcon" :size="isSidebar ? 32 : 64" class="empty-call-view__icon" />
<h2>{{ title }}</h2>
<template v-if="!isSmall">
<p v-if="message" class="emptycontent-additional">
{{ message }}
</p>
<NcButton
v-if="showLink"
variant="primary"
@click.stop="handleCopyLink">
{{ t('spreed', 'Copy link') }}
</NcButton>
</template>
</div>
</template>
<script>
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconAccountMultipleOutline from 'vue-material-design-icons/AccountMultipleOutline.vue'
import IconLink from 'vue-material-design-icons/Link.vue'
import IconPhoneOutline from 'vue-material-design-icons/PhoneOutline.vue'
import { useGetToken } from '../../../composables/useGetToken.ts'
import { CONVERSATION, PARTICIPANT } from '../../../constants.ts'
import { copyConversationLinkToClipboard } from '../../../utils/handleUrl.ts'
export default {
name: 'EmptyCallView',
components: {
NcButton,
NcLoadingIcon,
IconAccountMultipleOutline,
IconLink,
IconPhoneOutline,
},
props: {
isGrid: {
type: Boolean,
default: false,
},
isSidebar: {
type: Boolean,
default: false,
},
isSmall: {
type: Boolean,
default: false,
},
},
setup() {
return {
token: useGetToken(),
}
},
computed: {
isConnecting() {
return this.$store.getters.isConnecting(this.token)
},
conversation() {
return this.$store.getters.conversation(this.token)
},
isGroupConversation() {
return this.conversation && this.conversation.type === CONVERSATION.TYPE.GROUP
},
isPublicConversation() {
return this.conversation && this.conversation.type === CONVERSATION.TYPE.PUBLIC
},
isOneToOneConversation() {
return this.conversation?.type === CONVERSATION.TYPE.ONE_TO_ONE
|| this.conversation?.type === CONVERSATION.TYPE.ONE_TO_ONE_FORMER
},
isPasswordRequestConversation() {
return this.conversation && this.conversation.objectType === CONVERSATION.OBJECT_TYPE.VIDEO_VERIFICATION
},
isFileConversation() {
return this.conversation && this.conversation.objectType === CONVERSATION.OBJECT_TYPE.FILE
},
isPhoneConversation() {
return this.conversation
&& (this.conversation.objectType === CONVERSATION.OBJECT_TYPE.PHONE_LEGACY
|| this.conversation.objectType === CONVERSATION.OBJECT_TYPE.PHONE_PERSISTENT
|| this.conversation.objectType === CONVERSATION.OBJECT_TYPE.PHONE_TEMPORARY)
},
conversationDisplayName() {
return this.conversation && this.conversation.displayName
},
canInviteOthers() {
return this.conversation && (
this.conversation.participantType === PARTICIPANT.TYPE.OWNER
|| this.conversation.participantType === PARTICIPANT.TYPE.MODERATOR)
},
canInviteOthersInPublicConversations() {
return this.canInviteOthers
|| (this.conversation && this.conversation.participantType === PARTICIPANT.TYPE.GUEST_MODERATOR)
},
emptyCallViewIcon() {
if (this.isConnecting) {
return NcLoadingIcon
} else if (this.isPhoneConversation) {
return IconPhoneOutline
} else {
return this.isPublicConversation ? IconLink : IconAccountMultipleOutline
}
},
title() {
if (this.isConnecting) {
return t('spreed', 'Connecting …')
}
if (this.isPhoneConversation) {
return t('spreed', 'Calling …')
}
if (this.isOneToOneConversation) {
return t('spreed', 'Waiting for {user} to join the call', { user: this.conversationDisplayName })
}
return t('spreed', 'Waiting for others to join the call …')
},
message() {
if (this.isConnecting) {
return ''
}
if (this.isPasswordRequestConversation || this.isFileConversation) {
return ''
}
if (!this.isGroupConversation && !this.isPublicConversation) {
return ''
}
if (this.isGroupConversation && !this.canInviteOthers) {
return ''
}
if (this.isPhoneConversation) {
return ''
}
if (this.isGroupConversation) {
return t('spreed', 'You can invite others in the participant tab of the sidebar')
}
if (this.isPublicConversation && this.canInviteOthersInPublicConversations) {
return t('spreed', 'You can invite others in the participant tab of the sidebar or share this link to invite others!')
}
return t('spreed', 'Share this link to invite others!')
},
showLink() {
return this.isPublicConversation && !this.isPasswordRequestConversation && !this.isFileConversation
},
},
methods: {
t,
handleCopyLink() {
copyConversationLinkToClipboard(this.token)
},
},
}
</script>
<style lang="scss" scoped>
.empty-call-view {
height: 100%;
width: 100%;
position: absolute;
display: flex;
flex-direction: column;
align-content: center;
justify-content: center;
text-align: center;
z-index: 1; // Otherwise the "Copy link" button is not clickable
.icon {
background-size: 64px;
height: 64px;
width: 64px;
margin: 0 auto 15px;
}
button {
margin: 4px auto;
}
&__icon,
h2, p {
color: var(--color-main-text);
}
&--sidebar {
padding-bottom: 16px;
h2, p {
font-size: 90%;
}
.icon {
transform: scale(0.7);
margin-top: 0;
margin-bottom: 0;
}
}
&--small {
border-radius: calc(var(--default-clickable-area) / 2);
background-color: rgba(34, 34, 34, 0.8); /* Copy from the call view */
padding: 8px;
h2 {
font-size: 1rem;
font-weight: normal;
}
.icon {
transform: none;
margin-bottom: 0;
background-size: 32px;
height: 32px;
width: 32px;
}
}
}
</style>
@@ -0,0 +1,435 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
ref="transcript"
class="transcript">
<TranscriptBlock
v-for="item in transcriptBlocks"
ref="transcriptBlocks"
:key="item.id"
:token="token"
:model="item.model"
:chunks="item.chunks"
:rightToLeft="item.rightToLeft" />
</div>
</template>
<script lang="ts">
import type { PropType } from 'vue'
import type { Chunk } from './TranscriptBlock.vue'
import TranscriptBlock from './TranscriptBlock.vue'
import { useLiveTranscriptionStore } from '../../../stores/liveTranscription.ts'
declare module 'vue' {
interface TypeRefs {
transcript: HTMLDivElement
transcriptBlocks: undefined | Array<TranscriptBlock>
}
interface ComponentCustomProperties {
$refs: TypeRefs
}
}
interface CallParticipantModel {
attributes: {
peerId: string
actorId: string | null | undefined
actorType: string | null | undefined
userId: string | null | undefined
name: string | null | undefined
}
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
on: (event: string, handler: (model: CallParticipantModel, ...args: any[]) => void) => void
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
off: (event: string, handler: (model: CallParticipantModel, ...args: any[]) => void) => void
}
interface TranscriptBlockData {
id: number
model: CallParticipantModel
chunks: Array<Chunk>
rightToLeft: boolean
}
interface BlockAndLine {
block: number
line: number
}
type TranscriptBlock = InstanceType<typeof TranscriptBlock>
export default {
name: 'LiveTranscriptionRenderer',
components: {
TranscriptBlock,
},
props: {
/**
* The conversation token
*/
token: {
type: String,
required: true,
},
callParticipantModels: {
type: Array as PropType<Array<CallParticipantModel>>,
required: true,
},
},
setup() {
const liveTranscriptionStore = useLiveTranscriptionStore()
return {
liveTranscriptionStore,
}
},
data() {
return {
registeredModels: {} as { [key: string]: CallParticipantModel },
resizeObserver: null as null | ResizeObserver,
transcriptBlocks: [] as TranscriptBlockData[],
lastScrolledToBlockAndLine: null as null | BlockAndLine,
pendingScrollToBottomLineByLine: undefined as undefined | ReturnType<typeof setTimeout>,
}
},
computed: {
liveTranscriptionLanguages() {
const liveTranscriptionLanguages = this.liveTranscriptionStore.getLiveTranscriptionLanguages()
if (!liveTranscriptionLanguages) {
return {}
}
return liveTranscriptionLanguages
},
},
watch: {
callParticipantModels: {
immediate: true,
handler(models: Array<CallParticipantModel>) {
// Subscribe connected models for transcript events
const addedModels = models.filter((model) => !this.registeredModels[model.attributes.peerId])
addedModels.forEach((addedModel) => {
this.registeredModels[addedModel.attributes.peerId] = addedModel
this.registeredModels[addedModel.attributes.peerId].on('transcript', this.handleTranscript)
})
// Unsubscribe disconnected models
const removedModelIds = Object.keys(this.registeredModels).filter((registeredModelId) => !models.find((model) => model.attributes.peerId === registeredModelId))
removedModelIds.forEach((removedModelId) => {
this.registeredModels[removedModelId].off('transcript', this.handleTranscript)
delete this.registeredModels[removedModelId]
})
},
},
},
mounted() {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.transcript)
},
beforeUnmount() {
Object.keys(this.registeredModels).forEach((modelId) => {
this.registeredModels[modelId].off('transcript', this.handleTranscript)
delete this.registeredModels[modelId]
})
this.resizeObserver!.disconnect()
clearTimeout(this.pendingScrollToBottomLineByLine)
},
methods: {
/**
* Handle resizings of the transcript element.
*
* After the transcript is resized the previous lines might have
* changed. For simplicity, and given that it was probably at the bottom
* or close to it already, rather than trying to keep the same visible
* lines the transcript is just scrolled to the bottom; any pending
* scroll to bottom is also cancelled.
*
* @param entries
* @param observer
*/
handleResize(entries: ResizeObserverEntry[], observer: ResizeObserver) {
if (!this.$refs.transcriptBlocks) {
return
}
for (let i = 0; i < this.$refs.transcriptBlocks.length; i++) {
this.$refs.transcriptBlocks[i].reset()
}
this.$refs.transcript.scrollTo({
top: this.$refs.transcript.scrollHeight,
})
// This should not happen, but just in case
if (!this.lastScrolledToBlockAndLine) {
this.lastScrolledToBlockAndLine = {
block: 0,
line: 0,
}
}
this.lastScrolledToBlockAndLine.block = this.$refs.transcriptBlocks.length - 1
const lastTranscriptBlock = this.$refs.transcriptBlocks[this.lastScrolledToBlockAndLine.block]
const lastTranscriptBlockLineBoundaries = lastTranscriptBlock.getLineBoundaries()
this.lastScrolledToBlockAndLine.line = lastTranscriptBlockLineBoundaries.length - 1
if (this.pendingScrollToBottomLineByLine) {
clearTimeout(this.pendingScrollToBottomLineByLine)
this.pendingScrollToBottomLineByLine = undefined
}
},
/**
* Handle a new received transcript.
*
* The transcript is added to the last block if it comes from the same
* participant, or a new block is added if it comes from another one. A
* new block will be used even for the same participant if the text
* direction changed.
*
* @param model the CallParticipantModel for the participant
* that was transcribed.
* @param message the transcribed message.
* @param languageId the ID of the language of the transcribed
* message.
* @param final true if the transcript will not be updated afterwards,
* false otherwise.
*/
handleTranscript(model: CallParticipantModel, message: string, languageId: string, final: boolean) {
let lastTranscriptBlock = this.transcriptBlocks.at(-1)
const messageIsRightToLeft = this.liveTranscriptionLanguages[languageId]?.metadata.rtl || false
if (lastTranscriptBlock?.model.attributes.peerId !== model.attributes.peerId
|| lastTranscriptBlock?.rightToLeft !== messageIsRightToLeft) {
const transcriptBlock = {
id: lastTranscriptBlock ? lastTranscriptBlock.id + 1 : 0,
model,
chunks: [],
rightToLeft: messageIsRightToLeft,
}
this.transcriptBlocks.push(transcriptBlock)
lastTranscriptBlock = transcriptBlock
}
const newTranscriptChunk = {
message,
languageId,
final,
}
const lastTranscriptChunk = lastTranscriptBlock.chunks.at(-1)
if (!lastTranscriptChunk || lastTranscriptChunk.final) {
lastTranscriptBlock.chunks.push(newTranscriptChunk)
} else {
lastTranscriptBlock.chunks.splice(-1, 1, newTranscriptChunk)
}
this.$nextTick(() => {
this.scrollToBottomLineByLine()
})
},
/**
* Scroll to the bottom, one line at a time, with a small pause at each
* line.
*
* If there are no more lines the no longer visible blocks are removed.
*/
scrollToBottomLineByLine() {
if (this.pendingScrollToBottomLineByLine) {
return
}
if (!this.scrollToNextLine()) {
this.removeNoLongerVisibleTranscriptBlocks()
return
}
this.pendingScrollToBottomLineByLine = setTimeout(() => {
this.pendingScrollToBottomLineByLine = undefined
this.scrollToBottomLineByLine()
}, 2000)
},
/**
* Scroll to the next line after the last visible one.
*
* @return {boolean} true if there was a line to scroll to, false
* otherwise.
*/
scrollToNextLine() {
if (!this.lastScrolledToBlockAndLine) {
this.scrollToBlockAndLine(0, 0)
return true
}
const lastScrolledToBlockLineBoundaries = this.$refs.transcriptBlocks![this.lastScrolledToBlockAndLine.block].getLineBoundaries()
// Fix line number if last chunk was replaced with a shorter text
// that uses less lines.
if (this.lastScrolledToBlockAndLine.line >= lastScrolledToBlockLineBoundaries.length) {
this.lastScrolledToBlockAndLine.line = lastScrolledToBlockLineBoundaries.length - 1
}
if (this.lastScrolledToBlockAndLine.line < lastScrolledToBlockLineBoundaries.length - 1) {
this.scrollToBlockAndLine(this.lastScrolledToBlockAndLine.block, this.lastScrolledToBlockAndLine.line + 1)
return true
}
if (this.lastScrolledToBlockAndLine.block < this.$refs.transcriptBlocks!.length - 1) {
this.scrollToBlockAndLine(this.lastScrolledToBlockAndLine.block + 1, 0)
return true
}
return false
},
/**
* Scroll to the given line in the given block.
*
* The bottom of the line will be aligned with the bottom of the
* transcript element (unless the internal area of the transcript is not
* large enough yet to be scrolled).
*
* @param block the index of the block in the current list of
* blocks.
* @param line the index of the line in the current list of
* lines of the block.
*/
scrollToBlockAndLine(block: number, line: number) {
this.lastScrolledToBlockAndLine = {
block,
line,
}
const transcriptBoundaries = this.$refs.transcript.getBoundingClientRect()
const transcriptTop = transcriptBoundaries.top
const transcriptHeight = transcriptBoundaries.bottom - transcriptBoundaries.top
const scrollToBlockLineBoundaries = this.$refs.transcriptBlocks![block].getLineBoundaries()
const scrollToLineLineBoundaries = scrollToBlockLineBoundaries[line]
const scrollToLineHeight = scrollToLineLineBoundaries.bottom - scrollToLineLineBoundaries.top
const scrollToLineRelativeLineBoundaries = {
top: scrollToLineLineBoundaries.top - transcriptTop,
bottom: scrollToLineLineBoundaries.bottom - transcriptTop,
}
// Align bottom of line with bottom of transcript
const scrollToTop = this.$refs.transcript.scrollTop
+ (scrollToLineRelativeLineBoundaries.top - transcriptHeight)
+ scrollToLineHeight
this.$refs.transcript.scrollTo({
top: scrollToTop,
behavior: 'smooth',
})
},
/**
* Remove all the transcript blocks fully above the top of the
* transcript element.
*/
removeNoLongerVisibleTranscriptBlocks() {
const count = this.getNoLongerVisibleTranscriptBlocksCount()
this.transcriptBlocks.splice(0, count)
this.lastScrolledToBlockAndLine!.block = this.lastScrolledToBlockAndLine!.block - count
// The same scroll position is expected to be automatically kept
// after the elements are removed, so the scroll is not explicitly
// adjusted.
},
/**
* @return {number} the number of no longer visible transcript blocks.
*/
getNoLongerVisibleTranscriptBlocksCount() {
const transcriptTop = this.$refs.transcript.getBoundingClientRect().top
let count = 0
for (let i = 0; i < this.lastScrolledToBlockAndLine!.block; i++) {
if (this.$refs.transcriptBlocks![i].$el.getBoundingClientRect().bottom > transcriptTop) {
return count
}
count++
}
return count
},
},
}
</script>
<style lang="scss" scoped>
.transcript {
/**
* A unitless value in line-height would be a multiplier on the font size,
* but --line-height is used for other properties like max-height that
* require a unit, so the variable needs to be explicitly multiplied by the
* font size.
*/
--line-height: calc(var(--default-font-size) * 2);
position: absolute;
bottom: 20px;
inset-inline: 20%;
line-height: var(--line-height);
max-height: calc(var(--line-height) * 4);
overflow: hidden;
display: flex;
align-items: center;
flex-direction: column;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
backdrop-filter: var(--filter-background-blur);
z-index: 1;
}
@media (height <= calc(var(--line-height) * 8)) {
.transcript {
max-height: calc(var(--line-height) * 2);
}
}
@media (max-width: 768px) {
.transcript {
inset-inline: 10%;
}
}
@media (max-width: 512px) {
.transcript {
inset-inline: 5%;
}
}
</style>
@@ -0,0 +1,400 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="local-audio-control-wrapper">
<NcPopover
ref="popover"
:boundary="boundaryElement"
:showTriggers="[]"
:hideTriggers="['click']"
:autoHide="false"
noFocusTrap
:shown="popupShown">
<template #trigger>
<NcButton
:title="audioButtonTitle"
:variant="audioStreamError ? 'error' : variant"
:aria-label="audioButtonAriaLabel"
:class="{
'no-audio-available': !isAudioAvailable,
'audio-control-button': showDevices,
}"
:disabled="resumeAudioAfterChange"
@click.stop="toggleAudio">
<template #icon>
<VolumeIndicator
:audioPreviewAvailable="isAudioAvailable"
:audioEnabled="showMicrophoneOn || resumeAudioAfterChange"
:currentVolume="model.attributes.currentVolume"
:volumeThreshold="model.attributes.volumeThreshold"
overlayMutedColor="#888888" />
</template>
</NcButton>
</template>
<div class="popover-hint">
<span>{{ speakingWhileMutedWarner?.message }}</span>
</div>
</NcPopover>
<NcActions
v-if="showDevices"
:disabled="!isAudioAllowed && !audioOutputSupported || !!audioStreamError"
class="audio-selector-button"
:class="{
'no-audio-available': !isAudioAvailable,
}"
@open="updateDevices">
<template #icon>
<IconChevronUp :size="16" />
</template>
<template v-if="isAudioAllowed">
<NcActionCaption :name="t('spreed', 'Select a microphone')" />
<NcActionButton
v-for="device in audioInputDevices"
:key="device.deviceId ?? 'none'"
class="audio-selector__action"
type="radio"
:modelValue="audioInputId"
:value="device.deviceId"
:title="device.label"
@click="handleAudioInputIdChange(device.deviceId)">
{{ device.label }}
</NcActionButton>
</template>
<NcActionSeparator v-if="isAudioAllowed && audioOutputSupported" />
<template v-if="audioOutputSupported">
<NcActionCaption :name="t('spreed', 'Select a speaker')" />
<NcActionButton
v-for="device in audioOutputDevices"
:key="device.deviceId ?? 'none'"
class="audio-selector__action"
type="radio"
:modelValue="audioOutputId"
:value="device.deviceId"
:title="device.label"
@click="handleAudioOutputIdChange(device.deviceId)">
{{ device.label }}
</NcActionButton>
</template>
</NcActions>
</div>
</template>
<script>
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { onBeforeUnmount, ref, watch } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionCaption from '@nextcloud/vue/components/NcActionCaption'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcPopover from '@nextcloud/vue/components/NcPopover'
import IconChevronUp from 'vue-material-design-icons/ChevronUp.vue'
import VolumeIndicator from '../../UIShared/VolumeIndicator.vue'
import { useDevices } from '../../../composables/useDevices.js'
import { PARTICIPANT } from '../../../constants.ts'
import SpeakingWhileMutedWarner from '../../../utils/webrtc/SpeakingWhileMutedWarner.js'
export default {
name: 'LocalAudioControlButton',
components: {
NcActions,
NcActionButton,
NcActionCaption,
NcActionSeparator,
NcButton,
NcPopover,
VolumeIndicator,
IconChevronUp,
},
props: {
conversation: {
type: Object,
required: true,
},
model: {
type: Object,
required: true,
},
disableKeyboardShortcuts: {
type: Boolean,
default: OCP.Accessibility.disableKeyboardShortcuts(),
},
disableMutedWarning: {
type: Boolean,
default: false,
},
variant: {
type: String,
default: 'tertiary-no-background',
},
token: {
type: String,
required: true,
},
showDevices: {
type: Boolean,
default: false,
},
},
expose: ['toggleAudio'],
setup(props) {
const boundaryElement = document.querySelector('.main-view')
const popover = ref(null)
const popupShown = ref(false)
const speakingWhileMutedWarner = !props.disableMutedWarning
? ref(new SpeakingWhileMutedWarner(props.model))
: ref(null)
if (!props.disableMutedWarning) {
watch(() => speakingWhileMutedWarner.value.showPopup, (newValue) => {
popupShown.value = newValue && isVisible(popover.value?.$el)
})
onBeforeUnmount(() => {
speakingWhileMutedWarner.value.destroy()
})
}
const {
devices,
audioInputId,
audioOutputId,
audioStreamError,
updateDevices,
audioOutputSupported,
updatePreferences,
subscribeToDevices,
unsubscribeFromDevices,
} = useDevices()
/* Flag to smoothly toggle the audio while in call */
const resumeAudioAfterChange = ref(false)
/**
* Check if component is visible and not obstructed by others
*
* @param element HTML element
*/
function isVisible(element) {
if (!element) {
return false // Element doesn't exist, therefore - not visible
}
const rect = element.getBoundingClientRect()
return document.elementsFromPoint(rect.left, rect.top)?.[0] === element
}
return {
boundaryElement,
popover,
popupShown,
speakingWhileMutedWarner,
devices,
audioInputId,
audioOutputId,
audioStreamError,
updateDevices,
audioOutputSupported,
updatePreferences,
subscribeToDevices,
unsubscribeFromDevices,
resumeAudioAfterChange,
}
},
computed: {
isAudioAllowed() {
return this.conversation.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_AUDIO
},
isAudioAvailable() {
return this.model.attributes.audioAvailable
},
showMicrophoneOn() {
return this.isAudioAvailable && this.model.attributes.audioEnabled
},
audioButtonTitle() {
if (!this.isAudioAllowed) {
return t('spreed', 'You are not allowed to enable audio')
}
if (!this.isAudioAvailable) {
return t('spreed', 'No audio. Click to select device')
}
if (this.model.attributes.audioEnabled) {
return this.disableKeyboardShortcuts
? t('spreed', 'Mute audio')
: t('spreed', 'Mute audio (M)')
} else {
return this.disableKeyboardShortcuts
? t('spreed', 'Unmute audio')
: t('spreed', 'Unmute audio (M)')
}
},
audioButtonAriaLabel() {
if (!this.isAudioAvailable) {
return t('spreed', 'No audio. Click to select device')
}
return this.model.attributes.audioEnabled
? t('spreed', 'Mute audio')
: t('spreed', 'Unmute audio')
},
audioInputDevices() {
return [
...this.devices.filter((device) => device.kind === 'audioinput')
.map((device) => ({
deviceId: device.deviceId,
label: device.label || device.fallbackLabel,
})),
{ deviceId: null, label: t('spreed', 'None') },
]
},
audioOutputDevices() {
return this.devices.filter((device) => device.kind === 'audiooutput')
.map((device) => ({
deviceId: device.deviceId,
label: device.label || device.fallbackLabel,
}))
},
},
watch: {
isAudioAvailable(newValue) {
if (newValue && this.resumeAudioAfterChange) {
// New track is available, resume audio
this.model.enableAudio()
this.resumeAudioAfterChange = false
}
},
audioInputId(newValue) {
if (!newValue && this.resumeAudioAfterChange) {
this.resumeAudioAfterChange = false
}
},
},
created() {
useHotKey('m', this.toggleAudio)
useHotKey(' ', this.toggleAudio, { push: true })
},
mounted() {
this.subscribeToDevices()
},
beforeUnmount() {
this.unsubscribeFromDevices()
},
methods: {
t,
toggleAudio() {
if (!this.isAudioAllowed || !this.isAudioAvailable) {
emit('talk:media-settings:show')
return
}
if (this.model.attributes.audioEnabled) {
this.model.disableAudio()
} else {
this.model.enableAudio()
}
},
handleAudioInputIdChange(audioInputId) {
if (this.showDevices && this.showMicrophoneOn) {
// If input was changed from bottom bar while active, it should not be muted after track change
this.resumeAudioAfterChange = true
}
this.audioInputId = audioInputId
this.updatePreferences('audioinput')
},
handleAudioOutputIdChange(audioOutputId) {
this.audioOutputId = audioOutputId
this.updatePreferences('audiooutput')
},
},
}
</script>
<style scoped lang="scss">
.no-audio-available {
opacity: .7;
}
.popover-hint {
padding: calc(3 * var(--default-grid-baseline));
max-width: 300px;
text-align: start;
}
.audio-selector-button :deep(.action-item__menutoggle) {
--button-size: var(--clickable-area-small);
height: var(--default-clickable-area);
border-start-start-radius: 2px;
border-end-start-radius: 2px;
}
.local-audio-control-wrapper {
display: flex;
align-items: center;
gap: 1px;
// Overwriting NcButton styles
.audio-control-button {
border-start-end-radius: 2px;
border-end-end-radius: 2px;
}
}
.audio-selector__action {
// Overwriting NcActionButton styles
:deep(.action-button__longtext) {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
padding: 0;
max-width: 350px;
}
:deep(.action-button__longtext-wrapper) {
max-width: 350px;
}
:deep(.action-button__icon) {
width: 0;
margin-inline-start: calc(var(--default-grid-baseline) * 3);
}
:deep(.action-button > span) {
height: var(--default-clickable-area);
}
}
</style>
@@ -0,0 +1,539 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
ref="videoContainer"
class="localVideoContainer"
:class="videoContainerClass"
@mouseover="mouseover = true"
@mouseleave="mouseover = false"
@click="$emit('clickVideo')">
<img
v-if="screenshotModeUrl"
class="dev-mode-video--self videoWrapper"
alt="dev-mode-video--self"
:src="screenshotModeUrl">
<div
v-show="!screenshotModeUrl && localMediaModel.attributes.videoEnabled"
class="videoWrapper"
:style="videoWrapperStyle">
<video
id="localVideo"
ref="video"
disablePictureInPicture="true"
:class="fitVideo ? 'video--fit' : 'video--fill'"
class="video"
@playing="updateVideoAspectRatio" />
<IconAccountOffOutline
v-if="isPresenterOverlay && mouseover"
class="presenter-icon__hide"
:aria-label="t('spreed', 'Hide presenter video')"
:title="t('spreed', 'Hide presenter video')"
:size="32"
@click="$emit('clickPresenter')" />
<NcLoadingIcon
v-if="isNotConnected"
:size="avatarSize / 2"
class="video-loading" />
</div>
<div v-if="!screenshotModeUrl && !localMediaModel.attributes.videoEnabled && !isSidebar" class="avatar-container">
<VideoBackground
v-if="isGrid || isStripe"
:displayName="displayName"
:user="userId" />
<AvatarWrapper
:id="userId"
:token="token"
:name="displayName"
:source="actorStore.actorType"
:size="avatarSize"
:loading="isNotConnected"
disableMenu
disableTooltip />
</div>
<div class="bottom-bar">
<NcButton
v-if="isBig"
variant="tertiary"
class="bottom-bar__button"
@click="handleStopFollowing">
{{ stopFollowingLabel }}
</NcButton>
</div>
</div>
</template>
<script>
import { showError, showInfo, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { inject, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconAccountOffOutline from 'vue-material-design-icons/AccountOffOutline.vue'
import AvatarWrapper from '../../AvatarWrapper/AvatarWrapper.vue'
import VideoBackground from './VideoBackground.vue'
import { AVATAR } from '../../../constants.ts'
import { useActorStore } from '../../../stores/actor.ts'
import { useCallViewStore } from '../../../stores/callView.ts'
import attachMediaStream from '../../../utils/attachmediastream.js'
import { ConnectionState } from '../../../utils/webrtc/models/CallParticipantModel.js'
import { placeholderImage } from '../Grid/gridPlaceholders.ts'
export default {
name: 'LocalVideo',
components: {
AvatarWrapper,
IconAccountOffOutline,
NcButton,
VideoBackground,
NcLoadingIcon,
},
props: {
token: {
type: String,
required: true,
},
localMediaModel: {
type: Object,
required: true,
},
localCallParticipantModel: {
type: Object,
required: true,
},
isGrid: {
type: Boolean,
default: false,
},
isStripe: {
type: Boolean,
default: false,
},
fitVideo: {
type: Boolean,
default: false,
},
isSidebar: {
type: Boolean,
default: false,
},
showControls: {
type: Boolean,
default: true,
},
unSelectable: {
type: Boolean,
default: false,
},
isBig: {
type: Boolean,
default: false,
},
isSmall: {
type: Boolean,
default: false,
},
isPresenterOverlay: {
type: Boolean,
default: false,
},
},
emits: ['clickVideo', 'clickPresenter'],
setup() {
const devMode = inject('CallView:devModeEnabled', ref(false))
const screenshotMode = inject('CallView:screenshotModeEnabled', ref(false))
return {
devMode,
screenshotMode,
callViewStore: useCallViewStore(),
actorStore: useActorStore(),
}
},
data() {
return {
notificationHandle: null,
videoAspectRatio: null,
containerAspectRatio: null,
resizeObserver: null,
mouseover: false,
}
},
computed: {
stopFollowingLabel() {
return t('spreed', 'Back')
},
isNotConnected() {
// When there is no sender participant (when the MCU is not used, or
// if it is used but no peer object has been set yet) the local
// video is shown as connected.
return this.localCallParticipantModel.attributes.peerNeeded
&& this.localCallParticipantModel.attributes.connectionState !== ConnectionState.CONNECTED && this.localCallParticipantModel.attributes.connectionState !== ConnectionState.COMPLETED
},
videoContainerClass() {
return {
'not-connected': this.isNotConnected,
'video-container-grid': this.isGrid,
'video-container-stripe': this.isStripe,
'video-container-big': this.isBig,
'video-container-small': this.isSmall,
presenter: this.isPresenterOverlay && this.mouseover,
'presenter-overlay': this.isPresenterOverlay,
'hover-shadow': this.isSelectable && this.mouseover,
speaking: this.localMediaModel.attributes.speaking,
}
},
videoWrapperStyle() {
if (!this.containerAspectRatio || !this.videoAspectRatio || !this.isBig || this.isGrid) {
return
}
return (this.containerAspectRatio > this.videoAspectRatio)
? `width: ${this.$refs.videoContainer.clientHeight * this.videoAspectRatio}px`
: `height: ${this.$refs.videoContainer.clientWidth / this.videoAspectRatio}px`
},
userId() {
return this.actorStore.userId
},
displayName() {
return this.actorStore.displayName
},
avatarSize() {
if (this.isStripe || (!this.isBig && !this.isGrid)) {
return AVATAR.SIZE.LARGE
} else if (!this.containerAspectRatio) {
return AVATAR.SIZE.FULL
} else {
return Math.min(AVATAR.SIZE.FULL, this.$refs.videoContainer.clientHeight / 2, this.$refs.videoContainer.clientWidth / 2)
}
},
localStreamVideoError() {
return this.localMediaModel.attributes.localStream && this.localMediaModel.attributes.localStreamRequestVideoError
},
hasLocalVideo() {
return this.localMediaModel.attributes.videoEnabled
},
isSelected() {
return this.callViewStore.selectedVideoPeerId === 'local'
},
isSelectable() {
return !this.unSelectable && !this.isSidebar && this.hasLocalVideo && this.callViewStore.selectedVideoPeerId !== 'local'
},
screenshotModeUrl() {
return this.screenshotMode ? placeholderImage(8) : ''
},
},
watch: {
localCallParticipantModel: {
immediate: true,
handler(localCallParticipantModel, oldLocalCallParticipantModel) {
if (oldLocalCallParticipantModel) {
oldLocalCallParticipantModel.off('forcedMute', this._handleForcedMute)
}
if (localCallParticipantModel) {
localCallParticipantModel.on('forcedMute', this._handleForcedMute)
}
},
},
'localMediaModel.attributes.localStream': function(localStream) {
this._setLocalStream(localStream)
},
localStreamVideoError: {
immediate: true,
handler(error) {
if (error) {
if (error.name === 'NotAllowedError') {
this.notificationHandle = showError(t('spreed', 'Access to camera was denied'))
} else if (error.name === 'NotReadableError' || error.name === 'AbortError') {
// when camera in use, Chrome gives NotReadableError, Firefox gives AbortError
this.notificationHandle = showError(t('spreed', 'Error while accessing camera: It is likely in use by another program'), {
timeout: TOAST_PERMANENT_TIMEOUT,
})
} else {
console.error('Error while accessing camera: ', error.message, error.name)
this.notificationHandle = showError(t('spreed', 'Error while accessing camera'), {
timeout: TOAST_PERMANENT_TIMEOUT,
})
}
}
},
},
},
mounted() {
// Set initial state
this._setLocalStream(this.localMediaModel.attributes.localStream)
if (this.isBig || this.isGrid) {
this.resizeObserver = new ResizeObserver(this.updateContainerAspectRatio)
this.resizeObserver.observe(this.$refs.videoContainer)
}
},
beforeUnmount() {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
},
unmounted() {
if (this.notificationHandle) {
this.notificationHandle.hideToast()
}
if (this.localCallParticipantModel) {
this.localCallParticipantModel.off('forcedMute', this._handleForcedMute)
}
},
methods: {
t,
_handleForcedMute() {
// The default toast selector is "body-user", but as this toast can
// be shown to guests too, a generic selector valid both for logged-in
// users and guests needs to be used instead (undefined selects
// the body element).
showInfo(t('spreed', 'You have been muted by a moderator'), { selector: undefined })
},
_setLocalStream(localStream) {
if (!localStream) {
// Do not clear the srcObject of the video element, just leave
// the previous stream as a frozen image.
return
}
const options = {
autoplay: true,
mirror: true,
muted: true,
}
attachMediaStream(localStream, this.$refs.video, options)
},
handleStopFollowing() {
this.callViewStore.setSelectedVideoPeerId(null)
this.callViewStore.stopPresentation(this.token)
},
updateContainerAspectRatio([{ target }]) {
this.containerAspectRatio = target.clientWidth / target.clientHeight
},
updateVideoAspectRatio() {
if (!this.isBig) {
return
}
this.videoAspectRatio = this.localMediaModel.attributes.localStream.getVideoTracks()?.[0].getSettings().aspectRatio
// Fallback for Firefox
?? this.$refs.video.videoWidth / this.$refs.video.videoHeight
},
},
}
</script>
<style lang="scss" scoped>
.not-connected {
video,
.avatar-container {
opacity: 0.5;
}
}
// Always display the local video in the last row
.localVideoContainer {
grid-row-end: -1;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
z-index: 1;
}
.video-container-grid {
position: relative;
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.video-container-stripe:not(.local-video--sidebar) {
// aspect-ratio is set according to the maximum video resolution after applying constraints (720*540)
--aspect-ratio: 1.33333;
--stripe-height: 242px;
position: relative;
flex: 0 0 calc(var(--aspect-ratio) * var(--stripe-height));
overflow: hidden;
display: flex;
flex-direction: column;
margin-top: auto;
height: var(--stripe-height) !important;
}
.video-container-big {
position: absolute;
width: calc(100% - var(--grid-gap) * 2);
height: 100%;
display: flex;
flex-direction: column;
& .videoWrapper {
margin: auto;
}
}
.video-container-small {
border-radius: var(--border-radius-large);
}
.videoWrapper,
.video {
height: 100%;
width: 100%;
}
.video-loading {
position: absolute;
top: 0;
inset-inline-end: 0;
height: 100%;
width: 100%;
}
.video--fit {
/* Fit the frame */
object-fit: contain;
}
.video--fill {
/* Fill the frame */
object-fit: cover;
}
.avatar-container {
margin: auto;
}
.presenter-overlay,
.presenter-overlay * {
border-radius: 50%;
}
.localVideoContainer::after {
position: absolute;
height: 100%;
width: 100%;
top: 0;
inset-inline-start: 0;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
}
.presenter-overlay::after {
border-radius: 50%;
z-index: 1;
}
.hover-shadow::after {
content: '';
box-shadow: inset 0 0 0 3px white;
cursor: pointer;
}
.speaking::after {
content: '';
box-shadow: inset 0 0 0 2px white;
}
.bottom-bar {
position: absolute;
bottom: 0;
width: 100%;
padding: 0 calc(var(--default-grid-baseline) * 3);
padding-bottom: calc(var(--default-grid-baseline) * 2);
display: flex;
justify-content: center;
align-items: center;
&--big {
justify-content: center;
}
& &__button {
opacity: 0.8;
background-color: var(--color-background-dark);
&:hover,
&:focus {
opacity: 1;
}
}
}
.dev-mode-video--self {
object-fit: cover !important;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
.presenter-overlay & {
border-radius: 50%;
}
}
.presenter-icon__hide {
position: absolute;
color: white;
inset-inline-start: calc(50% - var(--default-clickable-area) / 2);
top: calc(100% - var(--default-grid-baseline) - var(--default-clickable-area));
opacity: 0.7;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
padding: 6px;
width: var(--default-clickable-area);
height: var(--default-clickable-area);
z-index: 2; // Above video and its border
&:hover {
cursor: pointer;
opacity: 0.9;
}
}
</style>
@@ -0,0 +1,306 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="local-video-control-wrapper">
<NcButton
:title="videoButtonTitle"
:variant="videoStreamError ? 'error' : variant"
:aria-label="videoButtonAriaLabel"
:class="{
'no-video-available': !isVideoAvailable,
'video-control-button': showDevices,
}"
:disabled="resumeVideoAfterChange"
@click.stop="toggleVideo">
<template #icon>
<IconVideo v-if="showVideoOn || resumeVideoAfterChange" :size="20" />
<IconVideoOffOutline v-else :size="20" />
</template>
</NcButton>
<NcActions
v-if="showDevices"
:disabled="!isVideoAvailable || !isVideoAllowed || !!videoStreamError"
class="video-selector-button"
@open="updateDevices">
<template #icon>
<IconChevronUp :size="16" />
</template>
<NcActionCaption :name="t('spreed', 'Select a video device')" />
<NcActionButton
v-for="device in videoDevices"
:key="device.deviceId ?? 'none'"
class="video-selector__action"
type="radio"
:modelValue="videoInputId"
:value="device.deviceId"
:title="device.label"
@click="handleVideoInputIdChange(device.deviceId)">
{{ device.label }}
</NcActionButton>
</NcActions>
</div>
</template>
<script>
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { ref } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionCaption from '@nextcloud/vue/components/NcActionCaption'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcButton from '@nextcloud/vue/components/NcButton'
import IconChevronUp from 'vue-material-design-icons/ChevronUp.vue'
import IconVideo from 'vue-material-design-icons/Video.vue' // Filled for better indication
import IconVideoOffOutline from 'vue-material-design-icons/VideoOffOutline.vue'
import { useDevices } from '../../../composables/useDevices.js'
import { PARTICIPANT } from '../../../constants.ts'
export default {
name: 'LocalVideoControlButton',
components: {
NcActions,
NcActionButton,
NcActionCaption,
NcButton,
IconChevronUp,
IconVideo,
IconVideoOffOutline,
},
props: {
conversation: {
type: Object,
required: true,
},
model: {
type: Object,
required: true,
},
disableKeyboardShortcuts: {
type: Boolean,
default: OCP.Accessibility.disableKeyboardShortcuts(),
},
variant: {
type: String,
default: 'tertiary-no-background',
},
token: {
type: String,
required: true,
},
showDevices: {
type: Boolean,
default: false,
},
},
setup(props) {
const {
devices,
videoInputId,
videoStreamError,
updateDevices,
updatePreferences,
subscribeToDevices,
unsubscribeFromDevices,
} = useDevices()
/* Flag to smoothly toggle the video while in call */
const resumeVideoAfterChange = ref(false)
return {
devices,
videoInputId,
videoStreamError,
updateDevices,
updatePreferences,
subscribeToDevices,
unsubscribeFromDevices,
resumeVideoAfterChange,
}
},
computed: {
isVideoAllowed() {
return this.conversation.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_VIDEO
},
isVideoAvailable() {
return this.model.attributes.videoAvailable
},
showVideoOn() {
return this.isVideoAvailable && this.model.attributes.videoEnabled
},
videoButtonTitle() {
if (!this.isVideoAllowed) {
return t('spreed', 'You are not allowed to enable video')
}
if (!this.isVideoAvailable) {
return t('spreed', 'No video. Click to select device')
}
if (this.model.attributes.videoEnabled) {
return this.disableKeyboardShortcuts
? t('spreed', 'Disable video')
: t('spreed', 'Disable video (V)')
}
if (!this.model.getWebRtc() || !this.model.getWebRtc().connection || this.model.getWebRtc().connection.getSendVideoIfAvailable()) {
return this.disableKeyboardShortcuts
? t('spreed', 'Enable video')
: t('spreed', 'Enable video (V)')
}
return this.disableKeyboardShortcuts
? t('spreed', 'Enable video - Your connection will be briefly interrupted when enabling the video for the first time')
: t('spreed', 'Enable video (V) - Your connection will be briefly interrupted when enabling the video for the first time')
},
videoButtonAriaLabel() {
if (!this.isVideoAvailable) {
return t('spreed', 'No video. Click to select device')
}
if (this.model.attributes.videoEnabled) {
return t('spreed', 'Disable video')
}
if (!this.model.getWebRtc() || !this.model.getWebRtc().connection || this.model.getWebRtc().connection.getSendVideoIfAvailable()) {
return t('spreed', 'Enable video')
}
return t('spreed', 'Enable video. Your connection will be briefly interrupted when enabling the video for the first time')
},
videoDevices() {
return [
...this.devices.filter((device) => device.kind === 'videoinput')
.map((device) => ({
deviceId: device.deviceId,
label: device.label || device.fallbackLabel,
})),
{ deviceId: null, label: t('spreed', 'None') },
]
},
},
watch: {
isVideoAvailable(newValue) {
if (newValue && this.resumeVideoAfterChange) {
// New track is available, resume video
this.model.enableVideo()
this.resumeVideoAfterChange = false
}
},
videoInputId(newValue) {
if (!newValue && this.resumeVideoAfterChange) {
this.resumeVideoAfterChange = false
}
},
},
created() {
useHotKey('v', this.toggleVideo)
},
mounted() {
this.subscribeToDevices()
},
beforeUnmount() {
this.unsubscribeFromDevices()
},
methods: {
t,
toggleVideo() {
if (!this.isVideoAllowed || !this.isVideoAvailable) {
emit('talk:media-settings:show')
return
}
if (this.model.attributes.videoEnabled) {
this.model.disableVideo()
} else {
this.model.enableVideo()
}
},
handleVideoInputIdChange(videoInputId) {
if (this.showDevices && this.showVideoOn) {
// If input was changed from bottom bar while active, it should not be muted after track change
this.resumeVideoAfterChange = true
}
this.videoInputId = videoInputId
this.updatePreferences('videoinput')
},
},
}
</script>
<style scoped lang="scss">
.no-video-available {
opacity: .7;
}
.video-selector-button :deep(.action-item__menutoggle) {
--button-size: var(--clickable-area-small);
height: var(--default-clickable-area);
border-start-start-radius: 2px;
border-end-start-radius: 2px;
}
.local-video-control-wrapper {
display: flex;
align-items: center;
gap: 1px;
// Overwriting NcButton styles
.video-control-button {
border-start-end-radius: 2px;
border-end-end-radius: 2px;
}
}
.video-selector__action {
// Overwriting NcActionButton styles
:deep(.action-button__longtext) {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
padding: 0;
max-width: 350px;
}
:deep(.action-button__longtext-wrapper) {
max-width: 350px;
}
:deep(.action-button__icon) {
width: 0;
margin-inline-start: calc(var(--default-grid-baseline) * 3);
}
:deep(.action-button > span) {
height: var(--default-clickable-area);
}
}
</style>
@@ -0,0 +1,220 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div ref="presenterOverlayContainer" class="presenter-overlay__container">
<VueDraggableResizable
v-if="!isCollapsed"
ref="presenterOverlay"
parent
class="presenter-overlay"
:resizable="false"
:h="presenterOverlaySize"
:w="presenterOverlaySize"
:x="isDirectionRTL ? parentWidth - presenterOverlaySize - 10 : 10"
:y="10"
@dragging="isDragging = true"
@dragstop="isDragging = false">
<LocalVideo
v-if="isLocalPresenter"
class="presenter-overlay__video"
:token="token"
:localMediaModel="localMediaModel"
:localCallParticipantModel="model"
isPresenterOverlay
unSelectable
hideBottomBar
@clickPresenter="$emit('click')" />
<VideoVue
v-else
:token="token"
:class="{ dragging: isDragging }"
class="presenter-overlay__video"
:model="model"
:sharedData="sharedData"
isPresenterOverlay
unSelectable
hideBottomBar
@clickPresenter="$emit('click')" />
</VueDraggableResizable>
<!-- presenter button when presenter overlay is collapsed -->
<NcButton
v-else
:aria-label="t('spreed', 'Show presenter')"
:title="t('spreed', 'Show presenter')"
class="presenter-overlay--collapsed"
variant="tertiary-no-background"
@click="$emit('click')">
<template #icon>
<AccountBox fillColor="#ffffff" :size="20" />
</template>
</NcButton>
</div>
</template>
<script>
import { isRTL, t } from '@nextcloud/l10n'
import { ref } from 'vue'
import VueDraggableResizable from 'vue-draggable-resizable'
import NcButton from '@nextcloud/vue/components/NcButton'
import AccountBox from 'vue-material-design-icons/AccountBoxOutline.vue'
import LocalVideo from './LocalVideo.vue'
import VideoVue from './VideoVue.vue'
const isDirectionRTL = isRTL()
export default {
name: 'PresenterOverlay',
components: {
AccountBox,
VueDraggableResizable,
NcButton,
LocalVideo,
VideoVue,
},
props: {
token: {
type: String,
required: true,
},
model: {
type: Object,
required: true,
},
sharedData: {
type: Object,
required: true,
},
isCollapsed: {
type: Boolean,
required: true,
},
isLocalPresenter: {
type: Boolean,
default: false,
},
localMediaModel: {
type: Object,
default: null,
},
},
emits: ['click'],
setup() {
const parentWidth = ref(document.getElementById('videos').getBoundingClientRect().width)
return {
parentWidth,
isDirectionRTL,
}
},
data() {
return {
resizeObserver: null,
presenterOverlaySize: 128,
isDragging: false,
}
},
mounted() {
this.resizeObserver = new ResizeObserver(this.updateSize)
this.resizeObserver.observe(this.$refs.presenterOverlayContainer)
},
beforeUnmount() {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
},
methods: {
t,
updateSize() {
if (!this.$refs.presenterOverlay) {
// overlay is collapsed, do not process size update
return
}
// Size should be proportionate to the screen share size
const newSize = Math.round(this.$refs.presenterOverlayContainer.clientWidth * 0.1)
this.presenterOverlaySize = Math.min(Math.max(newSize, 100), 242)
// FIXME: inner method should be triggered to re-parent element
this.$refs.presenterOverlay.checkParentSize()
// FIXME: if it stays out of bounds (right and bottom), bring it back
// FIXME: should consider RTL
if (this.$refs.presenterOverlay.right < 0 && this.$refs.presenterOverlay.parentWidth > this.presenterOverlaySize) {
this.$refs.presenterOverlay.moveHorizontally(this.$refs.presenterOverlay.parentWidth - this.presenterOverlaySize)
}
if (this.$refs.presenterOverlay.bottom < 0 && this.$refs.presenterOverlay.parentHeight > this.presenterOverlaySize) {
this.$refs.presenterOverlay.moveVertically(this.$refs.presenterOverlay.parentHeight - this.presenterOverlaySize)
}
},
},
}
</script>
<style lang="scss" scoped>
.presenter-overlay__container {
position: absolute;
inset: 0;
// Make container transparent to user events
pointer-events: none;
& > * {
pointer-events: auto;
}
}
.presenter-overlay__video {
position: relative;
--max-size: 242px;
--min-size: 100px;
max-width: var(--max-size);
max-height: var(--max-size);
min-width: var(--min-size);
min-height: var(--min-size);
z-index: 10;
aspect-ratio: 1;
&:hover {
cursor: grab;
}
&.dragging {
cursor: grabbing;
}
}
.presenter-overlay--collapsed {
position: absolute !important;
opacity: .7;
bottom: calc(var(--default-clickable-area) + var(--default-grid-baseline));
inset-inline-end: var(--grid-gap);
#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;
}
}
}
:deep(div) {
// prevent default cursor
cursor: inherit;
}
</style>
@@ -0,0 +1,305 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<ul class="toaster">
<li
v-for="toast in toasts"
:key="toast.seed"
class="toast"
:style="styled(toast.name, toast.seed)">
<img
v-if="toast.reactionURL"
class="toast__reaction-img"
:src="toast.reactionURL"
:alt="toast.reaction"
width="34"
height="34">
<span v-else class="toast__reaction">
{{ toast.reaction }}
</span>
<span class="toast__name">
{{ toast.name }}
</span>
</li>
</ul>
</template>
<script>
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { imagePath } from '@nextcloud/router'
import { usernameToColor } from '@nextcloud/vue/functions/usernameToColor'
import Hex from 'crypto-js/enc-hex.js'
import SHA1 from 'crypto-js/sha1.js'
import { useActorStore } from '../../../stores/actor.ts'
import { useGuestNameStore } from '../../../stores/guestName.ts'
const reactions = {
'❤️': 'Heart.gif',
'🎉': 'Party.gif',
'👏': 'Clap.gif',
'👋': 'Wave.gif',
'👍': 'Thumbs-up.gif',
'👎': 'Thumbs-down.gif',
'🔥': 'Fire.gif',
'😂': 'Joy.gif',
'🤩': 'Star-struck.gif',
'🤔': 'Thinking-face.gif',
'😲': 'Surprised.gif',
'😥': 'Concerned.gif',
}
const ANIMATION_LENGTH = 2_000
const TOAST_INTERVAL = 500
// Timestamp of when the next reaction should be shown in UI
let nextProcessedTimestamp = 0
export default {
name: 'ReactionToaster',
props: {
/**
* The conversation token
*/
token: {
type: String,
required: true,
},
/**
* Supported reactions
*/
supportedReactions: {
type: Array,
validator: (prop) => prop.every((e) => typeof e === 'string'),
required: true,
},
callParticipantModels: {
type: Array,
required: true,
},
},
setup() {
const guestNameStore = useGuestNameStore()
return {
guestNameStore,
actorStore: useActorStore(),
}
},
data() {
return {
registeredModels: {},
reactionsQueue: [],
intervalId: null,
toasts: [],
}
},
computed: {
participants() {
return this.$store.getters.participantsList(this.token)
},
},
watch: {
callParticipantModels: {
handler(models) {
// subscribe connected models for reaction signals
const addedModels = models.filter((model) => !this.registeredModels[model.attributes.peerId])
addedModels.forEach((addedModel) => {
this.registeredModels[addedModel.attributes.peerId] = addedModel
this.registeredModels[addedModel.attributes.peerId].on('reaction', this.handleReaction)
})
// unsubscribe disconnected models
const removedModelIds = Object.keys(this.registeredModels).filter((registeredModelId) => !models.find((model) => model.attributes.peerId === registeredModelId))
removedModelIds.forEach((removedModelId) => {
this.registeredModels[removedModelId].off('reaction', this.handleReaction)
delete this.registeredModels[removedModelId]
})
},
immediate: true,
},
},
mounted() {
this.intervalId = setInterval(this.processReactionsQueue, TOAST_INTERVAL)
subscribe('send-reaction', this.handleOwnReaction)
},
beforeUnmount() {
clearInterval(this.intervalId)
unsubscribe('send-reaction', this.handleOwnReaction)
Object.keys(this.registeredModels).forEach((modelId) => {
this.registeredModels[modelId].off('reaction', this.handleReaction)
delete this.registeredModels[modelId]
})
},
methods: {
t,
handleOwnReaction({ model, reaction }) {
this.handleReaction(model, reaction, true)
},
handleReaction(model, reaction, isLocalModel = false) {
// prevent spamming to queue from a single account (unless in debug mode)
if (!OC.debug && this.reactionsQueue.some((item) => item.id === model.attributes.peerId)) {
return
}
// prevent receiving anything rather than defined reactions in capabilities
if (!this.supportedReactions.includes(reaction)) {
return
}
this.reactionsQueue.push({
id: model.attributes.peerId,
reaction,
reactionURL: this.getReactionURL(reaction),
name: isLocalModel
? this.actorStore.displayName || t('spreed', 'Guest')
: this.getParticipantName(model),
seed: Math.random(),
timestamp: Date.now(),
})
},
processReactionsQueue() {
if (this.reactionsQueue.length === 0) {
return
}
// Prevent spamming with reactions, if tab was suspended
const now = Date.now()
if (now < nextProcessedTimestamp) {
return
}
// Discard reactions, that should have been fired long ago
if (this.reactionsQueue.at(0).timestamp < now - 30_000) {
this.reactionsQueue = this.reactionsQueue.filter((reaction) => reaction.timestamp >= now - 30_000)
if (this.reactionsQueue.length === 0) {
return
}
}
nextProcessedTimestamp = now + TOAST_INTERVAL
// Move reactions from queue to visible array
this.toasts.push(this.reactionsQueue.shift())
// Delete reactions from array after animation ends
setTimeout(() => {
this.toasts.shift()
}, ANIMATION_LENGTH)
},
getParticipantName(model) {
const { name, nextcloudSessionId } = model.attributes
if (name) {
return name
}
const participant = this.participants.find((participant) => participant.sessionIds.includes(nextcloudSessionId))
if (participant?.displayName) {
return participant.displayName
}
return this.guestNameStore.getGuestName(this.token, Hex.stringify(SHA1(nextcloudSessionId)))
},
getReactionURL(emoji) {
return reactions[emoji]
? imagePath('spreed', 'emojis/' + reactions[emoji])
: undefined
},
styled(name, seed) {
const color = usernameToColor(name)
return {
'--background-color': `rgb(${color.r}, ${color.g}, ${color.b})`,
'--animation-length': `${ANIMATION_LENGTH + 300}ms`,
'--horizontal-offset': `${10 + 20 * seed}%`,
'--vertical-offset': 30 + 5 * seed,
}
},
},
}
</script>
<style lang="scss" scoped>
.toaster {
position: absolute;
bottom: 20px;
inset-inline-start: 0;
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
z-index: 1;
}
.toast {
position: absolute;
bottom: 0;
inset-inline-start: var(--horizontal-offset, 0);
display: flex;
align-items: center;
gap: 8px;
animation: toast-floating var(--animation-length) linear;
&__reaction {
font-size: 250%;
line-height: 100%;
@media only screen and (max-width: 1920px) {
& {
font-size: 150%;
}
&-img {
width: 30px;
height: 30px;
}
}
}
&__name {
padding: 8px 12px;
border-radius: 6px;
line-height: 100%;
white-space: nowrap;
color: #ffffff;
background-color: var(--background-color);
box-shadow: 1px 1px 4px var(--color-box-shadow);
}
}
@keyframes toast-floating {
0% {
transform: translateY(0);
opacity: 0;
}
5% {
transform: translateY(calc(-0.05 * var(--vertical-offset) * 1vh));
opacity: 1;
}
50% {
transform: translateY(calc(-0.5 * var(--vertical-offset) * 1vh));
opacity: 1;
}
100% {
transform: translateY(calc(-1 * var(--vertical-offset) * 1vh));
opacity: 0;
}
}
</style>
@@ -0,0 +1,274 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
:id="screenContainerId"
class="screenContainer"
@dblclick.capture="onDoubleClick">
<video
v-show="(localMediaModel && localMediaModel.attributes.localScreen) || (callParticipantModel && callParticipantModel.attributes.screen)"
ref="screen"
:disablePictureInPicture="!isBig ? 'true' : 'false'"
class="screen"
:class="screenClass" />
<VideoBottomBar
v-if="isBig"
:token="token"
:sharedData="sharedData"
isBig
isScreen
:model="model"
:participantName="remoteParticipantName" />
</div>
</template>
<script>
import { t } from '@nextcloud/l10n'
import Hex from 'crypto-js/enc-hex.js'
import SHA1 from 'crypto-js/sha1.js'
import panzoom from 'panzoom'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import VideoBottomBar from './VideoBottomBar.vue'
import { useGuestNameStore } from '../../../stores/guestName.ts'
import attachMediaStream from '../../../utils/attachmediastream.js'
const ZOOM_MIN = 1
const ZOOM_FACTOR = 4
const ZOOM_MAX = 8
export default {
name: 'ScreenShare',
components: {
VideoBottomBar,
},
props: {
token: {
type: String,
required: true,
},
localMediaModel: {
type: Object,
default: null,
},
callParticipantModel: {
type: Object,
default: null,
},
sharedData: {
type: Object,
required: true,
},
isBig: {
type: Boolean,
default: false,
},
},
setup(props) {
const guestNameStore = useGuestNameStore()
const screen = ref(null)
const instance = ref(null)
const instanceTransform = ref({ x: 0, y: 0, scale: 1 })
const instanceGrabbing = ref(false)
const screenClass = computed(() => {
if (!props.isBig) {
return ['screen--fill']
} else {
return [
'screen--fit',
instanceTransform.value.scale === 1
? 'screen--magnify'
: (instanceGrabbing.value ? 'screen--grabbing' : 'screen--grab'),
]
}
})
onMounted(() => {
if (props.isBig) {
instance.value = panzoom(screen.value, {
minZoom: ZOOM_MIN,
maxZoom: ZOOM_MAX,
bounds: true,
boundsPadding: 1,
})
instance.value.on('zoom', (instance) => {
instanceTransform.value = instance.getTransform()
})
instance.value.on('panstart', () => {
instanceGrabbing.value = true
})
instance.value.on('panend', () => {
instanceGrabbing.value = false
})
}
})
onBeforeUnmount(() => {
instance.value?.dispose()
})
/**
* Overriding method to handle double click event on screen share
*
* @param event Double click event
*/
function onDoubleClick(event) {
if (!instance.value) {
return
}
// panzoom library puts a listener on parent element
event.preventDefault()
event.stopPropagation()
// Calculate the offset of the click event from the top left corner of screen container
const screenContainerRect = event.currentTarget.getBoundingClientRect()
const offsetX = event.clientX - screenContainerRect.left
const offsetY = event.clientY - screenContainerRect.top
if (instanceTransform.value.scale === 1) {
// Zoom in the click point with specified zoom factor
instance.value.smoothZoom(offsetX, offsetY, ZOOM_FACTOR)
} else {
// Zoom out (0 is set to ensure the zoom is reset to 1)
instance.value.smoothZoomAbs(offsetX, offsetY, 0)
}
}
return {
guestNameStore,
screen,
screenClass,
onDoubleClick,
}
},
computed: {
model() {
if (this.callParticipantModel) {
return this.callParticipantModel
}
return this.localMediaModel
},
screenContainerId() {
if (this.localMediaModel) {
return 'localScreenContainer'
}
return 'container_' + this.callParticipantModel.attributes.peerId + '_screen_incoming'
},
remoteSessionHash() {
if (!this.callParticipantModel) {
return null
}
return Hex.stringify(SHA1(this.callParticipantModel.attributes.nextcloudSessionId))
},
remoteParticipantName() {
if (!this.callParticipantModel) {
return t('spreed', 'You')
}
let remoteParticipantName = this.callParticipantModel.attributes.name
// The name is undefined and not shown until a connection is made
// for registered users, so do not fall back to the guest name in
// the store either until the connection was made.
if (!this.callParticipantModel.attributes.userId && !remoteParticipantName && remoteParticipantName !== undefined) {
remoteParticipantName = this.guestNameStore.getGuestName(
this.token,
this.remoteSessionHash,
)
}
return remoteParticipantName
},
},
watch: {
'localMediaModel.attributes.localScreen': function(localScreen) {
this._setScreen(localScreen)
},
'callParticipantModel.attributes.screen': function(screen) {
this._setScreen(screen)
},
},
mounted() {
// Set initial state
if (this.localMediaModel) {
this._setScreen(this.localMediaModel.attributes.localScreen)
} else {
this._setScreen(this.callParticipantModel.attributes.screen)
}
},
methods: {
t,
_setScreen(screen) {
if (!screen) {
this.$refs.screen.srcObject = null
return
}
// The audio is played using an audio element in the model to be
// able to hear it even if there is no view for it.
attachMediaStream(screen, this.$refs.screen)
this.$refs.screen.muted = true
},
},
}
</script>
<style lang="scss" scoped>
.screenContainer {
width: 100%;
height: 100%;
}
.screen {
width: 100%;
height: 100%;
position: absolute;
top: 0;
inset-inline-start: 0;
&--fit {
object-fit: contain;
}
&--fill {
object-fit: cover;
}
&--magnify {
cursor: zoom-in;
}
&--grab {
cursor: grab;
}
&--grabbing {
cursor: grabbing;
}
}
</style>
@@ -0,0 +1,194 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, test } from 'vitest'
import TranscriptBlock from './TranscriptBlock.vue'
describe('TranscriptBlock.vue', () => {
describe('remove last chunk from lines', () => {
let wrapper
let lines
beforeEach(() => {
wrapper = shallowMount(TranscriptBlock, {
props: {
token: 'theToken',
model: {
attributes: {
peerId: 'thePeerId',
actorId: 'theActorId',
actorType: 'theActorType',
userId: 'theUserId',
name: 'The user name',
},
},
chunks: [],
rightToLeft: false,
},
})
lines = wrapper.vm.$data.lines
})
test('no lines', () => {
wrapper.vm.removeLastChunkFromLines()
expect(lines.length).toBe(0)
})
test('single line with single chunk', () => {
lines.push({
firstChunkIndex: 42,
lastChunkIndex: 42,
})
wrapper.vm.removeLastChunkFromLines()
expect(lines.length).toBe(0)
})
test('single line with several chunks', () => {
lines.push({
firstChunkIndex: 42,
lastChunkIndex: 108,
})
wrapper.vm.removeLastChunkFromLines()
expect(lines).toEqual([
{
firstChunkIndex: 42,
lastChunkIndex: 107,
},
])
})
test('several lines with single chunk', () => {
lines.push({
firstChunkIndex: 42,
lastChunkIndex: 42,
})
lines.push({
firstChunkIndex: 42,
lastChunkIndex: 42,
})
lines.push({
firstChunkIndex: 42,
lastChunkIndex: 42,
})
wrapper.vm.removeLastChunkFromLines()
expect(lines.length).toBe(0)
})
describe('several lines with several chunks', () => {
test('last chunk filling last line', () => {
lines.push({
firstChunkIndex: 23,
lastChunkIndex: 42,
})
lines.push({
firstChunkIndex: 108,
lastChunkIndex: 108,
})
wrapper.vm.removeLastChunkFromLines()
expect(lines).toEqual([
{
firstChunkIndex: 23,
lastChunkIndex: 42,
},
])
})
test('last chunk filling several lines', () => {
lines.push({
firstChunkIndex: 23,
lastChunkIndex: 42,
})
lines.push({
firstChunkIndex: 108,
lastChunkIndex: 108,
})
lines.push({
firstChunkIndex: 108,
lastChunkIndex: 108,
})
lines.push({
firstChunkIndex: 108,
lastChunkIndex: 108,
})
wrapper.vm.removeLastChunkFromLines()
expect(lines).toEqual([
{
firstChunkIndex: 23,
lastChunkIndex: 42,
},
])
})
test('last chunk partially in last line', () => {
lines.push({
firstChunkIndex: 23,
lastChunkIndex: 42,
})
lines.push({
firstChunkIndex: 42,
lastChunkIndex: 108,
})
wrapper.vm.removeLastChunkFromLines()
expect(lines).toEqual([
{
firstChunkIndex: 23,
lastChunkIndex: 42,
},
{
firstChunkIndex: 42,
lastChunkIndex: 107,
},
])
})
test('last chunk partially in several lines', () => {
lines.push({
firstChunkIndex: 23,
lastChunkIndex: 42,
})
lines.push({
firstChunkIndex: 42,
lastChunkIndex: 108,
})
lines.push({
firstChunkIndex: 108,
lastChunkIndex: 108,
})
lines.push({
firstChunkIndex: 108,
lastChunkIndex: 108,
})
wrapper.vm.removeLastChunkFromLines()
expect(lines).toEqual([
{
firstChunkIndex: 23,
lastChunkIndex: 42,
},
{
firstChunkIndex: 42,
lastChunkIndex: 107,
},
])
})
})
})
})
@@ -0,0 +1,416 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
class="transcript-block"
:style="transcriptBlockStyle">
<div class="transcript-block__avatar">
<AvatarWrapper
:id="actorId"
:token="token"
:name="actorDisplayName"
:source="actorType"
:size="AVATAR.SIZE.SMALL"
:disableMenu="true" />
</div>
<div class="transcript-block__text">
<p class="transcript-block__author">
{{ actorInfo }}
</p>
<p
ref="chunksWrapper"
class="transcript-block__chunks">
<span
v-for="(item, index) in chunksWithSeparator"
ref="chunks"
:key="index"
:lang="item.languageId">
{{ item.message }}
</span>
</p>
</div>
</div>
</template>
<script lang="ts">
import type { PropType, StyleValue } from 'vue'
import AvatarWrapper from '../../AvatarWrapper/AvatarWrapper.vue'
import { ATTENDEE, AVATAR } from '../../../constants.ts'
import { useLiveTranscriptionStore } from '../../../stores/liveTranscription.ts'
import { getDisplayNameWithFallback } from '../../../utils/getDisplayName.ts'
declare module 'vue' {
interface TypeRefs {
chunksWrapper: HTMLParagraphElement
chunks: undefined | Array<HTMLSpanElement>
}
interface ComponentCustomProperties {
$refs: TypeRefs
}
}
interface CallParticipantModel {
attributes: {
peerId: string
actorId: string | null | undefined
actorType: string | null | undefined
userId: string | null | undefined
name: string | null | undefined
}
}
interface Chunk {
message: string
languageId: string
final: boolean
}
interface ChunkElementData {
message: string
languageId: string
}
export type {
Chunk,
}
export default {
name: 'TranscriptBlock',
components: {
AvatarWrapper,
},
props: {
/**
* The conversation token.
*/
token: {
type: String,
required: true,
},
/**
* The CallParticipantModel for the participant being transcribed.
*/
model: {
type: Object as PropType<CallParticipantModel>,
required: true,
},
/**
* The transcript chunks.
*/
chunks: {
type: Array as PropType<Array<Chunk>>,
required: true,
},
/**
* Whether the transcript is written right to left.
*/
rightToLeft: {
type: Boolean,
required: false,
default: false,
},
},
setup() {
const liveTranscriptionStore = useLiveTranscriptionStore()
return {
liveTranscriptionStore,
}
},
data() {
return {
AVATAR,
resizeObserver: null as null | ResizeObserver,
lines: [] as Array<{
firstChunkIndex: number
lastChunkIndex: number
}>,
}
},
computed: {
transcriptBlockStyle() {
return {
direction: this.rightToLeft ? 'rtl' : 'ltr',
} as StyleValue
},
actorId() {
return this.model.attributes.actorId || ''
},
actorType() {
return this.model.attributes.actorType || ''
},
actorDisplayName() {
return this.model.attributes.name || ''
},
actorDisplayNameWithFallback() {
return getDisplayNameWithFallback(this.actorDisplayName, this.actorType)
},
remoteServer() {
return this.actorType === ATTENDEE.ACTOR_TYPE.FEDERATED_USERS
? '(' + this.actorId.split('@').pop() + ')'
: ''
},
actorInfo() {
return [this.actorDisplayNameWithFallback, this.remoteServer]
.filter((value) => value).join(' ')
},
liveTranscriptionLanguages() {
const liveTranscriptionLanguages = this.liveTranscriptionStore.getLiveTranscriptionLanguages()
if (!liveTranscriptionLanguages) {
return {}
}
return liveTranscriptionLanguages
},
chunksWithSeparator() {
const chunksWithSeparator = [] as Array<ChunkElementData>
if (!this.chunks.length) {
return chunksWithSeparator
}
// The returned languageId is a BCP 47 language tag (to be used in
// the HTML "lang" attribute), but the language and region may be
// separated by "_" in the language metadata, so it needs to be
// replaced by "-".
chunksWithSeparator.push({
message: this.chunks[0].message,
languageId: this.chunks[0].languageId.replace('_', '-'),
})
for (let i = 1; i < this.chunks.length; i++) {
const separator = this.getSeparatorBetweenChunks(this.chunks[i - 1], this.chunks[i])
chunksWithSeparator.push({
message: separator + this.chunks[i].message,
languageId: this.chunks[i].languageId.replace('_', '-'),
})
}
return chunksWithSeparator
},
},
mounted() {
this.resizeObserver = new ResizeObserver(this.handleChunksWrapperResize)
this.resizeObserver.observe(this.$refs.chunksWrapper)
},
beforeUnmount() {
this.resizeObserver!.disconnect()
},
methods: {
reset() {
this.lines = []
this.$refs.chunksWrapper!.style.removeProperty('min-height')
},
handleChunksWrapperResize(entries: ResizeObserverEntry[], observer: ResizeObserver) {
if (!this.$refs.chunksWrapper) {
return
}
const height = parseFloat(window.getComputedStyle(this.$refs.chunksWrapper).getPropertyValue('height'))
const minHeight = parseFloat(window.getComputedStyle(this.$refs.chunksWrapper).getPropertyValue('min-height'))
if (height > minHeight || Number.isNaN(minHeight)) {
this.$refs.chunksWrapper.style.setProperty('min-height', `${height}px`)
}
},
removeLastChunkFromLines() {
if (!this.lines.length) {
return
}
const lastKnownChunkIndex = this.lines.at(-1)!.lastChunkIndex
while (this.lines.length && this.lines.at(-1)!.firstChunkIndex === this.lines.at(-1)!.lastChunkIndex) {
this.lines.splice(-1, 1)
}
if (this.lines.length && this.lines.at(-1)!.lastChunkIndex === lastKnownChunkIndex) {
this.lines.at(-1)!.lastChunkIndex--
}
},
updateLines() {
if (!this.$refs.chunks || !this.$refs.chunks.length) {
return
}
// Remove information of last chunk to regenerate it, as it could
// have been updated and thus its lines could have changed.
this.removeLastChunkFromLines()
if (!this.lines.length) {
const firstChunkClientRectsLength = this.$refs.chunks[0].getClientRects().length
// If there is a single chunk and it has several bounding
// rectangles each rectangle will be in its own line.
for (let i = 0; i < firstChunkClientRectsLength; i++) {
this.lines.push({
firstChunkIndex: 0,
lastChunkIndex: 0,
})
}
}
const lastKnownChunkIndex = this.lines.at(-1)!.lastChunkIndex
if (lastKnownChunkIndex >= this.$refs.chunks.length - 1) {
return
}
let lastKnownChunkElement = this.$refs.chunks[lastKnownChunkIndex]
let lastKnownChunkElementTop = lastKnownChunkElement.getClientRects()[lastKnownChunkElement.getClientRects().length - 1].top
for (let i = lastKnownChunkIndex + 1; i < this.$refs.chunks.length; i++) {
const nextChunkElement = this.$refs.chunks[i]
const nextChunkElementClientRects = nextChunkElement.getClientRects()
const nextChunkElementTop = nextChunkElementClientRects[0].top
// If the first bounding rectangle has the same top value as the
// last known one they will be in the same line. Otherwise it
// will be in a new line.
if (nextChunkElementTop === lastKnownChunkElementTop) {
this.lines.at(-1)!.lastChunkIndex = i
} else {
this.lines.push({
firstChunkIndex: i,
lastChunkIndex: i,
})
}
// Any bounding rectangle after the first one will be in its own
// line.
for (let j = 1; j < nextChunkElementClientRects.length; j++) {
this.lines.push({
firstChunkIndex: i,
lastChunkIndex: i,
})
}
lastKnownChunkElement = nextChunkElement
lastKnownChunkElementTop = lastKnownChunkElement.getClientRects()[lastKnownChunkElement.getClientRects().length - 1].top
}
},
getLineBoundaries() {
this.updateLines()
const lineHeight = parseFloat(window.getComputedStyle(this.$el).getPropertyValue('line-height'))
let clientRectIndex = 0
return this.lines.map((line, index) => {
const clientRectsOfLastChunkInLine = this.$refs.chunks![line.lastChunkIndex].getClientRects()
if (index > 0 && line.lastChunkIndex === this.lines[index - 1].lastChunkIndex) {
clientRectIndex++
} else {
clientRectIndex = 0
}
const currentClientRectsOfLastChunkInLine = clientRectsOfLastChunkInLine[clientRectIndex]
// Chunks are shown as inline spans, which do not have the full
// line height. The spans are vertically centered on the line,
// so there is the same extra space at the top and at the
// bottom.
const chunkHeight = currentClientRectsOfLastChunkInLine.bottom - currentClientRectsOfLastChunkInLine.top
const chunkToLineHeightDifference = lineHeight - chunkHeight
return {
top: currentClientRectsOfLastChunkInLine.top - (chunkToLineHeightDifference / 2),
bottom: currentClientRectsOfLastChunkInLine.bottom + (chunkToLineHeightDifference / 2),
}
})
},
getSeparatorBetweenChunks(chunk1: Chunk, chunk2: Chunk) {
if (chunk1.languageId !== chunk2.languageId) {
return ' '
}
if (this.liveTranscriptionLanguages[chunk1.languageId]?.metadata) {
return this.liveTranscriptionLanguages[chunk1.languageId].metadata.separator
}
return ' '
},
},
}
</script>
<style lang="scss" scoped>
.transcript-block {
display: flex;
flex-direction: row;
align-items: flex-start;
align-self: end;
width: 100%;
background-color: rgba(34, 34, 34, 0.8);
color: white;
&__avatar {
position: sticky;
top: 0;
padding: calc(2 * var(--default-grid-baseline));
margin-top: calc(2 * var(--default-grid-baseline));
}
&__text {
display: flex;
flex-direction: column;
padding-inline-end: var(--default-grid-baseline);
}
&__author {
color: var(--color-text-maxcontrast);
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* Move the author closer to the chunks while keeping the line height,
* but not too much to avoid "overflowing" the line (which could
* partially show the author after scrolling to another line).
*/
margin-top: 4px;
margin-bottom: -4px;
}
&__chunks {
&::first-letter {
text-transform: capitalize;
}
}
}
</style>
@@ -0,0 +1,61 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="video-background" :style="{ 'background-color': backgroundColor }" />
</template>
<script>
import { usernameToColor } from '@nextcloud/vue/functions/usernameToColor'
export default {
name: 'VideoBackground',
props: {
displayName: {
type: String,
default: null,
},
user: {
type: String,
default: '',
},
},
computed: {
backgroundColor() {
// If the prop is empty. We're not checking for the default value
// because the user's displayName might be '?'
if (!this.displayName) {
return 'var(--color-text-maxcontrast)'
} else {
const color = usernameToColor(this.displayName)
return `rgb(${color.r}, ${color.g}, ${color.b})`
}
},
},
}
</script>
<style lang="scss" scoped>
.video-background {
position: absolute;
inset-inline-start: 0;
top: 0;
height: 100%;
width: 100%;
&::after {
content: ' ';
background-color: rgba(0, 0, 0, 0.12);
position: absolute;
width: 100%;
height: 100%;
top: 0;
inset-inline-start: 0;
}
}
</style>
@@ -0,0 +1,391 @@
/*
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { mount } from '@vue/test-utils'
import { cloneDeep } from 'lodash'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { createStore } from 'vuex'
import NcButton from '@nextcloud/vue/components/NcButton'
import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
import IconHandBackLeft from 'vue-material-design-icons/HandBackLeft.vue'
import IconVideo from 'vue-material-design-icons/Video.vue'
import IconVideoOffOutline from 'vue-material-design-icons/VideoOffOutline.vue'
import VideoBottomBar from './VideoBottomBar.vue'
import { CONVERSATION, PARTICIPANT } from '../../../constants.ts'
import storeConfig from '../../../store/storeConfig.js'
import { useActorStore } from '../../../stores/actor.ts'
import { useCallViewStore } from '../../../stores/callView.ts'
import { findNcButton } from '../../../test-helpers.js'
import { ConnectionState } from '../../../utils/webrtc/models/CallParticipantModel.js'
vi.mock('@nextcloud/event-bus', () => ({
emit: vi.fn(),
subscribe: vi.fn(),
}))
describe('VideoBottomBar.vue', () => {
const TOKEN = 'XXTOKENXX'
const PARTICIPANT_NAME = 'John Doe'
const PEER_ID = 'peer-id'
const USER_ID = 'user-id-1'
let store
let callViewStore
let testStoreConfig
let componentProps
let conversationProps
let actorStore
const audioIndicatorAriaLabels = [t('spreed', 'Mute'), t('spreed', 'Muted')]
const videoIndicatorAriaLabels = [t('spreed', 'Disable video'), t('spreed', 'Enable video')]
const screenSharingAriaLabel = t('spreed', 'Show screen')
const followingButtonAriaLabel = t('spreed', 'Stop following')
beforeEach(() => {
setActivePinia(createPinia())
callViewStore = useCallViewStore()
actorStore = useActorStore()
conversationProps = {
token: TOKEN,
lastCommonReadMessage: 0,
type: CONVERSATION.TYPE.GROUP,
participantType: PARTICIPANT.TYPE.OWNER,
readOnly: CONVERSATION.STATE.READ_WRITE,
}
componentProps = {
token: TOKEN,
model: {
attributes: {
connectionState: ConnectionState.CONNECTED,
raisedHand: {
state: false,
},
audioAvailable: true,
videoAvailable: true,
screen: true,
speaking: false,
peerId: PEER_ID,
},
forceMute: vi.fn(),
},
participantName: PARTICIPANT_NAME,
sharedData: {
remoteVideoBlocker: {
isVideoEnabled: vi.fn().mockReturnValue(true),
setVideoEnabled: vi.fn(),
},
},
}
testStoreConfig = cloneDeep(storeConfig)
testStoreConfig.modules.conversationsStore.getters.conversation = vi.fn().mockReturnValue((token) => conversationProps)
actorStore.userId = USER_ID
store = createStore(testStoreConfig)
})
afterEach(() => {
vi.clearAllMocks()
})
/**
* Shared function to mount component
*/
function mountVideoBottomBar(props) {
return mount(VideoBottomBar, {
global: {
plugins: [store],
},
props,
})
}
describe('unit tests', () => {
describe('render component', () => {
test('component renders properly', async () => {
const wrapper = mountVideoBottomBar(componentProps)
expect(wrapper.exists()).toBeTruthy()
expect(wrapper.classes('wrapper')).toBeDefined()
})
test('component has class "wrapper--big" for main view', async () => {
componentProps.isBig = true
const wrapper = mountVideoBottomBar(componentProps)
expect(wrapper.exists()).toBeTruthy()
expect(wrapper.classes('wrapper--big')).toBeDefined()
})
test('component renders all indicators by default', async () => {
const wrapper = mountVideoBottomBar(componentProps)
const indicators = wrapper.findAllComponents(NcButton)
expect(indicators).toHaveLength(3)
})
test('component does not render indicators for ScreenShare.vue component', async () => {
componentProps.isScreen = true
const wrapper = mountVideoBottomBar(componentProps)
const indicators = wrapper.findAllComponents(NcButton)
expect(indicators).toHaveLength(0)
})
test('component does not show indicators after video overlay is off', async () => {
const wrapper = mountVideoBottomBar(componentProps)
componentProps.showVideoOverlay = false
await wrapper.setProps(cloneDeep(componentProps))
const indicators = wrapper.findAllComponents(NcButton)
indicators.forEach((indicator) => {
expect(indicator.isVisible()).toBeFalsy()
})
})
test('component does not render anything when used in sidebar', async () => {
componentProps.isSidebar = true
const wrapper = mountVideoBottomBar(componentProps)
const participantName = wrapper.find('.participant-name')
expect(participantName.exists()).toBeFalsy()
const indicators = wrapper.findAllComponents(NcButton)
expect(indicators).toHaveLength(0)
})
})
describe('render participant name', () => {
test('name is shown by default', () => {
const wrapper = mountVideoBottomBar(componentProps)
const participantName = wrapper.find('.participant-name')
expect(participantName.isVisible()).toBeTruthy()
expect(participantName.text()).toBe(PARTICIPANT_NAME)
})
test('name is not shown if all checks are falsy', () => {
componentProps.showVideoOverlay = false
const wrapper = mountVideoBottomBar(componentProps)
const participantName = wrapper.find('.participant-name')
expect(participantName.isVisible()).toBeFalsy()
})
})
describe('render indicators', () => {
describe('connection failed indicator', () => {
test('indicator is not shown by default, other indicators are visible', () => {
componentProps.model.attributes.raisedHand.state = true
const wrapper = mountVideoBottomBar(componentProps)
const iceFailedIndicator = wrapper.findComponent(IconAlertCircleOutline)
expect(iceFailedIndicator.exists()).toBeFalsy()
const raiseHandIndicator = wrapper.findComponent(IconHandBackLeft)
expect(raiseHandIndicator.exists()).toBeTruthy()
const indicators = wrapper.findAllComponents(NcButton)
indicators.forEach((indicator) => {
expect(indicator.isVisible()).toBeTruthy()
})
})
test('indicator is shown when model prop is true, other indicators are hidden', () => {
componentProps.model.attributes.raisedHand.state = true
componentProps.model.attributes.connectionState = ConnectionState.FAILED_NO_RESTART
const wrapper = mountVideoBottomBar(componentProps)
const iceFailedIndicator = wrapper.findComponent(IconAlertCircleOutline)
expect(iceFailedIndicator.exists()).toBeTruthy()
const raiseHandIndicator = wrapper.findComponent(IconHandBackLeft)
expect(raiseHandIndicator.exists()).toBeFalsy()
const indicators = wrapper.findAllComponents(NcButton)
indicators.forEach((indicator) => {
expect(indicator.isVisible()).toBeFalsy()
})
})
})
describe('raise hand indicator', () => {
test('indicator is not shown by default', () => {
const wrapper = mountVideoBottomBar(componentProps)
const raiseHandIndicator = wrapper.findComponent(IconHandBackLeft)
expect(raiseHandIndicator.exists()).toBeFalsy()
})
test('indicator is shown when model prop is true', () => {
componentProps.model.attributes.raisedHand.state = true
const wrapper = mountVideoBottomBar(componentProps)
const raiseHandIndicator = wrapper.findComponent(IconHandBackLeft)
expect(raiseHandIndicator.exists()).toBeTruthy()
})
})
})
describe('render buttons', () => {
describe('audio indicator', () => {
test('button is rendered properly', () => {
const wrapper = mountVideoBottomBar(componentProps)
const audioIndicator = findNcButton(wrapper, audioIndicatorAriaLabels)
expect(audioIndicator.exists()).toBeTruthy()
})
test('button is visible for moderators when audio is available', () => {
const wrapper = mountVideoBottomBar(componentProps)
const audioIndicator = findNcButton(wrapper, audioIndicatorAriaLabels)
expect(audioIndicator.isVisible()).toBeTruthy()
})
test('button is not rendered for non-moderators when audio is available', () => {
conversationProps.participantType = PARTICIPANT.TYPE.USER
const wrapper = mountVideoBottomBar(componentProps)
const audioIndicator = findNcButton(wrapper, audioIndicatorAriaLabels)
expect(audioIndicator.exists()).toBeFalsy()
})
test('button is visible for everyone when audio is unavailable', () => {
conversationProps.participantType = PARTICIPANT.TYPE.USER
componentProps.model.attributes.audioAvailable = false
const wrapper = mountVideoBottomBar(componentProps)
const audioIndicator = findNcButton(wrapper, audioIndicatorAriaLabels)
expect(audioIndicator.isVisible()).toBeTruthy()
})
test('button is enabled for moderators', () => {
const wrapper = mountVideoBottomBar(componentProps)
const audioIndicator = findNcButton(wrapper, audioIndicatorAriaLabels)
expect(audioIndicator.attributes('disabled')).toBeUndefined()
})
test('button is disabled when audio is unavailable', () => {
componentProps.model.attributes.audioAvailable = false
const wrapper = mountVideoBottomBar(componentProps)
const audioIndicator = findNcButton(wrapper, audioIndicatorAriaLabels)
expect(audioIndicator.attributes('disabled')).toBeDefined()
})
test('method is called after click', async () => {
const wrapper = mountVideoBottomBar(componentProps)
const audioIndicator = findNcButton(wrapper, audioIndicatorAriaLabels)
await audioIndicator.trigger('click')
expect(wrapper.vm.$props.model.forceMute).toHaveBeenCalled()
})
})
describe('video indicator', () => {
test('button is rendered properly', () => {
const wrapper = mountVideoBottomBar(componentProps)
const videoIndicator = findNcButton(wrapper, videoIndicatorAriaLabels)
expect(videoIndicator.exists()).toBeTruthy()
})
test('button is visible when video is available', () => {
const wrapper = mountVideoBottomBar(componentProps)
const videoIndicator = findNcButton(wrapper, videoIndicatorAriaLabels)
expect(videoIndicator.isVisible()).toBeTruthy()
})
test('button is not rendered when video is unavailable', () => {
componentProps.model.attributes.videoAvailable = false
const wrapper = mountVideoBottomBar(componentProps)
const videoIndicator = findNcButton(wrapper, videoIndicatorAriaLabels)
expect(videoIndicator.exists()).toBeFalsy()
})
test('button shows proper icon if video is enabled', () => {
const wrapper = mountVideoBottomBar(componentProps)
const videoOnIcon = wrapper.findComponent(IconVideo)
expect(videoOnIcon.exists()).toBeTruthy()
})
test('button shows proper icon if video is blocked', () => {
componentProps.sharedData.remoteVideoBlocker.isVideoEnabled.mockReturnValue(false)
const wrapper = mountVideoBottomBar(componentProps)
const videoOffIcon = wrapper.findComponent(IconVideoOffOutline)
expect(videoOffIcon.exists()).toBeTruthy()
})
test('method is called after click', async () => {
const wrapper = mountVideoBottomBar(componentProps)
const videoIndicator = findNcButton(wrapper, videoIndicatorAriaLabels)
await videoIndicator.trigger('click')
expect(wrapper.vm.$props.sharedData.remoteVideoBlocker.setVideoEnabled).toHaveBeenCalled()
expect(wrapper.vm.$props.sharedData.remoteVideoBlocker.setVideoEnabled).toHaveBeenCalledWith(false)
})
})
describe('screen sharing indicator', () => {
test('button is rendered properly', () => {
const wrapper = mountVideoBottomBar(componentProps)
const screenSharingIndicator = findNcButton(wrapper, screenSharingAriaLabel)
expect(screenSharingIndicator.exists()).toBeTruthy()
})
test('button is visible when screen is available', () => {
const wrapper = mountVideoBottomBar(componentProps)
const screenSharingIndicator = findNcButton(wrapper, screenSharingAriaLabel)
expect(screenSharingIndicator.isVisible()).toBeTruthy()
})
test('button is not rendered when screen is unavailable', () => {
componentProps.model.attributes.screen = false
const wrapper = mountVideoBottomBar(componentProps)
const screenSharingIndicator = findNcButton(wrapper, screenSharingAriaLabel)
expect(screenSharingIndicator.exists()).toBeFalsy()
})
test('component emits peer id after click', async () => {
const wrapper = mountVideoBottomBar(componentProps)
const screenSharingIndicator = findNcButton(wrapper, screenSharingAriaLabel)
await screenSharingIndicator.trigger('click')
expect(emit).toHaveBeenCalledWith('switch-screen-to-id', PEER_ID)
})
})
describe('following button', () => {
test('button is not rendered for participants by default', () => {
const wrapper = mountVideoBottomBar(componentProps)
const followingButton = findNcButton(wrapper, followingButtonAriaLabel)
expect(followingButton.exists()).toBeFalsy()
})
test('button is not rendered for main speaker by default', () => {
componentProps.isBig = true
const wrapper = mountVideoBottomBar(componentProps)
const followingButton = findNcButton(wrapper, followingButtonAriaLabel)
expect(followingButton.exists()).toBeFalsy()
})
test('button is rendered when source is selected', () => {
callViewStore.setSelectedVideoPeerId(PEER_ID)
componentProps.isBig = true
const wrapper = mountVideoBottomBar(componentProps)
const followingButton = findNcButton(wrapper, followingButtonAriaLabel)
expect(followingButton.exists()).toBeTruthy()
})
test('method is called after click', async () => {
callViewStore.setSelectedVideoPeerId(PEER_ID)
callViewStore.startPresentation(TOKEN)
expect(callViewStore.selectedVideoPeerId).toBe(PEER_ID)
expect(callViewStore.presentationStarted).toBeTruthy()
componentProps.isBig = true
const wrapper = mountVideoBottomBar(componentProps)
const followingButton = findNcButton(wrapper, followingButtonAriaLabel)
await followingButton.trigger('click')
expect(callViewStore.selectedVideoPeerId).toBe(null)
expect(callViewStore.presentationStarted).toBeFalsy()
})
})
})
})
})
@@ -0,0 +1,410 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
class="wrapper"
:class="{ 'wrapper--big': isBig }"
@mouseover.stop="mouseover = true"
@mouseleave.stop="mouseover = false">
<div v-if="showRaiseHandIndicator" class="status-indicator raiseHandIndicator">
<IconHandBackLeft :size="18" fillColor="#ffffff" />
</div>
<div v-if="!isSidebar" class="bottom-bar">
<div
v-show="showParticipantName"
class="participant-name"
:class="{
'participant-name--active': isCurrentlyActive,
'participant-name--has-shadow': hasShadow,
}">
{{ participantName }}
</div>
<div
v-if="!isScreen"
v-show="showVideoOverlay"
class="media-indicators">
<NcButton
v-if="showAudioIndicator"
:title="audioButtonTitle"
:aria-label="audioButtonTitle"
class="audioIndicator"
variant="tertiary-no-background"
:disabled="isAudioButtonDisabled"
@click.stop="forceMute">
<template #icon>
<IconMicrophone v-if="model.attributes.audioAvailable" :size="20" />
<NcIconSvgWrapper v-else :svg="IconMicrophoneOffOutline" :size="20" />
</template>
</NcButton>
<NcButton
v-if="showVideoIndicator"
:title="videoButtonTitle"
:aria-label="videoButtonTitle"
class="videoIndicator"
variant="tertiary-no-background"
@click.stop="toggleVideo">
<template #icon>
<IconVideo v-if="isRemoteVideoEnabled" :size="20" />
<IconVideoOffOutline v-else :size="20" />
</template>
</NcButton>
<NcButton
v-if="showScreenSharingIndicator"
:title="t('spreed', 'Show screen')"
:aria-label="t('spreed', 'Show screen')"
class="screenSharingIndicator"
:class="{ 'screen-visible': sharedData.screenVisible }"
variant="tertiary-no-background"
@click.stop="switchToScreen">
<template #icon>
<IconMonitor :size="20" />
</template>
</NcButton>
<div
v-if="connectionStateFailedNoRestart"
class="status-indicator iceFailedIndicator">
<IconAlertCircleOutline :size="20" />
</div>
</div>
<NcButton
v-if="showStopFollowingButton"
class="following-button"
variant="tertiary"
@click="handleStopFollowing">
{{ t('spreed', 'Stop following') }}
</NcButton>
</div>
</div>
</template>
<script>
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
import IconHandBackLeft from 'vue-material-design-icons/HandBackLeft.vue' // Filled for better indication
import IconMicrophone from 'vue-material-design-icons/Microphone.vue' // Filled for better indication
import IconMonitor from 'vue-material-design-icons/Monitor.vue'
import IconVideo from 'vue-material-design-icons/Video.vue' // Filled for better indication
import IconVideoOffOutline from 'vue-material-design-icons/VideoOffOutline.vue'
import IconMicrophoneOffOutline from '../../../../img/material-icons/microphone-off-outline.svg?raw'
import { PARTICIPANT } from '../../../constants.ts'
import { useActorStore } from '../../../stores/actor.ts'
import { useCallViewStore } from '../../../stores/callView.ts'
import { ConnectionState } from '../../../utils/webrtc/models/CallParticipantModel.js'
export default {
name: 'VideoBottomBar',
components: {
IconAlertCircleOutline,
IconHandBackLeft,
IconMicrophone,
IconMonitor,
IconVideo,
IconVideoOffOutline,
NcButton,
NcIconSvgWrapper,
},
inheritAttrs: false,
props: {
token: {
type: String,
required: true,
},
isSidebar: {
type: Boolean,
default: false,
},
hasShadow: {
type: Boolean,
default: false,
},
isBig: {
type: Boolean,
default: false,
},
participantName: {
type: String,
default: '',
},
showVideoOverlay: {
type: Boolean,
default: true,
},
model: {
type: Object,
required: true,
},
sharedData: {
type: Object,
required: true,
},
// True if the bottom bar is used in the screen component
isScreen: {
type: Boolean,
default: false,
},
// The current promoted participant
isPromoted: {
type: Boolean,
default: false,
},
// Is the current selected participant
isSelected: {
type: Boolean,
default: false,
},
},
emits: ['bottomBarHover'],
setup() {
return {
IconMicrophoneOffOutline,
callViewStore: useCallViewStore(),
actorStore: useActorStore(),
}
},
data() {
return {
mouseover: false,
}
},
computed: {
connectionStateFailedNoRestart() {
return this.model.attributes.connectionState === ConnectionState.FAILED_NO_RESTART
},
// Common indicators
showRaiseHandIndicator() {
return !this.connectionStateFailedNoRestart && this.model.attributes.raisedHand.state
},
showStopFollowingButton() {
return this.isBig && this.callViewStore.selectedVideoPeerId !== null
},
// Audio indicator
showAudioIndicator() {
return !this.connectionStateFailedNoRestart && !this.isAudioButtonHidden
},
isAudioButtonHidden() {
return this.model.attributes.audioAvailable && !this.canFullModerate
},
isAudioButtonDisabled() {
return !this.model.attributes.audioAvailable || !this.canFullModerate
},
audioButtonTitle() {
return this.model.attributes.audioAvailable
? t('spreed', 'Mute')
: t('spreed', 'Muted')
},
// Video indicator
showVideoIndicator() {
return !this.connectionStateFailedNoRestart && this.model.attributes.videoAvailable
},
isRemoteVideoEnabled() {
return this.sharedData.remoteVideoBlocker?.isVideoEnabled()
},
isRemoteVideoBlocked() {
return this.sharedData.remoteVideoBlocker && !this.sharedData.remoteVideoBlocker.isVideoEnabled()
},
videoButtonTitle() {
return this.isRemoteVideoEnabled
? t('spreed', 'Disable video')
: t('spreed', 'Enable video')
},
// ScreenSharing indicator
showScreenSharingIndicator() {
return !this.connectionStateFailedNoRestart && this.model.attributes.screen
},
// Name indicator
isCurrentlyActive() {
return this.isSelected || this.model.attributes.speaking
},
showParticipantName() {
return !this.model.attributes.videoAvailable || this.isRemoteVideoBlocked
|| this.showVideoOverlay || this.isPromoted || this.isCurrentlyActive
},
// Moderator rights
participantType() {
return this.$store.getters.conversation(this.token)?.participantType
|| (this.actorStore.isLoggedIn
? PARTICIPANT.TYPE.USER
: PARTICIPANT.TYPE.GUEST)
},
canFullModerate() {
return this.participantType === PARTICIPANT.TYPE.OWNER || this.participantType === PARTICIPANT.TYPE.MODERATOR
},
},
watch: {
mouseover(value) {
if (!this.isBig) {
return
}
this.$emit('bottomBarHover', value)
},
},
methods: {
t,
forceMute() {
this.model.forceMute()
},
toggleVideo() {
this.sharedData.remoteVideoBlocker.setVideoEnabled(!this.isRemoteVideoEnabled)
},
switchToScreen() {
if (!this.sharedData.screenVisible || !this.isBig) {
emit('switch-screen-to-id', this.model.attributes.peerId)
}
},
handleStopFollowing() {
this.callViewStore.stopPresentation(this.token)
this.callViewStore.setSelectedVideoPeerId(null)
},
},
}
</script>
<style lang="scss" scoped>
.wrapper {
display: flex;
align-items: center;
position: absolute;
bottom: 0;
width: 100%;
padding: 0 calc(var(--default-grid-baseline) * 2) var(--default-grid-baseline);
z-index: 1;
&--big {
justify-content: center;
margin: 0 var(--default-clickable-area); // grid collapse button
width: calc(100% - var(--default-clickable-area) * 2);
& .bottom-bar {
width: unset;
padding: var(--default-grid-baseline);
&:hover {
background-color: rgba(0, 0, 0, 0.2);
border-radius: var(--border-radius-large);
}
}
& .participant-name {
margin-inline-end: 0;
}
}
}
.bottom-bar {
display: flex;
align-items: center;
gap: var(--default-grid-baseline);
width: 100%;
min-height: var(--default-clickable-area);
& .media-indicators {
display: flex;
}
& .following-button {
opacity: 0.8;
background-color: var(--color-background-dark);
&:hover,
&:focus {
opacity: 1;
}
}
}
.participant-name {
color: white;
margin-block: 0px;
margin-inline: 8px auto;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
filter: drop-shadow(1px 1px 4px var(--color-box-shadow));
&--active {
font-weight: bold;
}
&--has-shadow {
text-shadow: 0 0 4px rgba(0, 0, 0, .8);
}
}
.status-indicator {
width: var(--default-clickable-area);
height: var(--default-clickable-area);
display: flex;
align-items: center;
justify-content: center;
}
.iceFailedIndicator {
opacity: .8 !important;
}
.audioIndicator,
.videoIndicator,
.screenSharingIndicator,
.iceFailedIndicator {
color: #ffffff !important;
}
.audioIndicator[disabled],
.videoIndicator {
opacity: .7;
}
.videoIndicator {
&:hover,
&:focus {
opacity: 1;
}
}
</style>
File diff suppressed because it is too large Load Diff
+789
View File
@@ -0,0 +1,789 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
v-show="!placeholderForPromoted || sharedData.promoted"
:id="(placeholderForPromoted ? 'placeholder-' : '') + 'container_' + peerId + '_video_incoming'"
ref="videoContainer"
class="video-container"
:class="containerClass"
@mouseover="mouseover = true"
@mouseleave="mouseover = false"
@click="$emit('clickVideo')">
<div
v-show="showVideo"
:class="videoWrapperClass"
class="videoWrapper"
:style="videoWrapperStyle">
<video
ref="video"
:disablePictureInPicture="!isBig"
:class="fitVideo ? 'video--fit' : 'video--fill'"
class="video"
@playing="updateVideoAspectRatio" />
<IconAccountOffOutline
v-if="isPresenterOverlay && mouseover"
class="presenter-icon__hide"
:aria-label="t('spreed', 'Hide presenter video')"
:title="t('spreed', 'Hide presenter video')"
:size="32"
@click="$emit('clickPresenter')" />
<NcLoadingIcon
v-if="isLoading"
:size="avatarSize / 2"
class="video-loading" />
<img
v-if="screenshotModeUrl && isPresenterOverlay"
class="dev-mode-video--presenter"
alt="dev-mode-video--presenter"
:src="screenshotModeUrl">
</div>
<ScreenShare
v-if="showSharedScreen"
:isBig="isBig"
:token="token"
:callParticipantModel="model"
:sharedData="sharedData" />
<div
v-if="showBackgroundAndAvatar"
class="avatar-container">
<VideoBackground :displayName="displayName" :user="participantUserId" />
<AvatarWrapper
:id="participantUserId"
:token="token"
:name="displayName"
:source="participantActorType"
:size="avatarSize"
:loading="isLoading"
disableMenu
disableTooltip />
</div>
<div
v-if="showPlaceholderForPromoted"
class="placeholder-for-promoted">
<IconAccountCircleOutline v-if="isPromoted || isSelected" fillColor="#FFFFFF" :size="64" />
</div>
<div
v-if="connectionMessage"
:class="connectionMessageClass"
class="connection-message">
{{ connectionMessage }}
</div>
<slot v-if="!hideBottomBar" name="bottomBar">
<VideoBottomBar
:hasShadow="hasVideo"
:participantName="participantName"
v-bind="$props"
@bottomBarHover="handleHoverEvent" />
</slot>
</div>
</template>
<script>
import { t } from '@nextcloud/l10n'
import Hex from 'crypto-js/enc-hex.js'
import SHA1 from 'crypto-js/sha1.js'
import { inject, ref } from 'vue'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconAccountCircleOutline from 'vue-material-design-icons/AccountCircleOutline.vue'
import IconAccountOffOutline from 'vue-material-design-icons/AccountOffOutline.vue'
import AvatarWrapper from '../../AvatarWrapper/AvatarWrapper.vue'
import ScreenShare from './ScreenShare.vue'
import VideoBackground from './VideoBackground.vue'
import VideoBottomBar from './VideoBottomBar.vue'
import { ATTENDEE, AVATAR } from '../../../constants.ts'
import { EventBus } from '../../../services/EventBus.ts'
import { useCallViewStore } from '../../../stores/callView.ts'
import { useGuestNameStore } from '../../../stores/guestName.ts'
import attachMediaStream from '../../../utils/attachmediastream.js'
import { getDisplayNameWithFallback } from '../../../utils/getDisplayName.ts'
import { ConnectionState } from '../../../utils/webrtc/models/CallParticipantModel.js'
import { placeholderImage } from '../Grid/gridPlaceholders.ts'
export default {
name: 'VideoVue',
components: {
AvatarWrapper,
VideoBackground,
ScreenShare,
VideoBottomBar,
NcLoadingIcon,
// icons
IconAccountCircleOutline,
IconAccountOffOutline,
},
props: {
token: {
type: String,
required: true,
},
placeholderForPromoted: {
type: Boolean,
default: false,
},
model: {
type: Object,
required: true,
},
sharedData: {
type: Object,
required: true,
},
showVideoOverlay: {
type: Boolean,
default: true,
},
isGrid: {
type: Boolean,
default: false,
},
fitVideo: {
type: Boolean,
default: false,
},
isPresenterOverlay: {
type: Boolean,
default: false,
},
isBig: {
type: Boolean,
default: false,
},
// True if this video component is used in the promoted view's video stripe
isStripe: {
type: Boolean,
default: false,
},
// The current promoted participant
isPromoted: {
type: Boolean,
default: false,
},
// Is the current selected participant
isSelected: {
type: Boolean,
default: false,
},
// True when this component is used as main video in the sidebar
isSidebar: {
type: Boolean,
default: false,
},
// True when this video component is used in one to one conversations
isOneToOne: {
type: Boolean,
default: false,
},
unSelectable: {
type: Boolean,
default: false,
},
hideBottomBar: {
type: Boolean,
default: false,
},
},
emits: ['clickVideo', 'clickPresenter', 'forcePromoteVideo'],
setup() {
const screenshotMode = inject('CallView:screenshotModeEnabled', ref(false))
return {
callViewStore: useCallViewStore(),
guestNameStore: useGuestNameStore(),
screenshotMode,
}
},
data() {
return {
videoAspectRatio: null,
containerAspectRatio: null,
resizeObserver: null,
mouseover: false,
}
},
computed: {
videoWrapperStyle() {
if (!this.containerAspectRatio || !this.videoAspectRatio || !this.isBig || this.isGrid) {
return
}
return (this.containerAspectRatio > this.videoAspectRatio)
? `width: ${this.$refs.videoContainer.clientHeight * this.videoAspectRatio}px`
: `height: ${this.$refs.videoContainer.clientWidth / this.videoAspectRatio}px`
},
isSelectable() {
if (this.isStripe) {
return !this.isSelected
} else {
return true
}
},
wasConnectedAtLeastOnce() {
return this.model.attributes.connectedAtLeastOnce
},
isConnected() {
return this.model.attributes.connectionState === ConnectionState.CONNECTED || this.model.attributes.connectionState === ConnectionState.COMPLETED
},
isLoading() {
return !this.isConnected && this.model.attributes.connectionState !== ConnectionState.FAILED_NO_RESTART
},
isDisconnected() {
return this.model.attributes.connectionState !== ConnectionState.NEW && this.model.attributes.connectionState !== ConnectionState.CHECKING
&& this.model.attributes.connectionState !== ConnectionState.CONNECTED && this.model.attributes.connectionState !== ConnectionState.COMPLETED
},
/**
* Whether the connection to the participant is being tried again.
*
* The initial connection to the participant is excluded.
*
* A "failed" connection state will trigger a reconnection, but that may
* not immediately change the "negotiating" or "connecting" attributes
* (for example, while the new offer requested to the HPB was not
* received yet). Similarly, both "negotiating" and "connecting" need to
* be checked, as the negotiation will start before the connection
* attempt is started.
*
* If the negotiation is done while there is still a connection it is
* not regarded as reconnecting, as in that case it is a renegotiation
* to update the current connection.
*/
isReconnecting() {
return this.model.attributes.connectionState === ConnectionState.FAILED
|| (!this.model.attributes.initialConnection
&& ((this.model.attributes.negotiating && !this.isConnected) || this.model.attributes.connecting))
},
isNoLongerTryingToReconnect() {
return this.model.attributes.connectionState === ConnectionState.FAILED_NO_RESTART
},
connectionMessage() {
if (!this.wasConnectedAtLeastOnce && this.isNoLongerTryingToReconnect) {
return t('spreed', 'Connection could not be established …')
}
if (this.isNoLongerTryingToReconnect) {
return t('spreed', 'Connection was lost and could not be re-established …')
}
if (!this.wasConnectedAtLeastOnce && this.isReconnecting) {
return t('spreed', 'Connection could not be established. Trying again …')
}
if (this.isReconnecting) {
return t('spreed', 'Connection lost. Trying to reconnect …')
}
if (this.isDisconnected) {
return t('spreed', 'Connection problems …')
}
return null
},
containerClass() {
return {
'videoContainer-dummy': this.placeholderForPromoted,
'not-connected': !this.placeholderForPromoted && !this.isConnected,
speaking: !this.placeholderForPromoted && this.isSpeaking && !this.isBig,
hover: this.mouseover && !this.unSelectable && !this.isBig,
promoted: !this.placeholderForPromoted && this.sharedData.promoted && !this.isGrid,
presenter: this.isPresenterOverlay && this.mouseover,
'video-container-grid': this.isGrid,
'video-container-big': this.isBig,
'one-to-one': this.isOneToOne,
'presenter-overlay': this.isPresenterOverlay,
}
},
videoWrapperClass() {
return {
'presenter-overlay': this.isPresenterOverlay,
}
},
avatarSize() {
if (this.isStripe || (!this.isBig && !this.isGrid)) {
return AVATAR.SIZE.LARGE
} else if (!this.containerAspectRatio) {
return AVATAR.SIZE.FULL
} else {
return Math.min(AVATAR.SIZE.FULL, this.$refs.videoContainer.clientHeight / 2, this.$refs.videoContainer.clientWidth / 2)
}
},
connectionMessageClass() {
return {
'below-avatar': this.showBackgroundAndAvatar,
}
},
sessionHash() {
return Hex.stringify(SHA1(this.nextcloudSessionId))
},
peerData() {
let peerData = this.$store.getters.getPeer(this.token, this.nextcloudSessionId, this.model.attributes.userId)
if (!peerData.actorId) {
EventBus.emit('refresh-peer-list')
peerData = {
actorType: '',
actorId: '',
displayName: '',
}
}
return peerData
},
participant() {
/**
* This only works for logged-in users. Guests can not load the data
* via the participant list
*/
return this.$store.getters.findParticipant(this.token, {
sessionId: this.nextcloudSessionId,
}) || {}
},
participantActorType() {
if (this.model.attributes.actorType) {
return this.model.attributes.actorType
} else if (this.participant?.actorType) {
return this.participant.actorType
} else if (this.peerData?.actorType) {
return this.peerData.actorType
} else {
return this.participantUserId
? ATTENDEE.ACTOR_TYPE.USERS
: ATTENDEE.ACTOR_TYPE.GUESTS
}
},
participantUserId() {
if (this.model.attributes.actorId) {
return this.model.attributes.actorId
}
if (this.model.attributes.userId) {
return this.model.attributes.userId
}
// Check data from participant list
if (this.participant?.actorType) {
if (this.participant?.actorType === ATTENDEE.ACTOR_TYPE.USERS && this.participant?.actorId) {
return this.participant.actorId
}
// Not a user
return null
}
// Fallback to CallController::getPeers() endpoint
if (this.peerData.actorType === ATTENDEE.ACTOR_TYPE.USERS
|| this.peerData.actorType === ATTENDEE.ACTOR_TYPE.FEDERATED_USERS) {
return this.peerData.actorId
}
return null
},
displayName() {
if (this.model.attributes.name) {
return this.model.attributes.name
}
if (this.participant?.displayName) {
return this.participant.displayName
}
let participantName = this.model.attributes.name
// The name is undefined and not shown until a connection is made
// for registered users, so do not fall back to the guest name in
// the store either until the connection was made.
if (!this.model.attributes.userId && !participantName && participantName !== undefined) {
participantName = this.guestNameStore.getGuestName(
this.token,
this.sessionHash,
)
}
if (!participantName) {
participantName = this.peerData.displayName
}
return participantName?.trim() ?? ''
},
participantName() {
return getDisplayNameWithFallback(this.displayName, this.participantActorType)
},
isSpeaking() {
return this.model.attributes.speaking
},
hasVideo() {
return !this.model.attributes.videoBlocked
&& this.model.attributes.videoAvailable
&& this.sharedData.remoteVideoBlocker.isVideoEnabled() && (typeof this.model.attributes.stream === 'object')
},
hasSelectedVideo() {
return this.callViewStore.selectedVideoPeerId !== null
},
hasSharedScreen() {
return this.model.attributes.screen
},
isSharedScreenPromoted() {
return this.sharedData.screenVisible && (!this.hasSelectedVideo || this.isSelected)
},
showSharedScreen() {
// Big screen
if (this.isBig) {
// Always show shared screen if there's one
return this.hasSharedScreen
// Stripe
} else if (this.isStripe) {
if (this.isSharedScreenPromoted) {
return false
} else {
// Show the shared screen if not selected or promoted
return !((this.isSelected) ? this.isSelected : this.isPromoted) && this.hasSharedScreen
}
// Grid
} else {
// Always show shared screen if there's one
return this.hasSharedScreen && !this.isPresenterOverlay
}
},
showVideo() {
// Screenshare have higher priority so return false if screenshare
// is shown
if (this.hasSharedScreen) {
return (!this.showSharedScreen && this.hasVideo && !this.isSelected) || this.isPresenterOverlay
} else {
if (this.isStripe) {
if (this.hasSelectedVideo) {
return !this.isSelected && this.hasVideo
} else {
return !this.isPromoted && this.hasVideo
}
} else {
return this.hasVideo
}
}
},
showPlaceholderForPromoted() {
if (this.isStripe) {
if (this.showVideo || this.showSharedScreen) {
return false
} else if (this.hasSelectedVideo) {
return this.isSelected
} else {
return this.isPromoted
}
} else {
return false
}
},
showBackgroundAndAvatar() {
if (this.showSharedScreen || this.showVideo || this.showPlaceholderForPromoted) {
return false
} else {
return true
}
},
peerId() {
return this.model.attributes.peerId
},
nextcloudSessionId() {
return this.model.attributes.nextcloudSessionId
},
screenshotModeUrl() {
return this.screenshotMode ? placeholderImage(6) : ''
},
},
watch: {
'model.attributes.stream': function(stream) {
this._setStream(stream)
},
isSelected(bool) {
if (bool) {
this.mouseover = false
}
},
},
mounted() {
this.sharedData.remoteVideoBlocker.increaseVisibleCounter()
// Set initial state
this._setStream(this.model.attributes.stream)
if (this.isBig || this.isGrid) {
this.resizeObserver = new ResizeObserver(this.updateContainerAspectRatio)
this.resizeObserver.observe(this.$refs.videoContainer)
}
},
beforeUnmount() {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
},
unmounted() {
this.sharedData.remoteVideoBlocker.decreaseVisibleCounter()
},
methods: {
t,
_setStream(stream) {
if (!stream) {
// Do not clear the srcObject of the video element, just leave
// the previous stream as a frozen image.
return
}
// The audio is played using an audio element in the model to be
// able to hear it even if there is no view for it. Moreover, if
// there is a video track Chromium does not play audio in a video
// element until the video track starts to play; an audio element is
// thus needed to play audio when the remote peer starts with the
// camera available but disabled.
attachMediaStream(stream, this.$refs.video)
this.$refs.video.muted = true
// At least Firefox, Opera and Edge move the video to a wrong
// position instead of keeping it unchanged when
// "transform: scaleX(1)" is used ("transform: scaleX(-1)" is fine);
// as it should have no effect the transform is removed.
if (this.$refs.video.style.transform === 'scaleX(1)') {
this.$refs.video.style.transform = ''
}
},
updateContainerAspectRatio([{ target }]) {
this.containerAspectRatio = target.clientWidth / target.clientHeight
},
updateVideoAspectRatio() {
if (!this.isBig) {
return
}
this.videoAspectRatio = this.model.attributes.stream.getVideoTracks()?.[0].getSettings().aspectRatio
// Fallback for Firefox
?? this.$refs.video.videoWidth / this.$refs.video.videoHeight
},
handleHoverEvent(value) {
this.$emit('forcePromoteVideo', value ? this.model : null)
},
},
}
</script>
<style lang="scss" scoped>
.not-connected {
video,
.avatar-container {
opacity: 0.5;
}
}
.video-container-grid {
position: relative;
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
}
.video-container-big {
position: absolute;
&.one-to-one {
width: calc(100% - var(--wrapper-padding) * 2);
}
& .videoWrapper {
margin: auto;
}
}
.avatar-container {
width: 100%;
height: 100%;
position: absolute;
display: flex;
justify-content: center;
align-items: center;
}
.placeholder-for-promoted {
background: radial-gradient(146.1% 146.1% at 50% 50%, #333333 0%, #858585 100%);
width: 100%;
height: 100%;
position: absolute;
display: flex;
justify-content: center;
align-items: center;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
}
.videoWrapper,
.video {
height: 100%;
width: 100%;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
}
.videoWrapper.presenter-overlay {
& > video {
border-radius: 50%;
}
& > .dev-mode-video--presenter {
position: absolute;
top: 0;
inset-inline-start: 0;
height: 100%;
width: 100%;
object-fit: cover;
border-radius: 50%;
}
}
.video-loading {
position: absolute;
top: 0;
inset-inline-end: 0;
height: 100%;
width: 100%;
}
.video--fit {
/* Fit the frame */
object-fit: contain;
}
.video--fill {
/* Fill the frame */
object-fit: cover;
}
.connection-message {
width: 100%;
position: absolute;
top: calc(50% + 50px);
text-align: center;
z-index: 1;
color: white;
filter: drop-shadow(1px 1px 4px var(--color-box-shadow));
&.below-avatar {
top: calc(50% + 80px);
}
}
.video-container::after {
position: absolute;
height: 100%;
width: 100%;
top: 0;
inset-inline-start: 0;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
}
.video-container.presenter-overlay::after {
border-radius: 50%;
z-index: 1;
}
.video-container.speaking::after {
content: '';
box-shadow: inset 0 0 0 2px white;
}
.video-container.hover::after {
content: '';
box-shadow: inset 0 0 0 3px white;
cursor: pointer;
}
.presenter-icon__hide {
position: absolute;
color: white;
inset-inline-start: calc(50% - var(--default-clickable-area) / 2);
top: calc(100% - var(--default-grid-baseline) - var(--default-clickable-area));
opacity: 0.7;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
padding: 6px;
width: var(--default-clickable-area);
height: var(--default-clickable-area);
z-index: 2; // Above video and its border
&:hover {
cursor: pointer;
opacity: 0.9;
}
}
</style>
@@ -0,0 +1,343 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div ref="ghost" class="viewer-overlay-ghost">
<Teleport to="body">
<!-- Add .app-talk to use Talk icon classes outside of #content-vue -->
<div
class="viewer-overlay app-talk"
:style="computedStyle">
<div
class="viewer-overlay__collapse"
:class="{ collapsed: isCollapsed }">
<NcButton
variant="secondary"
class="viewer-overlay__button"
:aria-label="
isCollapsed ? t('spreed', 'Collapse') : t('spreed', 'Expand')
"
@click.stop="isCollapsed = !isCollapsed">
<template #icon>
<ChevronDown v-if="!isCollapsed" :size="20" />
<ChevronUp v-else :size="20" />
</template>
</NcButton>
</div>
<TransitionWrapper name="slide-down">
<div
v-show="!isCollapsed"
class="viewer-overlay__video-container"
tabindex="0"
@click="maximize">
<div class="video-overlay__top-bar">
<NcButton
variant="secondary"
class="viewer-overlay__button"
:aria-label="t('spreed', 'Expand')"
@click.stop="maximize">
<template #icon>
<ArrowExpand :size="20" />
</template>
</NcButton>
</div>
<!-- local screen -->
<ScreenShare
v-if="showLocalScreen"
:token="token"
:localMediaModel="localModel"
:sharedData="localSharedData" />
<!-- remote screen -->
<ScreenShare
v-else-if="model && screens[model.attributes.peerId]"
:token="token"
:callParticipantModel="model"
:sharedData="sharedData" />
<VideoVue
v-else-if="model"
class="viewer-overlay__video"
:token="token"
:model="model"
:sharedData="sharedData"
isGrid
unSelectable
hideBottomBar
@clickVideo="maximize">
<template #bottomBar />
</VideoVue>
<EmptyCallView v-else isSmall />
<LocalVideo
v-if="localModel.attributes.videoEnabled"
class="viewer-overlay__local-video"
:token="token"
:showControls="false"
:localMediaModel="localModel"
:localCallParticipantModel="localCallParticipantModel"
isSmall
unSelectable />
<div class="viewer-overlay__bottom-bar">
<LocalAudioControlButton
class="viewer-overlay__button"
:token="token"
:conversation="conversation"
:model="localModel"
variant="secondary"
disableKeyboardShortcuts />
<LocalVideoControlButton
class="viewer-overlay__button"
:token="token"
:conversation="conversation"
:model="localModel"
variant="secondary"
disableKeyboardShortcuts />
</div>
</div>
</TransitionWrapper>
</div>
</Teleport>
</div>
</template>
<script>
import { isRTL, t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import ArrowExpand from 'vue-material-design-icons/ArrowExpand.vue'
import ChevronDown from 'vue-material-design-icons/ChevronDown.vue'
import ChevronUp from 'vue-material-design-icons/ChevronUp.vue'
import TransitionWrapper from '../../UIShared/TransitionWrapper.vue'
import EmptyCallView from './EmptyCallView.vue'
import LocalAudioControlButton from './LocalAudioControlButton.vue'
import LocalVideo from './LocalVideo.vue'
import LocalVideoControlButton from './LocalVideoControlButton.vue'
import ScreenShare from './ScreenShare.vue'
import VideoVue from './VideoVue.vue'
import { useCallViewStore } from '../../../stores/callView.ts'
import { localCallParticipantModel, localMediaModel } from '../../../utils/webrtc/index.js'
export default {
name: 'ViewerOverlayCallView',
components: {
EmptyCallView,
LocalAudioControlButton,
LocalVideoControlButton,
ScreenShare,
LocalVideo,
ChevronUp,
ChevronDown,
NcButton,
TransitionWrapper,
VideoVue,
ArrowExpand,
},
props: {
token: {
type: String,
required: true,
},
// Promoted participant model
model: {
type: Object,
required: false,
default: null,
},
sharedData: {
type: Object,
required: false,
default: null,
},
localModel: {
type: Object,
required: false,
default: () => localMediaModel,
},
localCallParticipantModel: {
type: Object,
required: false,
default: () => localCallParticipantModel,
},
localSharedData: {
type: Object,
required: true,
default: () => {},
},
screens: {
type: Array,
required: false,
default: () => [],
},
},
setup() {
return {
callViewStore: useCallViewStore(),
}
},
data() {
return {
isCollapsed: false,
observer: null,
position: {
right: 0,
left: 0,
bottom: 0,
},
}
},
computed: {
conversation() {
return this.$store.getters.conversation(this.token)
},
hasLocalScreen() {
return !!this.localModel.attributes.localScreen
},
showLocalScreen() {
return this.hasLocalScreen && this.screens[0] === localCallParticipantModel.attributes.peerId
},
computedStyle() {
return {
[isRTL() ? 'left' : 'right']: this.position[isRTL() ? 'left' : 'right'] + 'px',
bottom: this.position.bottom + 'px',
}
},
},
mounted() {
this.updatePosition()
this.observer = new ResizeObserver(this.updatePosition)
this.observer.observe(this.$refs.ghost)
},
beforeUnmount() {
this.observer.disconnect()
},
methods: {
t,
maximize() {
if (OCA.Viewer) {
OCA.Viewer.close()
}
this.callViewStore.setIsViewerOverlay(false)
},
updatePosition() {
const { left, right, bottom } = this.$refs.ghost.getBoundingClientRect()
if (isRTL()) {
this.position.left = left
} else {
this.position.right = window.innerWidth - right
}
this.position.bottom = window.innerHeight - bottom
},
},
}
</script>
<style lang="scss" scoped>
.viewer-overlay-ghost {
position: absolute;
bottom: 8px;
inset-inline: 0 8px;
}
.viewer-overlay {
--aspect-ratio: calc(3 / 4);
--width: 20vw;
--min-width: 250px;
--max-width: 400px;
position: absolute;
width: var(--width);
min-width: var(--min-width);
max-width: var(--max-width);
min-height: calc(var(--default-clickable-area) + 8px);
z-index: 11000;
}
.viewer-overlay * {
box-sizing: border-box;
}
.viewer-overlay__collapse {
position: absolute;
top: 8px;
inset-inline-end: 8px;
z-index: 100;
}
.viewer-overlay__button {
opacity: 0.8;
&:active,
&:hover,
&:focus {
opacity: 0.9;
}
}
.video-overlay__top-bar {
position: absolute;
top: 8px;
inset-inline-start: 8px;
z-index: 100;
}
.viewer-overlay__bottom-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
position: absolute;
bottom: 0;
width: 100%;
padding: 0 12px 8px 12px;
z-index: 1;
}
.viewer-overlay__video-container {
width: 100%;
height: calc(var(--width) * var(--aspect-ratio));
min-height: calc(var(--min-width) * var(--aspect-ratio));
max-height: calc(var(--max-width) * var(--aspect-ratio));
/* Note: because of transition it always has position absolute on animation */
bottom: 0;
inset-inline-end: 0;
}
.viewer-overlay__local-video {
position: absolute;
bottom: 8px;
inset-inline-end: 8px;
width: 25%;
height: 25%;
overflow: hidden;
}
.viewer-overlay__video {
position: relative;
height: 100%;
}
:deep(.screen) {
border-radius: calc(var(--default-clickable-area) / 4);
}
</style>
+29
View File
@@ -0,0 +1,29 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { onUnmounted } from 'vue'
/**
* Request a wake lock to prevent the screen from turning off
*/
export function useWakeLock() {
if (!('wakeLock' in navigator)) {
return
}
const wakeLockRequest = navigator.wakeLock
.request('screen')
.catch(() => {
// Web Lock is not available, e.g. battery saving mode is enabled
// Ignoring
})
onUnmounted(async () => {
// Component unmount could happen before the WakeLock request is resolved
// Wait for the WakeLock request before releasing it
const wakeLock = await wakeLockRequest
wakeLock?.release()
})
}