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/manifest.json b/custom_components/webrtc/manifest.json index a196b57e..8bc44573 100644 --- a/custom_components/webrtc/manifest.json +++ b/custom_components/webrtc/manifest.json @@ -14,4 +14,4 @@ "issue_tracker": "https://github.com/AlexxIT/WebRTC/issues", "requirements": [], "version": "v3.6.1" -} \ No newline at end of file +} 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]; -