RTSP IP live camera in Dashboard 2.0

With the retirement of Dash1.0, I wanted to continue seeing the RTSP feed from my IP cameras in Dash2.0. While waiting for an "official" node from @kevigodell, I propose a Template node (DB2) which can stream my local RTSP IP cameras.

You need to install Kevin's nodes:

(or use the Palette).

[
    {
        "id": "022949e53279a741",
        "type": "inject",
        "z": "fe2c7bc6.bd49c8",
        "name": "stop default",
        "props": [
            {
                "p": "action",
                "v": "{\"command\":\"stop\"}",
                "vt": "json"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "x": 5840,
        "y": 5050,
        "wires": [
            [
                "d4b659133c509c6b"
            ]
        ]
    },
    {
        "id": "561cfc07f7e0c0da",
        "type": "ui-button",
        "z": "fe2c7bc6.bd49c8",
        "group": "5496b19a4e084472",
        "name": "refresh noire",
        "label": "",
        "order": 1,
        "width": "1",
        "height": "1",
        "emulateClick": false,
        "tooltip": "",
        "color": "",
        "bgcolor": "",
        "className": "",
        "icon": "refresh",
        "iconPosition": "left",
        "payload": "",
        "payloadType": "str",
        "topic": "",
        "topicType": "str",
        "buttonColor": "",
        "textColor": "",
        "iconColor": "",
        "enableClick": true,
        "enablePointerdown": false,
        "pointerdownPayload": "",
        "pointerdownPayloadType": "str",
        "enablePointerup": false,
        "pointerupPayload": "",
        "pointerupPayloadType": "str",
        "x": 5820,
        "y": 5080,
        "wires": [
            [
                "3fedb0188197880b"
            ]
        ]
    },
    {
        "id": "e11dd7afe2f06243",
        "type": "inject",
        "z": "fe2c7bc6.bd49c8",
        "name": "restart",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 5820,
        "y": 5110,
        "wires": [
            [
                "3fedb0188197880b"
            ]
        ]
    },
    {
        "id": "3fedb0188197880b",
        "type": "change",
        "z": "fe2c7bc6.bd49c8",
        "name": "restart noire",
        "rules": [
            {
                "t": "set",
                "p": "action",
                "pt": "msg",
                "to": "{\"command\":\"restart\",\"args\":[\"-loglevel\",\"quiet\",\"-rtsp_transport\",\"tcp\",\"-i\",\"rtsp://192.168.1.12:554/12\",\"-c:a\",\"aac\",\"-c:v\",\"copy\",\"-f\",\"mp4\",\"-movflags\",\"+frag_every_frame+empty_moov+default_base_moof\",\"-min_frag_duration\",\"500000\",\"pipe:1\",\"-progress\",\"pipe:2\"]}",
                "tot": "json"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 5960,
        "y": 5110,
        "wires": [
            [
                "d4b659133c509c6b"
            ]
        ]
    },
    {
        "id": "d4b659133c509c6b",
        "type": "ffmpeg",
        "z": "fe2c7bc6.bd49c8",
        "name": "",
        "outputs": 3,
        "cmdPath": "ffmpeg",
        "cmdArgs": "[\"-version\"]",
        "cmdOutputs": 2,
        "killSignal": "SIGTERM",
        "x": 6080,
        "y": 5070,
        "wires": [
            [
                "85f4d4cff74d42d3"
            ],
            [
                "85f4d4cff74d42d3"
            ],
            [
                "6b4334e0a2e9017a"
            ]
        ]
    },
    {
        "id": "85f4d4cff74d42d3",
        "type": "mp4frag",
        "z": "fe2c7bc6.bd49c8",
        "name": "testdash2noire",
        "outputs": 2,
        "basePath": "testdash2noire",
        "serveHttp": "true",
        "serveIo": "true",
        "hlsPlaylistSize": "3",
        "hlsPlaylistExtra": "1",
        "bufferPool": 0,
        "autoStart": "false",
        "preBuffer": 1,
        "timeLimit": "2000",
        "repeated": "false",
        "statusData": "playlist",
        "x": 6260,
        "y": 5030,
        "wires": [
            [
                "13352f64ced5b279"
            ],
            []
        ]
    },
    {
        "id": "6b4334e0a2e9017a",
        "type": "function",
        "z": "fe2c7bc6.bd49c8",
        "name": "progress",
        "func": "const props = msg.payload.toString().split('\\n');\nconst progress = {};\nprops.forEach(item => {\n    const [name, value] = item.split('=');\n    if (name && value) {\n        progress[name] = value;\n    }\n});\n\nconst color = progress['progress'] === 'continue' ? 'green' : 'red';\nconst message = `fps: ${progress['fps']}, bitrate: ${progress['bitrate']}`;\nnode.status({ fill: color, shape: 'dot', text: message });\nreturn { payload: progress };",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 6260,
        "y": 5080,
        "wires": [
            [
                "6f546b3baf7c9379"
            ]
        ]
    },
    {
        "id": "6f546b3baf7c9379",
        "type": "change",
        "z": "fe2c7bc6.bd49c8",
        "name": "",
        "rules": [
            {
                "t": "change",
                "p": "payload.bitrate",
                "pt": "msg",
                "from": "kbits/s",
                "fromt": "str",
                "to": "",
                "tot": "str"
            },
            {
                "t": "change",
                "p": "payload.bitrate",
                "pt": "msg",
                "from": " ",
                "fromt": "str",
                "to": "",
                "tot": "str"
            },
            {
                "t": "set",
                "p": "payload.bitrate",
                "pt": "msg",
                "to": "$number(payload.bitrate)",
                "tot": "jsonata"
            },
            {
                "t": "set",
                "p": "payload.bitrate",
                "pt": "msg",
                "to": "$round(payload.bitrate/8,1)",
                "tot": "jsonata"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 6375,
        "y": 5080,
        "wires": [
            [
                "3f174e338ae9c1fa"
            ]
        ],
        "l": false
    },
    {
        "id": "3f174e338ae9c1fa",
        "type": "function",
        "z": "fe2c7bc6.bd49c8",
        "name": "adapt text //1",
        "func": "//1.corbug erreur au demarrage\n\n// 0. SÉCURITÉ : Si payload n'existe pas ou n'est pas un objet, on quitte pour éviter le crash au Ctrl+F5\nif (!msg || !msg.payload || typeof msg.payload !== 'object') {\n    return null; // Stoppe le traitement de ce message fantôme\n}\n\n// 1. Récupérer et formater la date/heure\nconst timestamp = new Date();\nconst heureLocale = timestamp.toLocaleString('fr-FR', {\n    timeZone: 'Europe/Paris',\n    hour: '2-digit',\n    minute: '2-digit',\n    second: '2-digit',\n    day: '2-digit',\n    month: '2-digit'\n});\nconst [date, heure] = heureLocale.split(' ');\nconst datetime = `${heure}<br>${date}`;\n\n// 2. Gestion du LABEL (Partie gauche - Corrigé ici)\nconst labelText = msg.payload.bitrate !== undefined ? msg.payload.bitrate : 0;\nmsg.ui_update = {\n    label: `<div style='font-size: 12px; line-height: 1.2; color: #EEE;'>${labelText} KB/s<br><span style='color: grey;'>${datetime}</span></div>`,\n    fontSize: \"16px\"\n};\n\n// 3. Gestion du PAYLOAD (Partie droite)\nconst valeurBrute = msg.payload.fps !== undefined ? msg.payload.fps : 0;\nmsg.payload = `<div style=\"color: #0da3d5; font-weight: bold; font-size: 15px; text-align: right;\">\n    ${valeurBrute} \n    <span style=\\\"font-size: 10px; color: white;\\\">fps</span>\n</div>`;\n\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 6480,
        "y": 5080,
        "wires": [
            [
                "6c1db2e2510998ec"
            ]
        ]
    },
    {
        "id": "13352f64ced5b279",
        "type": "ui-template",
        "z": "fe2c7bc6.bd49c8",
        "group": "5496b19a4e084472",
        "page": "",
        "ui": "",
        "name": "noire socket.io  //2",
        "order": 3,
        "width": "6",
        "height": "6",
        "head": "",
        "format": "\n\n<template>\n    <div ref=\"containerEl\"\n        style=\"width:100%; height:100%; background:#000; position:relative; overflow:hidden; border-radius:4px; display:flex; align-items:center; justify-content:center;\">\n        <video ref=\"videoEl\" autoplay muted playsinline\n            style=\"width:100%; display:block; max-height:100%;\"></video>\n\n        \n        <div class=\"ptz-custom-controls\">\n            \n            <button @click.stop=\"toggleMute\" class=\"ptz-btn\" :title=\"isMuted ? 'Activer le son' : 'Couper le son'\">\n                <svg v-if=\"isMuted\" viewBox=\"0 0 24 24\" class=\"ptz-icon\"><path fill=\"currentColor\" d=\"M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.21.05-.42.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z\"/></svg>\n                <svg v-else viewBox=\"0 0 24 24\" class=\"ptz-icon\"><path fill=\"currentColor\" d=\"M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z\"/></svg>\n            </button>\n\n            \n            <button @click.stop=\"togglePiP\" class=\"ptz-btn\" title=\"Mode incrustation (PiP)\">\n                <svg viewBox=\"0 0 24 24\" class=\"ptz-icon\"><path fill=\"currentColor\" d=\"M19 11h-8v6h8v-6zm4 8V5c0-1.1-.9-2-2-2H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 0H3V5h18v14z\"/></svg>\n            </button>\n\n            \n            <button @click.stop=\"toggleFullscreen\" class=\"ptz-btn\" title=\"Plein écran\">\n                <svg viewBox=\"0 0 24 24\" class=\"ptz-icon\"><path fill=\"currentColor\" d=\"M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z\"/></svg>\n            </button>\n        </div>\n\n        <div v-if=\"shouldShowStatus\" :style=\"statusStyle\">\n            {{ status }}\n        </div>\n    </div>\n</template>\n\n<style>\n    .ptz-custom-controls {\n        position: absolute;\n        bottom: 8px;\n        right: 8px;\n        display: flex;\n        gap: 6px;\n        z-index: 20;\n        opacity: 0.2;\n        transition: opacity 0.3s ease;\n    }\n\n    .ptz-custom-controls:hover {\n        opacity: 1;\n    }\n\n    .ptz-btn {\n        background: rgba(0, 0, 0, 0.75);\n        border: 1px solid rgba(255, 255, 255, 0.2);\n        color: #ffffff;\n        border-radius: 6px;\n        width: 32px;\n        height: 32px;\n        cursor: pointer;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        padding: 0;\n        transition: background 0.2s, border-color 0.2s;\n    }\n\n    .ptz-btn:hover {\n        background: rgba(13, 163, 213, 0.9);\n        border-color: #0da3d5;\n    }\n\n    .ptz-icon {\n        width: 18px;\n        height: 18px;\n    }\n</style>\n\n<script>\nexport default {\n    name: 'UiMp4FragSocketIoFixe',\n\n    data() {\n        return {\n            status: 'Initialisation...',\n            isError: false,\n            isStoppedVoluntarily: false,\n            isPlayingNormally: false,\n            isMuted: true,\n            socketIoConfig: null\n        }\n    },\n\n    computed: {\n        shouldShowStatus() {\n            return !this.isPlayingNormally;\n        },\n\n        statusStyle() {\n            return {\n                position: 'absolute', top: '8px', left: '8px',\n                color: '#ffffff', fontSize: '11px',\n                fontFamily: 'monospace', pointerEvents: 'none',\n                zIndex: 10\n            }\n        }\n    },\n\n    watch: {\n        msg: {\n            handler(newMsg) {\n                if (!newMsg) return;\n                if (newMsg.status === 'reset' || newMsg.payload === '') {\n                    this.handleVoluntaryStop();\n                } else if (newMsg.status === 'playlist' && newMsg.payload?.socketIo) {\n                    this.socketIoConfig = newMsg.payload.socketIo;\n                    this.isStoppedVoluntarily = false;\n                    this.initStream();\n                }\n            },\n            immediate: true,\n            deep: true\n        }\n    },\n\n    mounted() {\n        this.$nextTick(() => {\n            this.startVisibilityWatcher();\n        });\n    },\n\n    unmounted() {\n        this.cleanup();\n        if (this._onVisibilityChange) {\n            document.removeEventListener('visibilitychange', this._onVisibilityChange);\n        }\n        if (this._intersectionObserver) {\n            this._intersectionObserver.disconnect();\n        }\n    },\n\n    methods: {\n        // --- BOUTONS ---\n        toggleMute() {\n            const video = this.$refs.videoEl;\n            if (!video) return;\n            video.muted = !video.muted;\n            this.isMuted = video.muted;\n        },\n\n        toggleFullscreen() {\n            const container = this.$refs.containerEl;\n            if (!container) return;\n            if (!document.fullscreenElement && !document.webkitFullscreenElement) {\n                (container.requestFullscreen || container.webkitRequestFullscreen).call(container);\n            } else {\n                (document.exitFullscreen || document.webkitExitFullscreen).call(document);\n            }\n        },\n\n        togglePiP() {\n            const video = this.$refs.videoEl;\n            if (!video) return;\n            if (document.pictureInPictureElement) document.exitPictureInPicture();\n            else if (video.requestPictureInPicture) video.requestPictureInPicture();\n        },\n\n        // --- STREAM SOCKET.IO ---\n        initStream() {\n            if (this.isStoppedVoluntarily || !this.socketIoConfig) return;\n            const video = this.$refs.videoEl;\n            if (!video) return;\n\n            if (window.io) {\n                this.setupSocketPlayer(video);\n            } else if (!document.getElementById('socketio-cdn-script')) {\n                const script = document.createElement('script');\n                script.id = 'socketio-cdn-script';\n                script.src = 'https://cdn.socket.io/4.7.5/socket.io.min.js';\n                script.onload = () => this.setupSocketPlayer(video);\n                document.head.appendChild(script);\n            }\n        },\n\n        setupSocketPlayer(video) {\n            this.cleanup();\n            this.status = 'Connexion...';\n            this.isPlayingNormally = false;\n\n            const { path, namespace, key } = this.socketIoConfig;\n\n            const socket = window.io(window.location.origin + namespace, {\n                path: path,\n                transports: ['websocket'],\n                forceNew: true,\n                auth: { key: key }\n            });\n            this._socket = socket;\n\n            let sourceBuffer = null;\n            let lastTimestamp = -1;\n            const queue = [];\n\n            const appendNext = () => {\n                if (sourceBuffer && !sourceBuffer.updating && queue.length > 0) {\n                    try { sourceBuffer.appendBuffer(queue.shift()); }\n                    catch(e) { console.warn('appendBuffer:', e); }\n                }\n            };\n\n            socket.on('auth', (authorized) => {\n                if (!authorized) return;\n                socket.emit('mime');\n                socket.emit('auth', { key: key });\n            });\n\n            socket.on('mime', (mimeCodecs) => {\n                if (!this._mediaSource) return;\n                const trySetup = () => {\n                    if (this._mediaSource.readyState === 'open') {\n                        try {\n                            sourceBuffer = this._mediaSource.addSourceBuffer(mimeCodecs);\n                            sourceBuffer.mode = 'sequence';\n                            sourceBuffer.addEventListener('updateend', appendNext);\n                            socket.emit('initialization');\n                        } catch(e) { console.error('SourceBuffer:', e); }\n                    } else {\n                        setTimeout(trySetup, 50);\n                    }\n                };\n                trySetup();\n            });\n\n            socket.on('initialization', (data) => {\n                const buf = data instanceof ArrayBuffer ? data\n                    : data?.segment ? new Uint8Array(data.segment).buffer\n                    : new Uint8Array(data).buffer;\n                queue.push(buf);\n                appendNext();\n                socket.emit('segment', { buffered: false, all: false });\n            });\n\n            socket.on('segment', (data) => {\n                const seg = data instanceof ArrayBuffer ? data\n                    : data?.segment ? new Uint8Array(data.segment).buffer\n                    : null;\n\n                if (data?.timestamp !== undefined) lastTimestamp = data.timestamp;\n\n                if (seg) {\n                    queue.push(seg);\n                    appendNext();\n                }\n\n                if (!this.isPlayingNormally) {\n                    video.muted = this.isMuted;\n                    video.play().then(() => {\n                        this.isPlayingNormally = true;\n                        this.isError = false;\n                    }).catch(e => {});\n                }\n\n                socket.emit('segment', { timestamp: lastTimestamp });\n            });\n\n            socket.on('mp4frag_error', (err) => {\n                this.isError = true;\n                this.status = 'Erreur : ' + (err?.message || err);\n            });\n\n            socket.on('connect', () => { this.isError = false; });\n\n            socket.on('connect_error', (err) => {\n                this.isError = true;\n                this.status = 'Erreur socket';\n            });\n\n            socket.on('disconnect', () => {\n                if (!this.isStoppedVoluntarily) {\n                    this.isPlayingNormally = false;\n                    this.status = 'Reconnexion...';\n                }\n            });\n\n            const mediaSource = new MediaSource();\n            this._mediaSource = mediaSource;\n            const objectUrl = URL.createObjectURL(mediaSource);\n            video.src = objectUrl;\n            mediaSource.addEventListener('sourceopen', () => URL.revokeObjectURL(objectUrl));\n        },\n\n        handleVoluntaryStop() {\n            this.isStoppedVoluntarily = true;\n            this.isPlayingNormally = false;\n            this.isError = false;\n            this.status = 'Flux arrêté';\n            this.cleanup();\n        },\n\n        cleanup() {\n            if (this._socket) {\n                this._socket.disconnect();\n                this._socket = null;\n            }\n            if (this._mediaSource && this._mediaSource.readyState === 'open') {\n                try { this._mediaSource.endOfStream(); } catch(e) {}\n            }\n            this._mediaSource = null;\n            const video = this.$refs.videoEl;\n            if (video) {\n                video.pause();\n                video.src = '';\n                video.load();\n            }\n        },\n\n        // --- VISIBILITY WATCHER ---\n        startVisibilityWatcher() {\n            this._onVisibilityChange = () => {\n                if (document.hidden) this._pauseForVisibility();\n                else this._resumeForVisibility();\n            };\n            document.addEventListener('visibilitychange', this._onVisibilityChange);\n\n            const video = this.$refs.videoEl;\n            if (video) {\n                this._intersectionObserver = new IntersectionObserver(\n                    (entries) => {\n                        if (entries[0].intersectionRatio < 0.5) this._pauseForVisibility();\n                        else this._resumeForVisibility();\n                    },\n                    { threshold: [0, 0.5, 1.0] }\n                );\n                this._intersectionObserver.observe(video);\n            }\n        },\n\n        _pauseForVisibility() {\n            if (this.isStoppedVoluntarily || this._pausedForVisibility) return;\n            this._pausedForVisibility = true;\n            const video = this.$refs.videoEl;\n            if (video) video.pause();\n            this.isPlayingNormally = false;\n            this.status = 'Pause...';\n        },\n\n        _resumeForVisibility() {\n            if (!this._pausedForVisibility || this.isStoppedVoluntarily) return;\n            this._pausedForVisibility = false;\n            const video = this.$refs.videoEl;\n            if (!video) return;\n            video.play().then(() => {\n                this.isPlayingNormally = true;\n            }).catch(e => console.log('Resume:', e));\n        }\n    }\n}\n</script>",
        "storeOutMessages": true,
        "passthru": false,
        "resendOnRefresh": true,
        "templateScope": "local",
        "className": "",
        "x": 6590,
        "y": 5050,
        "wires": [
            []
        ]
    },
    {
        "id": "6c1db2e2510998ec",
        "type": "ui-text",
        "z": "fe2c7bc6.bd49c8",
        "group": "5496b19a4e084472",
        "order": 4,
        "width": "3",
        "height": "1",
        "name": "noire fps",
        "label": "",
        "format": "{{msg.payload}}",
        "layout": "row-left",
        "style": false,
        "font": "Arial,Arial,Helvetica,sans-serif",
        "fontSize": "12",
        "color": "#ffffff",
        "wrapText": false,
        "className": "",
        "value": "payload",
        "valueType": "msg",
        "x": 6620,
        "y": 5080,
        "wires": []
    },
    {
        "id": "e862467bbd11be70",
        "type": "ui-button",
        "z": "fe2c7bc6.bd49c8",
        "group": "5496b19a4e084472",
        "name": "stop noire",
        "label": "",
        "order": 2,
        "width": "1",
        "height": "1",
        "emulateClick": false,
        "tooltip": "",
        "color": "",
        "bgcolor": "",
        "className": "",
        "icon": "stop",
        "iconPosition": "left",
        "payload": "",
        "payloadType": "str",
        "topic": "",
        "topicType": "str",
        "buttonColor": "",
        "textColor": "",
        "iconColor": "",
        "enableClick": true,
        "enablePointerdown": false,
        "pointerdownPayload": "",
        "pointerdownPayloadType": "str",
        "enablePointerup": false,
        "pointerupPayload": "",
        "pointerupPayloadType": "str",
        "x": 5810,
        "y": 5150,
        "wires": [
            [
                "1cc4a0af2f697617"
            ]
        ]
    },
    {
        "id": "1cc4a0af2f697617",
        "type": "change",
        "z": "fe2c7bc6.bd49c8",
        "name": "stop noire",
        "rules": [
            {
                "t": "set",
                "p": "action",
                "pt": "msg",
                "to": "{\"command\":\"stop\"}",
                "tot": "json"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 5970,
        "y": 5150,
        "wires": [
            [
                "d4b659133c509c6b"
            ]
        ]
    },
    {
        "id": "5496b19a4e084472",
        "type": "ui-group",
        "name": "noire",
        "page": "5e4ae4ec6d0fef71",
        "width": 6,
        "height": 1,
        "order": 1,
        "showTitle": true,
        "className": "",
        "visible": "true",
        "disabled": "false",
        "groupType": "default"
    },
    {
        "id": "5e4ae4ec6d0fef71",
        "type": "ui-page",
        "name": "test",
        "ui": "3dcb17d3fbb77cb1",
        "path": "/page8",
        "icon": "home",
        "layout": "grid",
        "theme": "2da75e915972c00d",
        "breakpoints": [
            {
                "name": "Default",
                "px": "0",
                "cols": "3"
            },
            {
                "name": "Tablet",
                "px": "576",
                "cols": "6"
            },
            {
                "name": "Small Desktop",
                "px": "768",
                "cols": "9"
            },
            {
                "name": "Desktop",
                "px": "1024",
                "cols": "12"
            }
        ],
        "order": 8,
        "className": "",
        "visible": "true",
        "disabled": "false"
    },
    {
        "id": "3dcb17d3fbb77cb1",
        "type": "ui-base",
        "name": "Page flowfuse",
        "path": "/dashboard",
        "appIcon": "",
        "includeClientData": true,
        "acceptsClientConfig": [
            "ui-control",
            "ui-notification"
        ],
        "showPathInSidebar": false,
        "headerContent": "page",
        "navigationStyle": "temporary",
        "titleBarStyle": "default",
        "showReconnectNotification": true,
        "notificationDisplayTime": 5,
        "showDisconnectNotification": true,
        "allowInstall": true
    },
    {
        "id": "2da75e915972c00d",
        "type": "ui-theme",
        "name": "Theme black",
        "colors": {
            "surface": "#177278",
            "primary": "#0da3d5",
            "bgPage": "#111111",
            "groupBg": "#333333",
            "groupOutline": "#787878"
        },
        "sizes": {
            "density": "default",
            "pagePadding": "1px",
            "groupGap": "4px",
            "groupBorderRadius": "2px",
            "widgetGap": "3px"
        }
    },
    {
        "id": "3dce48901209b9e9",
        "type": "global-config",
        "env": [],
        "modules": {
            "@flowfuse/node-red-dashboard": "1.30.2",
            "@kevingodell/node-red-ffmpeg": "0.1.1-beta.3",
            "@kevingodell/node-red-mp4frag": "0.1.1-beta.2"
        }
    }
]

My camera is called "noire" and the dashboard is in the page: test and the group: noire. Personalize to your liking.

In "Restart noire" at the line: "rtsp://192.168.1.12:554/12",
replace what is in the quotes with the address of your IP camera.

The result:

  • a stream from the camera with integrated buttons: mute, picture in picture and large screen.
  • A stream start/restart and stop button.
  • And optional flow rate indications.

As I am not a programmer, but I have ideas :grinning_face:, Obviously, I asked the AI ​​so there are surely imperfections in the Template, but I apologize in advance for not being able to resolve the bugs if there are any.

This Flow works well, I don't have many bugs. It consumes very few resources thanks to the performance of the original nodes from @kevingodell, on a Raspberry PI 3B+, and NR 4.1.11, Raspbian BUSTER, nodejs v18.20.2.

TO DO: upgrade to NR 5.0.1 and nodejs 24, with Raspberry Pi OS (64-bit) (3B compatible)

Enjoy

Hello @SuperNinja, nice to see you back here again!

Maybe this posting may give some additional (or maybe the same) info:

Hi @krambriw, yes, I still check the forum from time to time. :wink:
The switch to DB2 is proving more painful than I expected... but I'm managing reasonably well thanks to AI. My code probably looks awful to a pro programmer, but at least it works.
Thanks for the link—I'll take a look.
Cheers.