Files
lgtv_audio_cap/frontend/js/app.js
T
Rene KievitsandClaude Opus 5 f0f68a1aa7 Tolerate space-padded pactl output, and show the running app version
The device-source parser assumed pactl list short sources is strictly
tab-separated, true for stock PulseAudio but not guaranteed for a
TV's own heavily customized audio stack (this one names sources
tpcm_output/tpmedia/tptts/... — clearly not vanilla). A different
separator would have silently produced zero parsed sources with no
error, and the picker's own `when` guard would then just hide the row
entirely rather than show anything broken. Split on any whitespace
run instead of a literal tab; source names never contain embedded
whitespace, so this is strictly more permissive with no new failure
mode. Confirmed end to end on real hardware: tptts.monitor lit up
during the accessibility voice guide and reached HyperHDR.

Also: the System panel now shows the app's actual running version,
read from a <meta> tag substituted at package time (tools/build.sh
stage()) from frontend/appinfo.json — not hand-maintained, so it can't
drift from what was actually built. Requested after a version bump
alone wasn't enough to tell whether a reinstall had truly picked up
new files versus served something cached along the way; this settles
that question by inspection instead of by inference. Bumped to 1.0.2.

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

907 lines
29 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.',
},
],
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('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.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);