diff --git a/dev/web/public/app.mjs b/dev/web/public/app.mjs
index 93e73d5..8e0b1e6 100644
--- a/dev/web/public/app.mjs
+++ b/dev/web/public/app.mjs
@@ -1,3 +1,5 @@
+import { by_id, qs, clone, show, hide } from '/lib/dom.mjs';
+
/* V4L2 control type codes (must match CTRL_TYPE_* in v4l2_ctrl.h) */
const CTRL_INTEGER = 1;
const CTRL_BOOLEAN = 2;
@@ -9,332 +11,11 @@ const CTRL_INTEGER_MENU = 9;
const FLAG_DISABLED = 0x0001;
const FLAG_GRABBED = 0x0002;
const FLAG_READ_ONLY = 0x0004;
-const FLAG_INACTIVE = 0x0010;
/* V4L2_CAP bits (from linux/videodev2.h) */
const CAP_VIDEO_CAPTURE = 0x00000001;
const CAP_META_CAPTURE = 0x00800000;
-/* -------------------------------------------------------------------------
- * State
- * ------------------------------------------------------------------------- */
-
-let selected_device_idx = null;
-let device_data = null; /* last ENUM_DEVICES result */
-
-/* -------------------------------------------------------------------------
- * Utilities
- * ------------------------------------------------------------------------- */
-
-const $ = id => document.getElementById(id);
-
-function toast(msg, type = '') {
- const el = document.createElement('div');
- el.className = 'toast' + (type ? ` ${type}` : '');
- el.textContent = msg;
- $('toast-container').appendChild(el);
- setTimeout(() => {
- el.classList.add('fading');
- setTimeout(() => el.remove(), 450);
- }, 2500);
-}
-
-async function api(method, path, body) {
- const opts = { method, headers: { 'Content-Type': 'application/json' } };
- if (body !== undefined) { opts.body = JSON.stringify(body); }
- const res = await fetch(path, opts);
- const data = await res.json();
- if (!res.ok) { throw new Error(data.error || `HTTP ${res.status}`); }
- return data;
-}
-
-function caps_tags(device_caps) {
- const tags = [];
- if (device_caps & CAP_VIDEO_CAPTURE) { tags.push('video'); }
- if (device_caps & CAP_META_CAPTURE) { tags.push('meta'); }
- return tags.join(' ');
-}
-
-/* -------------------------------------------------------------------------
- * Connection UI
- * ------------------------------------------------------------------------- */
-
-async function refresh_status() {
- try {
- const s = await api('GET', '/api/status');
- const dot = $('status-dot');
- const text = $('status-text');
- if (s.connected) {
- dot.className = 'ok';
- text.textContent = `${s.host}:${s.port}`;
- $('btn-disconnect').style.display = '';
- $('btn-connect').style.display = 'none';
- await refresh_devices();
- } else {
- dot.className = '';
- text.textContent = 'disconnected';
- $('btn-disconnect').style.display = 'none';
- $('btn-connect').style.display = '';
- show_empty_devices();
- }
- } catch (err) {
- console.error(err);
- }
-}
-
-$('btn-connect').addEventListener('click', async () => {
- const host = $('inp-host').value.trim();
- const port = $('inp-port').value.trim();
- if (!host || !port) { toast('enter host and port', 'err'); return; }
- try {
- await api('POST', '/api/connect', { host, port: parseInt(port) });
- toast('connected', 'ok');
- await refresh_status();
- } catch (err) {
- toast(err.message, 'err');
- }
-});
-
-let discovery_es = null;
-
-$('btn-discover').addEventListener('click', () => {
- if (discovery_es) { return; } /* already open */
- show_peer_picker();
-});
-
-async function connect_to_peer(peer) {
- await api('POST', '/api/connect', { host: peer.addr, port: peer.tcp_port });
- $('inp-host').value = peer.addr;
- $('inp-port').value = peer.tcp_port;
- toast(`connected to ${peer.name}`, 'ok');
-}
-
-function show_peer_picker() {
- /* -- overlay ----------------------------------------------------------- */
- const overlay = document.createElement('div');
- overlay.style.cssText = `
- position:fixed; inset:0; background:rgba(0,0,0,0.6);
- display:flex; align-items:center; justify-content:center; z-index:200;
- `;
-
- const box = document.createElement('div');
- box.style.cssText = `
- background:var(--surface); border:1px solid var(--border);
- border-radius:8px; padding:20px; min-width:320px; max-width:480px;
- `;
-
- const header = document.createElement('div');
- header.style.cssText = 'display:flex; align-items:center; gap:10px; margin-bottom:14px;';
-
- const title = document.createElement('div');
- title.style.cssText = 'font-weight:600; flex:1;';
- title.textContent = 'Discovering nodes…';
-
- const spinner = document.createElement('span');
- spinner.textContent = '⟳';
- spinner.style.cssText = 'animation:spin 1s linear infinite; display:inline-block; font-size:16px;';
-
- const cancel_btn = document.createElement('button');
- cancel_btn.textContent = 'Close';
-
- header.appendChild(title);
- header.appendChild(spinner);
- header.appendChild(cancel_btn);
- box.appendChild(header);
-
- const peer_list = document.createElement('div');
- peer_list.style.cssText = 'display:flex; flex-direction:column; gap:6px; min-height:40px;';
-
- const hint = document.createElement('div');
- hint.style.cssText = 'color:var(--text-dim); font-size:12px; padding:8px 0;';
- hint.textContent = 'Waiting for announcements…';
- peer_list.appendChild(hint);
-
- box.appendChild(peer_list);
- overlay.appendChild(box);
- document.body.appendChild(overlay);
-
- /* add spin keyframe once */
- if (!document.getElementById('spin-style')) {
- const s = document.createElement('style');
- s.id = 'spin-style';
- s.textContent = '@keyframes spin { to { transform:rotate(360deg); } }';
- document.head.appendChild(s);
- }
-
- /* -- SSE --------------------------------------------------------------- */
- const es = new EventSource('/api/discover');
- discovery_es = es;
-
- es.onmessage = e => {
- const peer = JSON.parse(e.data);
- hint.remove();
- title.textContent = 'Select a node:';
-
- const btn = document.createElement('button');
- btn.style.cssText = 'display:flex; width:100%; text-align:left; padding:8px 12px; gap:12px; align-items:baseline;';
- btn.innerHTML = `
- ${peer.name}
-
- ${peer.addr}:${peer.tcp_port}
- `;
- btn.addEventListener('click', async () => {
- close_picker();
- try {
- await connect_to_peer(peer);
- await refresh_status();
- } catch (err) {
- toast(err.message, 'err');
- }
- });
- peer_list.appendChild(btn);
- };
-
- es.onerror = () => {
- title.textContent = 'Discovery error';
- spinner.style.display = 'none';
- };
-
- /* -- close ------------------------------------------------------------- */
- function close_picker() {
- es.close();
- discovery_es = null;
- overlay.remove();
- }
-
- cancel_btn.addEventListener('click', close_picker);
- overlay.addEventListener('click', e => { if (e.target === overlay) { close_picker(); } });
-}
-
-$('btn-disconnect').addEventListener('click', async () => {
- await api('POST', '/api/disconnect');
- selected_device_idx = null;
- device_data = null;
- show_empty_devices();
- show_empty_controls();
- await refresh_status();
-});
-
-$('btn-refresh-devices').addEventListener('click', refresh_devices);
-
-/* -------------------------------------------------------------------------
- * Device list
- * ------------------------------------------------------------------------- */
-
-function show_empty_devices() {
- $('device-list').innerHTML = '
Not connected
';
-}
-
-function show_empty_controls(msg = 'Select a device') {
- $('controls-title').textContent = 'Controls';
- $('controls-scroll').innerHTML = `${msg}
`;
-}
-
-async function refresh_devices() {
- try {
- device_data = await api('GET', '/api/devices');
- render_device_list();
- } catch (err) {
- toast(err.message, 'err');
- }
-}
-
-function render_device_list() {
- const list = $('device-list');
- list.innerHTML = '';
-
- if (!device_data) { return; }
-
- let flat_idx = 0;
-
- const { media, standalone } = device_data;
-
- if (media.length > 0) {
- for (const md of media) {
- const group = document.createElement('div');
- group.className = 'device-group';
-
- const gh = document.createElement('div');
- gh.className = 'device-group-header';
- gh.textContent = `${md.model} (${md.driver})`;
- group.appendChild(gh);
-
- for (const vn of md.video_nodes) {
- const idx = flat_idx++;
- group.appendChild(make_device_item(idx, vn.path,
- vn.entity_name, vn.device_caps, vn.is_capture));
- }
- list.appendChild(group);
- }
- }
-
- if (standalone.length > 0) {
- const group = document.createElement('div');
- group.className = 'device-group';
- const gh = document.createElement('div');
- gh.className = 'device-group-header';
- gh.textContent = 'Standalone';
- group.appendChild(gh);
- for (const sd of standalone) {
- const idx = flat_idx++;
- group.appendChild(make_device_item(idx, sd.path, sd.name, 0, false));
- }
- list.appendChild(group);
- }
-
- if (flat_idx === 0) {
- list.innerHTML = 'No devices found
';
- }
-
- /* re-select previously selected device */
- if (selected_device_idx !== null) {
- const item = list.querySelector(`[data-idx="${selected_device_idx}"]`);
- if (item) { item.classList.add('selected'); }
- }
-}
-
-function make_device_item(idx, path, label, device_caps, is_capture) {
- const el = document.createElement('div');
- el.className = 'device-item';
- el.dataset.idx = idx;
-
- const caps_text = caps_tags(device_caps);
- const badge = is_capture ? 'capture' : '';
-
- el.innerHTML = `
- ${path}${badge}
- ${label}
- ${caps_text ? `${caps_text}
` : ''}
- `;
-
- el.addEventListener('click', () => select_device(idx, path));
- return el;
-}
-
-async function select_device(idx, path) {
- selected_device_idx = idx;
- document.querySelectorAll('.device-item').forEach(el => {
- el.classList.toggle('selected', parseInt(el.dataset.idx) === idx);
- });
- await load_controls(idx, path);
-}
-
-/* -------------------------------------------------------------------------
- * Controls
- * ------------------------------------------------------------------------- */
-
-async function load_controls(device_idx, device_path) {
- $('controls-title').textContent = `Controls — ${device_path}`;
- $('controls-scroll').innerHTML = 'Loading…
';
- try {
- const result = await api('GET', `/api/devices/${device_idx}/controls`);
- render_controls(device_idx, result.controls ?? []);
- } catch (err) {
- toast(err.message, 'err');
- show_empty_controls('Failed to load controls');
- }
-}
-
/*
* V4L2 control IDs with their class prefix (from linux/v4l2-controls.h).
* Used for grouping controls into sections.
@@ -349,6 +30,46 @@ const CTRL_CLASSES = [
{ base: 0x00a20000, name: 'Codec' },
];
+/* -------------------------------------------------------------------------
+ * State
+ * ------------------------------------------------------------------------- */
+
+const known_peers = new Map(); /* key: addr:name -> peer */
+let selected_peer = null;
+let selected_device_idx = null;
+let device_data = null;
+
+/* -------------------------------------------------------------------------
+ * Utilities
+ * ------------------------------------------------------------------------- */
+
+function toast(msg, type = '') {
+ const el = document.createElement('div');
+ el.className = 'toast' + (type ? ` ${type}` : '');
+ el.textContent = msg;
+ by_id('toast-container').appendChild(el);
+ setTimeout(() => {
+ el.classList.add('fading');
+ setTimeout(() => el.remove(), 450);
+ }, 2500);
+}
+
+async function api(method, path, body) {
+ const opts = { method, headers: { 'Content-Type': 'application/json' } };
+ if (body !== undefined) { opts.body = JSON.stringify(body); }
+ const res = await fetch(path, opts);
+ const data = await res.json();
+ if (!res.ok) { throw new Error(data.error || `HTTP ${res.status}`); }
+ return data;
+}
+
+function caps_tags(device_caps) {
+ const tags = [];
+ if (device_caps & CAP_VIDEO_CAPTURE) { tags.push('video'); }
+ if (device_caps & CAP_META_CAPTURE) { tags.push('meta'); }
+ return tags.join(' ');
+}
+
function ctrl_class_name(id) {
for (const c of CTRL_CLASSES) {
if ((id & 0xFFFF0000) === c.base) { return c.name; }
@@ -356,12 +77,206 @@ function ctrl_class_name(id) {
return 'Other';
}
+function empty_el(msg) {
+ const el = document.createElement('div');
+ el.className = 'empty';
+ el.textContent = msg;
+ return el;
+}
+
+/* -------------------------------------------------------------------------
+ * Node panel — live SSE-fed list
+ * ------------------------------------------------------------------------- */
+
+const discovery_es = new EventSource('/api/discover');
+
+discovery_es.onmessage = e => {
+ const peer = JSON.parse(e.data);
+ const key = `${peer.addr}:${peer.name}`;
+ known_peers.set(key, peer);
+ render_node_list();
+};
+
+discovery_es.addEventListener('discovery_error', e => {
+ try {
+ const { error } = JSON.parse(e.data);
+ toast(`discovery: ${error}`, 'err');
+ } catch {}
+});
+
+function render_node_list() {
+ const list = by_id('node-list');
+ if (known_peers.size === 0) {
+ list.replaceChildren(empty_el('Discovering…'));
+ return;
+ }
+ list.replaceChildren(...[...known_peers.values()].map(make_node_item));
+}
+
+function make_node_item(peer) {
+ const key = `${peer.addr}:${peer.name}`;
+ const el = clone('t-node-item');
+ if (selected_peer && `${selected_peer.addr}:${selected_peer.name}` === key) {
+ el.classList.add('selected');
+ }
+ qs('.n-name', el).textContent = peer.name;
+ qs('.n-addr', el).textContent = `${peer.addr}:${peer.tcp_port}`;
+ el.addEventListener('click', () => select_node(peer));
+ return el;
+}
+
+async function select_node(peer) {
+ try {
+ await api('POST', '/api/connect', { host: peer.addr, port: peer.tcp_port });
+ selected_peer = peer;
+ selected_device_idx = null;
+ device_data = null;
+ render_node_list();
+ await refresh_status();
+ toast(`connected to ${peer.name}`, 'ok');
+ } catch (err) {
+ toast(err.message, 'err');
+ }
+}
+
+/* -------------------------------------------------------------------------
+ * Status
+ * ------------------------------------------------------------------------- */
+
+async function refresh_status() {
+ try {
+ const s = await api('GET', '/api/status');
+ by_id('status-dot').className = s.connected ? 'ok' : '';
+ by_id('status-text').textContent = s.connected ? `${s.host}:${s.port}` : 'not connected';
+ if (s.connected) {
+ show(by_id('btn-disconnect'));
+ await refresh_devices();
+ } else {
+ hide(by_id('btn-disconnect'));
+ show_empty_devices();
+ }
+ } catch (err) {
+ console.error(err);
+ }
+}
+
+/* -------------------------------------------------------------------------
+ * Device panel
+ * ------------------------------------------------------------------------- */
+
+function show_empty_devices(msg = 'Select a node') {
+ by_id('device-list').replaceChildren(empty_el(msg));
+}
+
+async function refresh_devices() {
+ try {
+ device_data = await api('GET', '/api/devices');
+ render_device_list();
+ } catch (err) {
+ toast(err.message, 'err');
+ }
+}
+
+function render_device_list() {
+ const list = by_id('device-list');
+
+ if (!device_data) {
+ list.replaceChildren(empty_el('Select a node'));
+ return;
+ }
+
+ let flat_idx = 0;
+ const children = [];
+ const { media, standalone } = device_data;
+
+ for (const md of media) {
+ const group = clone('t-device-group');
+ qs('.device-group-header', group).textContent = `${md.model} (${md.driver})`;
+ for (const vn of md.video_nodes) {
+ group.appendChild(make_device_item(flat_idx++, vn.path,
+ vn.entity_name, vn.device_caps, vn.is_capture));
+ }
+ children.push(group);
+ }
+
+ if (standalone.length > 0) {
+ const group = clone('t-device-group');
+ qs('.device-group-header', group).textContent = 'Standalone';
+ for (const sd of standalone) {
+ group.appendChild(make_device_item(flat_idx++, sd.path, sd.name, 0, false));
+ }
+ children.push(group);
+ }
+
+ if (flat_idx === 0) {
+ list.replaceChildren(empty_el('No devices found'));
+ return;
+ }
+
+ list.replaceChildren(...children);
+
+ if (selected_device_idx !== null) {
+ const item = list.querySelector(`[data-idx="${selected_device_idx}"]`);
+ if (item) { item.classList.add('selected'); }
+ }
+}
+
+function make_device_item(idx, path, label, device_caps, is_capture) {
+ const el = clone('t-device-item');
+ el.dataset.idx = idx;
+
+ const path_el = qs('.d-path', el);
+ path_el.textContent = path;
+ if (is_capture) { path_el.appendChild(clone('t-capture-badge')); }
+
+ qs('.d-meta', el).textContent = label;
+
+ const caps_text = caps_tags(device_caps);
+ if (caps_text) {
+ const caps_el = document.createElement('div');
+ caps_el.className = 'd-caps';
+ caps_el.textContent = caps_text;
+ el.appendChild(caps_el);
+ }
+
+ el.addEventListener('click', () => select_device(idx, path));
+ return el;
+}
+
+async function select_device(idx, path) {
+ selected_device_idx = idx;
+ document.querySelectorAll('.device-item').forEach(el => {
+ el.classList.toggle('selected', parseInt(el.dataset.idx) === idx);
+ });
+ await load_controls(idx, path);
+}
+
+/* -------------------------------------------------------------------------
+ * Controls panel
+ * ------------------------------------------------------------------------- */
+
+function show_empty_controls(msg = 'Select a device') {
+ by_id('controls-title').textContent = 'Controls';
+ by_id('controls-scroll').replaceChildren(empty_el(msg));
+}
+
+async function load_controls(device_idx, device_path) {
+ by_id('controls-title').textContent = `Controls — ${device_path}`;
+ by_id('controls-scroll').replaceChildren(empty_el('Loading…'));
+ try {
+ const result = await api('GET', `/api/devices/${device_idx}/controls`);
+ render_controls(device_idx, result.controls ?? []);
+ } catch (err) {
+ toast(err.message, 'err');
+ show_empty_controls('Failed to load controls');
+ }
+}
+
function render_controls(device_idx, controls) {
- const scroll = $('controls-scroll');
- scroll.innerHTML = '';
+ const scroll = by_id('controls-scroll');
if (controls.length === 0) {
- scroll.innerHTML = 'No controls
';
+ scroll.replaceChildren(empty_el('No controls'));
return;
}
@@ -372,42 +287,32 @@ function render_controls(device_idx, controls) {
groups.get(cls).push(ctrl);
}
+ const children = [];
for (const [cls_name, ctrls] of groups) {
- const group_el = document.createElement('div');
- group_el.className = 'ctrl-group';
-
- const title = document.createElement('div');
- title.className = 'ctrl-group-title';
- title.textContent = cls_name;
- group_el.appendChild(title);
-
+ const group_el = clone('t-ctrl-group');
+ qs('.ctrl-group-title', group_el).textContent = cls_name;
for (const ctrl of ctrls) {
const row = make_ctrl_row(device_idx, ctrl);
if (row) { group_el.appendChild(row); }
}
- scroll.appendChild(group_el);
+ children.push(group_el);
}
+ scroll.replaceChildren(...children);
}
function make_ctrl_row(device_idx, ctrl) {
const disabled = !!(ctrl.flags & FLAG_DISABLED);
const read_only = !!(ctrl.flags & (FLAG_READ_ONLY | FLAG_GRABBED));
- const row = document.createElement('div');
- row.className = 'ctrl-row'
- + (disabled ? ' disabled' : '')
- + (read_only ? ' readonly' : '');
+ const row = clone('t-ctrl-row');
+ if (disabled) { row.classList.add('disabled'); }
+ if (read_only) { row.classList.add('readonly'); }
+
+ const label_el = qs('.ctrl-label', row);
+ const input_wrap = qs('.ctrl-input', row);
+ const value_el = qs('.ctrl-value-display', row);
- const label_el = document.createElement('div');
- label_el.className = 'ctrl-label';
label_el.textContent = ctrl.name;
- row.appendChild(label_el);
-
- const input_wrap = document.createElement('div');
- input_wrap.className = 'ctrl-input';
-
- const value_el = document.createElement('div');
- value_el.className = 'ctrl-value-display';
let input_el = null;
let get_value = null;
@@ -415,18 +320,17 @@ function make_ctrl_row(device_idx, ctrl) {
switch (ctrl.type) {
case CTRL_BOOLEAN: {
input_el = document.createElement('input');
- input_el.type = 'checkbox';
+ input_el.type = 'checkbox';
input_el.checked = !!ctrl.current_val;
get_value = () => input_el.checked ? 1 : 0;
- value_el.textContent = '';
break;
}
case CTRL_MENU:
case CTRL_INTEGER_MENU: {
input_el = document.createElement('select');
for (const item of ctrl.menu_items) {
- const opt = document.createElement('option');
- opt.value = item.index;
+ const opt = document.createElement('option');
+ opt.value = item.index;
opt.textContent = ctrl.type === CTRL_INTEGER_MENU
? item.int_value.toString()
: item.name;
@@ -434,31 +338,25 @@ function make_ctrl_row(device_idx, ctrl) {
input_el.appendChild(opt);
}
get_value = () => parseInt(input_el.value);
- value_el.textContent = '';
break;
}
case CTRL_BUTTON: {
- input_el = document.createElement('button');
+ input_el = document.createElement('button');
input_el.textContent = ctrl.name;
- input_el.style.width = '100%';
- get_value = () => 1;
- value_el.textContent = '';
+ get_value = () => 1;
break;
}
case CTRL_INTEGER:
default: {
- const range = document.createElement('input');
- range.type = 'range';
- range.min = ctrl.min;
- range.max = ctrl.max;
- range.step = ctrl.step || 1;
- range.value = ctrl.current_val;
+ input_el = document.createElement('input');
+ input_el.type = 'range';
+ input_el.min = ctrl.min;
+ input_el.max = ctrl.max;
+ input_el.step = ctrl.step || 1;
+ input_el.value = ctrl.current_val;
value_el.textContent = ctrl.current_val;
- range.addEventListener('input', () => {
- value_el.textContent = range.value;
- });
- input_el = range;
- get_value = () => parseInt(range.value);
+ input_el.addEventListener('input', () => { value_el.textContent = input_el.value; });
+ get_value = () => parseInt(input_el.value);
break;
}
}
@@ -466,10 +364,7 @@ function make_ctrl_row(device_idx, ctrl) {
if (!input_el) { return null; }
input_wrap.appendChild(input_el);
- row.appendChild(input_wrap);
- row.appendChild(value_el);
- /* send SET_CONTROL on change/click */
if (!read_only && !disabled) {
const send = async () => {
try {
@@ -487,15 +382,51 @@ function make_ctrl_row(device_idx, ctrl) {
|| ctrl.type === CTRL_INTEGER_MENU) {
input_el.addEventListener('change', send);
} else {
- /* slider: send on pointerup so we don't flood the node */
input_el.addEventListener('pointerup', send);
input_el.addEventListener('keyup', send);
+ input_el.addEventListener('input', () => {
+ if (by_id('chk-continuous').checked) { send(); }
+ });
}
}
return row;
}
+/* -------------------------------------------------------------------------
+ * Event wiring
+ * ------------------------------------------------------------------------- */
+
+by_id('btn-connect').addEventListener('click', async () => {
+ const host = by_id('inp-host').value.trim();
+ const port = by_id('inp-port').value.trim();
+ if (!host || !port) { toast('enter host and port', 'err'); return; }
+ try {
+ await api('POST', '/api/connect', { host, port: parseInt(port) });
+ selected_peer = null;
+ selected_device_idx = null;
+ device_data = null;
+ render_node_list();
+ await refresh_status();
+ toast('connected', 'ok');
+ } catch (err) {
+ toast(err.message, 'err');
+ }
+});
+
+by_id('btn-disconnect').addEventListener('click', async () => {
+ await api('POST', '/api/disconnect');
+ selected_peer = null;
+ selected_device_idx = null;
+ device_data = null;
+ render_node_list();
+ show_empty_devices();
+ show_empty_controls();
+ await refresh_status();
+});
+
+by_id('btn-refresh-devices').addEventListener('click', refresh_devices);
+
/* -------------------------------------------------------------------------
* Init
* ------------------------------------------------------------------------- */
diff --git a/dev/web/public/index.html b/dev/web/public/index.html
index f1e0065..b88ec63 100644
--- a/dev/web/public/index.html
+++ b/dev/web/public/index.html
@@ -4,317 +4,45 @@
Video Node Inspector
-
+
Video Node Inspector
-
disconnected
-
-
-
-
-
-
-
-
-
-
+ not connected
+
-
-
- Devices
-
-
-
-
Not connected
+
-
-
Controls
+
+
+
+ Controls
+
+
@@ -323,6 +51,44 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ capture
+
+
+
+
+
+
+
+
+
+
+