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