Files
f7_talk/src/components/CallView/shared/LocalAudioControlButton.vue
T
glb_adm 557d2f0864 fix(камера): не держать превью-камеру весь звонок (лампочка горела при выкл. видео)
Первопричина: LocalVideoControlButton/LocalAudioControlButton на mounted() подписываются на
useDevices и держат подписку весь звонок; initializeDevices безусловно грабит камеру
(getUserMedia video:true) для превью, хотя кнопкам нужен лишь список устройств. Камера отпускалась
только по завершении звонка → индикатор горел при выключенном видео (медиа-конвейер звонка свой трек
останавливает корректно, но превью-подсистема — нет).

Фикс: разделены подписка на СПИСОК устройств и на ЖИВОЙ превью-поток. streamSubscribersCount +
единая точка контроля в updateAudio/VideoStream (без превью-подписчика поток не грабится и
останавливается — защищает и от watch). Кнопки звонка: requestStream:false. Диалог настроек —
без изменений (превью работает). enableDeviceEvents использует enumerateDevices (без камеры).

Сборка OK. НЕ деплоено — ждёт тест-фазу (docs/f7-fixes/camera-led-on-with-video-off.md).
2026-07-08 08:20:19 +00:00

403 lines
9.8 KiB
Vue

<!--
- 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() {
// [F7] Кнопке нужен только СПИСОК устройств — не живой поток микрофона.
// (Симметрично видео-кнопке; звук звонка идёт через медиа-конвейер, не через превью.)
this.subscribeToDevices({ requestStream: false })
},
beforeUnmount() {
this.unsubscribeFromDevices({ requestStream: false })
},
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>