Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 557d2f0864 |
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,45 @@
|
||||
# Фикс: лампочка камеры горит при выключенном видео в звонке
|
||||
|
||||
**Ветка:** `fix/camera-led-preview` · **Статус:** реализовано, СБОРКА OK, **ждёт тест-фазу** (не деплоено).
|
||||
|
||||
## Симптом
|
||||
Пользователь входит в конференцию с выключенной камерой (или выключает её). Своего видео
|
||||
в звонке нет — но индикатор камеры ноутбука ГОРИТ, будто камера используется. Приватностный баг.
|
||||
|
||||
## Первопричина (по исходникам, высокая уверенность)
|
||||
Два независимых слоя работы с камерой:
|
||||
1. **Медиа-конвейер звонка** (`utils/media/pipeline/MediaDevicesSource.js`) — при выключении видео
|
||||
вызывает `setVideoAllowed(false)` → `track.stop()`. Камеру отпускает КОРРЕКТНО.
|
||||
2. **Превью-подсистема** (`composables/useDevices.js`) — используется диалогом настроек И
|
||||
контрол-кнопками звонка. `initializeDevices()` БЕЗУСЛОВНО грабит камеру:
|
||||
`updateVideoStream()` → `getUserMedia({video:true})`. Отпускает только когда отписались ВСЕ.
|
||||
|
||||
Виновник — контрол-кнопки: `LocalVideoControlButton.vue` и `LocalAudioControlButton.vue` на
|
||||
`mounted()` зовут `subscribeToDevices()` и держат подписку ВЕСЬ звонок (`beforeUnmount` = конец
|
||||
звонка). Первый же подписчик поднимает `initializeDevices()` → камера включается и горит всё время,
|
||||
хотя кнопкам нужен лишь СПИСОК устройств для меню выбора, а не живой поток. У кнопок нет `<video>`
|
||||
превью — поток не показывается, просто удерживает устройство.
|
||||
|
||||
Проверено: `MediaDevicesManager.enableDeviceEvents()` берёт список через `enumerateDevices()` —
|
||||
камеру НЕ трогает. Значит список для меню доступен без превью-потока.
|
||||
|
||||
## Фикс
|
||||
Разделили в `useDevices` два вида подписки:
|
||||
- **список устройств** (нужен всем, камеру не трогает);
|
||||
- **живой превью-поток** (нужен только диалогу настроек, где есть `<video>`).
|
||||
|
||||
Изменения:
|
||||
- `useDevices.js`: новый счётчик `streamSubscribersCount`; `subscribeToDevices({requestStream})` /
|
||||
`unsubscribeFromDevices({requestStream})`; ЕДИНАЯ точка контроля — `updateAudioStream()`/
|
||||
`updateVideoStream()` при `streamSubscribersCount===0` не грабят и останавливают поток (защищает
|
||||
и от `watch(videoInputId)`); `initializeDevices` поднимает потоки только при наличии превью-подписчика.
|
||||
- `LocalVideoControlButton.vue`, `LocalAudioControlButton.vue`: `subscribeToDevices({requestStream:false})`.
|
||||
- `MediaSettings.vue`: без изменений — по умолчанию `requestStream:true` (превью в диалоге работает).
|
||||
|
||||
## Что проверить в тест-фазе
|
||||
1. Вход в звонок с выключенной камерой → лампочка НЕ горит; видео нет. (главный кейс)
|
||||
2. Диалог настроек мультимедиа → превью камеры/микрофона работает; при закрытии камера гаснет.
|
||||
3. Включение видео в звонке → камера работает, видео идёт; выключение → лампочка гаснет.
|
||||
4. Смена камеры/микрофона в меню кнопки во время звонка.
|
||||
5. Виртуальный фон в превью настроек.
|
||||
6. Несколько вкладок/повторные входы — нет «залипшего» потока.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2251
-2576
File diff suppressed because it is too large
Load Diff
+2253
-2580
File diff suppressed because it is too large
Load Diff
@@ -303,11 +303,13 @@ export default {
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.subscribeToDevices()
|
||||
// [F7] Кнопке нужен только СПИСОК устройств — не живой поток микрофона.
|
||||
// (Симметрично видео-кнопке; звук звонка идёт через медиа-конвейер, не через превью.)
|
||||
this.subscribeToDevices({ requestStream: false })
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
this.unsubscribeFromDevices()
|
||||
this.unsubscribeFromDevices({ requestStream: false })
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
@@ -219,11 +219,14 @@ export default {
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.subscribeToDevices()
|
||||
// [F7] Кнопке нужен только СПИСОК устройств для меню выбора — НЕ живой
|
||||
// превью-поток. Иначе камера держалась весь звонок (лампочка горела) даже
|
||||
// при выключенном видео. Превью-поток запрашивает лишь диалог настроек.
|
||||
this.subscribeToDevices({ requestStream: false })
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
this.unsubscribeFromDevices()
|
||||
this.unsubscribeFromDevices({ requestStream: false })
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
@@ -13,6 +13,13 @@ import VirtualBackground from '../utils/media/pipeline/VirtualBackground.js'
|
||||
import { callParticipantsAudioPlayer, mediaDevicesManager } from '../utils/webrtc/index.js'
|
||||
|
||||
let subscribersCount = 0
|
||||
// [F7] Подписчики, которым нужен ЖИВОЙ превью-поток (камера/микрофон), отдельно от
|
||||
// подписчиков, которым нужен лишь СПИСОК устройств. Контрол-кнопки в звонке
|
||||
// (LocalVideoControlButton/LocalAudioControlButton) держатся весь звонок, но им нужен
|
||||
// только список — раньше они безусловно захватывали камеру → лампочка горела при
|
||||
// выключенном видео. Превью-поток запрашивает только тот, кто реально показывает <video>
|
||||
// (диалог настроек). См. updateAudioStream/updateVideoStream — единая точка контроля.
|
||||
let streamSubscribersCount = 0
|
||||
const videoElement = ref(null)
|
||||
|
||||
/**
|
||||
@@ -136,9 +143,18 @@ export const useDevices = createSharedComposable(function() {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
function subscribeToDevices() {
|
||||
function subscribeToDevices({ requestStream = true } = {}) {
|
||||
// [F7] requestStream:false — подписчику нужен только список устройств,
|
||||
// БЕЗ захвата камеры/микрофона (контрол-кнопки звонка).
|
||||
if (requestStream) {
|
||||
streamSubscribersCount++
|
||||
}
|
||||
if (subscribersCount === 0) {
|
||||
initializeDevices()
|
||||
} else if (requestStream && streamSubscribersCount === 1) {
|
||||
// Превью понадобилось уже после инициализации — поднять потоки сейчас.
|
||||
updateAudioStream()
|
||||
updateVideoStream()
|
||||
}
|
||||
subscribersCount++
|
||||
}
|
||||
@@ -149,12 +165,22 @@ export const useDevices = createSharedComposable(function() {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
function unsubscribeFromDevices() {
|
||||
function unsubscribeFromDevices({ requestStream = true } = {}) {
|
||||
if (subscribersCount === 0) {
|
||||
console.error('Attempt to unsubscribe from devices when no subscribers')
|
||||
return
|
||||
}
|
||||
|
||||
// [F7] Отписался последний, кому нужен был превью-поток — освободить
|
||||
// камеру/микрофон (лампочка гаснет), но список устройств оставить работать.
|
||||
if (requestStream && streamSubscribersCount > 0) {
|
||||
streamSubscribersCount--
|
||||
if (streamSubscribersCount === 0) {
|
||||
stopAudioStream()
|
||||
stopVideoStream()
|
||||
}
|
||||
}
|
||||
|
||||
subscribersCount--
|
||||
if (subscribersCount === 0) {
|
||||
stopDevices()
|
||||
@@ -197,8 +223,12 @@ export const useDevices = createSharedComposable(function() {
|
||||
virtualBackground.value.connectTrackSink('default', videoTrackToStream.value, 'video')
|
||||
|
||||
mediaDevicesManager.enableDeviceEvents()
|
||||
updateAudioStream()
|
||||
updateVideoStream()
|
||||
// [F7] Потоки камеры/микрофона поднимаем только если есть подписчик превью
|
||||
// (enableDeviceEvents уже дал список устройств через enumerateDevices, без камеры).
|
||||
if (streamSubscribersCount > 0) {
|
||||
updateAudioStream()
|
||||
updateVideoStream()
|
||||
}
|
||||
|
||||
if (mediaDevicesManager.attributes.audioOutputId !== soundsStore.audioOutputDeviceId) {
|
||||
soundsStore.setGeneralAudioOutput(mediaDevicesManager.attributes.audioOutputId)
|
||||
@@ -319,6 +349,12 @@ export const useDevices = createSharedComposable(function() {
|
||||
if (!mediaDevicesManager.isSupported()) {
|
||||
return
|
||||
}
|
||||
// [F7] Единая точка контроля: без подписчика превью НЕ захватываем микрофон
|
||||
// (и освобождаем, если был захвачен). Защищает и от watch(audioInputId).
|
||||
if (streamSubscribersCount === 0) {
|
||||
stopAudioStream()
|
||||
return
|
||||
}
|
||||
if (audioStreamInputId.value && audioStreamInputId.value === audioInputId.value) {
|
||||
return
|
||||
}
|
||||
@@ -418,6 +454,12 @@ export const useDevices = createSharedComposable(function() {
|
||||
if (!mediaDevicesManager.isSupported()) {
|
||||
return
|
||||
}
|
||||
// [F7] Единая точка контроля: без подписчика превью НЕ захватываем камеру
|
||||
// (лампочка не горит при выключенном видео). Защищает и от watch(videoInputId).
|
||||
if (streamSubscribersCount === 0) {
|
||||
stopVideoStream()
|
||||
return
|
||||
}
|
||||
if (videoStreamInputId.value && videoStreamInputId.value === videoInputId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user