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).
This commit is contained in:
2026-07-08 08:20:19 +00:00
parent 49dd055a58
commit 557d2f0864
22 changed files with 105 additions and 13 deletions
@@ -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. Несколько вкладок/повторные входы — нет «залипшего» потока.