Files
lgtv_audio_cap/frontend/js/app.js
T
Rene KievitsandClaude Opus 5 aae5a33283 Add a brightness-only sink: keep the grabber's colour, pulse with sound
Every existing HyperHDR route replaces whatever else is on the LEDs:
routes 1/2 hand HyperHDR's own audio effect a device to read, route 3
sends a synthetic spectrum image, and both take over via HyperHDR's
priority system. For a setup that already has a real colour source
(a screen grabber, a USB capture card) feeding an ambilight-style LED
run, none of that is what's wanted -- the colour should stay put and
only brightness should react.

Read HyperHDR's own source (sources/api/JSONRPC_schema/schema-adjustment.json)
rather than guess: "adjustment" is a post-processing command with a
scaleOutput parameter (0-2.0) that applies regardless of which
priority is currently active. Confirmed the wire format too --
sources/jsonserver/JsonClientConnection.cpp frames it as plain
newline-delimited JSON over TCP (default port 19444), nothing like the
length-prefixed Flatbuffers protocol the visualiser sink speaks, and
with no handshake or registration needed before the first write.

sink_hyperhdr_adjust.c sends only that: no image, no priority, so it
never competes with an existing grabber. Non-blocking connect with the
same poll()+SO_ERROR pattern net/hyperion.c already uses, reconnects
every 5s, rate-limited to 20 Hz (a HyperHDR command every audio block
would be pointless flooding), and resets scaleOutput to 1.0 on close
rather than leaving the LEDs stuck at whatever it last sent. Cross-
compiles clean under -Wall -Wextra on the real webOS toolchain.

Wired through the same path every other sink follows: registered in
sink.c/sink.h, defaults in config.c, fields in frontend/js/app.js
(SINK_FIELDS/SINK_HELP), mock.js and ui_smoke.js updated for the new
sink card. Bumped to 1.0.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:36:31 +02:00

941 lines
30 KiB
JavaScript

