Compare commits

...

6 Commits

Author SHA1 Message Date
Silke pilon 490a9fe491
Merge 531f6bf65b into cc9ff74276 2025-10-09 14:52:58 +02:00
Aveline cc9ff74276
feat: add HDMI sleep mode (#881) 2025-10-09 14:52:51 +02:00
Silke pilon 531f6bf65b
Merge branch 'jetkvm:dev' into feat-mac-text-wait-delay 2025-09-23 17:46:06 +02:00
Silke pilon 25e476e715 Merge branch 'jetkvm:dev' into feat-mac-text-wait-delay 2025-09-21 15:50:04 +02:00
Silke pilon 8dd004ab54 feat(macros add import/export, sanitize imports, and refactor
- add buildDownloadFilename and pad2 helpers to consistently
  generate safe timestamped filenames for macro downloads
- extract macro download logic into handleDownloadMacro and wire up
  Download button to use it
- refactor normalizeSortOrders to a concise one-liner
- introduce sanitizeImportedStep and sanitizeImportedMacro to validate
  imported JSON, enforce types, default values, and limit name length,
  preventing malformed data from corrupting store
- generate new IDs for imported macros and ensure correct sortOrder
- update Memo dependencies to include handleDownloadMacro

These changes enable reliable macro export/import with sanitized
inputs, improve code clarity by extracting utilities, and prevent
issues from malformed external files.
2025-09-21 15:49:48 +02:00
Silke pilon 9f27a5d5c3 feat(macros): add text/wait step types and improve delays
Lower minimum step delay to 10ms to allow finer-grained macro timing.
Introduce optional "text" and "wait" fields on macro steps (Go and
TypeScript types, JSON-RPC parsing) so steps can either type text using
the selected keyboard layout or act as explicit wait-only pauses.

Implement client-side expansion of text steps into per-character key
events (handling shift, AltRight, dead/accent keys and trailing space)
and wire expansion into both remote and client-side macro execution.
Adjust macro execution logic to treat wait steps as no-op delays and
ensure key press followed by explicit release delay is sent for typed
keys.

These changes enable richer macro semantics (text composition and
explicit waits) and more responsive timing control.
2025-09-21 15:49:48 +02:00
12 changed files with 555 additions and 149 deletions

View File

@ -24,7 +24,7 @@ const (
MaxMacrosPerDevice = 25 MaxMacrosPerDevice = 25
MaxStepsPerMacro = 10 MaxStepsPerMacro = 10
MaxKeysPerStep = 10 MaxKeysPerStep = 10
MinStepDelay = 50 MinStepDelay = 10
MaxStepDelay = 2000 MaxStepDelay = 2000
) )
@ -32,6 +32,10 @@ type KeyboardMacroStep struct {
Keys []string `json:"keys"` Keys []string `json:"keys"`
Modifiers []string `json:"modifiers"` Modifiers []string `json:"modifiers"`
Delay int `json:"delay"` Delay int `json:"delay"`
// Optional: when set, this step types the given text using the configured keyboard layout.
// The delay value is treated as the per-character delay.
Text string `json:"text,omitempty"`
Wait bool `json:"wait,omitempty"`
} }
func (s *KeyboardMacroStep) Validate() error { func (s *KeyboardMacroStep) Validate() error {
@ -104,6 +108,7 @@ type Config struct {
UsbDevices *usbgadget.Devices `json:"usb_devices"` UsbDevices *usbgadget.Devices `json:"usb_devices"`
NetworkConfig *network.NetworkConfig `json:"network_config"` NetworkConfig *network.NetworkConfig `json:"network_config"`
DefaultLogLevel string `json:"default_log_level"` DefaultLogLevel string `json:"default_log_level"`
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
} }
func (c *Config) GetDisplayRotation() uint16 { func (c *Config) GetDisplayRotation() uint16 {

View File

@ -19,6 +19,7 @@ type Native struct {
onVideoFrameReceived func(frame []byte, duration time.Duration) onVideoFrameReceived func(frame []byte, duration time.Duration)
onIndevEvent func(event string) onIndevEvent func(event string)
onRpcEvent func(event string) onRpcEvent func(event string)
sleepModeSupported bool
videoLock sync.Mutex videoLock sync.Mutex
screenLock sync.Mutex screenLock sync.Mutex
} }
@ -62,6 +63,8 @@ func NewNative(opts NativeOptions) *Native {
} }
} }
sleepModeSupported := isSleepModeSupported()
return &Native{ return &Native{
ready: make(chan struct{}), ready: make(chan struct{}),
l: nativeLogger, l: nativeLogger,
@ -73,6 +76,7 @@ func NewNative(opts NativeOptions) *Native {
onVideoFrameReceived: onVideoFrameReceived, onVideoFrameReceived: onVideoFrameReceived,
onIndevEvent: onIndevEvent, onIndevEvent: onIndevEvent,
onRpcEvent: onRpcEvent, onRpcEvent: onRpcEvent,
sleepModeSupported: sleepModeSupported,
videoLock: sync.Mutex{}, videoLock: sync.Mutex{},
screenLock: sync.Mutex{}, screenLock: sync.Mutex{},
} }

View File

@ -1,5 +1,12 @@
package native package native
import (
"os"
)
const sleepModeFile = "/sys/devices/platform/ff470000.i2c/i2c-4/4-000f/sleep_mode"
// VideoState is the state of the video stream.
type VideoState struct { type VideoState struct {
Ready bool `json:"ready"` Ready bool `json:"ready"`
Error string `json:"error,omitempty"` //no_signal, no_lock, out_of_range Error string `json:"error,omitempty"` //no_signal, no_lock, out_of_range
@ -8,6 +15,58 @@ type VideoState struct {
FramePerSecond float64 `json:"fps"` FramePerSecond float64 `json:"fps"`
} }
func isSleepModeSupported() bool {
_, err := os.Stat(sleepModeFile)
return err == nil
}
func (n *Native) setSleepMode(enabled bool) error {
if !n.sleepModeSupported {
return nil
}
bEnabled := "0"
if enabled {
bEnabled = "1"
}
return os.WriteFile(sleepModeFile, []byte(bEnabled), 0644)
}
func (n *Native) getSleepMode() (bool, error) {
if !n.sleepModeSupported {
return false, nil
}
data, err := os.ReadFile(sleepModeFile)
if err == nil {
return string(data) == "1", nil
}
return false, nil
}
// VideoSetSleepMode sets the sleep mode for the video stream.
func (n *Native) VideoSetSleepMode(enabled bool) error {
n.videoLock.Lock()
defer n.videoLock.Unlock()
return n.setSleepMode(enabled)
}
// VideoGetSleepMode gets the sleep mode for the video stream.
func (n *Native) VideoGetSleepMode() (bool, error) {
n.videoLock.Lock()
defer n.videoLock.Unlock()
return n.getSleepMode()
}
// VideoSleepModeSupported checks if the sleep mode is supported.
func (n *Native) VideoSleepModeSupported() bool {
return n.sleepModeSupported
}
// VideoSetQualityFactor sets the quality factor for the video stream.
func (n *Native) VideoSetQualityFactor(factor float64) error { func (n *Native) VideoSetQualityFactor(factor float64) error {
n.videoLock.Lock() n.videoLock.Lock()
defer n.videoLock.Unlock() defer n.videoLock.Unlock()
@ -15,6 +74,7 @@ func (n *Native) VideoSetQualityFactor(factor float64) error {
return videoSetStreamQualityFactor(factor) return videoSetStreamQualityFactor(factor)
} }
// VideoGetQualityFactor gets the quality factor for the video stream.
func (n *Native) VideoGetQualityFactor() (float64, error) { func (n *Native) VideoGetQualityFactor() (float64, error) {
n.videoLock.Lock() n.videoLock.Lock()
defer n.videoLock.Unlock() defer n.videoLock.Unlock()
@ -22,6 +82,7 @@ func (n *Native) VideoGetQualityFactor() (float64, error) {
return videoGetStreamQualityFactor() return videoGetStreamQualityFactor()
} }
// VideoSetEDID sets the EDID for the video stream.
func (n *Native) VideoSetEDID(edid string) error { func (n *Native) VideoSetEDID(edid string) error {
n.videoLock.Lock() n.videoLock.Lock()
defer n.videoLock.Unlock() defer n.videoLock.Unlock()
@ -29,6 +90,7 @@ func (n *Native) VideoSetEDID(edid string) error {
return videoSetEDID(edid) return videoSetEDID(edid)
} }
// VideoGetEDID gets the EDID for the video stream.
func (n *Native) VideoGetEDID() (string, error) { func (n *Native) VideoGetEDID() (string, error) {
n.videoLock.Lock() n.videoLock.Lock()
defer n.videoLock.Unlock() defer n.videoLock.Unlock()
@ -36,6 +98,7 @@ func (n *Native) VideoGetEDID() (string, error) {
return videoGetEDID() return videoGetEDID()
} }
// VideoLogStatus gets the log status for the video stream.
func (n *Native) VideoLogStatus() (string, error) { func (n *Native) VideoLogStatus() (string, error) {
n.videoLock.Lock() n.videoLock.Lock()
defer n.videoLock.Unlock() defer n.videoLock.Unlock()
@ -43,6 +106,7 @@ func (n *Native) VideoLogStatus() (string, error) {
return videoLogStatus(), nil return videoLogStatus(), nil
} }
// VideoStop stops the video stream.
func (n *Native) VideoStop() error { func (n *Native) VideoStop() error {
n.videoLock.Lock() n.videoLock.Lock()
defer n.videoLock.Unlock() defer n.videoLock.Unlock()
@ -51,10 +115,14 @@ func (n *Native) VideoStop() error {
return nil return nil
} }
// VideoStart starts the video stream.
func (n *Native) VideoStart() error { func (n *Native) VideoStart() error {
n.videoLock.Lock() n.videoLock.Lock()
defer n.videoLock.Unlock() defer n.videoLock.Unlock()
// disable sleep mode before starting video
_ = n.setSleepMode(false)
videoStart() videoStart()
return nil return nil
} }

View File

@ -1038,6 +1038,15 @@ func setKeyboardMacros(params KeyboardMacrosParams) (any, error) {
step.Delay = int(delay) step.Delay = int(delay)
} }
// Optional text field for advanced steps
if txt, ok := stepMap["text"].(string); ok {
step.Text = txt
}
if wv, ok := stepMap["wait"].(bool); ok {
step.Wait = wv
}
steps = append(steps, step) steps = append(steps, step)
} }
} }
@ -1215,6 +1224,8 @@ var rpcHandlers = map[string]RPCHandler{
"getEDID": {Func: rpcGetEDID}, "getEDID": {Func: rpcGetEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}}, "setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"getVideoLogStatus": {Func: rpcGetVideoLogStatus}, "getVideoLogStatus": {Func: rpcGetVideoLogStatus},
"getVideoSleepMode": {Func: rpcGetVideoSleepMode},
"setVideoSleepMode": {Func: rpcSetVideoSleepMode, Params: []string{"duration"}},
"getDevChannelState": {Func: rpcGetDevChannelState}, "getDevChannelState": {Func: rpcGetDevChannelState},
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, "setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getLocalVersion": {Func: rpcGetLocalVersion}, "getLocalVersion": {Func: rpcGetLocalVersion},

View File

@ -77,6 +77,9 @@ func Main() {
// initialize display // initialize display
initDisplay() initDisplay()
// start video sleep mode timer
startVideoSleepModeTicker()
go func() { go func() {
time.Sleep(15 * time.Minute) time.Sleep(15 * time.Minute)
for { for {

View File

@ -66,7 +66,7 @@ export function MacroForm({
newErrors.steps = { 0: { keys: "At least one step is required" } }; newErrors.steps = { 0: { keys: "At least one step is required" } };
} else { } else {
const hasKeyOrModifier = macro.steps.some( const hasKeyOrModifier = macro.steps.some(
step => (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0, step => (step.text && step.text.length > 0) || (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
); );
if (!hasKeyOrModifier) { if (!hasKeyOrModifier) {
@ -163,6 +163,40 @@ export function MacroForm({
setMacro({ ...macro, steps: newSteps }); setMacro({ ...macro, steps: newSteps });
}; };
const handleStepTypeChange = (stepIndex: number, type: "keys" | "text" | "wait") => {
const newSteps = [...(macro.steps || [])];
const prev = newSteps[stepIndex] || { keys: [], modifiers: [], delay: DEFAULT_DELAY };
if (type === "text") {
newSteps[stepIndex] = { keys: [], modifiers: [], delay: prev.delay, text: prev.text || "" } as any;
} else if (type === "wait") {
newSteps[stepIndex] = { keys: [], modifiers: [], delay: prev.delay, wait: true } as any;
} else {
// switch back to keys; drop text
const { text, wait, ...rest } = prev as any;
newSteps[stepIndex] = { ...rest } as any;
}
setMacro({ ...macro, steps: newSteps });
};
const handleTextChange = (stepIndex: number, text: string) => {
const newSteps = [...(macro.steps || [])];
// Ensure this step is of text type
newSteps[stepIndex] = { ...(newSteps[stepIndex] || { keys: [], modifiers: [], delay: DEFAULT_DELAY }), text } as any;
setMacro({ ...macro, steps: newSteps });
};
const insertStepAfter = (index: number) => {
if (isMaxStepsReached) {
showTemporaryError(
`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`,
);
return;
}
const newSteps = [...(macro.steps || [])];
newSteps.splice(index + 1, 0, { keys: [], modifiers: [], delay: DEFAULT_DELAY });
setMacro(prev => ({ ...prev, steps: newSteps }));
};
const handleStepMove = (stepIndex: number, direction: "up" | "down") => { const handleStepMove = (stepIndex: number, direction: "up" | "down") => {
const newSteps = [...(macro.steps || [])]; const newSteps = [...(macro.steps || [])];
const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1; const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1;
@ -213,31 +247,46 @@ export function MacroForm({
<Fieldset> <Fieldset>
<div className="mt-2 space-y-4"> <div className="mt-2 space-y-4">
{(macro.steps || []).map((step, stepIndex) => ( {(macro.steps || []).map((step, stepIndex) => (
<MacroStepCard <div key={stepIndex} className="space-y-3">
key={stepIndex} <MacroStepCard
step={step} step={step}
stepIndex={stepIndex} stepIndex={stepIndex}
onDelete={ onDelete={
macro.steps && macro.steps.length > 1 macro.steps && macro.steps.length > 1
? () => { ? () => {
const newSteps = [...(macro.steps || [])]; const newSteps = [...(macro.steps || [])];
newSteps.splice(stepIndex, 1); newSteps.splice(stepIndex, 1);
setMacro(prev => ({ ...prev, steps: newSteps })); setMacro(prev => ({ ...prev, steps: newSteps }));
} }
: undefined : undefined
} }
onMoveUp={() => handleStepMove(stepIndex, "up")} onMoveUp={() => handleStepMove(stepIndex, "up")}
onMoveDown={() => handleStepMove(stepIndex, "down")} onMoveDown={() => handleStepMove(stepIndex, "down")}
onKeySelect={option => handleKeySelect(stepIndex, option)} onKeySelect={option => handleKeySelect(stepIndex, option)}
onKeyQueryChange={query => handleKeyQueryChange(stepIndex, query)} onKeyQueryChange={query => handleKeyQueryChange(stepIndex, query)}
keyQuery={keyQueries[stepIndex] || ""} keyQuery={keyQueries[stepIndex] || ""}
onModifierChange={modifiers => onModifierChange={modifiers =>
handleModifierChange(stepIndex, modifiers) handleModifierChange(stepIndex, modifiers)
} }
onDelayChange={delay => handleDelayChange(stepIndex, delay)} onDelayChange={delay => handleDelayChange(stepIndex, delay)}
isLastStep={stepIndex === (macro.steps?.length || 0) - 1} isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
keyboard={selectedKeyboard} keyboard={selectedKeyboard}
/> onStepTypeChange={type => handleStepTypeChange(stepIndex, type)}
onTextChange={text => handleTextChange(stepIndex, text)}
/>
{stepIndex < (macro.steps?.length || 0) - 1 && (
<div className="flex justify-center">
<Button
size="XS"
theme="light"
LeadingIcon={LuPlus}
text="Insert step here"
onClick={() => insertStepAfter(stepIndex)}
disabled={isMaxStepsReached}
/>
</div>
)}
</div>
))} ))}
</div> </div>
</Fieldset> </Fieldset>

View File

@ -38,16 +38,22 @@ const basePresetDelays = [
]; ];
const PRESET_DELAYS = basePresetDelays.map(delay => { const PRESET_DELAYS = basePresetDelays.map(delay => {
if (parseInt(delay.value, 10) === DEFAULT_DELAY) { if (parseInt(delay.value, 10) === DEFAULT_DELAY) return { ...delay, label: "Default" };
return { ...delay, label: "Default" };
}
return delay; return delay;
}); });
const TEXT_EXTRA_DELAYS = [
{ value: "10", label: "10ms" },
{ value: "20", label: "20ms" },
{ value: "30", label: "30ms" },
];
interface MacroStep { interface MacroStep {
keys: string[]; keys: string[];
modifiers: string[]; modifiers: string[];
delay: number; delay: number;
text?: string;
wait?: boolean;
} }
interface MacroStepCardProps { interface MacroStepCardProps {
@ -62,7 +68,9 @@ interface MacroStepCardProps {
onModifierChange: (modifiers: string[]) => void; onModifierChange: (modifiers: string[]) => void;
onDelayChange: (delay: number) => void; onDelayChange: (delay: number) => void;
isLastStep: boolean; isLastStep: boolean;
keyboard: KeyboardLayout keyboard: KeyboardLayout;
onStepTypeChange: (type: "keys" | "text" | "wait") => void;
onTextChange: (text: string) => void;
} }
const ensureArray = <T,>(arr: T[] | null | undefined): T[] => { const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
@ -81,7 +89,9 @@ export function MacroStepCard({
onModifierChange, onModifierChange,
onDelayChange, onDelayChange,
isLastStep, isLastStep,
keyboard keyboard,
onStepTypeChange,
onTextChange,
}: MacroStepCardProps) { }: MacroStepCardProps) {
const { keyDisplayMap } = keyboard; const { keyDisplayMap } = keyboard;
@ -106,6 +116,8 @@ export function MacroStepCard({
} }
}, [keyOptions, keyQuery, step.keys]); }, [keyOptions, keyQuery, step.keys]);
const stepType: "keys" | "text" | "wait" = step.wait ? "wait" : (step.text !== undefined ? "text" : "keys");
return ( return (
<Card className="p-4"> <Card className="p-4">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
@ -146,6 +158,46 @@ export function MacroStepCard({
</div> </div>
<div className="space-y-4 mt-2"> <div className="space-y-4 mt-2">
<div className="w-full flex flex-col gap-2">
<FieldLabel label="Step Type" />
<div className="inline-flex gap-2">
<Button
size="XS"
theme={stepType === "keys" ? "primary" : "light"}
text="Keys/Modifiers"
onClick={() => onStepTypeChange("keys")}
/>
<Button
size="XS"
theme={stepType === "text" ? "primary" : "light"}
text="Text"
onClick={() => onStepTypeChange("text")}
/>
<Button
size="XS"
theme={stepType === "wait" ? "primary" : "light"}
text="Wait"
onClick={() => onStepTypeChange("wait")}
/>
</div>
</div>
{stepType === "text" ? (
<div className="w-full flex flex-col gap-1">
<FieldLabel label="Text to type" description="Will be typed with this step's delay per character" />
<input
type="text"
className="w-full rounded-md border border-slate-200 px-2 py-1 text-sm dark:border-slate-700 dark:bg-slate-800"
value={step.text || ""}
onChange={e => onTextChange(e.target.value)}
placeholder="Enter text..."
/>
</div>
) : stepType === "wait" ? (
<div className="w-full flex flex-col gap-1">
<FieldLabel label="Wait" description="Pause execution for the specified duration." />
<p className="text-xs text-slate-500 dark:text-slate-400">This step waits for the configured duration, no keys are sent.</p>
</div>
) : (
<div className="w-full flex flex-col gap-2"> <div className="w-full flex flex-col gap-2">
<FieldLabel label="Modifiers" /> <FieldLabel label="Modifiers" />
<div className="inline-flex flex-wrap gap-3"> <div className="inline-flex flex-wrap gap-3">
@ -176,7 +228,8 @@ export function MacroStepCard({
))} ))}
</div> </div>
</div> </div>
)}
{stepType === "keys" && (
<div className="w-full flex flex-col gap-1"> <div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} /> <FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} />
@ -223,10 +276,10 @@ export function MacroStepCard({
/> />
</div> </div>
</div> </div>
)}
<div className="w-full flex flex-col gap-1"> <div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FieldLabel label="Step Duration" description="Time to wait before executing the next step." /> <FieldLabel label="Step Duration" description={stepType === "text" ? "Delay per character when typing text" : stepType === "wait" ? "How long to pause before the next step" : "Time to wait before executing the next step."} />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<SelectMenuBasic <SelectMenuBasic
@ -234,10 +287,11 @@ export function MacroStepCard({
fullWidth fullWidth
value={step.delay.toString()} value={step.delay.toString()}
onChange={(e) => onDelayChange(parseInt(e.target.value, 10))} onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
options={PRESET_DELAYS} options={stepType === 'text' ? [...TEXT_EXTRA_DELAYS, ...PRESET_DELAYS] : PRESET_DELAYS}
/> />
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
); );

View File

@ -763,6 +763,8 @@ export interface KeySequenceStep {
keys: string[]; keys: string[];
modifiers: string[]; modifiers: string[];
delay: number; delay: number;
text?: string; // optional: when set, type this text with per-character delay
wait?: boolean; // optional: when true, this is a pure wait step (pause for delay ms)
} }
export interface KeySequence { export interface KeySequence {

View File

@ -16,6 +16,7 @@ import {
import { useHidRpc } from "@/hooks/useHidRpc"; import { useHidRpc } from "@/hooks/useHidRpc";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
const MACRO_RESET_KEYBOARD_STATE = { const MACRO_RESET_KEYBOARD_STATE = {
keys: new Array(hidKeyBufferSize).fill(0), keys: new Array(hidKeyBufferSize).fill(0),
@ -27,6 +28,8 @@ export interface MacroStep {
keys: string[] | null; keys: string[] | null;
modifiers: string[] | null; modifiers: string[] | null;
delay: number; delay: number;
text?: string | undefined;
wait?: boolean | undefined;
} }
export type MacroSteps = MacroStep[]; export type MacroSteps = MacroStep[];
@ -34,6 +37,7 @@ export type MacroSteps = MacroStep[];
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms)); const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
export default function useKeyboard() { export default function useKeyboard() {
const { selectedKeyboard } = useKeyboardLayout();
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { rpcDataChannel } = useRTCStore(); const { rpcDataChannel } = useRTCStore();
const { keysDownState, setKeysDownState, setKeyboardLedState, setPasteModeEnabled } = const { keysDownState, setKeysDownState, setKeyboardLedState, setPasteModeEnabled } =
@ -284,9 +288,38 @@ export default function useKeyboard() {
// After the delay, the keys and modifiers are released and the next step is executed. // After the delay, the keys and modifiers are released and the next step is executed.
// If a step has no keys or modifiers, it is treated as a delay-only step. // If a step has no keys or modifiers, it is treated as a delay-only step.
// A small pause is added between steps to ensure that the device can process the events. // A small pause is added between steps to ensure that the device can process the events.
const expandTextSteps = useCallback((steps: MacroSteps): MacroSteps => {
const expanded: MacroSteps = [];
for (const step of steps) {
if (step.text && step.text.length > 0 && selectedKeyboard) {
for (const char of step.text) {
const keyprops = selectedKeyboard.chars[char];
if (!keyprops) continue;
const { key, shift, altRight, deadKey, accentKey } = keyprops;
if (!key) continue;
if (accentKey) {
const accentModifiers: string[] = [];
if (accentKey.shift) accentModifiers.push("ShiftLeft");
if (accentKey.altRight) accentModifiers.push("AltRight");
expanded.push({ keys: [String(accentKey.key)], modifiers: accentModifiers, delay: step.delay });
}
const mods: string[] = [];
if (shift) mods.push("ShiftLeft");
if (altRight) mods.push("AltRight");
expanded.push({ keys: [String(key)], modifiers: mods, delay: step.delay });
if (deadKey) expanded.push({ keys: ["Space"], modifiers: null, delay: step.delay });
}
} else {
expanded.push(step);
}
}
return expanded;
}, [selectedKeyboard]);
const executeMacroRemote = useCallback(async ( const executeMacroRemote = useCallback(async (
steps: MacroSteps, stepsIn: MacroSteps,
) => { ) => {
const steps = expandTextSteps(stepsIn);
const macro: KeyboardMacroStep[] = []; const macro: KeyboardMacroStep[] = [];
for (const [_, step] of steps.entries()) { for (const [_, step] of steps.entries()) {
@ -297,16 +330,22 @@ export default function useKeyboard() {
.reduce((acc, val) => acc + val, 0); .reduce((acc, val) => acc + val, 0);
// If the step has keys and/or modifiers, press them and hold for the delay if (step.wait) {
if (keyValues.length > 0 || modifierMask > 0) { // pure wait: send a no-op clear state with desired delay
macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
} else if (keyValues.length > 0 || modifierMask > 0) {
macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 }); macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 });
macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 }); macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
} else {
// empty step (pause only)
macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
} }
} }
sendKeyboardMacroEventHidRpc(macro); sendKeyboardMacroEventHidRpc(macro);
}, [sendKeyboardMacroEventHidRpc]); }, [sendKeyboardMacroEventHidRpc, expandTextSteps]);
const executeMacroClientSide = useCallback(async (steps: MacroSteps) => { const executeMacroClientSide = useCallback(async (stepsIn: MacroSteps) => {
const steps = expandTextSteps(stepsIn);
const promises: (() => Promise<void>)[] = []; const promises: (() => Promise<void>)[] = [];
const ac = new AbortController(); const ac = new AbortController();
@ -318,11 +357,14 @@ export default function useKeyboard() {
.map(mod => modifiers[mod]) .map(mod => modifiers[mod])
.reduce((acc, val) => acc + val, 0); .reduce((acc, val) => acc + val, 0);
// If the step has keys and/or modifiers, press them and hold for the delay if (step.wait) {
if (keyValues.length > 0 || modifierMask > 0) { promises.push(() => sleep(step.delay || 100));
} else if (keyValues.length > 0 || modifierMask > 0) {
promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac)); promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac));
promises.push(() => resetKeyboardState()); promises.push(() => resetKeyboardState());
promises.push(() => sleep(step.delay || 100)); promises.push(() => sleep(step.delay || 100));
} else {
promises.push(() => sleep(step.delay || 100));
} }
} }
@ -354,7 +396,7 @@ export default function useKeyboard() {
reject(error); reject(error);
}); });
}); });
}, [sendKeystrokeLegacy, resetKeyboardState, setAbortController]); }, [sendKeystrokeLegacy, resetKeyboardState, setAbortController, expandTextSteps]);
const executeMacro = useCallback(async (steps: MacroSteps) => { const executeMacro = useCallback(async (steps: MacroSteps) => {
if (rpcHidReady) { if (rpcHidReady) {
return executeMacroRemote(steps); return executeMacroRemote(steps);

View File

@ -1,4 +1,4 @@
import { useEffect, Fragment, useMemo, useState, useCallback } from "react"; import { useEffect, Fragment, useMemo, useState, useCallback, useRef } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { import {
LuPenLine, LuPenLine,
@ -9,6 +9,7 @@ import {
LuArrowDown, LuArrowDown,
LuTrash2, LuTrash2,
LuCommand, LuCommand,
LuDownload,
} from "react-icons/lu"; } from "react-icons/lu";
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
@ -22,13 +23,32 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
import LoadingSpinner from "@/components/LoadingSpinner"; import LoadingSpinner from "@/components/LoadingSpinner";
import useKeyboardLayout from "@/hooks/useKeyboardLayout"; import useKeyboardLayout from "@/hooks/useKeyboardLayout";
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => { const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => macros.map((m, i) => ({ ...m, sortOrder: i + 1 }));
return macros.map((macro, index) => ({
...macro, const pad2 = (n: number) => String(n).padStart(2, "0");
sortOrder: index + 1,
})); const buildMacroDownloadFilename = (macro: KeySequence) => {
const safeName = (macro.name || macro.id).replace(/[^a-z0-9-_]+/gi, "-").toLowerCase();
const now = new Date();
const ts = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
return `jetkvm-macro-${safeName}-${ts}.json`;
}; };
const sanitizeImportedStep = (raw: any) => ({
keys: Array.isArray(raw?.keys) ? raw.keys.filter((k: any) => typeof k === "string") : [],
modifiers: Array.isArray(raw?.modifiers) ? raw.modifiers.filter((m: any) => typeof m === "string") : [],
delay: typeof raw?.delay === "number" ? raw.delay : DEFAULT_DELAY,
text: typeof raw?.text === "string" ? raw.text : undefined,
wait: typeof raw?.wait === "boolean" ? raw.wait : false,
});
const sanitizeImportedMacro = (raw: any, sortOrder: number): KeySequence => ({
id: generateMacroId(),
name: (typeof raw?.name === "string" && raw.name.trim() ? raw.name : "Imported Macro").slice(0, 50),
steps: Array.isArray(raw?.steps) ? raw.steps.map(sanitizeImportedStep) : [],
sortOrder,
});
export default function SettingsMacrosRoute() { export default function SettingsMacrosRoute() {
const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore(); const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
const navigate = useNavigate(); const navigate = useNavigate();
@ -36,6 +56,7 @@ export default function SettingsMacrosRoute() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null); const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
const { selectedKeyboard } = useKeyboardLayout(); const { selectedKeyboard } = useKeyboardLayout();
const fileInputRef = useRef<HTMLInputElement>(null);
const isMaxMacrosReached = useMemo( const isMaxMacrosReached = useMemo(
() => macros.length >= MAX_TOTAL_MACROS, () => macros.length >= MAX_TOTAL_MACROS,
@ -48,74 +69,52 @@ export default function SettingsMacrosRoute() {
} }
}, [initialized, loadMacros]); }, [initialized, loadMacros]);
const handleDuplicateMacro = useCallback( const handleDuplicateMacro = useCallback(async (macro: KeySequence) => {
async (macro: KeySequence) => { if (!macro?.id || !macro?.name) {
if (!macro?.id || !macro?.name) { notifications.error("Invalid macro data");
notifications.error("Invalid macro data"); return;
return; }
} if (isMaxMacrosReached) {
notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
return;
}
setActionLoadingId(macro.id);
const newMacroCopy: KeySequence = {
...JSON.parse(JSON.stringify(macro)),
id: generateMacroId(),
name: `${macro.name} ${COPY_SUFFIX}`,
sortOrder: macros.length + 1,
};
try {
await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
} catch (e: any) {
notifications.error(`Failed to duplicate macro: ${e?.message || 'error'}`);
} finally {
setActionLoadingId(null);
}
}, [macros, saveMacros, isMaxMacrosReached]);
if (isMaxMacrosReached) { const handleMoveMacro = useCallback(async (index: number, direction: "up" | "down", macroId: string) => {
notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`); if (!Array.isArray(macros) || macros.length === 0) {
return; notifications.error("No macros available");
} return;
}
setActionLoadingId(macro.id); const newIndex = direction === "up" ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= macros.length) return;
const newMacroCopy: KeySequence = { setActionLoadingId(macroId);
...JSON.parse(JSON.stringify(macro)), try {
id: generateMacroId(), const newMacros = [...macros];
name: `${macro.name} ${COPY_SUFFIX}`, [newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
sortOrder: macros.length + 1, const updatedMacros = normalizeSortOrders(newMacros);
}; await saveMacros(updatedMacros);
notifications.success("Macro order updated successfully");
try { } catch (e: any) {
await saveMacros(normalizeSortOrders([...macros, newMacroCopy])); notifications.error(`Failed to reorder macros: ${e?.message || 'error'}`);
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`); } finally {
} catch (error: unknown) { setActionLoadingId(null);
if (error instanceof Error) { }
notifications.error(`Failed to duplicate macro: ${error.message}`); }, [macros, saveMacros]);
} else {
notifications.error("Failed to duplicate macro");
}
} finally {
setActionLoadingId(null);
}
},
[isMaxMacrosReached, macros, saveMacros, setActionLoadingId],
);
const handleMoveMacro = useCallback(
async (index: number, direction: "up" | "down", macroId: string) => {
if (!Array.isArray(macros) || macros.length === 0) {
notifications.error("No macros available");
return;
}
const newIndex = direction === "up" ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= macros.length) return;
setActionLoadingId(macroId);
try {
const newMacros = [...macros];
[newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
const updatedMacros = normalizeSortOrders(newMacros);
await saveMacros(updatedMacros);
notifications.success("Macro order updated successfully");
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to reorder macros: ${error.message}`);
} else {
notifications.error("Failed to reorder macros");
}
} finally {
setActionLoadingId(null);
}
},
[macros, saveMacros, setActionLoadingId],
);
const handleDeleteMacro = useCallback(async () => { const handleDeleteMacro = useCallback(async () => {
if (!macroToDelete?.id) return; if (!macroToDelete?.id) return;
@ -140,6 +139,17 @@ export default function SettingsMacrosRoute() {
} }
}, [macroToDelete, macros, saveMacros]); }, [macroToDelete, macros, saveMacros]);
const handleDownloadMacro = useCallback((macro: KeySequence) => {
const data = JSON.stringify(macro, null, 2);
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = buildMacroDownloadFilename(macro);
a.click();
URL.revokeObjectURL(url);
}, []);
const MacroList = useMemo( const MacroList = useMemo(
() => ( () => (
<div className="space-y-2"> <div className="space-y-2">
@ -178,9 +188,12 @@ export default function SettingsMacrosRoute() {
<span key={stepIndex} className="inline-flex items-center"> <span key={stepIndex} className="inline-flex items-center">
<StepIcon className="mr-1 h-3 w-3 shrink-0 text-slate-400 dark:text-slate-500" /> <StepIcon className="mr-1 h-3 w-3 shrink-0 text-slate-400 dark:text-slate-500" />
<span className="rounded-md border border-slate-200/50 bg-slate-50 px-2 py-0.5 dark:border-slate-700/50 dark:bg-slate-800"> <span className="rounded-md border border-slate-200/50 bg-slate-50 px-2 py-0.5 dark:border-slate-700/50 dark:bg-slate-800">
{(Array.isArray(step.modifiers) && {step.text && step.text.length > 0 ? (
step.modifiers.length > 0) || <span className="font-medium text-emerald-700 dark:text-emerald-300">Text: "{step.text}"</span>
(Array.isArray(step.keys) && step.keys.length > 0) ? ( ) : step.wait ? (
<span className="font-medium text-amber-600 dark:text-amber-300">Wait</span>
) : (Array.isArray(step.modifiers) && step.modifiers.length > 0) ||
(Array.isArray(step.keys) && step.keys.length > 0) ? (
<> <>
{Array.isArray(step.modifiers) && {Array.isArray(step.modifiers) &&
step.modifiers.map((modifier, idx) => ( step.modifiers.map((modifier, idx) => (
@ -224,7 +237,7 @@ export default function SettingsMacrosRoute() {
</> </>
) : ( ) : (
<span className="font-medium text-slate-500 dark:text-slate-400"> <span className="font-medium text-slate-500 dark:text-slate-400">
Delay only Pause only
</span> </span>
)} )}
{step.delay !== DEFAULT_DELAY && ( {step.delay !== DEFAULT_DELAY && (
@ -261,6 +274,7 @@ export default function SettingsMacrosRoute() {
disabled={actionLoadingId === macro.id} disabled={actionLoadingId === macro.id}
aria-label={`Duplicate macro ${macro.name}`} aria-label={`Duplicate macro ${macro.name}`}
/> />
<Button size="XS" theme="light" LeadingIcon={LuDownload} onClick={() => handleDownloadMacro(macro)} aria-label={`Download macro ${macro.name}`} disabled={actionLoadingId === macro.id} />
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
@ -290,19 +304,7 @@ export default function SettingsMacrosRoute() {
/> />
</div> </div>
), ),
[ [macros, showDeleteConfirm, macroToDelete?.name, macroToDelete?.id, actionLoadingId, handleDeleteMacro, handleMoveMacro, selectedKeyboard.modifierDisplayMap, selectedKeyboard.keyDisplayMap, handleDuplicateMacro, navigate, handleDownloadMacro],
macros,
showDeleteConfirm,
macroToDelete?.name,
macroToDelete?.id,
actionLoadingId,
handleDeleteMacro,
handleMoveMacro,
selectedKeyboard.modifierDisplayMap,
selectedKeyboard.keyDisplayMap,
handleDuplicateMacro,
navigate
],
); );
return ( return (
@ -312,18 +314,57 @@ export default function SettingsMacrosRoute() {
title="Keyboard Macros" title="Keyboard Macros"
description={`Combine keystrokes into a single action for faster workflows.`} description={`Combine keystrokes into a single action for faster workflows.`}
/> />
{macros.length > 0 && ( <div className="flex items-center pl-2">
<div className="flex items-center pl-2"> <Button
size="SM"
theme="primary"
text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"}
onClick={() => navigate("add")}
disabled={isMaxMacrosReached}
aria-label="Add new macro"
/>
<div className="ml-2 flex items-center gap-2">
<input ref={fileInputRef} type="file" accept="application/json" multiple className="hidden" onChange={async e => {
const fl = e.target.files;
if (!fl || fl.length === 0) return;
let working = [...macros];
const imported: string[] = [];
let errors = 0;
let skipped = 0;
for (const f of Array.from(fl)) {
if (working.length >= MAX_TOTAL_MACROS) { skipped++; continue; }
try {
const raw = await f.text();
const parsed = JSON.parse(raw);
const candidates = Array.isArray(parsed) ? parsed : [parsed];
for (const c of candidates) {
if (working.length >= MAX_TOTAL_MACROS) { skipped += (candidates.length - candidates.indexOf(c)); break; }
if (!c || typeof c !== "object") { errors++; continue; }
const sanitized = sanitizeImportedMacro(c, working.length + 1);
working.push(sanitized);
imported.push(sanitized.name);
}
} catch { errors++; }
}
try {
if (imported.length) {
await saveMacros(normalizeSortOrders(working));
notifications.success(`Imported ${imported.length} macro${imported.length === 1 ? '' : 's'}`);
}
if (errors) notifications.error(`${errors} file${errors === 1 ? '' : 's'} failed`);
if (skipped) notifications.error(`${skipped} macro${skipped === 1 ? '' : 's'} skipped (limit ${MAX_TOTAL_MACROS})`);
} finally {
if (fileInputRef.current) fileInputRef.current.value = '';
}
}} />
<Button <Button
size="SM" size="SM"
theme="primary" theme="light"
text={isMaxMacrosReached ? `Max Reached` : "Add New Macro"} text="Import Macro"
onClick={() => navigate("add")} onClick={() => fileInputRef.current?.click()}
disabled={isMaxMacrosReached}
aria-label="Add new macro"
/> />
</div> </div>
)} </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">

103
video.go
View File

@ -1,10 +1,22 @@
package kvm package kvm
import ( import (
"context"
"fmt"
"time"
"github.com/jetkvm/kvm/internal/native" "github.com/jetkvm/kvm/internal/native"
) )
var lastVideoState native.VideoState var (
lastVideoState native.VideoState
videoSleepModeCtx context.Context
videoSleepModeCancel context.CancelFunc
)
const (
defaultVideoSleepModeDuration = 1 * time.Minute
)
func triggerVideoStateUpdate() { func triggerVideoStateUpdate() {
go func() { go func() {
@ -17,3 +29,92 @@ func triggerVideoStateUpdate() {
func rpcGetVideoState() (native.VideoState, error) { func rpcGetVideoState() (native.VideoState, error) {
return lastVideoState, nil return lastVideoState, nil
} }
type rpcVideoSleepModeResponse struct {
Supported bool `json:"supported"`
Enabled bool `json:"enabled"`
Duration int `json:"duration"`
}
func rpcGetVideoSleepMode() rpcVideoSleepModeResponse {
sleepMode, _ := nativeInstance.VideoGetSleepMode()
return rpcVideoSleepModeResponse{
Supported: nativeInstance.VideoSleepModeSupported(),
Enabled: sleepMode,
Duration: config.VideoSleepAfterSec,
}
}
func rpcSetVideoSleepMode(duration int) error {
if duration < 0 {
duration = -1 // disable
}
config.VideoSleepAfterSec = duration
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
// we won't restart the ticker here,
// as the session can't be inactive when this function is called
return nil
}
func stopVideoSleepModeTicker() {
nativeLogger.Trace().Msg("stopping HDMI sleep mode ticker")
if videoSleepModeCancel != nil {
nativeLogger.Trace().Msg("canceling HDMI sleep mode ticker context")
videoSleepModeCancel()
videoSleepModeCancel = nil
videoSleepModeCtx = nil
}
}
func startVideoSleepModeTicker() {
if !nativeInstance.VideoSleepModeSupported() {
return
}
var duration time.Duration
if config.VideoSleepAfterSec == 0 {
duration = defaultVideoSleepModeDuration
} else if config.VideoSleepAfterSec > 0 {
duration = time.Duration(config.VideoSleepAfterSec) * time.Second
} else {
stopVideoSleepModeTicker()
return
}
// Stop any existing timer and goroutine
stopVideoSleepModeTicker()
// Create new context for this ticker
videoSleepModeCtx, videoSleepModeCancel = context.WithCancel(context.Background())
go doVideoSleepModeTicker(videoSleepModeCtx, duration)
}
func doVideoSleepModeTicker(ctx context.Context, duration time.Duration) {
timer := time.NewTimer(duration)
defer timer.Stop()
nativeLogger.Trace().Msg("HDMI sleep mode ticker started")
for {
select {
case <-timer.C:
if getActiveSessions() > 0 {
nativeLogger.Warn().Msg("not going to enter HDMI sleep mode because there are active sessions")
continue
}
nativeLogger.Trace().Msg("entering HDMI sleep mode")
_ = nativeInstance.VideoSetSleepMode(true)
case <-ctx.Done():
nativeLogger.Trace().Msg("HDMI sleep mode ticker stopped")
return
}
}
}

View File

@ -39,6 +39,34 @@ type Session struct {
keysDownStateQueue chan usbgadget.KeysDownState keysDownStateQueue chan usbgadget.KeysDownState
} }
var (
actionSessions int = 0
activeSessionsMutex = &sync.Mutex{}
)
func incrActiveSessions() int {
activeSessionsMutex.Lock()
defer activeSessionsMutex.Unlock()
actionSessions++
return actionSessions
}
func decrActiveSessions() int {
activeSessionsMutex.Lock()
defer activeSessionsMutex.Unlock()
actionSessions--
return actionSessions
}
func getActiveSessions() int {
activeSessionsMutex.Lock()
defer activeSessionsMutex.Unlock()
return actionSessions
}
func (s *Session) resetKeepAliveTime() { func (s *Session) resetKeepAliveTime() {
s.keepAliveJitterLock.Lock() s.keepAliveJitterLock.Lock()
defer s.keepAliveJitterLock.Unlock() defer s.keepAliveJitterLock.Unlock()
@ -312,9 +340,8 @@ func newSession(config SessionConfig) (*Session, error) {
if connectionState == webrtc.ICEConnectionStateConnected { if connectionState == webrtc.ICEConnectionStateConnected {
if !isConnected { if !isConnected {
isConnected = true isConnected = true
actionSessions++
onActiveSessionsChanged() onActiveSessionsChanged()
if actionSessions == 1 { if incrActiveSessions() == 1 {
onFirstSessionConnected() onFirstSessionConnected()
} }
} }
@ -353,9 +380,8 @@ func newSession(config SessionConfig) (*Session, error) {
} }
if isConnected { if isConnected {
isConnected = false isConnected = false
actionSessions--
onActiveSessionsChanged() onActiveSessionsChanged()
if actionSessions == 0 { if decrActiveSessions() == 0 {
onLastSessionDisconnected() onLastSessionDisconnected()
} }
} }
@ -364,16 +390,16 @@ func newSession(config SessionConfig) (*Session, error) {
return session, nil return session, nil
} }
var actionSessions = 0
func onActiveSessionsChanged() { func onActiveSessionsChanged() {
requestDisplayUpdate(true, "active_sessions_changed") requestDisplayUpdate(true, "active_sessions_changed")
} }
func onFirstSessionConnected() { func onFirstSessionConnected() {
_ = nativeInstance.VideoStart() _ = nativeInstance.VideoStart()
stopVideoSleepModeTicker()
} }
func onLastSessionDisconnected() { func onLastSessionDisconnected() {
_ = nativeInstance.VideoStop() _ = nativeInstance.VideoStop()
startVideoSleepModeTicker()
} }