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"
}