// 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://:/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);