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
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:
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user