{
  "widgetsBundle": {
    "alias": "video_streaming",
    "title": "Video Streaming",
    "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNDAiIGhlaWdodD0iMTUwIiB2aWV3Qm94PSIwIDAgMjQwIDE1MCI+PHJlY3Qgd2lkdGg9IjI0MCIgaGVpZ2h0PSIxNTAiIHJ4PSI4IiBmaWxsPSIjMUUyOTNCIi8+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjAgMzkpIiBmaWxsPSJub25lIiBzdHJva2U9IiM2MEE1RkEiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cmVjdCB4PSI2IiB5PSIxOCIgd2lkdGg9Ijc4IiBoZWlnaHQ9IjU0IiByeD0iNiIvPjxwYXRoIGQ9Ik05MCAzMGwzMCAtMTJ2NTRsLTMwIC0xMnoiLz48L2c+PGNpcmNsZSBjeD0iMTA1IiBjeT0iODQiIHI9IjE2IiBmaWxsPSIjNjBBNUZBIi8+PHBhdGggZD0iTTEwMCA3NmwxMiA4IC0xMiA4eiIgZmlsbD0iIzFFMjkzQiIvPjwvc3ZnPg==",
    "description": "Play video streams in a dashboard widget.",
    "order": 100000
  },
  "widgetTypes": [
    {
      "fqn": "video_streaming.hls_video_stream",
      "name": "HLS Video Stream",
      "deprecated": false,
      "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNDAiIGhlaWdodD0iMTUwIiB2aWV3Qm94PSIwIDAgMjQwIDE1MCI+PHJlY3Qgd2lkdGg9IjI0MCIgaGVpZ2h0PSIxNTAiIHJ4PSI4IiBmaWxsPSIjMUUyOTNCIi8+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjAgMzkpIiBmaWxsPSJub25lIiBzdHJva2U9IiM2MEE1RkEiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cmVjdCB4PSI2IiB5PSIxOCIgd2lkdGg9Ijc4IiBoZWlnaHQ9IjU0IiByeD0iNiIvPjxwYXRoIGQ9Ik05MCAzMGwzMCAtMTJ2NTRsLTMwIC0xMnoiLz48L2c+PGNpcmNsZSBjeD0iMTA1IiBjeT0iODQiIHI9IjE2IiBmaWxsPSIjNjBBNUZBIi8+PHBhdGggZD0iTTEwMCA3NmwxMiA4IC0xMiA4eiIgZmlsbD0iIzFFMjkzQiIvPjwvc3ZnPg==",
      "description": "Plays an HLS (.m3u8) stream — live or recorded. The stream URL is read from a configured data key, with an optional static fallback URL.",
      "tags": [
        "video",
        "hls",
        "streaming",
        "camera"
      ],
      "descriptor": {
        "type": "latest",
        "sizeX": 8,
        "sizeY": 6,
        "resources": [
          {
            "url": "https://cdn.jsdelivr.net/npm/hls.js@1.6.16/dist/hls.min.js"
          }
        ],
        "templateHtml": "<!--\n\n    ThingsBoard, Inc. (\"COMPANY\") CONFIDENTIAL\n\n    Copyright © 2016-2026 ThingsBoard, Inc. All Rights Reserved.\n\n    NOTICE: All information contained herein is, and remains\n    the property of ThingsBoard, Inc. and its suppliers,\n    if any.  The intellectual and technical concepts contained\n    herein are proprietary to ThingsBoard, Inc.\n    and its suppliers and may be covered by U.S. and Foreign Patents,\n    patents in process, and are protected by trade secret or copyright law.\n\n    Dissemination of this information or reproduction of this material is strictly forbidden\n    unless prior written permission is obtained from COMPANY.\n\n    Access to the source code contained herein is hereby forbidden to anyone except current COMPANY employees,\n    managers or contractors who have executed Confidentiality and Non-disclosure agreements\n    explicitly covering such access.\n\n    The copyright notice above does not evidence any actual or intended publication\n    or disclosure  of  this source code, which includes\n    information that is confidential and/or proprietary, and is a trade secret, of  COMPANY.\n    ANY REPRODUCTION, MODIFICATION, DISTRIBUTION, PUBLIC  PERFORMANCE,\n    OR PUBLIC DISPLAY OF OR THROUGH USE  OF THIS  SOURCE CODE  WITHOUT\n    THE EXPRESS WRITTEN CONSENT OF COMPANY IS STRICTLY PROHIBITED,\n    AND IN VIOLATION OF APPLICABLE LAWS AND INTERNATIONAL TREATIES.\n    THE RECEIPT OR POSSESSION OF THIS SOURCE CODE AND/OR RELATED INFORMATION\n    DOES NOT CONVEY OR IMPLY ANY RIGHTS TO REPRODUCE, DISCLOSE OR DISTRIBUTE ITS CONTENTS,\n    OR TO MANUFACTURE, USE, OR SELL ANYTHING THAT IT  MAY DESCRIBE, IN WHOLE OR IN PART.\n\n-->\n<div class=\"hls-widget-container\">\n    <video class=\"hls-video\" playsinline></video>\n    <div class=\"hls-overlay hls-loading\" style=\"display: none;\">\n        <div class=\"hls-spinner\"></div>\n    </div>\n    <div class=\"hls-overlay hls-error\" style=\"display: none;\">\n        <div class=\"hls-error-content\">\n            <div class=\"hls-error-message\"></div>\n            <button class=\"hls-retry-btn\" type=\"button\">Retry</button>\n        </div>\n    </div>\n    <div class=\"hls-overlay hls-no-stream\">\n        <div class=\"hls-no-stream-content\">\n            <svg class=\"hls-camera-icon\" viewBox=\"0 0 24 24\" width=\"48\" height=\"48\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n                <path d=\"M23 7l-7 5 7 5V7z\"/>\n                <rect x=\"1\" y=\"5\" width=\"15\" height=\"14\" rx=\"2\" ry=\"2\"/>\n            </svg>\n            <div class=\"hls-no-stream-text\">No stream URL configured</div>\n        </div>\n    </div>\n</div>",
        "templateCss": "/**\n * ThingsBoard, Inc. (\"COMPANY\") CONFIDENTIAL\n *\n * Copyright © 2016-2026 ThingsBoard, Inc. All Rights Reserved.\n *\n * NOTICE: All information contained herein is, and remains\n * the property of ThingsBoard, Inc. and its suppliers,\n * if any.  The intellectual and technical concepts contained\n * herein are proprietary to ThingsBoard, Inc.\n * and its suppliers and may be covered by U.S. and Foreign Patents,\n * patents in process, and are protected by trade secret or copyright law.\n *\n * Dissemination of this information or reproduction of this material is strictly forbidden\n * unless prior written permission is obtained from COMPANY.\n *\n * Access to the source code contained herein is hereby forbidden to anyone except current COMPANY employees,\n * managers or contractors who have executed Confidentiality and Non-disclosure agreements\n * explicitly covering such access.\n *\n * The copyright notice above does not evidence any actual or intended publication\n * or disclosure  of  this source code, which includes\n * information that is confidential and/or proprietary, and is a trade secret, of  COMPANY.\n * ANY REPRODUCTION, MODIFICATION, DISTRIBUTION, PUBLIC  PERFORMANCE,\n * OR PUBLIC DISPLAY OF OR THROUGH USE  OF THIS  SOURCE CODE  WITHOUT\n * THE EXPRESS WRITTEN CONSENT OF COMPANY IS STRICTLY PROHIBITED,\n * AND IN VIOLATION OF APPLICABLE LAWS AND INTERNATIONAL TREATIES.\n * THE RECEIPT OR POSSESSION OF THIS SOURCE CODE AND/OR RELATED INFORMATION\n * DOES NOT CONVEY OR IMPLY ANY RIGHTS TO REPRODUCE, DISCLOSE OR DISTRIBUTE ITS CONTENTS,\n * OR TO MANUFACTURE, USE, OR SELL ANYTHING THAT IT  MAY DESCRIBE, IN WHOLE OR IN PART.\n */\n.hls-widget-container {\n    width: 100%;\n    height: 100%;\n    background: #000;\n    position: relative;\n    overflow: hidden;\n}\n\n.hls-video {\n    width: 100%;\n    height: 100%;\n    object-fit: contain;\n    display: block;\n}\n\n.hls-overlay {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    z-index: 10;\n}\n\n/* Loading spinner */\n.hls-loading {\n    background: #000;\n}\n\n.hls-spinner {\n    width: 40px;\n    height: 40px;\n    border: 3px solid rgba(255, 255, 255, 0.2);\n    border-top-color: #fff;\n    border-radius: 50%;\n    animation: hls-spin 0.8s linear infinite;\n}\n\n@keyframes hls-spin {\n    to { transform: rotate(360deg); }\n}\n\n/* Error overlay */\n.hls-error {\n    background: rgba(0, 0, 0, 0.75);\n}\n\n.hls-error-content {\n    text-align: center;\n    color: #fff;\n    font-family: Roboto, sans-serif;\n}\n\n.hls-error-message {\n    font-size: 14px;\n    margin-bottom: 12px;\n    max-width: 280px;\n    word-wrap: break-word;\n}\n\n.hls-retry-btn {\n    background: rgba(255, 255, 255, 0.15);\n    color: #fff;\n    border: 1px solid rgba(255, 255, 255, 0.3);\n    padding: 8px 24px;\n    border-radius: 4px;\n    font-size: 13px;\n    font-family: Roboto, sans-serif;\n    cursor: pointer;\n    transition: background 0.2s;\n}\n\n.hls-retry-btn:hover {\n    background: rgba(255, 255, 255, 0.25);\n}\n\n/* No stream placeholder */\n.hls-no-stream {\n    background: #000;\n}\n\n.hls-no-stream-content {\n    text-align: center;\n    color: rgba(255, 255, 255, 0.5);\n    font-family: Roboto, sans-serif;\n}\n\n.hls-camera-icon {\n    margin-bottom: 12px;\n    opacity: 0.5;\n}\n\n.hls-no-stream-text {\n    font-size: 14px;\n}",
        "controllerScript": "/*\n * ThingsBoard, Inc. (\"COMPANY\") CONFIDENTIAL\n *\n * Copyright © 2016-2026 ThingsBoard, Inc. All Rights Reserved.\n *\n * NOTICE: All information contained herein is, and remains\n * the property of ThingsBoard, Inc. and its suppliers,\n * if any.  The intellectual and technical concepts contained\n * herein are proprietary to ThingsBoard, Inc.\n * and its suppliers and may be covered by U.S. and Foreign Patents,\n * patents in process, and are protected by trade secret or copyright law.\n *\n * Dissemination of this information or reproduction of this material is strictly forbidden\n * unless prior written permission is obtained from COMPANY.\n *\n * Access to the source code contained herein is hereby forbidden to anyone except current COMPANY employees,\n * managers or contractors who have executed Confidentiality and Non-disclosure agreements\n * explicitly covering such access.\n *\n * The copyright notice above does not evidence any actual or intended publication\n * or disclosure  of  this source code, which includes\n * information that is confidential and/or proprietary, and is a trade secret, of  COMPANY.\n * ANY REPRODUCTION, MODIFICATION, DISTRIBUTION, PUBLIC  PERFORMANCE,\n * OR PUBLIC DISPLAY OF OR THROUGH USE  OF THIS  SOURCE CODE  WITHOUT\n * THE EXPRESS WRITTEN CONSENT OF COMPANY IS STRICTLY PROHIBITED,\n * AND IN VIOLATION OF APPLICABLE LAWS AND INTERNATIONAL TREATIES.\n * THE RECEIPT OR POSSESSION OF THIS SOURCE CODE AND/OR RELATED INFORMATION\n * DOES NOT CONVEY OR IMPLY ANY RIGHTS TO REPRODUCE, DISCLOSE OR DISTRIBUTE ITS CONTENTS,\n * OR TO MANUFACTURE, USE, OR SELL ANYTHING THAT IT  MAY DESCRIBE, IN WHOLE OR IN PART.\n */\nvar STATES = { LOADING: 'loading', ERROR: 'error', NO_STREAM: 'nostream', PLAYING: 'playing' };\nvar MAX_RETRIES = 3;\nvar LOADING_TIMEOUT_MS = 10000;\n\nvar hls = null;\nvar videoEl = null;\nvar retryCount = 0;\nvar retryTimer = null;\nvar loadingTimer = null;\nvar isNativeHls = false;\nvar visibilityHandler = null;\nvar isLive = false;\n\nself.typeParameters = function() {\n    return {\n        maxDatasources: 1,\n        maxDataKeys: 1,\n        singleEntity: true,\n        previewWidth: '320px',\n        previewHeight: '180px'\n    };\n};\n\nself.onInit = function() {\n    var container = self.ctx.$container[0];\n    videoEl = container.querySelector('.hls-video');\n\n    // If a previous widget instance parked a <video> in PiP (e.g., the user\n    // switched the state entity in the same dashboard, which destroys+recreates\n    // the widget), exit that PiP so the old stream doesn't keep playing\n    // alongside this new instance. The parked element's `leavepictureinpicture`\n    // listener handles the hls destroy and DOM removal.\n    var strandedPip = document.pictureInPictureElement;\n    if (strandedPip && strandedPip !== videoEl\n            && strandedPip.classList && strandedPip.classList.contains('hls-video')) {\n        document.exitPictureInPicture().catch(function() {});\n    }\n\n    videoEl.addEventListener('seeking', function() {\n        if (!isLive) return;\n        if (videoEl.buffered.length === 0) return;\n        var bufferStart = videoEl.buffered.start(0);\n        if (videoEl.currentTime < bufferStart - 0.1) {\n            videoEl.currentTime = bufferStart;\n        }\n    });\n\n    visibilityHandler = function() {\n        if (!isLive) return;\n        // Don't stop loading while the video is in Picture-in-Picture — PiP needs fresh segments.\n        var inPip = typeof document !== 'undefined'\n            && document.pictureInPictureElement === videoEl;\n        if (document.hidden && !inPip) {\n            if (hls) {\n                hls.stopLoad();\n            }\n        } else if (!document.hidden) {\n            if (hls) {\n                hls.startLoad(-1); // -1 = resume from latest playlist segment (live edge)\n            } else if (isNativeHls && videoEl.buffered.length > 0) {\n                // Safari native HLS path — jump to the end of the buffered range.\n                videoEl.currentTime = videoEl.buffered.end(videoEl.buffered.length - 1);\n            }\n        }\n    };\n    document.addEventListener('visibilitychange', visibilityHandler);\n\n    var settings = self.ctx.settings || {};\n    videoEl.controls = settings.showControls !== false;\n    videoEl.muted = settings.muted !== false;\n    videoEl.autoplay = settings.autoplay !== false;\n\n    var retryBtn = container.querySelector('.hls-retry-btn');\n    retryBtn.addEventListener('click', function() {\n        retryCount = 0;\n        if (self.currentUrl) {\n            initPlayer(self.currentUrl);\n        }\n    });\n\n    self.currentUrl = null;\n    showState(STATES.NO_STREAM);\n};\n\nself.onDataUpdated = function() {\n    var url = null;\n    var settings = self.ctx.settings || {};\n\n    // Extract URL from subscription data\n    if (self.ctx.data && self.ctx.data.length > 0) {\n        var cellData = self.ctx.data[0];\n        if (cellData.data && cellData.data.length > 0) {\n            var latestValue = cellData.data[cellData.data.length - 1][1];\n            if (latestValue && typeof latestValue === 'string' && latestValue.trim().length > 0) {\n                url = latestValue.trim();\n            }\n        }\n    }\n\n    // Fall back to default URL from settings\n    if (!url && settings.defaultStreamUrl) {\n        url = settings.defaultStreamUrl.trim();\n    }\n\n    // No URL available\n    if (!url) {\n        if (self.currentUrl) {\n            destroyPlayer();\n            self.currentUrl = null;\n        }\n        showState(STATES.NO_STREAM);\n        return;\n    }\n\n    // URL unchanged — do nothing\n    if (url === self.currentUrl) {\n        return;\n    }\n\n    // New or changed URL\n    self.currentUrl = url;\n    retryCount = 0;\n    initPlayer(url);\n};\n\nself.onResize = function() {\n    // CSS handles responsive layout — no action needed\n};\n\nself.onDestroy = function() {\n    // If the widget's <video> is currently in PiP, hand it off so the\n    // browser's PiP window keeps receiving media after Angular unmounts\n    // this widget. Real cleanup happens when the user closes PiP.\n    if (videoEl && typeof document !== 'undefined'\n            && document.pictureInPictureElement === videoEl) {\n\n        // Capture current refs in a closure so the deferred cleanup has them\n        // after module-level state is cleared for the next widget instance.\n        var handoffVideo = videoEl;\n        var handoffHls = hls;\n        var handoffIsNative = isNativeHls;\n        var handoffRetryTimer = retryTimer;\n        var handoffLoadingTimer = loadingTimer;\n\n        // Park the element off-screen on document.body so Angular's teardown\n        // of the widget container doesn't rip it out of the DOM. The browser\n        // holds its own reference in the PiP window either way, but keeping\n        // it in-document avoids surprises with some browsers' PiP lifecycle.\n        handoffVideo.style.position = 'fixed';\n        handoffVideo.style.left = '-9999px';\n        handoffVideo.style.top = '0';\n        handoffVideo.style.width = '1px';\n        handoffVideo.style.height = '1px';\n        document.body.appendChild(handoffVideo);\n\n        // Deferred cleanup: runs when the user closes the PiP window.\n        handoffVideo.addEventListener('leavepictureinpicture', function onLeave() {\n            handoffVideo.removeEventListener('leavepictureinpicture', onLeave);\n            if (handoffRetryTimer) {\n                clearTimeout(handoffRetryTimer);\n            }\n            if (handoffLoadingTimer) {\n                clearTimeout(handoffLoadingTimer);\n            }\n            if (handoffHls) {\n                try {\n                    handoffHls.destroy();\n                } catch (e) {\n                    // no-op: hls instance may already be torn down\n                }\n            }\n            if (handoffIsNative) {\n                handoffVideo.removeAttribute('src');\n                try {\n                    handoffVideo.load();\n                } catch (e) {\n                    // no-op: element may be detached\n                }\n            }\n            if (handoffVideo.parentNode) {\n                handoffVideo.parentNode.removeChild(handoffVideo);\n            }\n        });\n\n        // Reset module-level state so the next widget instance starts clean.\n        hls = null;\n        videoEl = null;\n        isNativeHls = false;\n        retryCount = 0;\n        retryTimer = null;\n        loadingTimer = null;\n        self.currentUrl = null;\n        document.removeEventListener('visibilitychange', visibilityHandler);\n        visibilityHandler = null;\n        return;\n    }\n\n    // Not in PiP — behave exactly as before.\n    document.removeEventListener('visibilitychange', visibilityHandler);\n    visibilityHandler = null;\n    destroyPlayer();\n    self.currentUrl = null;\n};\n\nfunction initPlayer(url) {\n    destroyPlayer();\n    showState(STATES.LOADING);\n    startLoadingWatchdog();\n\n    if (typeof Hls !== 'undefined' && Hls.isSupported()) {\n        var hlsConfig = {\n            maxMaxBufferLength: 180\n        };\n        var settings = self.ctx.settings || {};\n        if (typeof settings.backBufferLength === 'number' && settings.backBufferLength >= 0) {\n            hlsConfig.backBufferLength = settings.backBufferLength;\n        }\n        hls = new Hls(hlsConfig);\n\n        hls.on(Hls.Events.LEVEL_LOADED, function(event, data) {\n            isLive = !!(data.details && data.details.live);\n        });\n\n        hls.on(Hls.Events.MANIFEST_PARSED, function() {\n            showState(STATES.PLAYING);\n            if (videoEl.autoplay) {\n                attemptAutoplay();\n            }\n        });\n\n        hls.on(Hls.Events.ERROR, function(event, data) {\n            if (!data.fatal) {\n                return;\n            }\n            handleFatalError(data);\n        });\n\n        hls.loadSource(url);\n        hls.attachMedia(videoEl);\n\n    } else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {\n        // Safari native HLS\n        isNativeHls = true;\n        videoEl.src = url;\n\n        videoEl.addEventListener('loadedmetadata', function onLoaded() {\n            videoEl.removeEventListener('loadedmetadata', onLoaded);\n            isLive = !isFinite(videoEl.duration);\n            showState(STATES.PLAYING);\n            if (videoEl.autoplay) {\n                attemptAutoplay();\n            }\n        });\n\n        videoEl.addEventListener('error', function onError() {\n            videoEl.removeEventListener('error', onError);\n            showError('Stream playback failed');\n        });\n\n    } else {\n        showError('HLS is not supported in this browser');\n    }\n}\n\nfunction handleFatalError(data) {\n    if (retryCount < MAX_RETRIES) {\n        retryCount++;\n        var delay = Math.pow(2, retryCount - 1) * 1000; // 1s, 2s, 4s\n\n        retryTimer = setTimeout(function() {\n            if (!hls) return;\n\n            if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {\n                hls.startLoad();\n            } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {\n                hls.recoverMediaError();\n            } else {\n                // Other fatal error — full reinit\n                initPlayer(self.currentUrl);\n            }\n        }, delay);\n    } else {\n        var msg = 'Stream error';\n        if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {\n            msg = 'Network error — could not load stream';\n        } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {\n            msg = 'Media error — stream format issue';\n        }\n        showError(msg);\n    }\n}\n\nfunction attemptAutoplay() {\n    var playPromise = videoEl.play();\n    if (!playPromise || typeof playPromise.catch !== 'function') {\n        return;\n    }\n    playPromise.catch(function() {\n        // Browsers block autoplay with sound unless there's user interaction.\n        // Fall back to muted autoplay so the stream still starts; users can\n        // unmute via the video controls if they want sound.\n        if (!videoEl.muted) {\n            videoEl.muted = true;\n            videoEl.play().catch(function() {});\n        }\n    });\n}\n\nfunction showError(message) {\n    var container = self.ctx.$container[0];\n    var errorMsg = container.querySelector('.hls-error-message');\n    errorMsg.textContent = message;\n    showState(STATES.ERROR);\n}\n\nfunction showState(state) {\n    if (state !== STATES.LOADING) {\n        clearLoadingWatchdog();\n    }\n    var container = self.ctx.$container[0];\n    container.querySelector('.hls-loading').style.display = state === STATES.LOADING ? 'flex' : 'none';\n    container.querySelector('.hls-error').style.display = state === STATES.ERROR ? 'flex' : 'none';\n    container.querySelector('.hls-no-stream').style.display = state === STATES.NO_STREAM ? 'flex' : 'none';\n}\n\nfunction startLoadingWatchdog() {\n    clearLoadingWatchdog();\n    loadingTimer = setTimeout(function() {\n        loadingTimer = null;\n        destroyPlayer();\n        showError(\"Can't reach the stream. Check the URL and try again.\");\n    }, LOADING_TIMEOUT_MS);\n}\n\nfunction clearLoadingWatchdog() {\n    if (loadingTimer) {\n        clearTimeout(loadingTimer);\n        loadingTimer = null;\n    }\n}\n\nfunction destroyPlayer() {\n    clearLoadingWatchdog();\n    if (retryTimer) {\n        clearTimeout(retryTimer);\n        retryTimer = null;\n    }\n    if (hls) {\n        hls.destroy();\n        hls = null;\n    }\n    if (isNativeHls) {\n        videoEl.removeAttribute('src');\n        videoEl.load();\n        isNativeHls = false;\n    }\n    isLive = false;\n    // retryCount is intentionally NOT reset here — callers that represent a\n    // genuine fresh start (URL change, manual retry, onDataUpdated) reset it\n    // themselves. Resetting here would allow infinite retries for fatal errors\n    // that trigger a full reinit via initPlayer().\n}",
        "settingsForm": [
          {
            "id": "videoStreamingHeader",
            "name": "Video streaming",
            "type": "htmlSection",
            "default": null,
            "htmlClassList": [
              "tb-form-panel-title"
            ],
            "htmlContent": "Video streaming"
          },
          {
            "id": "defaultStreamUrl",
            "name": "Fallback stream URL",
            "hint": "Optional fallback URL. Used when the data key is empty or missing.",
            "type": "text",
            "default": "",
            "divider": false,
            "fieldClass": "flex"
          },
          {
            "id": "autoplay",
            "name": "Autoplay",
            "hint": "Start playback automatically when the stream loads. If the browser blocks autoplay with sound, the widget falls back to muted autoplay.",
            "type": "switch",
            "default": true,
            "divider": false
          },
          {
            "id": "muted",
            "name": "Start muted",
            "hint": "Start playback muted. Users can unmute via the video controls.",
            "type": "switch",
            "default": true,
            "divider": false
          },
          {
            "id": "showControls",
            "name": "Show video controls",
            "hint": "Show video controls (play/pause, volume, fullscreen, picture-in-picture).",
            "type": "switch",
            "default": true,
            "divider": true
          },
          {
            "id": "backBufferLength",
            "name": "Back buffer length",
            "hint": "Seconds of already-played live video kept in memory for seeking back. Valid range: 0–60. Set to 0 to disable seek-back.",
            "type": "number",
            "default": 10,
            "required": true,
            "min": 0,
            "max": 60,
            "divider": false,
            "fieldSuffix": "sec"
          }
        ],
        "dataKeySettingsSchema": "{}",
        "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"hlsStreamUrl\",\"color\":\"#2196f3\",\"settings\":{},\"funcBody\":\"return 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';\"}]}],\"showTitle\":false,\"title\":\"HLS Video Stream\",\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(255, 255, 255, 0.87)\",\"padding\":\"0px\",\"enableFullscreen\":false,\"enableDataExport\":false,\"settings\":{\"defaultStreamUrl\":\"\",\"autoplay\":true,\"muted\":true,\"showControls\":true,\"backBufferLength\":10}}"
      }
    },
    {
      "fqn": "video_streaming.webrtc_video_stream",
      "name": "WebRTC Video Stream",
      "deprecated": false,
      "image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNDAiIGhlaWdodD0iMTUwIiB2aWV3Qm94PSIwIDAgMjQwIDE1MCI+PHJlY3Qgd2lkdGg9IjI0MCIgaGVpZ2h0PSIxNTAiIHJ4PSI4IiBmaWxsPSIjMUUyOTNCIi8+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjAgMzkpIiBmaWxsPSJub25lIiBzdHJva2U9IiM2MEE1RkEiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cmVjdCB4PSI2IiB5PSIxOCIgd2lkdGg9Ijc4IiBoZWlnaHQ9IjU0IiByeD0iNiIvPjxwYXRoIGQ9Ik05MCAzMGwzMCAtMTJ2NTRsLTMwIC0xMnoiLz48L2c+PGNpcmNsZSBjeD0iMjAiIGN5PSIyMCIgcj0iNiIgZmlsbD0iI0ZGM0IzMCIvPjx0ZXh0IHg9IjM0IiB5PSIyNCIgZm9udC1mYW1pbHk9IlJvYm90bywgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIxMiIgZm9udC13ZWlnaHQ9IjcwMCIgZmlsbD0iI0ZGRkZGRiI+TElWRTwvdGV4dD48L3N2Zz4=",
      "description": "Plays a live WebRTC stream via WHEP signaling. Sub-second latency, live-only. The WHEP URL is read from a configured data key, with an optional static fallback URL.",
      "tags": [
        "video",
        "webrtc",
        "whep",
        "streaming",
        "camera",
        "live"
      ],
      "descriptor": {
        "type": "latest",
        "sizeX": 8,
        "sizeY": 6,
        "resources": [],
        "templateHtml": "<!--\n\n    ThingsBoard, Inc. (\"COMPANY\") CONFIDENTIAL\n\n    Copyright © 2016-2026 ThingsBoard, Inc. All Rights Reserved.\n\n    NOTICE: All information contained herein is, and remains\n    the property of ThingsBoard, Inc. and its suppliers,\n    if any.  The intellectual and technical concepts contained\n    herein are proprietary to ThingsBoard, Inc.\n    and its suppliers and may be covered by U.S. and Foreign Patents,\n    patents in process, and are protected by trade secret or copyright law.\n\n    Dissemination of this information or reproduction of this material is strictly forbidden\n    unless prior written permission is obtained from COMPANY.\n\n    Access to the source code contained herein is hereby forbidden to anyone except current COMPANY employees,\n    managers or contractors who have executed Confidentiality and Non-disclosure agreements\n    explicitly covering such access.\n\n    The copyright notice above does not evidence any actual or intended publication\n    or disclosure  of  this source code, which includes\n    information that is confidential and/or proprietary, and is a trade secret, of  COMPANY.\n    ANY REPRODUCTION, MODIFICATION, DISTRIBUTION, PUBLIC  PERFORMANCE,\n    OR PUBLIC DISPLAY OF OR THROUGH USE  OF THIS  SOURCE CODE  WITHOUT\n    THE EXPRESS WRITTEN CONSENT OF COMPANY IS STRICTLY PROHIBITED,\n    AND IN VIOLATION OF APPLICABLE LAWS AND INTERNATIONAL TREATIES.\n    THE RECEIPT OR POSSESSION OF THIS SOURCE CODE AND/OR RELATED INFORMATION\n    DOES NOT CONVEY OR IMPLY ANY RIGHTS TO REPRODUCE, DISCLOSE OR DISTRIBUTE ITS CONTENTS,\n    OR TO MANUFACTURE, USE, OR SELL ANYTHING THAT IT  MAY DESCRIBE, IN WHOLE OR IN PART.\n\n-->\n<div class=\"webrtc-widget-container\">\n    <video class=\"webrtc-video\" playsinline></video>\n    <div class=\"webrtc-live-badge\" style=\"display: none;\">\n        <span class=\"webrtc-live-dot\"></span>LIVE\n    </div>\n    <div class=\"webrtc-overlay webrtc-loading\" style=\"display: none;\">\n        <div class=\"webrtc-spinner\"></div>\n    </div>\n    <div class=\"webrtc-overlay webrtc-error\" style=\"display: none;\">\n        <div class=\"webrtc-error-content\">\n            <div class=\"webrtc-error-message\"></div>\n            <button class=\"webrtc-retry-btn\" type=\"button\">Retry</button>\n        </div>\n    </div>\n    <div class=\"webrtc-overlay webrtc-no-stream\">\n        <div class=\"webrtc-no-stream-content\">\n            <svg class=\"webrtc-camera-icon\" viewBox=\"0 0 24 24\" width=\"48\" height=\"48\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n                <path d=\"M23 7l-7 5 7 5V7z\"/>\n                <rect x=\"1\" y=\"5\" width=\"15\" height=\"14\" rx=\"2\" ry=\"2\"/>\n            </svg>\n            <div class=\"webrtc-no-stream-text\">No stream URL configured</div>\n        </div>\n    </div>\n</div>",
        "templateCss": "/**\n * ThingsBoard, Inc. (\"COMPANY\") CONFIDENTIAL\n *\n * Copyright © 2016-2026 ThingsBoard, Inc. All Rights Reserved.\n *\n * NOTICE: All information contained herein is, and remains\n * the property of ThingsBoard, Inc. and its suppliers,\n * if any.  The intellectual and technical concepts contained\n * herein are proprietary to ThingsBoard, Inc.\n * and its suppliers and may be covered by U.S. and Foreign Patents,\n * patents in process, and are protected by trade secret or copyright law.\n *\n * Dissemination of this information or reproduction of this material is strictly forbidden\n * unless prior written permission is obtained from COMPANY.\n *\n * Access to the source code contained herein is hereby forbidden to anyone except current COMPANY employees,\n * managers or contractors who have executed Confidentiality and Non-disclosure agreements\n * explicitly covering such access.\n *\n * The copyright notice above does not evidence any actual or intended publication\n * or disclosure  of  this source code, which includes\n * information that is confidential and/or proprietary, and is a trade secret, of  COMPANY.\n * ANY REPRODUCTION, MODIFICATION, DISTRIBUTION, PUBLIC  PERFORMANCE,\n * OR PUBLIC DISPLAY OF OR THROUGH USE  OF THIS  SOURCE CODE  WITHOUT\n * THE EXPRESS WRITTEN CONSENT OF COMPANY IS STRICTLY PROHIBITED,\n * AND IN VIOLATION OF APPLICABLE LAWS AND INTERNATIONAL TREATIES.\n * THE RECEIPT OR POSSESSION OF THIS SOURCE CODE AND/OR RELATED INFORMATION\n * DOES NOT CONVEY OR IMPLY ANY RIGHTS TO REPRODUCE, DISCLOSE OR DISTRIBUTE ITS CONTENTS,\n * OR TO MANUFACTURE, USE, OR SELL ANYTHING THAT IT  MAY DESCRIBE, IN WHOLE OR IN PART.\n */\n.webrtc-widget-container {\n    width: 100%;\n    height: 100%;\n    background: #000;\n    position: relative;\n    overflow: hidden;\n}\n\n.webrtc-video {\n    width: 100%;\n    height: 100%;\n    object-fit: contain;\n    display: block;\n}\n\n.webrtc-overlay {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    z-index: 10;\n}\n\n/* LIVE badge */\n.webrtc-live-badge {\n    position: absolute;\n    top: 12px;\n    left: 12px;\n    z-index: 11;\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    padding: 4px 10px;\n    background: rgba(0, 0, 0, 0.6);\n    color: #fff;\n    font: 600 11px/1 Roboto, sans-serif;\n    letter-spacing: 0.5px;\n    border-radius: 3px;\n    pointer-events: none;\n}\n\n.webrtc-live-dot {\n    width: 8px;\n    height: 8px;\n    border-radius: 50%;\n    background: #ff3b30;\n    animation: webrtc-pulse 1.4s ease-in-out infinite;\n}\n\n@keyframes webrtc-pulse {\n    0%, 100% { opacity: 1; transform: scale(1); }\n    50% { opacity: 0.35; transform: scale(0.7); }\n}\n\n/* Loading spinner */\n.webrtc-loading {\n    background: #000;\n}\n\n.webrtc-spinner {\n    width: 40px;\n    height: 40px;\n    border: 3px solid rgba(255, 255, 255, 0.2);\n    border-top-color: #fff;\n    border-radius: 50%;\n    animation: webrtc-spin 0.8s linear infinite;\n}\n\n@keyframes webrtc-spin {\n    to { transform: rotate(360deg); }\n}\n\n/* Error overlay */\n.webrtc-error {\n    background: rgba(0, 0, 0, 0.75);\n}\n\n.webrtc-error-content {\n    text-align: center;\n    color: #fff;\n    font-family: Roboto, sans-serif;\n}\n\n.webrtc-error-message {\n    font-size: 14px;\n    margin-bottom: 12px;\n    max-width: 280px;\n    word-wrap: break-word;\n}\n\n.webrtc-retry-btn {\n    background: rgba(255, 255, 255, 0.15);\n    color: #fff;\n    border: 1px solid rgba(255, 255, 255, 0.3);\n    padding: 8px 24px;\n    border-radius: 4px;\n    font-size: 13px;\n    font-family: Roboto, sans-serif;\n    cursor: pointer;\n    transition: background 0.2s;\n}\n\n.webrtc-retry-btn:hover {\n    background: rgba(255, 255, 255, 0.25);\n}\n\n/* No stream placeholder */\n.webrtc-no-stream {\n    background: #000;\n}\n\n.webrtc-no-stream-content {\n    text-align: center;\n    color: rgba(255, 255, 255, 0.5);\n    font-family: Roboto, sans-serif;\n}\n\n.webrtc-camera-icon {\n    margin-bottom: 12px;\n    opacity: 0.5;\n}\n\n.webrtc-no-stream-text {\n    font-size: 14px;\n}",
        "controllerScript": "/*\n * ThingsBoard, Inc. (\"COMPANY\") CONFIDENTIAL\n *\n * Copyright © 2016-2026 ThingsBoard, Inc. All Rights Reserved.\n *\n * NOTICE: All information contained herein is, and remains\n * the property of ThingsBoard, Inc. and its suppliers,\n * if any.  The intellectual and technical concepts contained\n * herein are proprietary to ThingsBoard, Inc.\n * and its suppliers and may be covered by U.S. and Foreign Patents,\n * patents in process, and are protected by trade secret or copyright law.\n *\n * Dissemination of this information or reproduction of this material is strictly forbidden\n * unless prior written permission is obtained from COMPANY.\n *\n * Access to the source code contained herein is hereby forbidden to anyone except current COMPANY employees,\n * managers or contractors who have executed Confidentiality and Non-disclosure agreements\n * explicitly covering such access.\n *\n * The copyright notice above does not evidence any actual or intended publication\n * or disclosure  of  this source code, which includes\n * information that is confidential and/or proprietary, and is a trade secret, of  COMPANY.\n * ANY REPRODUCTION, MODIFICATION, DISTRIBUTION, PUBLIC  PERFORMANCE,\n * OR PUBLIC DISPLAY OF OR THROUGH USE  OF THIS  SOURCE CODE  WITHOUT\n * THE EXPRESS WRITTEN CONSENT OF COMPANY IS STRICTLY PROHIBITED,\n * AND IN VIOLATION OF APPLICABLE LAWS AND INTERNATIONAL TREATIES.\n * THE RECEIPT OR POSSESSION OF THIS SOURCE CODE AND/OR RELATED INFORMATION\n * DOES NOT CONVEY OR IMPLY ANY RIGHTS TO REPRODUCE, DISCLOSE OR DISTRIBUTE ITS CONTENTS,\n * OR TO MANUFACTURE, USE, OR SELL ANYTHING THAT IT  MAY DESCRIBE, IN WHOLE OR IN PART.\n */\nvar STATES = { LOADING: 'loading', ERROR: 'error', NO_STREAM: 'nostream', PLAYING: 'playing' };\nvar MAX_RETRIES = 3;\nvar LOADING_TIMEOUT_MS = 10000;\n\nvar pc = null;\nvar sessionUrl = null;\nvar videoEl = null;\nvar liveBadge = null;\nvar retryCount = 0;\nvar retryTimer = null;\nvar loadingTimer = null;\n\nself.typeParameters = function() {\n    return {\n        maxDatasources: 1,\n        maxDataKeys: 1,\n        singleEntity: true,\n        previewWidth: '320px',\n        previewHeight: '180px'\n    };\n};\n\nself.onInit = function() {\n    var container = self.ctx.$container[0];\n    videoEl = container.querySelector('.webrtc-video');\n    liveBadge = container.querySelector('.webrtc-live-badge');\n\n    // If a previous widget instance parked a <video> in PiP (e.g., the user\n    // switched the state entity in the same dashboard, which destroys+recreates\n    // the widget), exit that PiP so the old stream doesn't keep playing\n    // alongside this new instance. The parked element's `leavepictureinpicture`\n    // listener handles the RTCPeerConnection close, WHEP DELETE, and DOM removal.\n    var strandedPip = document.pictureInPictureElement;\n    if (strandedPip && strandedPip !== videoEl\n            && strandedPip.classList && strandedPip.classList.contains('webrtc-video')) {\n        document.exitPictureInPicture().catch(function() {});\n    }\n\n    var settings = self.ctx.settings || {};\n    videoEl.controls = settings.showControls !== false;\n    videoEl.muted = settings.muted !== false;\n    videoEl.autoplay = settings.autoplay !== false;\n\n    var retryBtn = container.querySelector('.webrtc-retry-btn');\n    retryBtn.addEventListener('click', function() {\n        retryCount = 0;\n        if (self.currentUrl) {\n            initPlayer(self.currentUrl);\n        }\n    });\n\n    self.currentUrl = null;\n    showState(STATES.NO_STREAM);\n};\n\nself.onDataUpdated = function() {\n    var url = null;\n    var settings = self.ctx.settings || {};\n\n    // Extract URL from subscription data\n    if (self.ctx.data && self.ctx.data.length > 0) {\n        var cellData = self.ctx.data[0];\n        if (cellData.data && cellData.data.length > 0) {\n            var latestValue = cellData.data[cellData.data.length - 1][1];\n            if (latestValue && typeof latestValue === 'string' && latestValue.trim().length > 0) {\n                url = latestValue.trim();\n            }\n        }\n    }\n\n    // Fall back to default URL from settings\n    if (!url && settings.defaultStreamUrl) {\n        url = settings.defaultStreamUrl.trim();\n    }\n\n    // No URL available\n    if (!url) {\n        if (self.currentUrl) {\n            destroyPlayer();\n            self.currentUrl = null;\n        }\n        showState(STATES.NO_STREAM);\n        return;\n    }\n\n    // URL unchanged — do nothing\n    if (url === self.currentUrl) {\n        return;\n    }\n\n    // New or changed URL\n    self.currentUrl = url;\n    retryCount = 0;\n    initPlayer(url);\n};\n\nself.onResize = function() {\n    // CSS handles responsive layout — no action needed\n};\n\nself.onDestroy = function() {\n    // If the widget's <video> is currently in PiP, hand it off so the\n    // browser's PiP window keeps receiving media after Angular unmounts\n    // this widget. Real cleanup happens when the user closes PiP.\n    if (videoEl && typeof document !== 'undefined'\n            && document.pictureInPictureElement === videoEl) {\n\n        // Capture current refs in a closure so the deferred cleanup has them\n        // after module-level state is cleared for the next widget instance.\n        var handoffVideo = videoEl;\n        var handoffPc = pc;\n        var handoffSessionUrl = sessionUrl;\n        var handoffRetryTimer = retryTimer;\n        var handoffLoadingTimer = loadingTimer;\n\n        // Park the element off-screen on document.body so Angular's teardown\n        // of the widget container doesn't rip it out of the DOM. The browser\n        // holds its own reference in the PiP window either way, but keeping\n        // it in-document avoids surprises with some browsers' PiP lifecycle.\n        handoffVideo.style.position = 'fixed';\n        handoffVideo.style.left = '-9999px';\n        handoffVideo.style.top = '0';\n        handoffVideo.style.width = '1px';\n        handoffVideo.style.height = '1px';\n        document.body.appendChild(handoffVideo);\n\n        // Deferred cleanup: runs when the user closes the PiP window.\n        handoffVideo.addEventListener('leavepictureinpicture', function onLeave() {\n            handoffVideo.removeEventListener('leavepictureinpicture', onLeave);\n            if (handoffRetryTimer) {\n                clearTimeout(handoffRetryTimer);\n            }\n            if (handoffLoadingTimer) {\n                clearTimeout(handoffLoadingTimer);\n            }\n            if (handoffSessionUrl) {\n                // Best-effort WHEP DELETE to release the server-side session.\n                try {\n                    fetch(handoffSessionUrl, { method: 'DELETE' }).catch(function() {});\n                } catch (e) {\n                    // no-op: fetch may throw synchronously in pathological cases\n                }\n            }\n            if (handoffPc) {\n                try {\n                    handoffPc.close();\n                } catch (e) {\n                    // no-op: peer connection may already be closed\n                }\n            }\n            try {\n                handoffVideo.srcObject = null;\n            } catch (e) {\n                // no-op: element may be detached\n            }\n            if (handoffVideo.parentNode) {\n                handoffVideo.parentNode.removeChild(handoffVideo);\n            }\n        });\n\n        // Reset module-level state so the next widget instance starts clean.\n        pc = null;\n        sessionUrl = null;\n        videoEl = null;\n        liveBadge = null;\n        retryCount = 0;\n        retryTimer = null;\n        loadingTimer = null;\n        self.currentUrl = null;\n        return;\n    }\n\n    // Not in PiP — tear down immediately.\n    destroyPlayer();\n    self.currentUrl = null;\n};\n\nfunction initPlayer(url) {\n    destroyPlayer();\n    showState(STATES.LOADING);\n    startLoadingWatchdog();\n\n    if (typeof RTCPeerConnection === 'undefined') {\n        showError('WebRTC is not supported in this browser');\n        return;\n    }\n\n    var settings = self.ctx.settings || {};\n    var rtcConfig = {};\n    if (settings.stunServer && typeof settings.stunServer === 'string' && settings.stunServer.trim().length > 0) {\n        rtcConfig.iceServers = [{ urls: settings.stunServer.trim() }];\n    }\n\n    var localPc = new RTCPeerConnection(rtcConfig);\n    pc = localPc;\n\n    localPc.addTransceiver('video', { direction: 'recvonly' });\n    localPc.addTransceiver('audio', { direction: 'recvonly' });\n\n    localPc.ontrack = function(ev) {\n        if (!videoEl) return;\n        if (ev.streams && ev.streams[0] && videoEl.srcObject !== ev.streams[0]) {\n            videoEl.srcObject = ev.streams[0];\n        }\n    };\n\n    localPc.onconnectionstatechange = function() {\n        // Ignore state changes for a peer connection that has been replaced or torn down.\n        if (localPc !== pc) return;\n        var state = localPc.connectionState;\n        if (state === 'connected') {\n            showState(STATES.PLAYING);\n            if (videoEl && videoEl.autoplay) {\n                attemptAutoplay();\n            }\n        } else if (state === 'failed' || state === 'disconnected' || state === 'closed') {\n            handleFatalError(new Error('WebRTC ' + state));\n        }\n    };\n\n    localPc.createOffer()\n        .then(function(offer) {\n            return localPc.setLocalDescription(offer).then(function() { return offer; });\n        })\n        .then(function(offer) {\n            return fetch(url, {\n                method: 'POST',\n                headers: {\n                    'Content-Type': 'application/sdp',\n                    'Accept': 'application/sdp'\n                },\n                body: offer.sdp\n            });\n        })\n        .then(function(resp) {\n            if (!resp.ok) {\n                throw new Error('WHEP HTTP ' + resp.status);\n            }\n            var loc = resp.headers.get('Location');\n            if (loc) {\n                try {\n                    sessionUrl = new URL(loc, url).toString();\n                } catch (e) {\n                    sessionUrl = loc;\n                }\n            }\n            return resp.text();\n        })\n        .then(function(answerSdp) {\n            // If the widget was torn down mid-flight, `pc` will have been cleared.\n            if (localPc !== pc) return;\n            return localPc.setRemoteDescription({ type: 'answer', sdp: answerSdp });\n        })\n        .catch(function(err) {\n            // Swallow errors from superseded peer connections.\n            if (localPc !== pc) return;\n            handleFatalError(err);\n        });\n}\n\nfunction handleFatalError(err) {\n    if (retryCount < MAX_RETRIES) {\n        retryCount++;\n        var delay = Math.pow(2, retryCount - 1) * 1000; // 1s, 2s, 4s\n\n        retryTimer = setTimeout(function() {\n            retryTimer = null;\n            if (self.currentUrl) {\n                initPlayer(self.currentUrl);\n            }\n        }, delay);\n    } else {\n        if (err && err.message) {\n            console.warn('[WebRTC widget] giving up after ' + MAX_RETRIES + ' retries:', err.message);\n        }\n        showError(\"Can't reach the stream. Check the URL and try again.\");\n    }\n}\n\nfunction attemptAutoplay() {\n    if (!videoEl) return;\n    var playPromise = videoEl.play();\n    if (!playPromise || typeof playPromise.catch !== 'function') {\n        return;\n    }\n    playPromise.catch(function() {\n        // Browsers block autoplay with sound unless there's user interaction.\n        // Fall back to muted autoplay so the stream still starts; users can\n        // unmute via the video controls if they want sound.\n        if (videoEl && !videoEl.muted) {\n            videoEl.muted = true;\n            videoEl.play().catch(function() {});\n        }\n    });\n}\n\nfunction showError(message) {\n    var container = self.ctx.$container[0];\n    var errorMsg = container.querySelector('.webrtc-error-message');\n    errorMsg.textContent = message;\n    showState(STATES.ERROR);\n}\n\nfunction showState(state) {\n    if (state !== STATES.LOADING) {\n        clearLoadingWatchdog();\n    }\n    var container = self.ctx.$container[0];\n    container.querySelector('.webrtc-loading').style.display = state === STATES.LOADING ? 'flex' : 'none';\n    container.querySelector('.webrtc-error').style.display = state === STATES.ERROR ? 'flex' : 'none';\n    container.querySelector('.webrtc-no-stream').style.display = state === STATES.NO_STREAM ? 'flex' : 'none';\n\n    // LIVE badge shows only while playing and only when the setting allows it.\n    if (liveBadge) {\n        var settings = self.ctx.settings || {};\n        var showBadge = state === STATES.PLAYING && settings.showLiveIndicator !== false;\n        liveBadge.style.display = showBadge ? 'flex' : 'none';\n    }\n}\n\nfunction startLoadingWatchdog() {\n    clearLoadingWatchdog();\n    loadingTimer = setTimeout(function() {\n        loadingTimer = null;\n        destroyPlayer();\n        showError(\"Can't reach the stream. Check the URL and try again.\");\n    }, LOADING_TIMEOUT_MS);\n}\n\nfunction clearLoadingWatchdog() {\n    if (loadingTimer) {\n        clearTimeout(loadingTimer);\n        loadingTimer = null;\n    }\n}\n\nfunction destroyPlayer() {\n    clearLoadingWatchdog();\n    if (retryTimer) {\n        clearTimeout(retryTimer);\n        retryTimer = null;\n    }\n    // Best-effort WHEP DELETE to release the server-side session.\n    if (sessionUrl) {\n        try {\n            fetch(sessionUrl, { method: 'DELETE' }).catch(function() {});\n        } catch (e) {\n            // no-op: fetch may throw synchronously in pathological cases\n        }\n        sessionUrl = null;\n    }\n    if (pc) {\n        try {\n            pc.close();\n        } catch (e) {\n            // no-op: peer connection may already be closed\n        }\n        pc = null;\n    }\n    if (videoEl) {\n        try {\n            videoEl.srcObject = null;\n        } catch (e) {\n            // no-op\n        }\n    }\n    // retryCount is intentionally NOT reset here — callers that represent a\n    // genuine fresh start (URL change, manual retry, onDataUpdated) reset it\n    // themselves. Resetting here would allow infinite retries for fatal errors\n    // that trigger a full reinit via initPlayer().\n}",
        "settingsForm": [
          {
            "id": "videoStreamingHeader",
            "name": "Video streaming",
            "type": "htmlSection",
            "default": null,
            "htmlClassList": [
              "tb-form-panel-title"
            ],
            "htmlContent": "Video streaming"
          },
          {
            "id": "defaultStreamUrl",
            "name": "Fallback stream URL",
            "hint": "Optional fallback WHEP URL. Used when the data key is empty or missing. Example: http://localhost:8889/camera1/whep.",
            "type": "text",
            "default": "",
            "divider": false,
            "fieldClass": "flex"
          },
          {
            "id": "autoplay",
            "name": "Autoplay",
            "hint": "Start playback automatically once the WebRTC session connects. If the browser blocks autoplay with sound, the widget falls back to muted autoplay.",
            "type": "switch",
            "default": true,
            "divider": false
          },
          {
            "id": "muted",
            "name": "Start muted",
            "hint": "Start playback muted. Users can unmute via the video controls.",
            "type": "switch",
            "default": true,
            "divider": false
          },
          {
            "id": "showControls",
            "name": "Show video controls",
            "hint": "Show video controls (play/pause, volume, fullscreen, picture-in-picture).",
            "type": "switch",
            "default": true,
            "divider": true
          },
          {
            "id": "showLiveIndicator",
            "name": "Show LIVE indicator",
            "hint": "Show a pulsing red LIVE badge in the top-left while the stream is playing.",
            "type": "switch",
            "default": true,
            "divider": true
          },
          {
            "id": "stunServer",
            "name": "STUN server URL",
            "hint": "Optional STUN server URL (e.g. stun:stun.l.google.com:19302). STUN (Session Traversal Utilities for NAT) lets the browser and MediaMTX discover each other's public IP addresses and initiate a direct WebRTC connection. Very restrictive networks may need a TURN server instead.",
            "type": "text",
            "default": "",
            "divider": false,
            "fieldClass": "flex"
          }
        ],
        "dataKeySettingsSchema": "{}",
        "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"webrtcStreamUrl\",\"color\":\"#2196f3\",\"settings\":{},\"funcBody\":\"return 'http://localhost:8889/camera1/whep';\"}]}],\"showTitle\":false,\"title\":\"WebRTC Video Stream\",\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(255, 255, 255, 0.87)\",\"padding\":\"0px\",\"enableFullscreen\":false,\"enableDataExport\":false,\"settings\":{\"defaultStreamUrl\":\"\",\"autoplay\":true,\"muted\":true,\"showControls\":true,\"showLiveIndicator\":true,\"stunServer\":\"\"}}"
      }
    }
  ]
}