// Wiring: load the settings, draw the panels, subscribe to the status feed.
//
// The service owns the settings; this file never keeps a second copy of the
// truth. Every edit goes out as a patch and the reply is what updates `state`.
(function (global) {
'use strict';
var SERVICE_ID = 'org.webosbrew.audiocap.service';
var SERVICE_DIR = '/media/developer/apps/usr/palm/services/' + SERVICE_ID;
var BOOT_SCRIPT = SERVICE_DIR + '/audiocapautostart';
var BOOT_LINK = '/var/lib/webosbrew/init.d/audiocapautostart';
var ELEVATE = '/media/developer/apps/usr/palm/services/'
+ 'org.webosbrew.hbchannel.service/elevate-service';
// Substituted into index.html at package time (tools/build.sh stage()).
// Unstaged — opened straight from the source tree, e.g. npm run serve —
// it is still the literal placeholder, which is exactly the tell that
// this isn't a packaged build.
function readAppVersion() {
var meta = document.querySelector('meta[name="app-version"]');
var content = meta && meta.getAttribute('content');
return content && content.indexOf('__') !== 0 ? content : 'dev build';
}
var state = {
settings: {},
status: null,
backends: [],
sinkDefs: [],
configPath: '',
persistent: true,
bootLinked: false,
diagnostics: null,
appVersion: readAppVersion(),
};
var statusSub = null;
var saveTimer = null;
var pendingPatch = null;
var toastTimer = null;
// --- small helpers --------------------------------------------------------
function $(id) {
return document.getElementById(id);
}
function merge(base, patch) {
Object.keys(patch).forEach(function (k) {
var v = patch[k];
if (v && typeof v === 'object' && !Array.isArray(v)
&& base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) {
merge(base[k], v);
} else {
base[k] = v;
}
});
return base;
}
function toast(message, bad) {
var node = $('toast');
node.textContent = message;
node.classList.toggle('bad', !!bad);
node.classList.remove('hidden');
if (toastTimer) {
clearTimeout(toastTimer);
}
toastTimer = setTimeout(function () {
node.classList.add('hidden');
}, bad ? 6000 : 3000);
}
function fail(message) {
toast(message, true);
}
function duration(ms) {
if (!ms) {
return '';
}
var total = Math.floor(ms / 1000);
var h = Math.floor(total / 3600);
var m = Math.floor((total % 3600) / 60);
var s = total % 60;
function pad(n) {
return n < 10 ? '0' + n : String(n);
}
return h ? h + ':' + pad(m) + ':' + pad(s) : m + ':' + pad(s);
}
// --- saving ---------------------------------------------------------------
// Edits are coalesced: holding Enter on a choice fires a change per press and
// there is no reason to write the settings file that often.
function setSetting(path, value) {
var patch = UI.patchFor(path, value);
merge(state.settings, patch);
pendingPatch = pendingPatch ? merge(pendingPatch, patch) : patch;
if (saveTimer) {
clearTimeout(saveTimer);
}
saveTimer = setTimeout(flush, 400);
}
function flush() {
saveTimer = null;
if (!pendingPatch) {
return;
}
var patch = pendingPatch;
pendingPatch = null;
Luna.setConfig(patch, function (reply) {
if (reply.settings) {
state.settings = reply.settings;
}
if (!reply.saved) {
toast('Saved to /tmp only — settings will be lost on reboot', true);
} else if (reply.restartRequired) {
toast('Restart the capture to apply');
}
}, fail);
}
// --- field building -------------------------------------------------------
// `specs` is a list of { path, label, hint, type, options, wide, when,
// rebuild }. `rebuild` marks a field whose value changes which other fields
// are shown, so the panel is redrawn after it changes.
function buildFields(container, specs, redraw) {
var focusedPath = document.activeElement
&& document.activeElement.getAttribute
&& document.activeElement.getAttribute('data-path');
UI.clear(container);
specs.forEach(function (spec) {
if (spec.when && !spec.when(state.settings)) {
return;
}
var value = UI.get(state.settings, spec.path);
var control;
function changed(v) {
setSetting(spec.path, v);
if (spec.rebuild && redraw) {
redraw();
}
}
if (spec.type === 'toggle') {
control = UI.toggle(!!value, changed);
} else if (spec.type === 'choice') {
var options = typeof spec.options === 'function' ? spec.options() : spec.options;
control = UI.choice(options, value, changed);
} else {
control = UI.text(value, changed, {
numeric: spec.type === 'number',
placeholder: spec.placeholder,
});
if (spec.wide) {
control.classList.add('wide');
}
}
control.setAttribute('data-path', spec.path);
container.appendChild(UI.row(spec.label, spec.hint, control));
});
if (focusedPath) {
var again = container.querySelector('[data-path="' + focusedPath + '"]');
if (again) {
again.focus();
}
}
}
// --- capture panel --------------------------------------------------------
function backendOptions() {
var out = [{ value: 'auto', label: 'Automatic' }];
state.backends.forEach(function (b) {
out.push({
value: b.id,
label: b.available === false ? b.name + ' (unavailable)' : b.name,
});
});
return out;
}
// Which backends a field applies to. "auto" has to be named explicitly:
// automatic only ever picks PulseAudio or ALSA, so the exec-only fields stay
// hidden until the user asks for that backend by name.
function backendIs(list) {
return function (s) {
return list.indexOf(s.capture && s.capture.backend) >= 0;
};
}
// Typing a PulseAudio source name blind, off a diagnostics dump you can
// only read on the TV itself, is exactly the kind of thing a D-pad picker
// exists for. Parsed from the same "pactl list short sources" text that
// System > Run diagnostics already fetches — nothing new to ask the
// service for. Stock PulseAudio tab-separates columns (index, name,
// driver, sample_spec, state), but split on any whitespace run rather than
// a literal tab: a TV's own pactl-alike is free to pad with spaces
// instead, and source names never contain embedded whitespace themselves.
function pulseSourceOptions() {
var diag = state.diagnostics && state.diagnostics.system;
var text = diag && diag.pactlSources;
var out = [{ value: '', label: 'Automatic (@DEFAULT_MONITOR@)' }];
if (!text) {
return out;
}
text.split('\n').forEach(function (line) {
var cols = line.trim().split(/\s+/);
var name = cols[1];
if (!name) {
return;
}
var running = cols[4] ? ' — ' + cols[4] : '';
out.push({ value: name, label: name + running });
});
return out;
}
// alsaCapturePcms lines look like "00-01: ALC1220 Analog : ... : capture 1"
// — "00-01" is card 0, device 1, so hw:0,1. Best-effort: a line that does
// not start with that pattern is skipped rather than guessed at.
function alsaDeviceOptions() {
var diag = state.diagnostics && state.diagnostics.system;
var lines = (diag && diag.alsaCapturePcms) || [];
var out = [{ value: '', label: 'Automatic (default)' }];
lines.forEach(function (line) {
var m = /^(\d+)-(\d+):\s*(.*)$/.exec(line);
if (!m) {
return;
}
var hw = 'hw:' + parseInt(m[1], 10) + ',' + parseInt(m[2], 10);
out.push({ value: hw, label: hw + ' — ' + m[3] });
});
return out;
}
var CAPTURE_FIELDS = [
{
path: 'capture.backend', label: 'Backend', type: 'choice',
options: backendOptions, rebuild: true,
hint: 'Automatic tries PulseAudio, then ALSA.',
},
{
path: 'capture.device', label: 'Device', type: 'text', wide: true,
when: backendIs(['auto', 'pulse', 'alsa']),
placeholder: 'blank = default monitor',
hint: 'PulseAudio source name, or an ALSA PCM such as hw:0,0. '
+ 'Run diagnostics, then use the picker below instead of typing.',
},
{
path: 'capture.device', label: 'Pick a discovered source', type: 'choice',
rebuild: true, wide: true,
when: function (s) {
return backendIs(['auto', 'pulse'])(s) && pulseSourceOptions().length > 1;
},
options: pulseSourceOptions,
hint: 'From the last diagnostics run. Press Enter to cycle through '
+ 'every source this TV reported; picking one fills the Device field above.',
},
{
path: 'capture.device', label: 'Pick a discovered device', type: 'choice',
rebuild: true, wide: true,
when: function (s) {
return backendIs(['alsa'])(s) && alsaDeviceOptions().length > 1;
},
options: alsaDeviceOptions,
hint: 'From the last diagnostics run.',
},
{
path: 'capture.server', label: 'PulseAudio server', type: 'text', wide: true,
when: backendIs(['auto', 'pulse']),
placeholder: 'blank = autodetect',
hint: 'Usually left blank. Example: unix:/var/run/pulse/native',
},
{
path: 'capture.command', label: 'Command', type: 'text', wide: true,
when: backendIs(['exec']),
placeholder: 'parec --format=s16le --rate=48000 --channels=2',
hint: 'Must write raw interleaved S16LE at the rate and channel count below.',
},
{
path: 'capture.rate', label: 'Sample rate', type: 'choice',
options: [
{ value: 44100, label: '44100 Hz' },
{ value: 48000, label: '48000 Hz' },
],
hint: 'The TV mixes at 48 kHz; anything else costs a resample.',
},
{
path: 'capture.channels', label: 'Channels', type: 'choice',
options: [
{ value: 1, label: 'Mono' },
{ value: 2, label: 'Stereo' },
],
},
];
var DSP_FIELDS = [
{
path: 'dsp.attack', label: 'Attack', type: 'number',
hint: 'Seconds to catch a rising level. 0.6 is a slow, calm meter.',
},
{
path: 'dsp.release', label: 'Release', type: 'number',
hint: 'Seconds to fall away after a peak.',
},
];
function renderCapture() {
buildFields($('capture-fields'), CAPTURE_FIELDS, renderCapture);
buildFields($('dsp-fields'), DSP_FIELDS, renderCapture);
}
// --- sinks panel ----------------------------------------------------------
var SINK_FIELDS = {
hyperhdr: [
{
path: 'hyperhdr.host', label: 'Receiver address', type: 'text', wide: true,
placeholder: '192.168.1.50',
hint: 'The machine running HyperHDR and the receiver script.',
},
{ path: 'hyperhdr.port', label: 'UDP port', type: 'number' },
{
path: 'hyperhdr.multicast', label: 'Multicast', type: 'toggle', rebuild: true,
hint: 'Send to a group address instead of one host, so several '
+ 'machines can listen.',
},
{
path: 'hyperhdr.multicastTtl', label: 'Multicast TTL', type: 'number',
when: function (s) { return !!(s.hyperhdr && s.hyperhdr.multicast); },
hint: '1 keeps it on this subnet.',
},
{
path: 'hyperhdr.sapAnnounce', label: 'Announce over SAP', type: 'toggle',
hint: 'Lets PulseAudio find the stream on its own '
+ '(module-rtp-recv, no manual SDP).',
},
],
hyperhdrViz: [
{
path: 'hyperhdrViz.host', label: 'HyperHDR address', type: 'text', wide: true,
placeholder: '192.168.1.50',
},
{
path: 'hyperhdrViz.port', label: 'Flatbuffers port', type: 'number',
hint: 'HyperHDR listens on 19400 by default.',
},
{
path: 'hyperhdrViz.mode', label: 'Style', type: 'choice',
options: [
{ value: 'spectrum', label: 'Spectrum' },
{ value: 'level', label: 'Level bar' },
{ value: 'pulse', label: 'Pulse' },
],
},
{ path: 'hyperhdrViz.width', label: 'Image width', type: 'number' },
{ path: 'hyperhdrViz.height', label: 'Image height', type: 'number' },
{
path: 'hyperhdrViz.fps', label: 'Frames per second', type: 'number',
hint: 'Above 30 buys nothing and costs the TV.',
},
{
path: 'hyperhdrViz.priority', label: 'Priority', type: 'number',
hint: 'Lower wins in HyperHDR. Keep it above your capture source '
+ 'unless you want this to take over.',
},
{ path: 'hyperhdrViz.saturation', label: 'Saturation', type: 'number' },
{
path: 'hyperhdrViz.minBrightness', label: 'Minimum brightness', type: 'number',
hint: '0 lets the lights go fully dark between beats.',
},
],
hyperhdrAdjust: [
{
path: 'hyperhdrAdjust.host', label: 'HyperHDR address', type: 'text', wide: true,
placeholder: '192.168.1.50',
},
{
path: 'hyperhdrAdjust.port', label: 'JSON-RPC port', type: 'number',
hint: 'HyperHDR\'s classic control port, 19444 by default. Not the '
+ 'web UI port (8090) or the Flatbuffers port (19400).',
},
{
path: 'hyperhdrAdjust.level', label: 'Follows', type: 'choice',
options: [
{ value: 'rms', label: 'Average level (steadier)' },
{ value: 'peak', label: 'Peak level (punchier)' },
],
},
{
path: 'hyperhdrAdjust.minScale', label: 'Minimum brightness', type: 'number',
hint: '1.0 is HyperHDR\'s normal brightness. Below that dims during quiet parts.',
},
{
path: 'hyperhdrAdjust.maxScale', label: 'Maximum brightness', type: 'number',
hint: 'Above 1.0 boosts past normal on loud peaks. HyperHDR accepts up to 2.0.',
},
],
udp: [
{
path: 'udp.host', label: 'Destination', type: 'text', wide: true,
placeholder: '192.168.1.50',
hint: 'A host, a multicast group, or 255.255.255.255 to broadcast.',
},
{ path: 'udp.port', label: 'Port', type: 'number' },
{ path: 'udp.multicastTtl', label: 'Multicast TTL', type: 'number' },
],
tcp: [
{
path: 'tcp.port', label: 'Listen port', type: 'number',
hint: 'The TV listens; connect to it to pull the audio.',
},
{ path: 'tcp.maxClients', label: 'Maximum clients', type: 'number' },
],
http: [
{ path: 'http.port', label: 'Listen port', type: 'number' },
{ path: 'http.maxClients', label: 'Maximum clients', type: 'number' },
],
};
var SINK_HELP = {
hyperhdr: 'Run host/lgtv-audiocap-receiver.py on the HyperHDR machine. It '
+ 'turns this stream into a sound device HyperHDR can listen to, which is '
+ 'the closest thing to a real audio input HyperHDR has.',
hyperhdrViz: 'No host setup at all: the TV does the analysis and sends '
+ 'finished images over the Flatbuffers port. Use it when you cannot add '
+ 'a sound device on the HyperHDR machine.',
hyperhdrAdjust: 'For an existing ambilight/grabber setup: leaves colour '
+ 'entirely to whatever HyperHDR is already showing, and only turns its '
+ 'overall brightness up and down with the sound. Sends no image, so it '
+ 'never competes for priority with a grabber.',
udp: 'Raw interleaved S16LE, no header, no framing. Lowest latency and no '
+ 'connection to lose.',
tcp: 'Raw interleaved S16LE over a stream. Reliable, at the cost of '
+ 'latency when the network stalls.',
http: 'Point VLC at http://<tv>:<port>/audio.wav.',
};
function sinkEnabled(id) {
var list = state.settings.sinks || [];
return list.indexOf(id) >= 0;
}
function setSinkEnabled(id, on) {
var list = (state.settings.sinks || []).slice();
var at = list.indexOf(id);
if (on && at < 0) {
list.push(id);
} else if (!on && at >= 0) {
list.splice(at, 1);
}
setSetting('sinks', list);
}
function renderSinks() {
var host = $('sink-cards');
var focusedId = document.activeElement
&& document.activeElement.getAttribute
&& document.activeElement.getAttribute('data-sink');
UI.clear(host);
state.sinkDefs.forEach(function (def) {
var on = sinkEnabled(def.id);
var card = UI.el('div', 'card sink-card' + (on ? '' : ' off'));
var head = UI.el('div', 'sink-head');
head.appendChild(UI.el('h2', null, def.name || def.id));
if (def.id === 'hyperhdr') {
head.appendChild(UI.el('span', 'badge', 'Recommended'));
}
var holder = UI.el('div', 'field-control');
var sw = UI.toggle(on, function (v) {
setSinkEnabled(def.id, v);
renderSinks();
});
sw.setAttribute('data-sink', def.id);
holder.appendChild(sw);
head.appendChild(holder);
card.appendChild(head);
var body = UI.el('div', 'sink-body');
body.appendChild(UI.el('p', 'blurb', SINK_HELP[def.id] || def.description || ''));
buildFields(body, SINK_FIELDS[def.id] || [], renderSinks);
card.appendChild(body);
host.appendChild(card);
});
if (focusedId) {
var again = host.querySelector('[data-sink="' + focusedId + '"]');
if (again) {
again.focus();
}
}
}
// --- system panel ---------------------------------------------------------
var SYSTEM_FIELDS = [
{
path: 'logLevel', label: 'Log level', type: 'choice',
options: [
{ value: 'error', label: 'Errors only' },
{ value: 'warn', label: 'Warnings' },
{ value: 'info', label: 'Info' },
{ value: 'debug', label: 'Debug' },
],
},
];
function renderSystem() {
var host = $('system-fields');
UI.clear(host);
// Two separate things wear one switch: the boot script that launches the
// service, and the setting that tells the service to start capturing.
// Splitting them would only invite the half-on state where the service
// wakes up at boot and then sits there doing nothing.
var boot = UI.toggle(state.settings.autoStart && state.bootLinked, function (v) {
setSetting('autoStart', v);
setBootLink(v);
});
boot.setAttribute('data-path', 'autoStart');
host.appendChild(UI.row(
'Start on boot',
'Installs a Homebrew Channel startup script and starts capturing '
+ 'as soon as the TV comes up.',
boot
));
// The TV's audio devices are root-only. The Homebrew Channel ships the
// tool that grants a service root, but it has to be asked.
var diag = state.diagnostics && state.diagnostics.system;
var rooted = diag && diag.root;
var elevate = UI.button(rooted ? 'Re-apply' : 'Grant root access', grantRoot,
rooted ? null : 'primary');
elevate.setAttribute('data-path', 'elevate');
host.appendChild(UI.row(
'Root access',
diag
? (rooted
? 'The service is running as root.'
: 'The service is running as uid ' + diag.uid + ' and will not be '
+ 'able to open the TV\'s audio devices. Grant it root, then it '
+ 'restarts by itself.')
: 'Checking…',
elevate
));
var fields = UI.el('div');
host.appendChild(fields);
buildFields(fields, SYSTEM_FIELDS, renderSystem);
var info = $('config-path');
UI.clear(info);
info.appendChild(infoItem('App version', state.appVersion));
info.appendChild(infoItem('Path', state.configPath || '—'));
info.appendChild(infoItem('Storage', state.persistent
? 'Persistent' : 'Temporary (/tmp)'));
info.appendChild(infoItem('Boot script', state.bootLinked
? 'Installed' : 'Not installed'));
}
// Elevation only takes effect on a fresh process, so the service is asked to
// quit and is started again by the next call the page makes.
function grantRoot() {
Luna.exec(ELEVATE + ' ' + SERVICE_ID, function () {
toast('Elevated — restarting the service');
Luna.quit(refreshAfterRestart, refreshAfterRestart);
}, function (err) {
fail('Could not elevate the service: ' + err
+ ' — is the Homebrew Channel installed?');
});
}
function refreshAfterRestart() {
setTimeout(function () {
if (statusSub) {
statusSub.cancel();
}
statusSub = Luna.subscribeStatus(renderStatus, fail);
refreshDiagnostics();
}, 1500);
}
function refreshDiagnostics() {
Luna.getDiagnostics(function (reply) {
state.diagnostics = reply;
renderSystem();
}, function () {
// Not fatal: the panel just says "Checking…" until the next attempt.
});
}
function setBootLink(on) {
var command = on
? 'mkdir -p /var/lib/webosbrew/init.d && chmod +x ' + BOOT_SCRIPT
+ ' && ln -sf ' + BOOT_SCRIPT + ' ' + BOOT_LINK
: 'rm -f ' + BOOT_LINK;
Luna.exec(command, function () {
state.bootLinked = on;
renderSystem();
toast(on ? 'Will start with the TV' : 'Boot script removed');
}, function (err) {
state.bootLinked = !on;
renderSystem();
fail('Could not change the boot script: ' + err
+ ' — is the Homebrew Channel installed?');
});
}
function checkBootLink() {
Luna.exec('test -e ' + BOOT_LINK + ' && echo yes || echo no', function (reply) {
state.bootLinked = String(reply.stdoutString || '').indexOf('yes') >= 0;
renderSystem();
}, function () {
// No Homebrew Channel service, or it refused. Leave the switch off
// rather than claiming a boot script that is not there.
state.bootLinked = false;
});
}
// --- status ---------------------------------------------------------------
function infoItem(key, value) {
var item = UI.el('div', 'info-item');
item.appendChild(UI.el('div', 'info-key', key));
var v = UI.el('div', 'info-value', value);
v.title = String(value);
item.appendChild(v);
return item;
}
var bandNodes = [];
function buildBands() {
var host = $('bands');
UI.clear(host);
bandNodes = [];
for (var i = 0; i < 16; i++) {
var b = UI.el('div', 'band');
b.style.height = '3px';
host.appendChild(b);
bandNodes.push(b);
}
}
function setBar(id, value) {
var fill = $(id).firstChild;
fill.style.width = (Math.max(0, Math.min(1, value)) * 100).toFixed(1) + '%';
}
function db(value) {
if (value === undefined || value === null || value <= -89) {
return '−∞ dB';
}
return value.toFixed(1) + ' dB';
}
// Detail line under each sink in the status panel. Every sink reports
// different counters, so pick out the ones worth reading at a glance.
function sinkDetail(s) {
var bits = [];
if (s.target) {
bits.push(s.target + ':' + s.port);
} else if (s.port !== undefined) {
bits.push('port ' + s.port);
}
if (s.clients !== undefined) {
bits.push(s.clients + ' client' + (s.clients === 1 ? '' : 's'));
}
if (s.packetsSent !== undefined) {
bits.push(s.packetsSent.toLocaleString() + ' packets');
}
if (s.framesSent !== undefined) {
bits.push(s.framesSent.toLocaleString() + ' frames');
}
if (s.updatesSent !== undefined) {
bits.push(s.updatesSent.toLocaleString() + ' updates');
}
if (s.connected !== undefined) {
bits.push(s.connected ? 'connected' : 'not connected');
}
if (s.sendErrors) {
bits.push(s.sendErrors + ' send errors');
}
if (s.droppedBytes) {
bits.push(Math.round(s.droppedBytes / 1024) + ' kB dropped');
}
if (s.lastError) {
bits.push(s.lastError);
}
if (s.error) {
bits.push(s.error);
}
return bits.join(' · ');
}
function renderStatus(st) {
state.status = st;
var pill = $('state-pill');
pill.textContent = {
running: 'Running', starting: 'Starting', error: 'Error',
}[st.state] || 'Stopped';
pill.className = 'pill ' + (st.state || 'stopped');
$('power').textContent = st.running ? 'Stop' : 'Start';
$('uptime').textContent = duration(st.capture && st.capture.uptimeMs);
var levels = st.levels || {};
setBar('meter-peak', levels.peak || 0);
setBar('meter-rms', levels.rms || 0);
$('meter-peak-db').textContent = db(levels.peakDb);
$('meter-rms-db').textContent = db(levels.rmsDb);
$('clip').classList.toggle('hidden', !levels.clipping);
var bands = levels.bands || [];
for (var i = 0; i < bandNodes.length; i++) {
var v = Math.max(0, Math.min(1, bands[i] || 0));
bandNodes[i].style.height = Math.max(3, v * 168).toFixed(0) + 'px';
}
var cap = st.capture || {};
var info = $('capture-info');
UI.clear(info);
info.appendChild(infoItem('Backend', cap.backendName || cap.backend || '—'));
info.appendChild(infoItem('Device', cap.device || 'default'));
info.appendChild(infoItem('Format', cap.rate
? cap.rate + ' Hz · ' + (cap.channels === 1 ? 'mono' : 'stereo') : '—'));
info.appendChild(infoItem('Frames', (cap.frames || 0).toLocaleString()));
if (cap.timeouts) {
info.appendChild(infoItem('Read timeouts', cap.timeouts));
}
var sinks = $('sink-status');
UI.clear(sinks);
if (!st.sinks || !st.sinks.length) {
sinks.appendChild(UI.el('div', 'muted', st.running
? 'No outputs enabled.' : 'Not running.'));
} else {
st.sinks.forEach(function (s) {
var line = UI.el('div', 'sink-line');
line.appendChild(UI.el('span', 'dot ' + (s.ok ? 'ok' : 'bad')));
line.appendChild(UI.el('span', 'sink-name', s.name || s.id));
line.appendChild(UI.el('span', 'sink-detail', sinkDetail(s)));
sinks.appendChild(line);
});
}
$('error-card').classList.toggle('hidden', !st.error);
$('error-text').textContent = st.error || '';
}
// --- tabs -----------------------------------------------------------------
var tabs = [];
function activateTab(panelId) {
tabs.forEach(function (t) {
var on = t.getAttribute('data-panel') === panelId;
t.classList.toggle('active', on);
$(t.getAttribute('data-panel')).classList.toggle('hidden', !on);
});
$('content').scrollTop = 0;
}
function currentTab() {
for (var i = 0; i < tabs.length; i++) {
if (tabs[i].classList.contains('active')) {
return i;
}
}
return 0;
}
// --- actions --------------------------------------------------------------
function togglePower() {
var running = state.status && state.status.running;
// Send any settings the user just touched before restarting, so the run
// picks them up instead of the previous values.
if (saveTimer) {
clearTimeout(saveTimer);
flush();
}
if (running) {
Luna.stop(function () { toast('Stopped'); }, fail);
} else {
Luna.start({}, function (reply) {
if (reply.error) {
fail(reply.error);
} else {
toast('Started');
}
}, fail);
}
}
function showOutput(text) {
var out = $('output');
out.textContent = text;
out.classList.remove('hidden');
out.scrollTop = 0;
}
function runDiagnostics() {
Luna.getDiagnostics(function (reply) {
state.diagnostics = reply;
// The Capture panel's device pickers are built from this same reply,
// so refresh it if that's the panel currently open.
renderCapture();
var copy = JSON.parse(JSON.stringify(reply));
delete copy.returnValue;
showOutput(JSON.stringify(copy, null, 2));
}, fail);
}
function loadLogs(clear) {
Luna.getLogs(clear, function (reply) {
showOutput(reply.logs || '(empty)');
if (clear) {
toast('Log cleared');
}
}, fail);
}
// --- boot -----------------------------------------------------------------
function loadConfig(then) {
Luna.getConfig(function (reply) {
state.settings = reply.settings || {};
state.configPath = reply.path || '';
state.persistent = reply.persistent !== false;
if (then) {
then();
}
}, fail);
}
function init() {
buildBands();
tabs = Array.prototype.slice.call(document.querySelectorAll('.tab'));
tabs.forEach(function (t) {
t.addEventListener('click', function () {
activateTab(t.getAttribute('data-panel'));
});
});
activateTab('panel-status');
$('power').addEventListener('click', togglePower);
$('run-diagnostics').addEventListener('click', runDiagnostics);
$('load-logs').addEventListener('click', function () { loadLogs(false); });
$('clear-logs').addEventListener('click', function () { loadLogs(true); });
$('reset-config').addEventListener('click', function () {
Luna.resetConfig(function (reply) {
state.settings = reply.settings || {};
renderCapture();
renderSinks();
renderSystem();
toast('Settings reset');
}, fail);
});
// Back steps to the Status tab first, and only leaves the app from there.
Nav.onBack(function () {
if (currentTab() !== 0) {
activateTab('panel-status');
Nav.focus(tabs[0]);
return true;
}
return false;
});
loadConfig(function () {
Luna.listBackends(function (reply) {
state.backends = reply.backends || [];
renderCapture();
}, fail);
Luna.listSinks(function (reply) {
state.sinkDefs = reply.sinks || [];
renderSinks();
}, fail);
renderCapture();
renderSystem();
checkBootLink();
refreshDiagnostics();
});
statusSub = Luna.subscribeStatus(renderStatus, function (err) {
fail('Lost contact with the service: ' + err);
$('state-pill').textContent = 'No service';
$('state-pill').className = 'pill error';
});
Nav.focus($('power'));
// webOS suspends the page rather than unloading it, so drop the
// subscription on the way out and pick it back up on return.
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
if (statusSub) {
statusSub.cancel();
statusSub = null;
}
} else if (!statusSub) {
statusSub = Luna.subscribeStatus(renderStatus, fail);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
global.App = { state: state };
})(window);