From 972a0999fb5e802ceffea4665a2f147487b96b27 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 14 May 2023 20:00:23 -0700 Subject: [PATCH] Allow default media controls to be hidden. --- README.md | 76 +- src/components/live/go2rtc/README.md | 15 + .../live}/go2rtc/video-rtc.d.ts | 6 + src/components/live/go2rtc/video-rtc.js | 682 ++++++++++++++++++ src/components/live/live-go2rtc.ts | 194 +---- src/components/live/live-ha.ts | 9 +- src/components/live/live-image.ts | 2 +- src/components/live/live-jsmpeg.ts | 26 +- src/components/live/live-webrtc-card.ts | 12 +- src/components/live/live.ts | 7 +- src/components/viewer.ts | 28 +- src/const.ts | 3 + src/editor.ts | 20 +- src/external/go2rtc/README.md | 9 - src/external/go2rtc/video-rtc.js | 598 --------------- src/localize/languages/en.json | 1 + src/localize/languages/it.json | 1 + src/localize/languages/pt-BR.json | 1 + src/localize/languages/pt-PT.json | 1 + src/patches/ha-camera-stream.ts | 4 +- src/patches/ha-hls-player.ts | 11 +- src/patches/ha-web-rtc-player.ts | 11 +- src/types.ts | 7 +- 23 files changed, 909 insertions(+), 815 deletions(-) create mode 100644 src/components/live/go2rtc/README.md rename src/{external => components/live}/go2rtc/video-rtc.d.ts (81%) create mode 100644 src/components/live/go2rtc/video-rtc.js delete mode 100644 src/external/go2rtc/README.md delete mode 100644 src/external/go2rtc/video-rtc.js diff --git a/README.md b/README.md index 9a0d24bf..88c35511 100644 --- a/README.md +++ b/README.md @@ -474,6 +474,19 @@ See the [fully expanded live configuration example](#config-expanded-live) for h | `layout` | | :white_check_mark: | See [media layout](#media-layout) below.| | `microphone` | | :white_check_mark: | See [microphone](#microphone) below.| +#### Live Controls + +All configuration is under: + +```yaml +live: + controls: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `builtin` | `true` | :white_check_mark: | Whether to show the built in (browser) video controls on live video. | + #### Live Controls: Thumbnails All configuration is under: @@ -592,6 +605,19 @@ See the [fully expanded Media viewer configuration example](#config-expanded-med | `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `media_viewer` (e.g. `clip`, `snapshot`). See [actions](#actions) below.| | `layout` | | :white_check_mark: | See [media layout](#media-layout) below.| +#### Media Viewer Controls + +All configuration is under: + +```yaml +media_viewer: + controls: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `builtin` | `true` | :white_check_mark: | Whether to show the built in (browser) video controls on media viewer video. | + #### Media Viewer Controls: Next / Previous All configuration is under: @@ -1265,7 +1291,7 @@ Parameters for the `custom:frigate-card-ptz` element: | Parameter | Description | | - | - | | `action` | Must be `custom:frigate-card-action`. | -| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_on`, `live_substream_off`, `live_substream_select`, `expand`, `microphone_mute`, `microphone_unmute`| +| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_on`, `live_substream_off`, `live_substream_select`, `expand`, `microphone_mute`, `microphone_unmute`, `mute`, `unmute`, `play`, `pause`| @@ -1284,6 +1310,8 @@ Parameters for the `custom:frigate-card-ptz` element: |`live_substream_select`| Perform a media player action. Takes a `camera` parameter with the [camera ID](#camera-ids) of the substream camera. | |`expand`| Expand the card into a dialog/popup. | |`microphone_mute`, `microphone_unmute`| Mute or unmute the microphone. See [Using 2-way audio](#using-2-way-audio). | +|`mute`, `unmute`| Mute or unmute the loaded media. | +|`play`, `pause`| Play or pause the loaded media. | @@ -1830,6 +1858,22 @@ menu: enabled: false alignment: matching icon: mdi:cast + microphone: + priority: 50 + enabled: false + alignment: matching + icon: mdi:microphone + type: momentary + mute: + priority: 50 + enabled: false + alignment: matching + icon: mdi:volume-off + play: + priority: 50 + enabled: false + alignment: matching + icon: mdi:play button_size: 40 ``` @@ -1854,6 +1898,7 @@ live: zoomable: true transition_effect: slide controls: + builtin: true next_previous: style: chevrons size: 48 @@ -1918,6 +1963,7 @@ media_viewer: snapshot_click_plays_clip: true transition_effect: slide controls: + builtin: true next_previous: size: 48 style: thumbnails @@ -3570,6 +3616,32 @@ overrides: ``` +### Using menu-based video controls instead of browser builtin controls + +
+ Expand: Using menu-based video controls + +Disable the stock video controls and add menu button equivalents. + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.back_yard +live: + controls: + builtin: false +media_viewer: + controls: + builtin: false +menu: + buttons: + play: + enabled: true + mute: + enabled: true +``` +
+ ### Automatically trigger "fullscreen" mode The card cannot automatically natively trigger fullscreen mode without the user @@ -3788,6 +3860,8 @@ To send an action to a named Frigate card on the dashboard: | `media_player`| :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/frigate-hass-card/issues) if you need this. | | `menu_toggle` | :white_check_mark: | | | `microphone_mute`, `microphone_unmute`| :heavy_multiplication_x: | | +| `mute`, `unmute` | :heavy_multiplication_x: | | +| `play`, `pause` | :heavy_multiplication_x: | | | `recording` | :white_check_mark: | | | `recordings` | :white_check_mark: | | | `snapshot` | :white_check_mark: | | diff --git a/src/components/live/go2rtc/README.md b/src/components/live/go2rtc/README.md new file mode 100644 index 00000000..8a43e428 --- /dev/null +++ b/src/components/live/go2rtc/README.md @@ -0,0 +1,15 @@ +# go2rtc Player + +This is a modified version of the go2rtc example video player code, modified for +use in the Frigate card. In order to make future maintenance easier, +modifications have been kept to a minimum. + +## Original Example Code + +**Link**: https://github.com/AlexxIT/go2rtc/tree/master/www + +**Description**: Example video player imported from go2rtc. + +**Copyright**: [Alexey Khit](https://github.com/AlexxIT) + +**License**: [MIT](https://github.com/AlexxIT/go2rtc/blob/master/LICENSE) diff --git a/src/external/go2rtc/video-rtc.d.ts b/src/components/live/go2rtc/video-rtc.d.ts similarity index 81% rename from src/external/go2rtc/video-rtc.d.ts rename to src/components/live/go2rtc/video-rtc.d.ts index edfa8fdf..8261c7fb 100644 --- a/src/external/go2rtc/video-rtc.d.ts +++ b/src/components/live/go2rtc/video-rtc.d.ts @@ -27,4 +27,10 @@ export class VideoRTC extends HTMLElement { onopen(): void; onwebrtc(): void; onmessage: Record void>; + + // Custom methods/members. + controls: boolean; + containingPlayer: FrigateCardMediaPlayer | null; + microphoneStream: MediaStream | null; + reconnect(); } diff --git a/src/components/live/go2rtc/video-rtc.js b/src/components/live/go2rtc/video-rtc.js new file mode 100644 index 00000000..2803e17f --- /dev/null +++ b/src/components/live/go2rtc/video-rtc.js @@ -0,0 +1,682 @@ +import { mayHaveAudio } from '../../../utils/audio'; +import { + hideMediaControlsTemporarily, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS +} from '../../../utils/media'; +import { + dispatchMediaLoadedEvent, + dispatchMediaPauseEvent, + dispatchMediaPlayEvent, + dispatchMediaVolumeChangeEvent +} from '../../../utils/media-info'; + +/** + * Video player for go2rtc streaming application. + * + * All modern web technologies are supported in almost any browser except Apple Safari. + * + * Support: + * - RTCPeerConnection for Safari iOS 11.0+ + * - IntersectionObserver for Safari iOS 12.2+ + * + * Doesn't support: + * - MediaSource for Safari iOS all + * - Customized built-in elements (extends HTMLVideoElement) because all Safari + * - Public class fields because old Safari (before 14.0) + * - Autoplay for Safari + */ +export class VideoRTC extends HTMLElement { + constructor() { + super(); + + this.DISCONNECT_TIMEOUT = 5000; + this.RECONNECT_TIMEOUT = 30000; + + this.CODECS = [ + 'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen) + 'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen) + 'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV) + 'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra) + 'mp4a.40.2', // AAC LC + 'mp4a.40.5', // AAC HE + 'flac', // FLAC (PCM compatible) + 'opus', // OPUS Chrome, Firefox + ]; + + /** + * [config] Supported modes (webrtc, mse, mp4, mjpeg). + * @type {string} + */ + this.mode = 'webrtc,mse,mp4,mjpeg'; + + /** + * [config] Run stream when not displayed on the screen. Default `false`. + * @type {boolean} + */ + this.background = false; + + /** + * [config] Run stream only when player in the viewport. Stop when user scroll out player. + * Value is percentage of visibility from `0` (not visible) to `1` (full visible). + * Default `0` - disable; + * @type {number} + */ + this.visibilityThreshold = 0; + + /** + * [config] Run stream only when browser page on the screen. Stop when user change browser + * tab or minimise browser windows. + * @type {boolean} + */ + this.visibilityCheck = true; + + /** + * [config] WebRTC configuration + * @type {RTCConfiguration} + */ + this.pcConfig = { + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], + sdpSemantics: 'unified-plan', // important for Chromecast 1 + }; + + /** + * [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED + * @type {number} + */ + this.wsState = WebSocket.CLOSED; + + /** + * [info] WebRTC connection state. + * @type {number} + */ + this.pcState = WebSocket.CLOSED; + + /** + * @type {HTMLVideoElement} + */ + this.video = null; + + /** + * @type {WebSocket} + */ + this.ws = null; + + /** + * @type {string|URL} + */ + this.wsURL = ''; + + /** + * @type {RTCPeerConnection} + */ + this.pc = null; + + /** + * @type {number} + */ + this.connectTS = 0; + + /** + * @type {string} + */ + this.mseCodecs = ''; + + /** + * [internal] Disconnect TimeoutID. + * @type {number} + */ + this.disconnectTID = 0; + + /** + * [internal] Reconnect TimeoutID. + * @type {number} + */ + this.reconnectTID = 0; + + /** + * [internal] Handler for receiving Binary from WebSocket. + * @type {Function} + */ + this.ondata = null; + + /** + * [internal] Handlers list for receiving JSON from WebSocket + * @type {Object.}} + */ + this.onmessage = null; + + /** + * A microphone stream to attach to a WebRTC connection. + * @type {MediaStream}} + */ + this.microphoneStream = null; + + /** + * A reference to a containing FrigateCardMediaPlayer object. + * @type {FrigateCardMediaPlayer}} + */ + this.containingPlayer = null; + + /** + * Whether to show or hide video controls. + * @type {boolean}} + */ + this.controls = true; + } + + /** + * Reconnect the stream. + */ + reconnect() { + if (this.wsState !== WebSocket.CLOSED) { + // The websocket has onclose handlers, so don't call onconnect directly, + // wait until the eventhandlers are finished.. + this.ws?.addEventListener('close', () => this.onconnect()); + this.ondisconnect(); + } else { + // Still call ondisconnect() as there may be an RTC connection to + // terminate even if the websocket is closed. + this.ondisconnect(); + this.onconnect(); + } + } + + /** + * Set video source (WebSocket URL). Support relative path. + * @param {string|URL} value + */ + set src(value) { + if (typeof value !== 'string') value = value.toString(); + if (value.startsWith('http')) { + value = 'ws' + value.substring(4); + } else if (value.startsWith('/')) { + value = 'ws' + location.origin.substring(4) + value; + } + + this.wsURL = value; + + this.onconnect(); + } + + /** + * Play video. Support automute when autoplay blocked. + * https://developer.chrome.com/blog/autoplay/ + */ + play() { + // Frigate card controls playing at a higher level. + } + + /** + * Send message to server via WebSocket + * @param {Object} value + */ + send(value) { + if (this.ws) this.ws.send(JSON.stringify(value)); + } + + codecs(type) { + const test = + type === 'mse' + ? (codec) => MediaSource.isTypeSupported(`video/mp4; codecs="${codec}"`) + : (codec) => this.video.canPlayType(`video/mp4; codecs="${codec}"`); + return this.CODECS.filter(test).join(); + } + + /** + * `CustomElement`. Invoked each time the custom element is appended into a + * document-connected element. + */ + connectedCallback() { + if (this.disconnectTID) { + clearTimeout(this.disconnectTID); + this.disconnectTID = 0; + } + + // because video autopause on disconnected from DOM + if (this.video) { + const seek = this.video.seekable; + if (seek.length > 0) { + this.video.currentTime = seek.end(seek.length - 1); + } + this.play(); + } else { + this.oninit(); + } + + this.onconnect(); + } + + /** + * `CustomElement`. Invoked each time the custom element is disconnected from the + * document's DOM. + */ + disconnectedCallback() { + if (this.background || this.disconnectTID) return; + if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return; + + this.disconnectTID = setTimeout(() => { + if (this.reconnectTID) { + clearTimeout(this.reconnectTID); + this.reconnectTID = 0; + } + + this.disconnectTID = 0; + + this.ondisconnect(); + }, this.DISCONNECT_TIMEOUT); + } + + /** + * Creates child DOM elements. Called automatically once on `connectedCallback`. + */ + oninit() { + this.video = document.createElement('video'); + this.video.controls = this.controls; + this.video.playsInline = true; + this.video.preload = 'auto'; + + this.video.style.display = 'block'; // fix bottom margin 4px + this.video.style.width = '100%'; + this.video.style.height = '100%'; + + this.appendChild(this.video); + + if (this.background) return; + + if ('hidden' in document && this.visibilityCheck) { + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + this.disconnectedCallback(); + } else if (this.isConnected) { + this.connectedCallback(); + } + }); + } + + if ('IntersectionObserver' in window && this.visibilityThreshold) { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) { + this.disconnectedCallback(); + } else if (this.isConnected) { + this.connectedCallback(); + } + }); + }, + { threshold: this.visibilityThreshold }, + ); + observer.observe(this); + } + + this.video.onloadeddata = () => { + if (this.controls) { + hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); + } + dispatchMediaLoadedEvent(this, this.video, { + player: this.containingPlayer, + capabilities: { + // 2-way audio is only supported on WebRTC connections. The state of + // `this.microphoneStream` is not taken into account here since + // that can be created after the fact -- this is purely saying that + // were a microphone stream available it could be used usefully. + supports2WayAudio: !!this.pc, + supportsPause: true, + hasAudio: mayHaveAudio(this.video), + }, + }); + }; + this.video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this); + this.video.onplay = () => dispatchMediaPlayEvent(this); + this.video.onpause = () => dispatchMediaPauseEvent(this); + // Always started muted. Media may be unmuted in accordance with user + // configuration. + this.video.muted = true; + } + + /** + * Connect to WebSocket. Called automatically on `connectedCallback`. + * @return {boolean} true if the connection has started. + */ + onconnect() { + if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false; + + // CLOSED or CONNECTING => CONNECTING + this.wsState = WebSocket.CONNECTING; + + this.connectTS = Date.now(); + + this.ws = new WebSocket(this.wsURL); + this.ws.binaryType = 'arraybuffer'; + this.ws.addEventListener('open', (ev) => this.onopen(ev)); + this.ws.addEventListener('close', (ev) => this.onclose(ev)); + + return true; + } + + ondisconnect() { + this.wsState = WebSocket.CLOSED; + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.pcState = WebSocket.CLOSED; + if (this.pc) { + this.pc.close(); + this.pc = null; + } + } + + /** + * @returns {Array.} of modes (mse, webrtc, etc.) + */ + onopen() { + // CONNECTING => OPEN + this.wsState = WebSocket.OPEN; + + this.ws.addEventListener('message', (ev) => { + if (typeof ev.data === 'string') { + const msg = JSON.parse(ev.data); + for (const mode in this.onmessage) { + this.onmessage[mode](msg); + } + } else { + this.ondata(ev.data); + } + }); + + this.ondata = null; + this.onmessage = {}; + + const modes = []; + + if (this.mode.indexOf('mse') >= 0 && 'MediaSource' in window) { + // iPhone + modes.push('mse'); + this.onmse(); + } else if (this.mode.indexOf('mp4') >= 0) { + modes.push('mp4'); + this.onmp4(); + } + + if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) { + // macOS Desktop app + modes.push('webrtc'); + this.onwebrtc(); + } + + if (this.mode.indexOf('mjpeg') >= 0) { + if (modes.length) { + this.onmessage['mjpeg'] = (msg) => { + if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return; + this.onmjpeg(); + }; + } else { + modes.push('mjpeg'); + this.onmjpeg(); + } + } + + return modes; + } + + /** + * @return {boolean} true if reconnection has started. + */ + onclose() { + if (this.wsState === WebSocket.CLOSED) return false; + + // CONNECTING, OPEN => CONNECTING + this.wsState = WebSocket.CONNECTING; + this.ws = null; + + // reconnect no more than once every X seconds + const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0); + + this.reconnectTID = setTimeout(() => { + this.reconnectTID = 0; + this.onconnect(); + }, delay); + + return true; + } + + onmse() { + const ms = new MediaSource(); + ms.addEventListener( + 'sourceopen', + () => { + URL.revokeObjectURL(this.video.src); + this.send({ type: 'mse', value: this.codecs('mse') }); + }, + { once: true }, + ); + + this.video.src = URL.createObjectURL(ms); + this.video.srcObject = null; + this.play(); + + this.mseCodecs = ''; + + this.onmessage['mse'] = (msg) => { + if (msg.type !== 'mse') return; + + this.mseCodecs = msg.value; + + const sb = ms.addSourceBuffer(msg.value); + sb.mode = 'segments'; // segments or sequence + sb.addEventListener('updateend', () => { + if (sb.updating) return; + + try { + if (bufLen > 0) { + const data = buf.slice(0, bufLen); + bufLen = 0; + sb.appendBuffer(data); + } else if (sb.buffered && sb.buffered.length) { + const end = sb.buffered.end(sb.buffered.length - 1) - 15; + const start = sb.buffered.start(0); + if (end > start) { + sb.remove(start, end); + ms.setLiveSeekableRange(end, end + 15); + } + // console.debug("VideoRTC.buffered", start, end); + } + } catch (e) { + // console.debug(e); + } + }); + + const buf = new Uint8Array(2 * 1024 * 1024); + let bufLen = 0; + + this.ondata = (data) => { + if (sb.updating || bufLen > 0) { + const b = new Uint8Array(data); + buf.set(b, bufLen); + bufLen += b.byteLength; + // console.debug("VideoRTC.buffer", b.byteLength, bufLen); + } else { + try { + sb.appendBuffer(data); + } catch (e) { + // console.debug(e); + } + } + }; + }; + } + + onwebrtc() { + const pc = new RTCPeerConnection(this.pcConfig); + + const video2 = document.createElement('video'); + video2.addEventListener('loadeddata', (ev) => this.onpcvideo(ev), { once: true }); + + pc.addEventListener('icecandidate', (ev) => { + const candidate = ev.candidate ? ev.candidate.toJSON().candidate : ''; + this.send({ type: 'webrtc/candidate', value: candidate }); + }); + + pc.addEventListener('track', (ev) => { + // when stream already init + if (video2.srcObject !== null) return; + + // when audio track not exist in Chrome + if (ev.streams.length === 0) return; + + // when audio track not exist in Firefox + if (ev.streams[0].id[0] === '{') return; + + // Filter out tracks that are not video related. + if (ev.track.kind !== 'video') return; + + video2.srcObject = ev.streams[0]; + }); + + pc.addEventListener('connectionstatechange', () => { + if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { + pc.close(); // stop next events + + this.pcState = WebSocket.CLOSED; + this.pc = null; + + this.onconnect(); + } + }); + + this.onmessage['webrtc'] = (msg) => { + switch (msg.type) { + case 'webrtc/candidate': + pc.addIceCandidate({ + candidate: msg.value, + sdpMid: '0', + }).catch(() => console.debug); + break; + case 'webrtc/answer': + pc.setRemoteDescription({ + type: 'answer', + sdp: msg.value, + }).catch(() => console.debug); + break; + case 'error': + if (msg.value.indexOf('webrtc/offer') < 0) return; + pc.close(); + } + }; + + // Safari doesn't support "offerToReceiveVideo" + pc.addTransceiver('video', { direction: 'recvonly' }); + pc.addTransceiver('audio', { direction: 'recvonly' }); + + // Must add microphone tracks prior to making the offer. + this.microphoneStream?.getTracks().forEach((track) => { + pc.addTransceiver(track, { direction: 'sendonly' }); + }); + + pc.createOffer().then((offer) => { + pc.setLocalDescription(offer).then(() => { + this.send({ type: 'webrtc/offer', value: offer.sdp }); + }); + }); + + this.pcState = WebSocket.CONNECTING; + this.pc = pc; + } + + /** + * @param ev {Event} + */ + onpcvideo(ev) { + if (!this.pc) return; + + /** @type {HTMLVideoElement} */ + const video2 = ev.target; + const state = this.pc.connectionState; + + // Firefox doesn't support pc.connectionState + if (state === 'connected' || state === 'connecting' || !state) { + // Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE + let rtcPriority = 0, + msePriority = 0; + + /** @type {MediaStream} */ + const ms = video2.srcObject; + if (ms.getVideoTracks().length > 0) rtcPriority += 0x220; + if (ms.getAudioTracks().length > 0) rtcPriority += 0x102; + + if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230; + if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210; + if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101; + + if (rtcPriority >= msePriority) { + this.video.srcObject = ms; + this.play(); + + this.pcState = WebSocket.OPEN; + + this.wsState = WebSocket.CLOSED; + this.ws.close(); + this.ws = null; + } else { + this.pcState = WebSocket.CLOSED; + this.pc.close(); + this.pc = null; + } + } + + video2.srcObject = null; + } + + onmjpeg() { + this.ondata = (data) => { + this.video.controls = false; + this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data); + }; + + this.send({ type: 'mjpeg' }); + } + + onmp4() { + /** @type {HTMLCanvasElement} **/ + const canvas = document.createElement('canvas'); + /** @type {CanvasRenderingContext2D} */ + let context; + + /** @type {HTMLVideoElement} */ + const video2 = document.createElement('video'); + video2.autoplay = true; + video2.playsInline = true; + video2.muted = true; + + video2.addEventListener('loadeddata', (ev) => { + if (!context) { + canvas.width = video2.videoWidth; + canvas.height = video2.videoHeight; + context = canvas.getContext('2d'); + } + + context.drawImage(video2, 0, 0, canvas.width, canvas.height); + + this.video.controls = false; + this.video.poster = canvas.toDataURL('image/jpeg'); + }); + + this.ondata = (data) => { + video2.src = 'data:video/mp4;base64,' + VideoRTC.btoa(data); + }; + + this.send({ type: 'mp4', value: this.codecs('mp4') }); + } + + static btoa(buffer) { + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + let binary = ''; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } +} diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts index 32f23d2f..8b7347c2 100644 --- a/src/components/live/live-go2rtc.ts +++ b/src/components/live/live-go2rtc.ts @@ -4,33 +4,24 @@ import { LitElement, PropertyValues, TemplateResult, - unsafeCSS + unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { CameraEndpoints } from '../../camera-manager/types.js'; -import { VideoRTC } from '../../external/go2rtc/video-rtc'; import { localize } from '../../localize/localize'; import liveMSEStyle from '../../scss/live-go2rtc.scss'; import { CameraConfig, ExtendedHomeAssistant, FrigateCardMediaPlayer, - MicrophoneConfig + MicrophoneConfig, } from '../../types.js'; -import { mayHaveAudio } from '../../utils/audio'; import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; -import { - hideMediaControlsTemporarily, - MEDIA_LOAD_CONTROLS_HIDE_SECONDS -} from '../../utils/media'; -import { - dispatchMediaLoadedEvent, - dispatchMediaPauseEvent, - dispatchMediaPlayEvent, - dispatchMediaVolumeChangeEvent -} from '../../utils/media-info'; import '../image.js'; import { dispatchErrorMessageEvent } from '../message'; +import { VideoRTC } from './go2rtc/video-rtc'; + +customElements.define('frigate-card-live-go2rtc-player', VideoRTC); // Note (2023-02-18): Depending on the behavior of the player / browser is // possible this URL will need to be re-signed in order to avoid HA spamming @@ -39,152 +30,6 @@ import { dispatchErrorMessageEvent } from '../message'; // provider). const GO2RTC_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; -@customElement('frigate-card-live-go2rtc-player') -class FrigateCardGo2RTCPlayer extends VideoRTC { - protected _microphoneStream?: MediaStream; - protected _containingPlayer?: FrigateCardMediaPlayer; - - constructor(containingPlayer: FrigateCardMediaPlayer, microphoneStream?: MediaStream) { - super(); - this._containingPlayer = containingPlayer; - if (microphoneStream) { - this._microphoneStream = microphoneStream; - } - } - - public play(): void { - // Let Frigate card control auto playing. - } - - protected reconnect(): void { - if (this.wsState !== WebSocket.CLOSED) { - // The websocket has onclose handlers, so don't call onconnect directly, - // wait until the eventhandlers are finished.. - this.ws?.addEventListener('close', () => this.onconnect()); - this.ondisconnect(); - } else { - // Still call ondisconnect() as there may be an RTC connection to - // terminate even if the websocket is closed. - this.ondisconnect(); - this.onconnect(); - } - } - - public async setMicrophoneStream(microphoneStream?: MediaStream): Promise { - if (this._microphoneStream !== microphoneStream) { - this._microphoneStream = microphoneStream; - this.reconnect(); - } - } - - public oninit(): void { - super.oninit(); - - if (this.video) { - this.video.onloadeddata = () => { - hideMediaControlsTemporarily(this.video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); - dispatchMediaLoadedEvent(this, this.video, { - player: this._containingPlayer, - capabilities: { - // 2-way audio is only supported on WebRTC connections. The state of - // `this._microphoneStream` is not taken into account here since - // that can be created after the fact -- this is purely saying that - // were a microphone stream available it could be used usefully. - supports2WayAudio: !!this.pc, - supportsPause: true, - hasAudio: mayHaveAudio(this.video), - }, - }); - }; - this.video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this), - this.video.onplay = () => dispatchMediaPlayEvent(this), - this.video.onpause = () => dispatchMediaPauseEvent(this), - - // Always started muted. Media may be unmuted in accordance with user - // configuration. - this.video.muted = true; - } - } - - // This is a modified version of onwebrtc() to support 2-way audio. - override onwebrtc() { - const pc = new RTCPeerConnection(this.pcConfig); - - const video2: HTMLVideoElement = document.createElement('video'); - video2.addEventListener('loadeddata', (ev) => this.onpcvideo(ev), { once: true }); - - pc.addEventListener('icecandidate', (ev) => { - const candidate = ev.candidate ? ev.candidate.toJSON().candidate : ''; - this.send({ type: 'webrtc/candidate', value: candidate }); - }); - - pc.addEventListener('track', (ev) => { - // when stream already init - if (video2.srcObject !== null) return; - - // when audio track not exist in Chrome - if (ev.streams.length === 0) return; - - // when audio track not exist in Firefox - if (ev.streams[0].id[0] === '{') return; - - // Filter out tracks that are not video related. - if (ev.track.kind !== 'video') return; - - video2.srcObject = ev.streams[0]; - }); - - pc.addEventListener('connectionstatechange', () => { - if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { - pc.close(); // stop next events - - this.pcState = WebSocket.CLOSED; - this.pc = null; - - this.onconnect(); - } - }); - - this.onmessage['webrtc'] = (msg) => { - switch (msg.type) { - case 'webrtc/candidate': - pc.addIceCandidate({ - candidate: msg.value, - sdpMid: '0', - }).catch(() => console.debug); - break; - case 'webrtc/answer': - pc.setRemoteDescription({ - type: 'answer', - sdp: msg.value, - }).catch(() => console.debug); - break; - case 'error': - if (msg.value.indexOf('webrtc/offer') < 0) return; - pc.close(); - } - }; - - // Safari doesn't support "offerToReceiveVideo" - pc.addTransceiver('video', { direction: 'recvonly' }); - pc.addTransceiver('audio', { direction: 'recvonly' }); - - // Must add microphone tracks prior to making the offer. - this._microphoneStream?.getTracks().forEach((track) => { - pc.addTransceiver(track, { direction: 'sendonly' }); - }); - - pc.createOffer().then((offer) => { - pc.setLocalDescription(offer).then(() => { - this.send({ type: 'webrtc/offer', value: offer.sdp }); - }); - }); - - this.pcState = WebSocket.CONNECTING; - this.pc = pc; - } -} - @customElement('frigate-card-live-go2rtc') export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPlayer { // Not an reactive property to avoid resetting the video. @@ -202,7 +47,10 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla @property({ attribute: false }) public microphoneConfig?: MicrophoneConfig; - protected _player?: FrigateCardGo2RTCPlayer; + @property({ attribute: true, type: Boolean }) + public controls = false; + + protected _player?: VideoRTC; public async play(): Promise { return this._player?.video?.play(); @@ -234,9 +82,9 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla } } - public async setControls(controls: boolean): Promise { + public async setControls(controls?: boolean): Promise { if (this._player?.video) { - this._player.video.controls = controls; + this._player.video.controls = controls ?? this.controls; } } @@ -278,9 +126,12 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla return; } - this._player = new FrigateCardGo2RTCPlayer(this, this.microphoneStream); + this._player = new VideoRTC(); + this._player.containingPlayer = this; + this._player.microphoneStream = this.microphoneStream ?? null; this._player.src = address; this._player.visibilityCheck = false; + this._player.controls = this.controls; if (this.cameraConfig?.go2rtc?.modes && this.cameraConfig.go2rtc.modes.length) { this._player.mode = this.cameraConfig.go2rtc.modes.join(','); @@ -293,8 +144,19 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla if (!this._player || changedProps.has('cameraEndpoints')) { this._createPlayer(); } - if (changedProps.has('microphoneStream')) { - this._player?.setMicrophoneStream(this.microphoneStream); + + if (changedProps.has('controls') && this._player) { + this._player.controls = this.controls; + } + + if (this._player && changedProps.has('microphoneStream')) { + if (this._player?.microphoneStream !== this.microphoneStream) { + this._player.microphoneStream = this.microphoneStream ?? null; + + // Need to force a reconnect if the microphone stream changes since + // WebRTC cannot introduce a new stream after the offer is already made. + this._player.reconnect(); + } } } diff --git a/src/components/live/live-ha.ts b/src/components/live/live-ha.ts index 5816691e..6ad1017a 100644 --- a/src/components/live/live-ha.ts +++ b/src/components/live/live-ha.ts @@ -17,6 +17,9 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla @property({ attribute: false }) public cameraConfig?: CameraConfig; + @property({ attribute: true, type: Boolean }) + public controls = true; + protected _playerRef: Ref = createRef(); public async play(): Promise { @@ -43,8 +46,8 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla this._playerRef.value?.seek(seconds); } - public async setControls(controls: boolean): Promise { - this._playerRef.value?.setControls(controls); + public async setControls(controls?: boolean): Promise { + this._playerRef.value?.setControls(controls ?? this.controls); } public isPaused(): boolean { @@ -65,7 +68,7 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla ${ref(this._playerRef)} .hass=${this.hass} .stateObj=${stateObj} - .controls=${true} + .controls=${this.controls} .muted=${true} > `; diff --git a/src/components/live/live-image.ts b/src/components/live/live-image.ts index 0c5df793..8f872d49 100644 --- a/src/components/live/live-image.ts +++ b/src/components/live/live-image.ts @@ -41,7 +41,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia await this._refImage.value?.seek(seconds); } - public async setControls(controls: boolean): Promise { + public async setControls(controls?: boolean): Promise { await this._refImage.value?.setControls(controls); } diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/live-jsmpeg.ts index cc2510b9..c0f9b830 100644 --- a/src/components/live/live-jsmpeg.ts +++ b/src/components/live/live-jsmpeg.ts @@ -10,13 +10,13 @@ import { CameraConfig, CardWideConfig, ExtendedHomeAssistant, - FrigateCardMediaPlayer + FrigateCardMediaPlayer, } from '../../types.js'; import { getEndpointAddressOrDispatchError } from '../../utils/endpoint.js'; import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, - dispatchMediaPlayEvent + dispatchMediaPlayEvent, } from '../../utils/media-info.js'; import { dispatchErrorMessageEvent } from '../message.js'; @@ -89,7 +89,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi * @returns A JSMPEG player. */ protected async _createJSMPEGPlayer(url: string): Promise { - return new Promise((resolve) => { + this._jsmpegVideoPlayer = await new Promise((resolve) => { let videoDecoded = false; const player = new JSMpeg.VideoElement( this, @@ -119,12 +119,6 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi // ignore any subsequent calls. if (!videoDecoded && this._jsmpegCanvasElement) { videoDecoded = true; - dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement, { - player: this, - capabilities: { - supportsPause: true, - }, - }); resolve(player); } }, @@ -133,6 +127,18 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi }, ); }); + + // The media loaded event must be dispatched after the player is assigned to + // `this._jsmpegVideoPlayer`, since the load call may (will!) result in + // calls back to the player to check for pause status for menu buttons. + if (this._jsmpegCanvasElement) { + dispatchMediaLoadedEvent(this, this._jsmpegCanvasElement, { + player: this, + capabilities: { + supportsPause: true, + }, + }); + } } /** @@ -206,7 +212,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi return; } - this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(address); + await this._createJSMPEGPlayer(address); this._refreshPlayerTimerID = window.setTimeout(() => { this.requestUpdate(); }, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); diff --git a/src/components/live/live-webrtc-card.ts b/src/components/live/live-webrtc-card.ts index e69eb797..47d46c36 100644 --- a/src/components/live/live-webrtc-card.ts +++ b/src/components/live/live-webrtc-card.ts @@ -41,6 +41,9 @@ export class FrigateCardLiveWebRTCCard @property({ attribute: false }) public cardWideConfig?: CardWideConfig; + @property({ attribute: true, type: Boolean }) + public controls = false; + protected hass?: HomeAssistant; // A task to await the load of the WebRTC component. @@ -79,10 +82,10 @@ export class FrigateCardLiveWebRTCCard } } - public async setControls(controls: boolean): Promise { + public async setControls(controls?: boolean): Promise { const player = this._getPlayer(); if (player) { - player.controls = controls; + player.controls = controls ?? this.controls; } } @@ -189,7 +192,9 @@ export class FrigateCardLiveWebRTCCard const video = this._getPlayer(); if (video) { video.onloadeddata = () => { - hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); + if (this.controls) { + hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); + } dispatchMediaLoadedEvent(this, video, { player: this, capabilities: { @@ -201,6 +206,7 @@ export class FrigateCardLiveWebRTCCard video.onplay = () => dispatchMediaPlayEvent(this); video.onpause = () => dispatchMediaPauseEvent(this); video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this); + video.controls = this.controls; } }); } diff --git a/src/components/live/live.ts b/src/components/live/live.ts index d8ee6ffb..46f05e6a 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -781,7 +781,7 @@ export class FrigateCardLiveProvider this._refProvider.value?.seek(seconds); } - public async setControls(controls: boolean): Promise { + public async setControls(controls?: boolean): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; this._refProvider.value?.setControls(controls); @@ -891,7 +891,7 @@ export class FrigateCardLiveProvider return this.liveConfig?.zoomable ? html` this.setControls(false)} - @frigate-card:zoom:unzoomed=${() => this.setControls(true)} + @frigate-card:zoom:unzoomed=${() => this.setControls()} > ${template} ` @@ -943,6 +943,7 @@ export class FrigateCardLiveProvider class=${classMap(providerClasses)} .hass=${this.hass} .cameraConfig=${this.cameraConfig} + ?controls=${this.liveConfig.controls.builtin} @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} > ` @@ -955,6 +956,7 @@ export class FrigateCardLiveProvider .cameraEndpoints=${this.cameraEndpoints} .microphoneStream=${this.microphoneStream} .microphoneConfig=${this.liveConfig.microphone} + ?controls=${this.liveConfig.controls.builtin} @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} > ` @@ -966,6 +968,7 @@ export class FrigateCardLiveProvider .cameraConfig=${this.cameraConfig} .cameraEndpoints=${this.cameraEndpoints} .cardWideConfig=${this.cardWideConfig} + ?controls=${this.liveConfig.controls.builtin} @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} > ` diff --git a/src/components/viewer.ts b/src/components/viewer.ts index bcd2cd9d..699ced25 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -6,7 +6,7 @@ import { LitElement, PropertyValues, TemplateResult, - unsafeCSS + unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { guard } from 'lit/directives/guard.js'; @@ -25,23 +25,28 @@ import { FrigateCardMediaPlayer, MediaLoadedInfo, TransitionEffect, - ViewerConfig + ViewerConfig, } from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { mayHaveAudio } from '../utils/audio.js'; import { contentsChanged, errorToConsole } from '../utils/basic.js'; import { canonicalizeHAURL } from '../utils/ha/index.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; -import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, dispatchMediaPlayEvent, dispatchMediaVolumeChangeEvent } from '../utils/media-info.js'; +import { + dispatchMediaLoadedEvent, + dispatchMediaPauseEvent, + dispatchMediaPlayEvent, + dispatchMediaVolumeChangeEvent, +} from '../utils/media-info.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import { changeViewToRecentEventsForCameraAndDependents, - changeViewToRecentRecordingForCameraAndDependents + changeViewToRecentRecordingForCameraAndDependents, } from '../utils/media-to-view.js'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, - playMediaMutingIfNecessary + playMediaMutingIfNecessary, } from '../utils/media.js'; import { ViewMediaClassifier } from '../view/media-classifier'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; @@ -53,7 +58,7 @@ import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload } from './embla-plugins/lazyload.js'; import { FrigateCardMediaCarousel, - wrapMediaLoadedEventForCarousel + wrapMediaLoadedEventForCarousel, } from './media-carousel.js'; import './next-prev-control.js'; import './surround.js'; @@ -603,11 +608,11 @@ export class FrigateCardViewerProvider } } - public async setControls(controls: boolean): Promise { + public async setControls(controls?: boolean): Promise { if (this._refFrigateCardMediaPlayer.value) { return this._refFrigateCardMediaPlayer.value.setControls(controls); } else if (this._refVideoProvider.value) { - this._refVideoProvider.value.controls = controls; + this._refVideoProvider.value.controls = controls ?? this.viewerConfig?.controls.builtin ?? true; } } @@ -702,7 +707,7 @@ export class FrigateCardViewerProvider return this.viewerConfig?.zoomable ? html` this.setControls(false)} - @frigate-card:zoom:unzoomed=${() => this.setControls(true)} + @frigate-card:zoom:unzoomed=${() => this.setControls()} > ${template} ` @@ -740,6 +745,7 @@ export class FrigateCardViewerProvider title="${this.media.getTitle() ?? ''}" url=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''} .hass=${this.hass} + ?controls=${this.viewerConfig.controls.builtin} > ` : html` @@ -748,11 +754,11 @@ export class FrigateCardViewerProvider aria-label="${this.media.getTitle() ?? ''}" title="${this.media.getTitle() ?? ''}" muted - controls playsinline ?autoplay=${false} + ?controls=${this.viewerConfig.controls.builtin} @loadedmetadata=${(ev: Event) => { - if (ev.target) { + if (ev.target && !!this.viewerConfig?.controls.builtin) { hideMediaControlsTemporarily( ev.target as HTMLVideoElement, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, diff --git a/src/const.ts b/src/const.ts index e635fdab..4fa722e6 100644 --- a/src/const.ts +++ b/src/const.ts @@ -104,6 +104,8 @@ export const CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP = `${CONF_MEDIA_VIEWER}.snapshot_click_plays_clip` as const; export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT = `${CONF_MEDIA_VIEWER}.transition_effect` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_BUILTIN = + `${CONF_MEDIA_VIEWER}.controls.builtin` as const; export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE = `${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const; export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE = @@ -149,6 +151,7 @@ export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const; export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` as const; export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const; export const CONF_LIVE_AUTO_UNMUTE = `${CONF_LIVE}.auto_unmute` as const; +export const CONF_LIVE_CONTROLS_BUILTIN = `${CONF_LIVE}.controls.builtin` as const; export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE = `${CONF_LIVE}.controls.next_previous.style` as const; export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE = diff --git a/src/editor.ts b/src/editor.ts index 057d38fd..41d4d513 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -60,6 +60,7 @@ import { CONF_LIVE_AUTO_PAUSE, CONF_LIVE_AUTO_PLAY, CONF_LIVE_AUTO_UNMUTE, + CONF_LIVE_CONTROLS_BUILTIN, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA, @@ -83,6 +84,7 @@ import { CONF_LIVE_LAYOUT_POSITION_Y, CONF_LIVE_LAZY_LOAD, CONF_LIVE_LAZY_UNLOAD, + CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED, CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS, CONF_LIVE_PRELOAD, CONF_LIVE_SHOW_IMAGE_DURING_LOAD, @@ -98,6 +100,7 @@ import { CONF_MEDIA_VIEWER_AUTO_PAUSE, CONF_MEDIA_VIEWER_AUTO_PLAY, CONF_MEDIA_VIEWER_AUTO_UNMUTE, + CONF_MEDIA_VIEWER_CONTROLS_BUILTIN, CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE, @@ -156,7 +159,6 @@ import { CONF_VIEW_UPDATE_FORCE, CONF_VIEW_UPDATE_SECONDS, MEDIA_CHUNK_SIZE_MAX, - CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED, } from './const.js'; import { localize } from './localize/localize.js'; import { setLowPerformanceProfile } from './performance.js'; @@ -1782,7 +1784,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { label: localize('config.menu.buttons.type') }, )}`, )} - ${this._renderMenuButton('play')} + ${this._renderMenuButton('play') /* */} ${this._renderMenuButton('mute')} ` @@ -1829,6 +1831,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor 'config.live.controls.editor_label', { name: 'mdi:gamepad' }, html` + ${this._renderSwitch( + CONF_LIVE_CONTROLS_BUILTIN, + this._defaults.live.controls.builtin, + { + label: localize('config.common.controls.builtin'), + }, + )} ${this._renderNextPreviousControls( MENU_LIVE_CONTROLS_NEXT_PREVIOUS, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, @@ -1953,6 +1962,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor 'config.media_viewer.controls.editor_label', { name: 'mdi:gamepad' }, html` + ${this._renderSwitch( + CONF_MEDIA_VIEWER_CONTROLS_BUILTIN, + this._defaults.media_viewer.controls.builtin, + { + label: localize('config.common.controls.builtin'), + }, + )} ${this._renderNextPreviousControls( MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS, CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, diff --git a/src/external/go2rtc/README.md b/src/external/go2rtc/README.md deleted file mode 100644 index 1fc4c6bb..00000000 --- a/src/external/go2rtc/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# go2rtc Player - -**Link**: https://github.com/AlexxIT/go2rtc/tree/master/www - -**Description**: A video player imported from go2rtc. - -**Copyright**: [Alexey Khit](https://github.com/AlexxIT) - -**License**: [MIT](https://github.com/AlexxIT/go2rtc/blob/master/LICENSE) diff --git a/src/external/go2rtc/video-rtc.js b/src/external/go2rtc/video-rtc.js deleted file mode 100644 index 11ec30be..00000000 --- a/src/external/go2rtc/video-rtc.js +++ /dev/null @@ -1,598 +0,0 @@ -/** - * Video player for go2rtc streaming application. - * - * All modern web technologies are supported in almost any browser except Apple Safari. - * - * Support: - * - RTCPeerConnection for Safari iOS 11.0+ - * - IntersectionObserver for Safari iOS 12.2+ - * - * Doesn't support: - * - MediaSource for Safari iOS all - * - Customized built-in elements (extends HTMLVideoElement) because all Safari - * - Public class fields because old Safari (before 14.0) - * - Autoplay for Safari - */ -export class VideoRTC extends HTMLElement { - constructor() { - super(); - - this.DISCONNECT_TIMEOUT = 5000; - this.RECONNECT_TIMEOUT = 30000; - - this.CODECS = [ - "avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen) - "avc1.64002A", // H.264 high 4.2 (Chromecast 3rd Gen) - "avc1.640033", // H.264 high 5.1 (Chromecast with Google TV) - "hvc1.1.6.L153.B0", // H.265 main 5.1 (Chromecast Ultra) - "mp4a.40.2", // AAC LC - "mp4a.40.5", // AAC HE - "flac", // FLAC (PCM compatible) - "opus", // OPUS Chrome, Firefox - ]; - - /** - * [config] Supported modes (webrtc, mse, mp4, mjpeg). - * @type {string} - */ - this.mode = "webrtc,mse,mp4,mjpeg"; - - /** - * [config] Run stream when not displayed on the screen. Default `false`. - * @type {boolean} - */ - this.background = false; - - /** - * [config] Run stream only when player in the viewport. Stop when user scroll out player. - * Value is percentage of visibility from `0` (not visible) to `1` (full visible). - * Default `0` - disable; - * @type {number} - */ - this.visibilityThreshold = 0; - - /** - * [config] Run stream only when browser page on the screen. Stop when user change browser - * tab or minimise browser windows. - * @type {boolean} - */ - this.visibilityCheck = true; - - /** - * [config] WebRTC configuration - * @type {RTCConfiguration} - */ - this.pcConfig = { - iceServers: [{urls: 'stun:stun.l.google.com:19302'}], - sdpSemantics: 'unified-plan', // important for Chromecast 1 - }; - - /** - * [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED - * @type {number} - */ - this.wsState = WebSocket.CLOSED; - - /** - * [info] WebRTC connection state. - * @type {number} - */ - this.pcState = WebSocket.CLOSED; - - /** - * @type {HTMLVideoElement} - */ - this.video = null; - - /** - * @type {WebSocket} - */ - this.ws = null; - - /** - * @type {string|URL} - */ - this.wsURL = ""; - - /** - * @type {RTCPeerConnection} - */ - this.pc = null; - - /** - * @type {number} - */ - this.connectTS = 0; - - /** - * @type {string} - */ - this.mseCodecs = ""; - - /** - * [internal] Disconnect TimeoutID. - * @type {number} - */ - this.disconnectTID = 0; - - /** - * [internal] Reconnect TimeoutID. - * @type {number} - */ - this.reconnectTID = 0; - - /** - * [internal] Handler for receiving Binary from WebSocket. - * @type {Function} - */ - this.ondata = null; - - /** - * [internal] Handlers list for receiving JSON from WebSocket - * @type {Object.}} - */ - this.onmessage = null; - } - - /** - * Set video source (WebSocket URL). Support relative path. - * @param {string|URL} value - */ - set src(value) { - if (typeof value !== "string") value = value.toString(); - if (value.startsWith("http")) { - value = "ws" + value.substring(4); - } else if (value.startsWith("/")) { - value = "ws" + location.origin.substring(4) + value; - } - - this.wsURL = value; - - this.onconnect(); - } - - /** - * Play video. Support automute when autoplay blocked. - * https://developer.chrome.com/blog/autoplay/ - */ - play() { - this.video.play().catch(er => { - if (er.name === "NotAllowedError" && !this.video.muted) { - this.video.muted = true; - this.video.play().catch(() => console.debug); - } - }); - } - - /** - * Send message to server via WebSocket - * @param {Object} value - */ - send(value) { - if (this.ws) this.ws.send(JSON.stringify(value)); - } - - codecs(type) { - const test = type === "mse" - ? codec => MediaSource.isTypeSupported(`video/mp4; codecs="${codec}"`) - : codec => this.video.canPlayType(`video/mp4; codecs="${codec}"`); - return this.CODECS.filter(test).join(); - } - - /** - * `CustomElement`. Invoked each time the custom element is appended into a - * document-connected element. - */ - connectedCallback() { - if (this.disconnectTID) { - clearTimeout(this.disconnectTID); - this.disconnectTID = 0; - } - - // because video autopause on disconnected from DOM - if (this.video) { - const seek = this.video.seekable; - if (seek.length > 0) { - this.video.currentTime = seek.end(seek.length - 1); - } - this.play(); - } else { - this.oninit(); - } - - this.onconnect(); - } - - /** - * `CustomElement`. Invoked each time the custom element is disconnected from the - * document's DOM. - */ - disconnectedCallback() { - if (this.background || this.disconnectTID) return; - if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return; - - this.disconnectTID = setTimeout(() => { - if (this.reconnectTID) { - clearTimeout(this.reconnectTID); - this.reconnectTID = 0; - } - - this.disconnectTID = 0; - - this.ondisconnect(); - }, this.DISCONNECT_TIMEOUT); - } - - /** - * Creates child DOM elements. Called automatically once on `connectedCallback`. - */ - oninit() { - this.video = document.createElement("video"); - this.video.controls = true; - this.video.playsInline = true; - this.video.preload = "auto"; - - this.video.style.display = "block"; // fix bottom margin 4px - this.video.style.width = "100%"; - this.video.style.height = "100%" - - this.appendChild(this.video); - - if (this.background) return; - - if ("hidden" in document && this.visibilityCheck) { - document.addEventListener("visibilitychange", () => { - if (document.hidden) { - this.disconnectedCallback(); - } else if (this.isConnected) { - this.connectedCallback(); - } - }) - } - - if ("IntersectionObserver" in window && this.visibilityThreshold) { - const observer = new IntersectionObserver(entries => { - entries.forEach(entry => { - if (!entry.isIntersecting) { - this.disconnectedCallback(); - } else if (this.isConnected) { - this.connectedCallback(); - } - }); - }, {threshold: this.visibilityThreshold}); - observer.observe(this); - } - } - - /** - * Connect to WebSocket. Called automatically on `connectedCallback`. - * @return {boolean} true if the connection has started. - */ - onconnect() { - if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false; - - // CLOSED or CONNECTING => CONNECTING - this.wsState = WebSocket.CONNECTING; - - this.connectTS = Date.now(); - - this.ws = new WebSocket(this.wsURL); - this.ws.binaryType = "arraybuffer"; - this.ws.addEventListener("open", ev => this.onopen(ev)); - this.ws.addEventListener("close", ev => this.onclose(ev)); - - return true; - } - - ondisconnect() { - this.wsState = WebSocket.CLOSED; - if (this.ws) { - this.ws.close(); - this.ws = null; - } - - this.pcState = WebSocket.CLOSED; - if (this.pc) { - this.pc.close(); - this.pc = null; - } - } - - /** - * @returns {Array.} of modes (mse, webrtc, etc.) - */ - onopen() { - // CONNECTING => OPEN - this.wsState = WebSocket.OPEN; - - this.ws.addEventListener("message", ev => { - if (typeof ev.data === "string") { - const msg = JSON.parse(ev.data); - for (const mode in this.onmessage) { - this.onmessage[mode](msg); - } - } else { - this.ondata(ev.data); - } - }); - - this.ondata = null; - this.onmessage = {}; - - const modes = []; - - if (this.mode.indexOf("mse") >= 0 && "MediaSource" in window) { // iPhone - modes.push("mse"); - this.onmse(); - } else if (this.mode.indexOf("mp4") >= 0) { - modes.push("mp4"); - this.onmp4(); - } - - if (this.mode.indexOf("webrtc") >= 0 && "RTCPeerConnection" in window) { // macOS Desktop app - modes.push("webrtc"); - this.onwebrtc(); - } - - if (this.mode.indexOf("mjpeg") >= 0) { - if (modes.length) { - this.onmessage["mjpeg"] = msg => { - if (msg.type !== "error" || msg.value.indexOf(modes[0]) !== 0) return; - this.onmjpeg(); - } - } else { - modes.push("mjpeg"); - this.onmjpeg(); - } - } - - return modes; - } - - /** - * @return {boolean} true if reconnection has started. - */ - onclose() { - if (this.wsState === WebSocket.CLOSED) return false; - - // CONNECTING, OPEN => CONNECTING - this.wsState = WebSocket.CONNECTING; - this.ws = null; - - // reconnect no more than once every X seconds - const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0); - - this.reconnectTID = setTimeout(() => { - this.reconnectTID = 0; - this.onconnect(); - }, delay); - - return true; - } - - onmse() { - const ms = new MediaSource(); - ms.addEventListener("sourceopen", () => { - URL.revokeObjectURL(this.video.src); - this.send({type: "mse", value: this.codecs("mse")}); - }, {once: true}); - - this.video.src = URL.createObjectURL(ms); - this.video.srcObject = null; - this.play(); - - this.mseCodecs = ""; - - this.onmessage["mse"] = msg => { - if (msg.type !== "mse") return; - - this.mseCodecs = msg.value; - - const sb = ms.addSourceBuffer(msg.value); - sb.mode = "segments"; // segments or sequence - sb.addEventListener("updateend", () => { - if (sb.updating) return; - - try { - if (bufLen > 0) { - const data = buf.slice(0, bufLen); - bufLen = 0; - sb.appendBuffer(data); - } else if (sb.buffered && sb.buffered.length) { - const end = sb.buffered.end(sb.buffered.length - 1) - 15; - const start = sb.buffered.start(0); - if (end > start) { - sb.remove(start, end); - ms.setLiveSeekableRange(end, end + 15); - } - // console.debug("VideoRTC.buffered", start, end); - } - } catch (e) { - // console.debug(e); - } - }); - - const buf = new Uint8Array(2 * 1024 * 1024); - let bufLen = 0; - - this.ondata = data => { - if (sb.updating || bufLen > 0) { - const b = new Uint8Array(data); - buf.set(b, bufLen); - bufLen += b.byteLength; - // console.debug("VideoRTC.buffer", b.byteLength, bufLen); - } else { - try { - sb.appendBuffer(data); - } catch (e) { - // console.debug(e); - } - } - } - } - } - - onwebrtc() { - const pc = new RTCPeerConnection(this.pcConfig); - - /** @type {HTMLVideoElement} */ - const video2 = document.createElement("video"); - video2.addEventListener("loadeddata", ev => this.onpcvideo(ev), {once: true}); - - pc.addEventListener("icecandidate", ev => { - const candidate = ev.candidate ? ev.candidate.toJSON().candidate : ""; - this.send({type: "webrtc/candidate", value: candidate}); - }); - - pc.addEventListener("track", ev => { - // when stream already init - if (video2.srcObject !== null) return; - - // when audio track not exist in Chrome - if (ev.streams.length === 0) return; - - // when audio track not exist in Firefox - if (ev.streams[0].id[0] === '{') return; - - video2.srcObject = ev.streams[0]; - }); - - pc.addEventListener("connectionstatechange", () => { - if (pc.connectionState === "failed" || pc.connectionState === "disconnected") { - pc.close(); // stop next events - - this.pcState = WebSocket.CLOSED; - this.pc = null; - - this.onconnect(); - } - }); - - this.onmessage["webrtc"] = msg => { - switch (msg.type) { - case "webrtc/candidate": - pc.addIceCandidate({ - candidate: msg.value, - sdpMid: "0" - }).catch(() => console.debug); - break; - case "webrtc/answer": - pc.setRemoteDescription({ - type: "answer", - sdp: msg.value - }).catch(() => console.debug); - break; - case "error": - if (msg.value.indexOf("webrtc/offer") < 0) return; - pc.close(); - } - }; - - // Safari doesn't support "offerToReceiveVideo" - pc.addTransceiver("video", {direction: "recvonly"}); - pc.addTransceiver("audio", {direction: "recvonly"}); - - pc.createOffer().then(offer => { - pc.setLocalDescription(offer).then(() => { - this.send({type: "webrtc/offer", value: offer.sdp}); - }); - }); - - this.pcState = WebSocket.CONNECTING; - this.pc = pc; - } - - /** - * @param ev {Event} - */ - onpcvideo(ev) { - if (!this.pc) return; - - /** @type {HTMLVideoElement} */ - const video2 = ev.target; - const state = this.pc.connectionState; - - // Firefox doesn't support pc.connectionState - if (state === "connected" || state === "connecting" || !state) { - // Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE - let rtcPriority = 0, msePriority = 0; - - /** @type {MediaStream} */ - const ms = video2.srcObject; - if (ms.getVideoTracks().length > 0) rtcPriority += 0x220; - if (ms.getAudioTracks().length > 0) rtcPriority += 0x102; - - if (this.mseCodecs.indexOf("hvc1.") >= 0) msePriority += 0x230; - if (this.mseCodecs.indexOf("avc1.") >= 0) msePriority += 0x210; - if (this.mseCodecs.indexOf("mp4a.") >= 0) msePriority += 0x101; - - if (rtcPriority >= msePriority) { - this.video.srcObject = ms; - this.play(); - - this.pcState = WebSocket.OPEN; - - this.wsState = WebSocket.CLOSED; - this.ws.close(); - this.ws = null; - } else { - this.pcState = WebSocket.CLOSED; - this.pc.close(); - this.pc = null; - } - } - - video2.srcObject = null; - } - - onmjpeg() { - this.ondata = data => { - this.video.controls = false; - this.video.poster = "data:image/jpeg;base64," + VideoRTC.btoa(data); - }; - - this.send({type: "mjpeg"}); - } - - onmp4() { - /** @type {HTMLCanvasElement} **/ - const canvas = document.createElement("canvas"); - /** @type {CanvasRenderingContext2D} */ - let context; - - /** @type {HTMLVideoElement} */ - const video2 = document.createElement("video"); - video2.autoplay = true; - video2.playsInline = true; - video2.muted = true; - - video2.addEventListener("loadeddata", ev => { - if (!context) { - canvas.width = video2.videoWidth; - canvas.height = video2.videoHeight; - context = canvas.getContext('2d'); - } - - context.drawImage(video2, 0, 0, canvas.width, canvas.height); - - this.video.controls = false; - this.video.poster = canvas.toDataURL("image/jpeg"); - }); - - this.ondata = data => { - video2.src = "data:video/mp4;base64," + VideoRTC.btoa(data); - }; - - this.send({type: "mp4", value: this.codecs("mp4")}); - } - - static btoa(buffer) { - const bytes = new Uint8Array(buffer); - const len = bytes.byteLength; - let binary = ""; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(bytes[i]); - } - return window.btoa(binary); - } -} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 4c07d494..59b48fdb 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -84,6 +84,7 @@ }, "common": { "controls": { + "builtin": "Built-in video controls", "filter": { "editor_label": "Media Filter", "mode": "Filter mode", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 9ef35b33..bc9653f7 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -84,6 +84,7 @@ }, "common": { "controls": { + "builtin": "", "filter": { "editor_label": "Filtro multimediale", "mode": "Modalità filtro", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index bc1bcb36..eb978cce 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -84,6 +84,7 @@ }, "common": { "controls": { + "builtin": "", "filter": { "editor_label": "Filtro de Mídia", "mode": "Modo do filtro", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 88777a08..ccb210ea 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -84,6 +84,7 @@ }, "common": { "controls": { + "builtin": "", "filter": { "editor_label": "Editor de titulos", "mode": "Modo", diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 140b94b6..97e2ca15 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -71,9 +71,9 @@ customElements.whenDefined('ha-camera-stream').then(() => { this._player?.seek(seconds); } - public async setControls(controls: boolean): Promise { + public async setControls(controls?: boolean): Promise { if (this._player) { - this._player.setControls(controls); + this._player.setControls(controls ?? this.controls); } } diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index b193c922..776961b3 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -73,9 +73,9 @@ customElements.whenDefined('ha-hls-player').then(() => { } } - public async setControls(controls: boolean): Promise { + public async setControls(controls?: boolean): Promise { if (this._video) { - this._video.controls = controls; + this._video.controls = controls ?? this.controls; } } @@ -104,7 +104,12 @@ customElements.whenDefined('ha-hls-player').then(() => { ?playsinline=${this.playsInline} ?controls=${this.controls} @loadedmetadata=${() => { - hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); + if (this.controls) { + hideMediaControlsTemporarily( + this._video, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, + ); + } }} @loadeddata=${(ev) => { dispatchMediaLoadedEvent(this, ev, { diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index 2e55a135..d3d40d5e 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -72,9 +72,9 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { } } - public async setControls(controls: boolean): Promise { + public async setControls(controls?: boolean): Promise { if (this._video) { - this._video.controls = controls; + this._video.controls = controls ?? this.controls; } } @@ -100,7 +100,12 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { ?playsinline=${this.playsInline} ?controls=${this.controls} @loadedmetadata=${() => { - hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); + if (this.controls) { + hideMediaControlsTemporarily( + this._video, + MEDIA_LOAD_CONTROLS_HIDE_SECONDS, + ); + } }} @loadeddata=${(ev) => { dispatchMediaLoadedEvent(this, ev, { diff --git a/src/types.ts b/src/types.ts index d778fc7c..dbb5628d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -948,6 +948,7 @@ const liveConfigDefault = { transition_effect: 'slide' as const, show_image_during_load: true, controls: { + builtin: true, next_previous: { size: 48, style: 'chevrons' as const, @@ -974,6 +975,7 @@ const liveOverridableConfigSchema = z .object({ controls: z .object({ + builtin: z.boolean().default(liveConfigDefault.controls.builtin), next_previous: nextPreviousControlConfigSchema .extend({ // Live cannot show thumbnails, remove that option. @@ -1142,6 +1144,7 @@ const viewerConfigDefault = { transition_effect: 'slide' as const, snapshot_click_plays_clip: true, controls: { + builtin: true, next_previous: { size: 48, style: 'thumbnails' as const, @@ -1188,6 +1191,7 @@ const viewerConfigSchema = z .default(viewerConfigDefault.snapshot_click_plays_clip), controls: z .object({ + builtin: z.boolean().default(viewerConfigDefault.controls.builtin), next_previous: viewerNextPreviousControlConfigSchema.default( viewerConfigDefault.controls.next_previous, ), @@ -1526,7 +1530,8 @@ export interface FrigateCardMediaPlayer { unmute(): Promise; isMuted(): boolean; seek(seconds: number): Promise; - setControls(controls: boolean): Promise; + // If no value for controls if specified, the player should use the default. + setControls(controls?: boolean): Promise; isPaused(): boolean; }