diff options
-rw-r--r-- | kvmd/apps/kvmd/server.py | 26 | ||||
-rw-r--r-- | kvmd/apps/kvmd/streamer.py | 81 | ||||
-rw-r--r-- | web/share/js/kvm/session.js | 7 | ||||
-rw-r--r-- | web/share/js/kvm/stream.js | 198 | ||||
-rw-r--r-- | web/share/js/kvm/stream_janus.js | 2 | ||||
-rw-r--r-- | web/share/js/kvm/stream_mjpeg.js | 8 | ||||
-rw-r--r-- | web/share/js/tools.js | 8 |
7 files changed, 201 insertions, 129 deletions
diff --git a/kvmd/apps/kvmd/server.py b/kvmd/apps/kvmd/server.py index 50642725..4b6b57a0 100644 --- a/kvmd/apps/kvmd/server.py +++ b/kvmd/apps/kvmd/server.py @@ -151,6 +151,7 @@ class _Subsystem: class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-instance-attributes __EV_GPIO_STATE = "gpio_state" __EV_INFO_STATE = "info_state" + __EV_STREAMER_STATE = "streamer_state" def __init__( # pylint: disable=too-many-arguments,too-many-locals self, @@ -362,13 +363,16 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins ) async def __poll_state(self, event_type: str, poller: AsyncGenerator[dict, None]) -> None: - if event_type == self.__EV_GPIO_STATE: - await self.__poll_gpio_state(poller) - elif event_type == self.__EV_INFO_STATE: - await self.__poll_info_state(poller) - else: - async for state in poller: - await self._broadcast_ws_event(event_type, state) + match event_type: + case self.__EV_GPIO_STATE: + await self.__poll_gpio_state(poller) + case self.__EV_INFO_STATE: + await self.__poll_info_state(poller) + case self.__EV_STREAMER_STATE: + await self.__poll_streamer_state(poller) + case _: + async for state in poller: + await self._broadcast_ws_event(event_type, state) async def __poll_gpio_state(self, poller: AsyncGenerator[dict, None]) -> None: prev: dict = {"state": {"inputs": {}, "outputs": {}}} @@ -387,3 +391,11 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins await self._broadcast_ws_event(self.__EV_INFO_STATE, state, legacy=False) for (key, value) in state.items(): await self._broadcast_ws_event(f"info_{key}_state", value, legacy=True) + + async def __poll_streamer_state(self, poller: AsyncGenerator[dict, None]) -> None: + prev: dict = {} + async for state in poller: + await self._broadcast_ws_event(self.__EV_STREAMER_STATE, state, legacy=False) + prev.update(state) + if "features" in prev: # Complete/Full + await self._broadcast_ws_event(self.__EV_STREAMER_STATE, prev, legacy=True) diff --git a/kvmd/apps/kvmd/streamer.py b/kvmd/apps/kvmd/streamer.py index 5ddb7298..d02bf50d 100644 --- a/kvmd/apps/kvmd/streamer.py +++ b/kvmd/apps/kvmd/streamer.py @@ -137,6 +137,11 @@ class _StreamerParams: class Streamer: # pylint: disable=too-many-instance-attributes + __ST_FULL = 0xFF + __ST_PARAMS = 0x01 + __ST_STREAMER = 0x02 + __ST_SNAPSHOT = 0x04 + def __init__( # pylint: disable=too-many-arguments,too-many-locals self, @@ -261,6 +266,7 @@ class Streamer: # pylint: disable=too-many-instance-attributes def set_params(self, params: dict) -> None: assert not self.__streamer_task + self.__notifier.notify(self.__ST_PARAMS) return self.__params.set_params(params) def get_params(self) -> dict: @@ -269,49 +275,72 @@ class Streamer: # pylint: disable=too-many-instance-attributes # ===== async def get_state(self) -> dict: - streamer_state = None - if self.__streamer_task: - session = self.__ensure_client_session() - try: - streamer_state = await session.get_state() - except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError): - pass - except Exception: - get_logger().exception("Invalid streamer response from /state") - - snapshot: (dict | None) = None - if self.__snapshot: - snapshot = dataclasses.asdict(self.__snapshot) - del snapshot["headers"] - del snapshot["data"] - return { + "features": self.__params.get_features(), "limits": self.__params.get_limits(), "params": self.__params.get_params(), - "snapshot": {"saved": snapshot}, - "streamer": streamer_state, - "features": self.__params.get_features(), + "streamer": (await self.__get_streamer_state()), + "snapshot": self.__get_snapshot_state(), } async def trigger_state(self) -> None: - self.__notifier.notify(1) + self.__notifier.notify(self.__ST_FULL) async def poll_state(self) -> AsyncGenerator[dict, None]: def signal_handler(*_: Any) -> None: get_logger(0).info("Got SIGUSR2, checking the stream state ...") - self.__notifier.notify() + self.__notifier.notify(self.__ST_STREAMER) get_logger(0).info("Installing SIGUSR2 streamer handler ...") asyncio.get_event_loop().add_signal_handler(signal.SIGUSR2, signal_handler) prev: dict = {} while True: - if (await self.__notifier.wait(timeout=self.__state_poll)) > 0: - prev = {} - new = await self.get_state() - if new != prev: + new: dict = {} + + mask = await self.__notifier.wait(timeout=self.__state_poll) + if mask == self.__ST_FULL: + new = await self.get_state() prev = copy.deepcopy(new) yield new + continue + + if mask < 0: + mask = self.__ST_STREAMER + + def check_update(key: str, value: (dict | None)) -> None: + if prev.get(key) != value: + new[key] = value + + if mask & self.__ST_PARAMS: + check_update("params", self.__params.get_params()) + if mask & self.__ST_STREAMER: + check_update("streamer", await self.__get_streamer_state()) + if mask & self.__ST_SNAPSHOT: + check_update("snapshot", self.__get_snapshot_state()) + + if new and prev != new: + prev.update(copy.deepcopy(new)) + yield new + + async def __get_streamer_state(self) -> (dict | None): + if self.__streamer_task: + session = self.__ensure_client_session() + try: + return (await session.get_state()) + except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError): + pass + except Exception: + get_logger().exception("Invalid streamer response from /state") + return None + + def __get_snapshot_state(self) -> dict: + if self.__snapshot: + snapshot = dataclasses.asdict(self.__snapshot) + del snapshot["headers"] + del snapshot["data"] + return {"saved": snapshot} + return {"saved": None} # ===== @@ -325,7 +354,7 @@ class Streamer: # pylint: disable=too-many-instance-attributes if snapshot.online or allow_offline: if save: self.__snapshot = snapshot - self.__notifier.notify() + self.__notifier.notify(self.__ST_SNAPSHOT) return snapshot logger.error("Stream is offline, no signal or so") except (aiohttp.ClientConnectionError, aiohttp.ServerConnectionError) as ex: diff --git a/web/share/js/kvm/session.js b/web/share/js/kvm/session.js index 22cefe89..1e7fefcd 100644 --- a/web/share/js/kvm/session.js +++ b/web/share/js/kvm/session.js @@ -57,7 +57,7 @@ export function Session() { var __info_fan_state = null; var __init__ = function() { - __startSession(); + __streamer.ensureDeps(() => __startSession()); }; /************************************************************************/ @@ -281,11 +281,6 @@ export function Session() { tools.feature.setEnabled($("system-tool-webterm"), has_webterm); $("webterm-window").show_hook = show_hook; $("webterm-window").close_hook = close_hook; - - __streamer.setJanusEnabled( - (state.janus && (state.janus.enabled || state.janus.started)) - || (state.janus_static && (state.janus_static.enabled || state.janus_static.started)) - ); }; var __startSession = function() { diff --git a/web/share/js/kvm/stream.js b/web/share/js/kvm/stream.js index f96f02d2..7c1296dc 100644 --- a/web/share/js/kvm/stream.js +++ b/web/share/js/kvm/stream.js @@ -35,11 +35,11 @@ export function Streamer() { /************************************************************************/ - var __janus_enabled = null; + var __janus_imported = null; var __streamer = null; var __state = null; - var __resolution = {"width": 640, "height": 480}; + var __res = {"width": 640, "height": 480}; var __init__ = function() { __streamer = new MjpegStreamer(__setActive, __setInactive, __setInfo); @@ -47,22 +47,22 @@ export function Streamer() { $("stream-led").title = "Stream inactive"; tools.slider.setParams($("stream-quality-slider"), 5, 100, 5, 80, function(value) { - $("stream-quality-value").innerHTML = `${value}%`; + $("stream-quality-value").innerText = `${value}%`; }); tools.slider.setOnUpDelayed($("stream-quality-slider"), 1000, (value) => __sendParam("quality", value)); tools.slider.setParams($("stream-h264-bitrate-slider"), 25, 20000, 25, 5000, function(value) { - $("stream-h264-bitrate-value").innerHTML = value; + $("stream-h264-bitrate-value").innerText = value; }); tools.slider.setOnUpDelayed($("stream-h264-bitrate-slider"), 1000, (value) => __sendParam("h264_bitrate", value)); tools.slider.setParams($("stream-h264-gop-slider"), 0, 60, 1, 30, function(value) { - $("stream-h264-gop-value").innerHTML = value; + $("stream-h264-gop-value").innerText = value; }); tools.slider.setOnUpDelayed($("stream-h264-gop-slider"), 1000, (value) => __sendParam("h264_gop", value)); tools.slider.setParams($("stream-desired-fps-slider"), 0, 120, 1, 0, function(value) { - $("stream-desired-fps-value").innerHTML = (value === 0 ? "Unlimited" : value); + $("stream-desired-fps-value").innerText = (value === 0 ? "Unlimited" : value); }); tools.slider.setOnUpDelayed($("stream-desired-fps-slider"), 1000, (value) => __sendParam("desired_fps", value)); @@ -86,7 +86,7 @@ export function Streamer() { tools.slider.setParams($("stream-audio-volume-slider"), 0, 100, 1, 0, function(value) { $("stream-video").muted = !value; $("stream-video").volume = value / 100; - $("stream-audio-volume-value").innerHTML = value + "%"; + $("stream-audio-volume-value").innerText = value + "%"; if (__streamer.getMode() === "janus") { let allow_audio = !$("stream-video").muted; if (__streamer.isAudioAllowed() !== allow_audio) { @@ -104,6 +104,13 @@ export function Streamer() { /************************************************************************/ + self.ensureDeps = function(callback) { + JanusStreamer.ensure_janus(function(avail) { + __janus_imported = avail; + callback(); + }); + }; + self.getGeometry = function() { // Первоначально обновление геометрии считалось через ResizeObserver. // Но оно не ловило некоторые события, например в последовательности: @@ -126,90 +133,106 @@ export function Streamer() { }; }; - self.setJanusEnabled = function(enabled) { - let has_webrtc = JanusStreamer.is_webrtc_available(); - let has_h264 = JanusStreamer.is_h264_available(); - - let set_enabled = function(imported) { - tools.hidden.setVisible($("stream-message-no-webrtc"), enabled && !has_webrtc); - tools.hidden.setVisible($("stream-message-no-h264"), enabled && !has_h264); - __janus_enabled = (enabled && has_webrtc && imported); // Don't check has_h264 for sure - tools.feature.setEnabled($("stream-mode"), __janus_enabled); - tools.info( - `Stream: Janus WebRTC state: enabled=${enabled},` - + ` webrtc=${has_webrtc}, h264=${has_h264}, imported=${imported}` - ); - let mode = (__janus_enabled ? tools.storage.get("stream.mode", "janus") : "mjpeg"); - tools.radio.clickValue("stream-mode-radio", mode); - if (!__janus_enabled) { - tools.feature.setEnabled($("stream-audio"), false); // Enabling in stream_janus.js + self.setState = function(state) { + if (state) { + if (!__state) { + __state = {}; } - self.setState(__state); - }; - - if (enabled && has_webrtc) { - JanusStreamer.ensure_janus(set_enabled); + if (state.features) { + __state.features = state.features; + __state.limits = state.limits; // Following together with features + } + if (__state.features && state.streamer !== undefined) { + __state.streamer = state.streamer; + } + __setControlsEnabled(!!state.streamer); } else { - set_enabled(false); + __state = null; } + let visible = wm.isWindowVisible($("stream-window")); + __applyState((visible && __state && __state.features) ? state : null); }; - self.setState = function(state) { - __state = state; - if (__janus_enabled !== null) { - __applyState(wm.isWindowVisible($("stream-window")) ? __state : null); + var __applyState = function(state) { + if (__janus_imported === null) { + alert("__janus_imported is null, please report"); + return; } - }; - var __applyState = function(state) { - if (state) { - tools.feature.setEnabled($("stream-quality"), state.features.quality && (state.streamer === null || state.streamer.encoder.quality > 0)); - tools.feature.setEnabled($("stream-h264-bitrate"), state.features.h264 && __janus_enabled); - tools.feature.setEnabled($("stream-h264-gop"), state.features.h264 && __janus_enabled); - tools.feature.setEnabled($("stream-resolution"), state.features.resolution); + if (!state) { + __streamer.stopStream(); + return; + } + + if (state.features) { + let f = state.features; + let l = state.limits; + let has_webrtc = JanusStreamer.is_webrtc_available(); + let has_h264 = JanusStreamer.is_h264_available(); + let has_janus = (__janus_imported && f.h264 && has_webrtc); // Don't check has_h264 for sure - if (state.streamer) { - tools.el.setEnabled($("stream-quality-slider"), true); - tools.slider.setValue($("stream-quality-slider"), state.streamer.encoder.quality); + tools.info( + `Stream: Janus WebRTC state: features.h264=${f.h264},` + + ` webrtc=${has_webrtc}, h264=${has_h264}, janus_imported=${__janus_imported}` + ); - if (state.features.h264 && __janus_enabled) { - __setLimitsAndValue($("stream-h264-bitrate-slider"), state.limits.h264_bitrate, state.streamer.h264.bitrate); - tools.el.setEnabled($("stream-h264-bitrate-slider"), true); + tools.hidden.setVisible($("stream-message-no-webrtc"), __janus_imported && f.h264 && !has_webrtc); + tools.hidden.setVisible($("stream-message-no-h264"), __janus_imported && f.h264 && !has_h264); - __setLimitsAndValue($("stream-h264-gop-slider"), state.limits.h264_gop, state.streamer.h264.gop); - tools.el.setEnabled($("stream-h264-gop-slider"), true); + tools.slider.setRange($("stream-desired-fps-slider"), l.desired_fps.min, l.desired_fps.max); + if (f.resolution) { + let el = $("stream-resolution-selector"); + el.options.length = 0; + for (let res of l.available_resolutions) { + tools.selector.addOption(el, res, res); } + } else { + $("stream-resolution-selector").options.length = 0; + } + if (has_janus) { + tools.slider.setRange($("stream-h264-bitrate-slider"), l.h264_bitrate.min, l.h264_bitrate.max); + tools.slider.setRange($("stream-h264-gop-slider"), l.h264_gop.min, l.h264_gop.max); + } - __setLimitsAndValue($("stream-desired-fps-slider"), state.limits.desired_fps, state.streamer.source.desired_fps); - tools.el.setEnabled($("stream-desired-fps-slider"), true); + // tools.feature.setEnabled($("stream-quality"), f.quality); // Only on s.encoder.quality + tools.feature.setEnabled($("stream-resolution"), f.resolution); + tools.feature.setEnabled($("stream-h264-bitrate"), has_janus); + tools.feature.setEnabled($("stream-h264-gop"), has_janus); + tools.feature.setEnabled($("stream-mode"), has_janus); + if (!has_janus) { + tools.feature.setEnabled($("stream-audio"), false); + } - let resolution_str = __makeStringResolution(state.streamer.source.resolution); - if (__makeStringResolution(__resolution) !== resolution_str) { - __resolution = state.streamer.source.resolution; - } + let mode = (has_janus ? tools.storage.get("stream.mode", "janus") : "mjpeg"); + tools.radio.clickValue("stream-mode-radio", mode); + } + + if (state.streamer !== undefined) { + let ok = (state.streamer !== null); + if (ok) { + let s = state.streamer; + __res = s.source.resolution; - if (state.features.resolution) { + { + let res = `${__res.width}x${__res.height}`; let el = $("stream-resolution-selector"); - if (!state.limits.available_resolutions.includes(resolution_str)) { - state.limits.available_resolutions.push(resolution_str); + if (!tools.selector.hasValue(el, res)) { + tools.selector.addOption(el, res, res); } - tools.selector.setValues(el, state.limits.available_resolutions); - tools.selector.setSelectedValue(el, resolution_str); - tools.el.setEnabled(el, true); + el.value = res; + } + tools.slider.setValue($("stream-quality-slider"), Math.max(s.encoder.quality, 1)); + tools.slider.setValue($("stream-desired-fps-slider"), s.source.desired_fps); + if (s.h264 && s.h264.bitrate) { + tools.slider.setValue($("stream-h264-bitrate-slider"), s.h264.bitrate); + tools.slider.setValue($("stream-h264-gop-slider"), s.h264.gop); // Following together with gop } - } else { - tools.el.setEnabled($("stream-quality-slider"), false); - tools.el.setEnabled($("stream-h264-bitrate-slider"), false); - tools.el.setEnabled($("stream-h264-gop-slider"), false); - tools.el.setEnabled($("stream-desired-fps-slider"), false); - tools.el.setEnabled($("stream-resolution-selector"), false); - } - - __streamer.ensureStream(state.streamer); + tools.feature.setEnabled($("stream-quality"), (s.encoder.quality > 0)); - } else { - __streamer.stopStream(); + __streamer.ensureStream(s); + } + __setControlsEnabled(ok); } }; @@ -223,16 +246,24 @@ export function Streamer() { $("stream-led").title = "Stream inactive"; }; + var __setControlsEnabled = function(enabled) { + tools.el.setEnabled($("stream-quality-slider"), enabled); + tools.el.setEnabled($("stream-desired-fps-slider"), enabled); + tools.el.setEnabled($("stream-resolution-selector"), enabled); + tools.el.setEnabled($("stream-h264-bitrate-slider"), enabled); + tools.el.setEnabled($("stream-h264-gop-slider"), enabled); + }; + var __setInfo = function(is_active, online, text) { $("stream-box").classList.toggle("stream-box-offline", !online); let el_grab = document.querySelector("#stream-window-header .window-grab"); let el_info = $("stream-info"); - let title = `${__streamer.getName()} – `; + let title = `${__streamer.getName()} - `; if (is_active) { if (!online) { title += "No signal / "; } - title += __makeStringResolution(__resolution); + title += `${__res.width}x${__res.height}`; if (text.length > 0) { title += " / " + text; } @@ -243,12 +274,7 @@ export function Streamer() { title += "Inactive"; } } - el_grab.innerHTML = el_info.innerHTML = title; - }; - - var __setLimitsAndValue = function(el, limits, value) { - tools.slider.setRange(el, limits.min, limits.max); - tools.slider.setValue(el, value); + el_grab.innerText = el_info.innerText = title; }; var __resetStream = function(mode=null) { @@ -268,7 +294,7 @@ export function Streamer() { tools.feature.setEnabled($("stream-audio"), false); // Enabling in stream_janus.js } if (wm.isWindowVisible($("stream-window"))) { - __streamer.ensureStream(__state ? __state.streamer : null); + __streamer.ensureStream((__state && __state.streamer !== undefined) ? __state.streamer : null); } }; @@ -305,6 +331,12 @@ export function Streamer() { }; var __sendParam = function(name, value) { + tools.el.setEnabled($("stream-quality-slider"), false); + tools.el.setEnabled($("stream-desired-fps-slider"), false); + tools.el.setEnabled($("stream-resolution-selector"), false); + tools.el.setEnabled($("stream-h264-bitrate-slider"), false); + tools.el.setEnabled($("stream-h264-gop-slider"), false); + tools.httpPost("/api/streamer/set_params", {[name]: value}, function(http) { if (http.status !== 200) { wm.error("Can't configure stream", http.responseText); @@ -312,9 +344,5 @@ export function Streamer() { }); }; - var __makeStringResolution = function(resolution) { - return `${resolution.width}x${resolution.height}`; - }; - __init__(); } diff --git a/web/share/js/kvm/stream_janus.js b/web/share/js/kvm/stream_janus.js index be62dbbd..27d3f55d 100644 --- a/web/share/js/kvm/stream_janus.js +++ b/web/share/js/kvm/stream_janus.js @@ -383,7 +383,7 @@ export function JanusStreamer(__setActive, __setInactive, __setInfo, __orient, _ }; var __isOnline = function() { - return !!(__state && __state.source && __state.source.online); + return !!(__state && __state.source.online); }; var __sendWatch = function() { diff --git a/web/share/js/kvm/stream_mjpeg.js b/web/share/js/kvm/stream_mjpeg.js index c914140d..6efbff7d 100644 --- a/web/share/js/kvm/stream_mjpeg.js +++ b/web/share/js/kvm/stream_mjpeg.js @@ -117,10 +117,10 @@ export function MjpegStreamer(__setActive, __setInactive, __setInfo) { }; var __findId = function() { - let stream_client = tools.cookies.get("stream_client"); - if (__id.length === 0 && stream_client && stream_client.startsWith(__key + "/")) { - __logInfo("Found acceptable stream_client cookie:", stream_client); - __id = stream_client.slice(stream_client.indexOf("/") + 1); + let sc = tools.cookies.get("stream_client"); + if (__id.length === 0 && sc && sc.startsWith(__key + "/")) { + __logInfo("Found acceptable stream_client cookie:", sc); + __id = sc.slice(sc.indexOf("/") + 1); } }; diff --git a/web/share/js/tools.js b/web/share/js/tools.js index 604f9711..97eb0abf 100644 --- a/web/share/js/tools.js +++ b/web/share/js/tools.js @@ -309,6 +309,14 @@ export var tools = new function() { self.selector.addComment(el, "\u2500".repeat(repeat)); } }, + "hasValue": function(el, value) { + for (let el_op of el.options) { + if (el_op.value === value) { + return true; + } + } + return false; + }, "setValues": function(el, values, empty_title=null) { if (values.constructor == Object) { |