Captures the TV's audio and sends it out over several transports. The
primary one is HyperHDR: RTP/L16 to a host-side loopback device, since
HyperHDR has no network audio input of its own. A second route renders
the spectrum on the TV and sends FlatBuffers images to port 19400
instead, for setups where touching the host's sound config is not an
option.
native/ the service: capture backends (PulseAudio, ALSA, exec,
test tone, all dlopen-based), DSP, and one file per sink
frontend/ D-pad driven UI at a fixed 1920x1080
servicefiles/ native service manifest plus the boot script
host/ RTP receiver and the loopback installer for the HyperHDR
machine
tools/ build/package, asset generation, Homebrew Channel
manifest, on-TV probe
test/ host-side suites: FlatBuffers and RTP verified against
real decoders, the engine end to end, the page in jsdom
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
827 lines
25 KiB
JavaScript
827 lines
25 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';
|
|
|
|
var state = {
|
|
settings: {},
|
|
status: null,
|
|
backends: [],
|
|
sinkDefs: [],
|
|
configPath: '',
|
|
persistent: true,
|
|
bootLinked: false,
|
|
diagnostics: null,
|
|
};
|
|
|
|
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;
|
|
};
|
|
}
|
|
|
|
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 to see what this TV has.',
|
|
},
|
|
{
|
|
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.',
|
|
},
|
|
],
|
|
|
|
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.',
|
|
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('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.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) {
|
|
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);
|