From 5c00fb96fed65d64f9a6f85982ad4c178c703a71 Mon Sep 17 00:00:00 2001 From: Ricardo Sanz <12608029+rsre@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:29:10 +0200 Subject: [PATCH 1/6] Add microphone mute control --- README.md | 1 + custom_components/webrtc/www/video-rtc.js | 7 +++++++ custom_components/webrtc/www/webrtc-camera.js | 18 +++++++++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c36a101d..b908a8b9 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ media: video,audio # select only video or audio track, default both server: http://192.168.1.123:1984/ # custom go2rtc server address, default empty ui: true # custom video controls, default false +# when `media` includes `microphone`, the UI also shows a microphone mute button digital_ptz: # digital zoom and pan via mouse/touch, defaults: mouse_drag_pan: true diff --git a/custom_components/webrtc/www/video-rtc.js b/custom_components/webrtc/www/video-rtc.js index 8ecbce72..1e3cdf58 100644 --- a/custom_components/webrtc/www/video-rtc.js +++ b/custom_components/webrtc/www/video-rtc.js @@ -44,6 +44,12 @@ export class VideoRTC extends HTMLElement { */ this.media = 'video,audio'; + /** + * [config] Current state of outgoing microphone tracks. + * @type {boolean} + */ + this.microphoneMuted = false; + /** * [config] Run stream when not displayed on the screen. Default `false`. * @type {boolean} @@ -552,6 +558,7 @@ export class VideoRTC extends HTMLElement { if (this.media.includes('microphone')) { const media = await navigator.mediaDevices.getUserMedia({audio: true}); media.getTracks().forEach(track => { + track.enabled = !this.microphoneMuted; pc.addTransceiver(track, {direction: 'sendonly'}); }); } diff --git a/custom_components/webrtc/www/webrtc-camera.js b/custom_components/webrtc/www/webrtc-camera.js index f65b239d..5b92d840 100644 --- a/custom_components/webrtc/www/webrtc-camera.js +++ b/custom_components/webrtc/www/webrtc-camera.js @@ -118,6 +118,11 @@ class WebRTCCamera extends VideoRTC { this.mode = stream.mode || this.config.mode; this.media = stream.media || this.config.media; + const microphone = this.shadowRoot && this.shadowRoot.querySelector('.microphone'); + if (microphone) { + microphone.style.display = this.media.includes('microphone') ? 'block' : 'none'; + } + if (reload) { this.ondisconnect(); setTimeout(() => this.onconnect(), 100); // wait ws.close event @@ -479,6 +484,9 @@ class WebRTCCamera extends VideoRTC { .volume { display: none; } + .microphone { + display: ${this.media.includes('microphone') ? 'block' : 'none'}; + } .stream { padding-top: 2px; margin-left: 2px; @@ -500,6 +508,7 @@ class WebRTCCamera extends VideoRTC { ${this.streamName} + @@ -551,6 +560,14 @@ class WebRTCCamera extends VideoRTC { video.muted = false; } else if (icon === 'mdi:volume-high') { video.muted = true; + } else if (icon === 'mdi:microphone' || icon === 'mdi:microphone-off') { + this.microphoneMuted = !this.microphoneMuted; + if (this.pc) { + this.pc.getSenders() + .filter(sender => sender.track && sender.track.kind === 'audio') + .forEach(sender => sender.track.enabled = !this.microphoneMuted); + } + ev.target.icon = this.microphoneMuted ? 'mdi:microphone-off' : 'mdi:microphone'; } else if (icon === 'mdi:fullscreen') { this.requestFullscreen().catch(console.warn); } else if (icon === 'mdi:fullscreen-exit') { @@ -693,4 +710,3 @@ const card = { // Apple iOS 12 doesn't support `||=` if (window.customCards) window.customCards.push(card); else window.customCards = [card]; - From 62fe490ac1bf6a8d041fe97b618878368aa90539 Mon Sep 17 00:00:00 2001 From: Ricardo Sanz <12608029+rsre@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:39:22 +0200 Subject: [PATCH 2/6] Revert "Add microphone mute control" This reverts commit 5c00fb96fed65d64f9a6f85982ad4c178c703a71. --- README.md | 1 - custom_components/webrtc/www/video-rtc.js | 7 ------- custom_components/webrtc/www/webrtc-camera.js | 18 +----------------- 3 files changed, 1 insertion(+), 25 deletions(-) diff --git a/README.md b/README.md index b908a8b9..c36a101d 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,6 @@ media: video,audio # select only video or audio track, default both server: http://192.168.1.123:1984/ # custom go2rtc server address, default empty ui: true # custom video controls, default false -# when `media` includes `microphone`, the UI also shows a microphone mute button digital_ptz: # digital zoom and pan via mouse/touch, defaults: mouse_drag_pan: true diff --git a/custom_components/webrtc/www/video-rtc.js b/custom_components/webrtc/www/video-rtc.js index 1e3cdf58..8ecbce72 100644 --- a/custom_components/webrtc/www/video-rtc.js +++ b/custom_components/webrtc/www/video-rtc.js @@ -44,12 +44,6 @@ export class VideoRTC extends HTMLElement { */ this.media = 'video,audio'; - /** - * [config] Current state of outgoing microphone tracks. - * @type {boolean} - */ - this.microphoneMuted = false; - /** * [config] Run stream when not displayed on the screen. Default `false`. * @type {boolean} @@ -558,7 +552,6 @@ export class VideoRTC extends HTMLElement { if (this.media.includes('microphone')) { const media = await navigator.mediaDevices.getUserMedia({audio: true}); media.getTracks().forEach(track => { - track.enabled = !this.microphoneMuted; pc.addTransceiver(track, {direction: 'sendonly'}); }); } diff --git a/custom_components/webrtc/www/webrtc-camera.js b/custom_components/webrtc/www/webrtc-camera.js index 5b92d840..f65b239d 100644 --- a/custom_components/webrtc/www/webrtc-camera.js +++ b/custom_components/webrtc/www/webrtc-camera.js @@ -118,11 +118,6 @@ class WebRTCCamera extends VideoRTC { this.mode = stream.mode || this.config.mode; this.media = stream.media || this.config.media; - const microphone = this.shadowRoot && this.shadowRoot.querySelector('.microphone'); - if (microphone) { - microphone.style.display = this.media.includes('microphone') ? 'block' : 'none'; - } - if (reload) { this.ondisconnect(); setTimeout(() => this.onconnect(), 100); // wait ws.close event @@ -484,9 +479,6 @@ class WebRTCCamera extends VideoRTC { .volume { display: none; } - .microphone { - display: ${this.media.includes('microphone') ? 'block' : 'none'}; - } .stream { padding-top: 2px; margin-left: 2px; @@ -508,7 +500,6 @@ class WebRTCCamera extends VideoRTC { ${this.streamName} - @@ -560,14 +551,6 @@ class WebRTCCamera extends VideoRTC { video.muted = false; } else if (icon === 'mdi:volume-high') { video.muted = true; - } else if (icon === 'mdi:microphone' || icon === 'mdi:microphone-off') { - this.microphoneMuted = !this.microphoneMuted; - if (this.pc) { - this.pc.getSenders() - .filter(sender => sender.track && sender.track.kind === 'audio') - .forEach(sender => sender.track.enabled = !this.microphoneMuted); - } - ev.target.icon = this.microphoneMuted ? 'mdi:microphone-off' : 'mdi:microphone'; } else if (icon === 'mdi:fullscreen') { this.requestFullscreen().catch(console.warn); } else if (icon === 'mdi:fullscreen-exit') { @@ -710,3 +693,4 @@ const card = { // Apple iOS 12 doesn't support `||=` if (window.customCards) window.customCards.push(card); else window.customCards = [card]; + From 57c0db378ec1bb79c69730b931ef6749b8d85b38 Mon Sep 17 00:00:00 2001 From: Ricardo Sanz <12608029+rsre@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:14:27 +0200 Subject: [PATCH 3/6] Add push-to-talk mode --- README.md | 13 ++ custom_components/webrtc/www/webrtc-camera.js | 180 +++++++++++++++++- 2 files changed, 190 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c36a101d..e5ffbe0a 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,19 @@ streams: media: video,audio,microphone ``` +You can also enable push-to-talk. The microphone is captured only while the button is held, and +the received camera audio is muted while talking to prevent echo: + +```yaml +type: 'custom:webrtc-camera' +url: doorbell +media: video,audio,microphone +ptt: true +``` + +The first press may ask for microphone permission and show `Microphone ready — hold again to talk` +while the card reconnects. Microphone access requires HTTPS. + **PS.** For Hass [Mobile App](https://www.home-assistant.io/integrations/mobile_app/) ensure that you can use microphone with the built-in [Assist](https://www.home-assistant.io/voice_control/). ## Snapshots to Telegram diff --git a/custom_components/webrtc/www/webrtc-camera.js b/custom_components/webrtc/www/webrtc-camera.js index f65b239d..65147742 100644 --- a/custom_components/webrtc/www/webrtc-camera.js +++ b/custom_components/webrtc/www/webrtc-camera.js @@ -36,6 +36,7 @@ class WebRTCCamera extends VideoRTC { * muted: boolean, * intersection: number, * ui: boolean, + * ptt: boolean, * style: string, * background: boolean, * @@ -62,7 +63,8 @@ class WebRTCCamera extends VideoRTC { * }} config */ this.config = Object.assign({ - mode: config.mse === false ? 'webrtc' : config.webrtc === false ? 'mse' : this.mode, + mode: config.ptt && !config.mode ? 'webrtc' : + config.mse === false ? 'webrtc' : config.webrtc === false ? 'mse' : this.mode, media: this.media, streams: [{url: config.url, entity: config.entity}], poster_remote: config.poster && (config.poster.indexOf('://') > 0 || config.poster.charAt(0) === '/'), @@ -89,7 +91,7 @@ class WebRTCCamera extends VideoRTC { * Called by the Hass to calculate default card height. */ getCardSize() { - return 5; // x 50px + return this.config.ptt ? 6 : 5; // x 50px } /** @@ -132,6 +134,7 @@ class WebRTCCamera extends VideoRTC { oninit() { super.oninit(); this.renderMain(); + this.renderPTT(); this.renderDigitalPTZ(); this.renderPTZ(); this.renderCustomUI(); @@ -208,6 +211,49 @@ class WebRTCCamera extends VideoRTC { } } + /** + * Avoid requesting microphone access during negotiation when push-to-talk is enabled. + * A track captured by the first button press is used once to negotiate an audio sender. + * @param pc {RTCPeerConnection} + * @return {Promise} + */ + async createOffer(pc) { + if (!this.config || !this.config.ptt || !this.media.includes('microphone')) { + return super.createOffer(pc); + } + + const media = this.media; + const track = this.pttPendingTrack; + this.pttPendingTrack = null; + this.pttSender = null; + + try { + if (track) { + this.pttSender = pc.addTransceiver(track, {direction: 'sendonly'}).sender; + } + this.media = media.split(',').filter(kind => kind !== 'microphone').join(','); + return await super.createOffer(pc); + } finally { + this.media = media; + if (track) { + try { + if (this.pttSender) await this.pttSender.replaceTrack(null); + } finally { + track.stop(); + } + } + } + } + + ondisconnect() { + if (this.pttTrack) this.pttTrack.stop(); + if (this.pttPendingTrack) this.pttPendingTrack.stop(); + this.pttTrack = null; + this.pttPendingTrack = null; + this.pttSender = null; + super.ondisconnect(); + } + renderMain() { const shadow = this.attachShadow({mode: 'open'}); shadow.innerHTML = ` @@ -271,6 +317,135 @@ class WebRTCCamera extends VideoRTC { if (this.config.poster_remote) this.video.poster = this.config.poster; } + renderPTT() { + if (!this.config.ptt || !this.media.includes('microphone')) return; + + const card = this.querySelector('.card'); + card.insertAdjacentHTML('beforebegin', ` + + `); + card.insertAdjacentHTML('afterend', ` +
+ +
+
+ `); + + const button = this.querySelector('.ptt-button'); + const message = this.querySelector('.ptt-message'); + + const resetPlayback = () => { + if (this.pttVideoMuted !== undefined) { + this.video.muted = this.pttVideoMuted; + this.pttVideoMuted = undefined; + } + button.classList.remove('active'); + button.innerText = 'Hold to talk'; + }; + + const stop = () => { + this.pttPressed = false; + const track = this.pttTrack; + this.pttTrack = null; + if (track) { + track.stop(); + if (this.pttSender) this.pttSender.replaceTrack(null).catch(console.warn); + } + resetPlayback(); + }; + + const errorText = error => { + if (!window.isSecureContext) return 'Microphone requires HTTPS'; + if (error.name === 'NotAllowedError') return 'Microphone permission denied'; + if (error.name === 'NotFoundError') return 'No microphone found'; + return error.message || 'Unable to access microphone'; + }; + + const start = async ev => { + ev.preventDefault(); + if (this.pttPressed) return; + + this.pttPressed = true; + this.pttVideoMuted = this.video.muted; + this.video.muted = true; + button.classList.add('active'); + button.innerText = 'Talking...'; + message.innerText = ''; + message.classList.remove('error'); + + try { + const stream = await navigator.mediaDevices.getUserMedia({audio: true}); + const track = stream.getAudioTracks()[0]; + stream.getTracks().filter(value => value !== track).forEach(value => value.stop()); + if (!track) throw new Error('No microphone track available'); + + if (!this.pttSender) { + this.pttPressed = false; + this.pttPendingTrack = track; + message.innerText = 'Microphone ready — hold again to talk'; + resetPlayback(); + super.ondisconnect(); + setTimeout(() => this.onconnect(), 100); + return; + } + + if (!this.pttPressed) { + track.stop(); + resetPlayback(); + return; + } + + this.pttTrack = track; + await this.pttSender.replaceTrack(track); + } catch (error) { + this.pttPressed = false; + if (this.pttTrack) this.pttTrack.stop(); + this.pttTrack = null; + message.innerText = errorText(error); + message.classList.add('error'); + resetPlayback(); + } + }; + + button.addEventListener('mousedown', start); + button.addEventListener('touchstart', start, {passive: false}); + for (const event of ['mouseup', 'touchend', 'touchcancel', 'blur']) { + window.addEventListener(event, stop); + } + } + renderDigitalPTZ() { if (this.config.digital_ptz === false) return; new DigitalPTZ( @@ -693,4 +868,3 @@ const card = { // Apple iOS 12 doesn't support `||=` if (window.customCards) window.customCards.push(card); else window.customCards = [card]; - From a7a4196beb58c7108f4355eefea7ca228fbfecf7 Mon Sep 17 00:00:00 2001 From: Ricardo Sanz <12608029+rsre@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:21:41 +0200 Subject: [PATCH 4/6] Bump version to 3.6.2 --- custom_components/webrtc/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/webrtc/manifest.json b/custom_components/webrtc/manifest.json index a196b57e..0030bab0 100644 --- a/custom_components/webrtc/manifest.json +++ b/custom_components/webrtc/manifest.json @@ -13,5 +13,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/AlexxIT/WebRTC/issues", "requirements": [], - "version": "v3.6.1" -} \ No newline at end of file + "version": "v3.6.2" +} From fd75b0d16e1021bf3541f800376507fc48c07cf8 Mon Sep 17 00:00:00 2001 From: Ricardo Sanz <12608029+rsre@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:23:22 +0200 Subject: [PATCH 5/6] Bump version to 3.6.4 --- custom_components/webrtc/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/webrtc/manifest.json b/custom_components/webrtc/manifest.json index 0030bab0..0ede8dd1 100644 --- a/custom_components/webrtc/manifest.json +++ b/custom_components/webrtc/manifest.json @@ -13,5 +13,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/AlexxIT/WebRTC/issues", "requirements": [], - "version": "v3.6.2" + "version": "v3.6.4" } From a449b59dc9bc82702d4b8f9eb6f061f7c41fa86f Mon Sep 17 00:00:00 2001 From: Ricardo Sanz Date: Tue, 8 Sep 2026 00:38:02 +0200 Subject: [PATCH 6/6] Keep version --- custom_components/webrtc/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/webrtc/manifest.json b/custom_components/webrtc/manifest.json index 0ede8dd1..8bc44573 100644 --- a/custom_components/webrtc/manifest.json +++ b/custom_components/webrtc/manifest.json @@ -13,5 +13,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/AlexxIT/WebRTC/issues", "requirements": [], - "version": "v3.6.4" + "version": "v3.6.1" }