Audio capture and streaming app for webOS 5/6
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>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "org.webosbrew.audiocap",
|
||||
"version": "1.0.0",
|
||||
"vendor": "Homebrew",
|
||||
"type": "web",
|
||||
"main": "index.html",
|
||||
"title": "Audio Cap",
|
||||
"appDescription": "Capture TV audio and stream it to HyperHDR and other receivers",
|
||||
"icon": "assets/icon.png",
|
||||
"largeIcon": "assets/largeIcon.png",
|
||||
"iconColor": "#101820",
|
||||
"splashBackground": "assets/splash.png",
|
||||
"bgImage": "assets/splash.png",
|
||||
"disableBackHistoryAPI": true
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,514 @@
|
||||
/* Designed against a 1920x1080 viewport, which is what webOS reports to an app
|
||||
* on both a 1080p and a 4K panel. Sizes are absolute px for that reason.
|
||||
*
|
||||
* Two rules shape everything here: text is read from the sofa, so nothing is
|
||||
* smaller than 22px; and focus is the only cursor, so the focused element has
|
||||
* to be unmistakable across the room. */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #0b0e13;
|
||||
color: #e8ecf3;
|
||||
font-family: "LG Smart UI", "Museo Sans", "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 26px;
|
||||
line-height: 1.4;
|
||||
/* The remote is the pointer. A caret blinking somewhere off-screen is only
|
||||
* ever confusing. */
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
/* Overscan safe area: older sets crop up to 5% of each edge. */
|
||||
padding: 40px 60px 30px;
|
||||
}
|
||||
|
||||
/* --- header --------------------------------------------------------------- */
|
||||
|
||||
#header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
gap: 30px;
|
||||
padding-bottom: 22px;
|
||||
border-bottom: 2px solid #1d2430;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
font-size: 46px;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #7c8798;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.header-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 8px 24px;
|
||||
border-radius: 999px;
|
||||
background: #1d2430;
|
||||
color: #9aa6b8;
|
||||
font-size: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill.running {
|
||||
background: #123a24;
|
||||
color: #57d98a;
|
||||
}
|
||||
|
||||
.pill.starting {
|
||||
background: #3a3212;
|
||||
color: #e2c657;
|
||||
}
|
||||
|
||||
.pill.error {
|
||||
background: #3d1a1c;
|
||||
color: #ff7b7b;
|
||||
}
|
||||
|
||||
.uptime {
|
||||
color: #7c8798;
|
||||
font-size: 22px;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
/* --- tabs ----------------------------------------------------------------- */
|
||||
|
||||
#tabs {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 14px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 34px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 12px;
|
||||
background: #151b25;
|
||||
color: #9aa6b8;
|
||||
font: inherit;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #1e2a3d;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* --- content -------------------------------------------------------------- */
|
||||
|
||||
#content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 14px;
|
||||
/* Room to scroll the last card clear of the bottom edge. */
|
||||
padding-bottom: 120px;
|
||||
}
|
||||
|
||||
#content::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
#content::-webkit-scrollbar-thumb {
|
||||
border-radius: 5px;
|
||||
background: #2a3444;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 26px 32px;
|
||||
border: 2px solid #1d2430;
|
||||
border-radius: 16px;
|
||||
background: #121722;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 6px;
|
||||
color: #cfd8e6;
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.card.error {
|
||||
border-color: #5a2226;
|
||||
background: #1c1215;
|
||||
color: #ff9d9d;
|
||||
}
|
||||
|
||||
.blurb {
|
||||
margin: 0 0 14px;
|
||||
max-width: 1200px;
|
||||
color: #7c8798;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #7c8798;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* --- fields --------------------------------------------------------------- */
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid #1a212c;
|
||||
}
|
||||
|
||||
.field:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.field-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
color: #7c8798;
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.field-control {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
/* --- controls ------------------------------------------------------------- */
|
||||
|
||||
.btn {
|
||||
min-width: 150px;
|
||||
padding: 12px 30px;
|
||||
border: 2px solid #2c3646;
|
||||
border-radius: 12px;
|
||||
background: #1c2432;
|
||||
color: #e8ecf3;
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: #1f4d76;
|
||||
border-color: #2b6ba6;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
border-color: #5a2226;
|
||||
background: #2a171a;
|
||||
color: #ff9d9d;
|
||||
}
|
||||
|
||||
.btn.toggle {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.btn.toggle.on {
|
||||
border-color: #2f7a4d;
|
||||
background: #143324;
|
||||
color: #6ee29a;
|
||||
}
|
||||
|
||||
.btn.choice {
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 360px;
|
||||
padding: 12px 18px;
|
||||
border: 2px solid #2c3646;
|
||||
border-radius: 12px;
|
||||
background: #0d1119;
|
||||
color: #e8ecf3;
|
||||
font: inherit;
|
||||
/* Text fields are the one place typing happens. */
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.input.wide {
|
||||
width: 620px;
|
||||
}
|
||||
|
||||
/* One focus treatment for every control: a bright ring plus a lift. Visible
|
||||
* from across the room on both an OLED and a dim LCD. */
|
||||
.focusable:focus {
|
||||
outline: none;
|
||||
border-color: #4aa3ff;
|
||||
box-shadow: 0 0 0 4px rgba(74, 163, 255, 0.35);
|
||||
}
|
||||
|
||||
.btn.focusable:focus,
|
||||
.tab.focusable:focus {
|
||||
background: #2b6ba6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn.toggle.on.focusable:focus {
|
||||
background: #2f7a4d;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn.danger.focusable:focus {
|
||||
background: #8a2f36;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* --- meter ---------------------------------------------------------------- */
|
||||
|
||||
.meter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 10px 0 18px;
|
||||
}
|
||||
|
||||
.meter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.meter-label {
|
||||
width: 90px;
|
||||
color: #7c8798;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.meter-db {
|
||||
width: 130px;
|
||||
color: #9aa6b8;
|
||||
font-size: 22px;
|
||||
text-align: right;
|
||||
/* dB numbers jitter constantly; tabular digits stop the label dancing. */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
height: 26px;
|
||||
overflow: hidden;
|
||||
border-radius: 13px;
|
||||
background: #0d1119;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: 13px;
|
||||
background: linear-gradient(90deg, #3aa76d, #7bd44f 70%, #e2c657 88%, #ff5f5f);
|
||||
/* Short enough to feel live at the service's 100 ms notify interval. */
|
||||
transition: width 80ms linear;
|
||||
}
|
||||
|
||||
.bar.rms .bar-fill {
|
||||
background: linear-gradient(90deg, #2b6ba6, #4aa3ff);
|
||||
}
|
||||
|
||||
.bands {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
height: 180px;
|
||||
padding: 12px 0 0;
|
||||
border-top: 1px solid #1a212c;
|
||||
}
|
||||
|
||||
.band {
|
||||
flex: 1;
|
||||
min-height: 3px;
|
||||
border-radius: 5px 5px 2px 2px;
|
||||
background: linear-gradient(180deg, #4aa3ff, #1f4d76);
|
||||
transition: height 80ms linear;
|
||||
}
|
||||
|
||||
.clip {
|
||||
margin-top: 14px;
|
||||
color: #ff7b7b;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
/* --- info grid ------------------------------------------------------------ */
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 18px 30px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-key {
|
||||
color: #7c8798;
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
overflow: hidden;
|
||||
font-size: 27px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- sink status ---------------------------------------------------------- */
|
||||
|
||||
.sink-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.sink-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #4b5666;
|
||||
}
|
||||
|
||||
.dot.ok {
|
||||
background: #57d98a;
|
||||
}
|
||||
|
||||
.dot.bad {
|
||||
background: #ff5f5f;
|
||||
}
|
||||
|
||||
.sink-name {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.sink-detail {
|
||||
color: #7c8798;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
/* --- sink cards ----------------------------------------------------------- */
|
||||
|
||||
#sink-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.sink-card .sink-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.sink-card .sink-head h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sink-card .sink-head .field-control {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sink-card.off .sink-body {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 16px;
|
||||
border-radius: 999px;
|
||||
background: #1f4d76;
|
||||
color: #9fd0ff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* --- output / logs -------------------------------------------------------- */
|
||||
|
||||
.output {
|
||||
max-height: 520px;
|
||||
margin: 18px 0 0;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
border-radius: 12px;
|
||||
background: #0d1119;
|
||||
color: #b8c4d4;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 20px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* --- toast ---------------------------------------------------------------- */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 48px;
|
||||
z-index: 10;
|
||||
padding: 16px 40px;
|
||||
transform: translateX(-50%);
|
||||
border: 2px solid #2c3646;
|
||||
border-radius: 14px;
|
||||
background: #1c2432;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.toast.bad {
|
||||
border-color: #5a2226;
|
||||
background: #2a171a;
|
||||
color: #ff9d9d;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=1920, initial-scale=1">
|
||||
<title>Audio Cap</title>
|
||||
<link rel="stylesheet" href="css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="header">
|
||||
<div class="brand">
|
||||
<h1>Audio Cap</h1>
|
||||
<div class="subtitle">TV audio to HyperHDR and friends</div>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<span id="state-pill" class="pill">Connecting</span>
|
||||
<span id="uptime" class="uptime"></span>
|
||||
</div>
|
||||
<button id="power" type="button" class="focusable btn primary">Start</button>
|
||||
</header>
|
||||
|
||||
<nav id="tabs">
|
||||
<button type="button" class="focusable tab" data-panel="panel-status">Status</button>
|
||||
<button type="button" class="focusable tab" data-panel="panel-sinks">Outputs</button>
|
||||
<button type="button" class="focusable tab" data-panel="panel-capture">Capture</button>
|
||||
<button type="button" class="focusable tab" data-panel="panel-system">System</button>
|
||||
</nav>
|
||||
|
||||
<main id="content">
|
||||
<section id="panel-status" class="panel">
|
||||
<div class="card">
|
||||
<h2>Level</h2>
|
||||
<div class="meter">
|
||||
<div class="meter-row">
|
||||
<span class="meter-label">Peak</span>
|
||||
<div id="meter-peak" class="bar peak"><div class="bar-fill"></div></div>
|
||||
<span id="meter-peak-db" class="meter-db">—</span>
|
||||
</div>
|
||||
<div class="meter-row">
|
||||
<span class="meter-label">RMS</span>
|
||||
<div id="meter-rms" class="bar rms"><div class="bar-fill"></div></div>
|
||||
<span id="meter-rms-db" class="meter-db">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bands" class="bands"></div>
|
||||
<div id="clip" class="clip hidden">Clipping</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Capture</h2>
|
||||
<div id="capture-info" class="info-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Outputs</h2>
|
||||
<div id="sink-status" class="sink-status">
|
||||
<div class="muted">Not running.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error-card" class="card error hidden">
|
||||
<h2>Error</h2>
|
||||
<div id="error-text"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="panel-sinks" class="panel hidden">
|
||||
<div id="sink-cards"></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-capture" class="panel hidden">
|
||||
<div class="card">
|
||||
<h2>Source</h2>
|
||||
<div id="capture-fields"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Level follower</h2>
|
||||
<p class="blurb">
|
||||
How quickly the reported level rises and falls. Lower is faster.
|
||||
</p>
|
||||
<div id="dsp-fields"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="panel-system" class="panel hidden">
|
||||
<div class="card">
|
||||
<h2>Service</h2>
|
||||
<div id="system-fields"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Diagnostics</h2>
|
||||
<p class="blurb">
|
||||
What the service can see on this TV: audio libraries, capture
|
||||
devices and whether it is running as root.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button id="run-diagnostics" type="button" class="focusable btn">Run diagnostics</button>
|
||||
<button id="load-logs" type="button" class="focusable btn">Show log</button>
|
||||
<button id="clear-logs" type="button" class="focusable btn">Clear log</button>
|
||||
</div>
|
||||
<pre id="output" class="output hidden"></pre>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Settings file</h2>
|
||||
<div id="config-path" class="info-grid"></div>
|
||||
<div class="actions">
|
||||
<button id="reset-config" type="button" class="focusable btn danger">Reset to defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
</div>
|
||||
|
||||
<script src="js/mock.js"></script>
|
||||
<script src="js/luna.js"></script>
|
||||
<script src="js/nav.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,826 @@
|
||||
// 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);
|
||||
@@ -0,0 +1,132 @@
|
||||
// Luna bus access.
|
||||
//
|
||||
// Talks to PalmServiceBridge directly rather than pulling in webOSTV.js: it is
|
||||
// the same object webOSTV.js wraps, it is injected into every webOS app, and
|
||||
// it means there is no third-party file to keep in sync.
|
||||
//
|
||||
// Opened in a desktop browser the bridge is absent, so everything falls back
|
||||
// to a small mock. That is what makes the UI developable without a TV.
|
||||
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var SERVICE = 'luna://org.webosbrew.audiocap.service/';
|
||||
var HBCHANNEL = 'luna://org.webosbrew.hbchannel.service/';
|
||||
|
||||
var haveBridge = typeof global.PalmServiceBridge !== 'undefined';
|
||||
|
||||
// A call in flight. `cancel()` tears down a subscription.
|
||||
function Request(bridge) {
|
||||
this.bridge = bridge;
|
||||
this.cancelled = false;
|
||||
}
|
||||
|
||||
Request.prototype.cancel = function () {
|
||||
this.cancelled = true;
|
||||
if (this.bridge && this.bridge.cancel) {
|
||||
this.bridge.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
// Low-level call. `onReply` fires once per reply, so subscriptions keep
|
||||
// calling it until cancelled.
|
||||
function call(uri, params, onReply, onError) {
|
||||
if (!haveBridge) {
|
||||
return global.LunaMock.call(uri, params, onReply, onError);
|
||||
}
|
||||
|
||||
var bridge = new global.PalmServiceBridge();
|
||||
var request = new Request(bridge);
|
||||
|
||||
bridge.onservicecallback = function (raw) {
|
||||
if (request.cancelled) {
|
||||
return;
|
||||
}
|
||||
var reply;
|
||||
try {
|
||||
reply = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
if (onError) {
|
||||
onError('Malformed reply from ' + uri);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Luna reports both its own failures and ours through returnValue.
|
||||
if (reply.returnValue === false) {
|
||||
if (onError) {
|
||||
onError(reply.errorText || reply.errorMessage || 'Call to ' + uri + ' failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (onReply) {
|
||||
onReply(reply);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
bridge.call(uri, JSON.stringify(params || {}));
|
||||
} catch (e) {
|
||||
if (onError) {
|
||||
onError(String(e));
|
||||
}
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
var Luna = {
|
||||
available: haveBridge,
|
||||
|
||||
// --- our service -------------------------------------------------------
|
||||
start: function (patch, ok, fail) {
|
||||
return call(SERVICE + 'start', patch || {}, ok, fail);
|
||||
},
|
||||
stop: function (ok, fail) {
|
||||
return call(SERVICE + 'stop', {}, ok, fail);
|
||||
},
|
||||
subscribeStatus: function (ok, fail) {
|
||||
return call(SERVICE + 'getStatus', { subscribe: true }, ok, fail);
|
||||
},
|
||||
getConfig: function (ok, fail) {
|
||||
return call(SERVICE + 'getConfig', {}, ok, fail);
|
||||
},
|
||||
setConfig: function (patch, ok, fail) {
|
||||
return call(SERVICE + 'setConfig', { settings: patch }, ok, fail);
|
||||
},
|
||||
resetConfig: function (ok, fail) {
|
||||
return call(SERVICE + 'resetConfig', {}, ok, fail);
|
||||
},
|
||||
listBackends: function (ok, fail) {
|
||||
return call(SERVICE + 'listBackends', {}, ok, fail);
|
||||
},
|
||||
listSinks: function (ok, fail) {
|
||||
return call(SERVICE + 'listSinks', {}, ok, fail);
|
||||
},
|
||||
getDiagnostics: function (ok, fail) {
|
||||
return call(SERVICE + 'getDiagnostics', {}, ok, fail);
|
||||
},
|
||||
// Ends the service process. Any later call starts a fresh one, which is
|
||||
// how the service picks up new bus permissions after being elevated.
|
||||
quit: function (ok, fail) {
|
||||
return call(SERVICE + 'quit', {}, ok, fail);
|
||||
},
|
||||
// `clear` empties the ring buffer after reading it, so the reply is the
|
||||
// last thing anyone sees of those lines.
|
||||
getLogs: function (clear, ok, fail) {
|
||||
return call(SERVICE + 'getLogs', { clear: !!clear }, ok, fail);
|
||||
},
|
||||
|
||||
// --- Homebrew Channel --------------------------------------------------
|
||||
// Used for the boot symlink. Needs the Homebrew Channel installed, which
|
||||
// it will be on any TV that can run this app.
|
||||
exec: function (command, ok, fail) {
|
||||
return call(HBCHANNEL + 'exec', { command: command }, ok, fail);
|
||||
},
|
||||
|
||||
// --- system ------------------------------------------------------------
|
||||
getNetworkStatus: function (ok, fail) {
|
||||
return call('luna://com.palm.connectionmanager/getStatus', {}, ok, fail);
|
||||
},
|
||||
};
|
||||
|
||||
global.Luna = Luna;
|
||||
})(window);
|
||||
@@ -0,0 +1,203 @@
|
||||
// Stand-in for the Luna bus so the UI can be opened in a desktop browser.
|
||||
// Only loaded when PalmServiceBridge is missing, which never happens on a TV.
|
||||
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var settings = {
|
||||
autoStart: false,
|
||||
logLevel: 'info',
|
||||
capture: { backend: 'auto', device: '', server: '', command: '', rate: 48000, channels: 2 },
|
||||
dsp: { attack: 0.6, release: 0.12 },
|
||||
sinks: ['hyperhdr'],
|
||||
hyperhdr: { host: '192.168.1.50', port: 5004, multicast: false, multicastTtl: 4, sapAnnounce: true },
|
||||
hyperhdrViz: {
|
||||
host: '', port: 19400, priority: 150, width: 64, height: 36, fps: 30,
|
||||
mode: 'spectrum', saturation: 1.0, minBrightness: 0.02,
|
||||
},
|
||||
udp: { host: '', port: 4010, multicastTtl: 4 },
|
||||
tcp: { port: 4011, maxClients: 4 },
|
||||
http: { port: 4012, maxClients: 4 },
|
||||
};
|
||||
|
||||
var running = false;
|
||||
var subscribers = [];
|
||||
var startedAt = 0;
|
||||
|
||||
function merge(base, patch) {
|
||||
Object.keys(patch).forEach(function (k) {
|
||||
if (patch[k] && typeof patch[k] === 'object' && !Array.isArray(patch[k])
|
||||
&& base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) {
|
||||
merge(base[k], patch[k]);
|
||||
} else {
|
||||
base[k] = patch[k];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function status(subscribed) {
|
||||
var t = Date.now() / 1000;
|
||||
var bands = [];
|
||||
for (var i = 0; i < 16; i++) {
|
||||
var v = running ? Math.abs(Math.sin(t * (1 + i * 0.25) + i)) * (1 - i / 24) : 0;
|
||||
bands.push(Math.max(0, Math.min(1, v)));
|
||||
}
|
||||
var peak = running ? 0.4 + 0.4 * Math.abs(Math.sin(t * 2)) : 0;
|
||||
|
||||
return {
|
||||
returnValue: true,
|
||||
subscribed: !!subscribed,
|
||||
state: running ? 'running' : 'stopped',
|
||||
running: running,
|
||||
error: null,
|
||||
capture: {
|
||||
backend: running ? 'pulse' : null,
|
||||
backendName: running ? 'PulseAudio (mock)' : null,
|
||||
device: '@DEFAULT_MONITOR@',
|
||||
rate: 48000,
|
||||
channels: 2,
|
||||
frames: running ? Math.round((Date.now() - startedAt) * 48) : 0,
|
||||
blocks: running ? Math.round((Date.now() - startedAt) / 10.7) : 0,
|
||||
timeouts: 0,
|
||||
uptimeMs: running ? Date.now() - startedAt : 0,
|
||||
},
|
||||
levels: {
|
||||
peak: peak,
|
||||
rms: peak * 0.6,
|
||||
peakDb: peak > 0 ? 20 * Math.log(peak) / Math.LN10 : -90,
|
||||
rmsDb: peak > 0 ? 20 * Math.log(peak * 0.6) / Math.LN10 : -90,
|
||||
clipping: peak > 0.98,
|
||||
bands: bands,
|
||||
},
|
||||
sinks: running ? settings.sinks.map(function (id) {
|
||||
return { id: id, ok: true, name: id, error: null, target: settings.hyperhdr.host, port: settings.hyperhdr.port, packetsSent: 1234 };
|
||||
}) : [],
|
||||
configPath: '/var/lib/webosbrew/audiocap/config.json',
|
||||
configPersistent: true,
|
||||
};
|
||||
}
|
||||
|
||||
setInterval(function () {
|
||||
subscribers.forEach(function (s) {
|
||||
if (!s.cancelled) {
|
||||
s.onReply(status(true));
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
|
||||
function respond(onReply, payload) {
|
||||
setTimeout(function () { onReply(payload); }, 30);
|
||||
return { cancel: function () {} };
|
||||
}
|
||||
|
||||
var LunaMock = {
|
||||
call: function (uri, params, onReply, onError) {
|
||||
var method = uri.split('/').pop();
|
||||
|
||||
switch (method) {
|
||||
case 'start':
|
||||
if (params && Object.keys(params).length) { merge(settings, params); }
|
||||
running = true;
|
||||
startedAt = Date.now();
|
||||
return respond(onReply, status(false));
|
||||
|
||||
case 'stop':
|
||||
running = false;
|
||||
return respond(onReply, status(false));
|
||||
|
||||
case 'getStatus': {
|
||||
var sub = { cancelled: false, onReply: onReply };
|
||||
if (params && params.subscribe) {
|
||||
subscribers.push(sub);
|
||||
}
|
||||
setTimeout(function () { onReply(status(!!(params && params.subscribe))); }, 30);
|
||||
return { cancel: function () { sub.cancelled = true; } };
|
||||
}
|
||||
|
||||
case 'getConfig':
|
||||
return respond(onReply, {
|
||||
returnValue: true,
|
||||
path: '/var/lib/webosbrew/audiocap/config.json',
|
||||
persistent: true,
|
||||
settings: JSON.parse(JSON.stringify(settings)),
|
||||
});
|
||||
|
||||
case 'setConfig':
|
||||
merge(settings, params.settings || params);
|
||||
return respond(onReply, {
|
||||
returnValue: true, saved: true, restartRequired: running,
|
||||
settings: JSON.parse(JSON.stringify(settings)),
|
||||
});
|
||||
|
||||
case 'listBackends':
|
||||
return respond(onReply, {
|
||||
returnValue: true,
|
||||
backends: [
|
||||
{ id: 'pulse', name: 'PulseAudio', description: 'Records a PulseAudio monitor source.', available: true },
|
||||
{ id: 'alsa', name: 'ALSA', description: 'Records from an ALSA capture PCM.', available: true },
|
||||
{ id: 'exec', name: 'External command', description: 'Reads raw PCM from a command you supply.', available: true },
|
||||
{ id: 'tone', name: 'Test tone', description: 'Synthesised sweep, for testing the transport.', available: true },
|
||||
],
|
||||
});
|
||||
|
||||
case 'listSinks':
|
||||
return respond(onReply, {
|
||||
returnValue: true,
|
||||
sinks: [
|
||||
{ id: 'hyperhdr', name: 'HyperHDR audio (RTP)', description: 'RTP/L16 audio to the HyperHDR host.' },
|
||||
{ id: 'hyperhdrViz', name: 'HyperHDR visualiser', description: 'Renders on the TV, sends images. No host setup.' },
|
||||
{ id: 'udp', name: 'Raw PCM over UDP', description: 'Fire-and-forget S16LE datagrams.' },
|
||||
{ id: 'tcp', name: 'Raw PCM over TCP', description: 'The TV listens; connect to pull audio.' },
|
||||
{ id: 'http', name: 'HTTP WAV stream', description: 'Open the URL in VLC.' },
|
||||
],
|
||||
});
|
||||
|
||||
// Same shape as capture_write_diagnostics(): backends at the top level,
|
||||
// everything about the machine under "system".
|
||||
case 'getDiagnostics':
|
||||
return respond(onReply, {
|
||||
returnValue: true,
|
||||
backends: [
|
||||
{ id: 'pulse', name: 'PulseAudio monitor', available: true },
|
||||
{ id: 'alsa', name: 'ALSA PCM', available: true },
|
||||
{ id: 'exec', name: 'External command', available: true },
|
||||
{ id: 'tone', name: 'Test tone', available: true },
|
||||
],
|
||||
system: {
|
||||
root: true,
|
||||
uid: 0,
|
||||
libraries: {
|
||||
'libpulse.so.0': '/usr/lib/libpulse.so.0',
|
||||
'libpulse-simple.so.0': null,
|
||||
'libasound.so.2': '/usr/lib/libasound.so.2',
|
||||
},
|
||||
binaries: { parec: false, pactl: true, pacat: false, arecord: true },
|
||||
pulseSockets: ['/var/run/pulse/native'],
|
||||
pactlSources: 'mock output',
|
||||
alsaCards: ['0 [Loopback]: Loopback - Loopback'],
|
||||
alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'],
|
||||
},
|
||||
});
|
||||
|
||||
case 'getLogs':
|
||||
return respond(onReply, {
|
||||
returnValue: true,
|
||||
logs: '[info] running in the browser mock\n[info] no TV attached\n',
|
||||
});
|
||||
|
||||
case 'quit':
|
||||
running = false;
|
||||
return respond(onReply, { returnValue: true });
|
||||
|
||||
case 'exec':
|
||||
return respond(onReply, { returnValue: true, stdoutString: '' });
|
||||
|
||||
default:
|
||||
if (onError) { onError('mock: unknown method ' + method); }
|
||||
return { cancel: function () {} };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
global.LunaMock = LunaMock;
|
||||
})(window);
|
||||
@@ -0,0 +1,171 @@
|
||||
// Remote-control navigation.
|
||||
//
|
||||
// webOS TVs have no tab key and no pointer worth designing for, so focus moves
|
||||
// geometrically: pressing Right picks the nearest focusable element whose
|
||||
// centre lies to the right, biased towards ones on the same row. Anything with
|
||||
// the `focusable` class joins in, which keeps the markup free of tab indices.
|
||||
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var KEY = {
|
||||
LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40,
|
||||
ENTER: 13, BACK: 461, ESCAPE: 27,
|
||||
RED: 403, GREEN: 404, YELLOW: 405, BLUE: 406,
|
||||
};
|
||||
|
||||
function visible(el) {
|
||||
if (el.disabled || el.classList.contains('hidden')) {
|
||||
return false;
|
||||
}
|
||||
var rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function candidates() {
|
||||
var all = document.querySelectorAll('.focusable');
|
||||
var out = [];
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
if (visible(all[i])) {
|
||||
out.push(all[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function centre(el) {
|
||||
var r = el.getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2, rect: r };
|
||||
}
|
||||
|
||||
// Minimum travel before a neighbour counts as being in that direction, so
|
||||
// elements that merely overlap slightly do not steal focus.
|
||||
var MIN_TRAVEL = 4;
|
||||
// How much drifting off-axis costs. High enough that a row of buttons is
|
||||
// traversed in order rather than diagonally.
|
||||
var ACROSS_PENALTY = 3;
|
||||
|
||||
function move(direction) {
|
||||
var current = document.activeElement;
|
||||
var list = candidates();
|
||||
if (!list.length) {
|
||||
return;
|
||||
}
|
||||
if (!current || !current.classList || !current.classList.contains('focusable')) {
|
||||
focus(list[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
var from = centre(current);
|
||||
var best = null;
|
||||
var bestScore = Infinity;
|
||||
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (list[i] === current) {
|
||||
continue;
|
||||
}
|
||||
var to = centre(list[i]);
|
||||
var dx = to.x - from.x;
|
||||
var dy = to.y - from.y;
|
||||
|
||||
var along;
|
||||
var across;
|
||||
if (direction === 'right') {
|
||||
along = dx;
|
||||
across = dy;
|
||||
} else if (direction === 'left') {
|
||||
along = -dx;
|
||||
across = dy;
|
||||
} else if (direction === 'down') {
|
||||
along = dy;
|
||||
across = dx;
|
||||
} else {
|
||||
along = -dy;
|
||||
across = dx;
|
||||
}
|
||||
|
||||
if (along < MIN_TRAVEL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var s = along + Math.abs(across) * ACROSS_PENALTY;
|
||||
if (s < bestScore) {
|
||||
bestScore = s;
|
||||
best = list[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (best) {
|
||||
focus(best);
|
||||
}
|
||||
}
|
||||
|
||||
function focus(el) {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.focus();
|
||||
// Keep the focused control clear of the sticky header.
|
||||
if (el.scrollIntoView) {
|
||||
var rect = el.getBoundingClientRect();
|
||||
if (rect.top < 120 || rect.bottom > global.innerHeight - 40) {
|
||||
el.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function focusFirstIn(container) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
var list = container.querySelectorAll('.focusable');
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (visible(list[i])) {
|
||||
focus(list[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var backHandler = null;
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
switch (e.keyCode) {
|
||||
case KEY.LEFT:
|
||||
move('left');
|
||||
break;
|
||||
case KEY.RIGHT:
|
||||
move('right');
|
||||
break;
|
||||
case KEY.UP:
|
||||
move('up');
|
||||
break;
|
||||
case KEY.DOWN:
|
||||
move('down');
|
||||
break;
|
||||
case KEY.BACK:
|
||||
case KEY.ESCAPE:
|
||||
if (backHandler && backHandler()) {
|
||||
break;
|
||||
}
|
||||
// Nothing wanted the Back press: leave the app the way the platform
|
||||
// expects rather than trapping the user inside it.
|
||||
if (global.webOS && global.webOS.platformBack) {
|
||||
global.webOS.platformBack();
|
||||
} else {
|
||||
global.close();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
global.Nav = {
|
||||
KEY: KEY,
|
||||
focus: focus,
|
||||
focusFirstIn: focusFirstIn,
|
||||
onBack: function (fn) { backHandler = fn; },
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,195 @@
|
||||
// Form controls built for a remote control.
|
||||
//
|
||||
// Everything is a button. Dropdowns and checkboxes are miserable to operate
|
||||
// with a D-pad, so a choice cycles through its values on Enter and a toggle
|
||||
// flips. Text fields are the one exception: focusing one and pressing Enter
|
||||
// brings up the TV's on-screen keyboard, which is the only way to type.
|
||||
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
function el(tag, className, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (className) {
|
||||
node.className = className;
|
||||
}
|
||||
if (text !== undefined && text !== null) {
|
||||
node.textContent = String(text);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// Reads "hyperhdr.host" out of a settings object.
|
||||
function get(obj, path) {
|
||||
var parts = path.split('.');
|
||||
var cur = obj;
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (cur === null || cur === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
cur = cur[parts[i]];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
// Builds { hyperhdr: { host: value } } so setConfig only carries the change.
|
||||
function patchFor(path, value) {
|
||||
var parts = path.split('.');
|
||||
var root = {};
|
||||
var cur = root;
|
||||
for (var i = 0; i < parts.length - 1; i++) {
|
||||
cur[parts[i]] = {};
|
||||
cur = cur[parts[i]];
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
return root;
|
||||
}
|
||||
|
||||
function row(label, hint, control) {
|
||||
var wrap = el('div', 'field');
|
||||
var text = el('div', 'field-text');
|
||||
text.appendChild(el('div', 'field-label', label));
|
||||
if (hint) {
|
||||
text.appendChild(el('div', 'field-hint', hint));
|
||||
}
|
||||
wrap.appendChild(text);
|
||||
var holder = el('div', 'field-control');
|
||||
holder.appendChild(control);
|
||||
wrap.appendChild(holder);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function button(label, onClick, extraClass) {
|
||||
var b = el('button', 'focusable btn' + (extraClass ? ' ' + extraClass : ''), label);
|
||||
b.type = 'button';
|
||||
b.addEventListener('click', onClick);
|
||||
return b;
|
||||
}
|
||||
|
||||
function toggle(value, onChange) {
|
||||
var b = el('button', 'focusable btn toggle');
|
||||
b.type = 'button';
|
||||
function paint(v) {
|
||||
b.textContent = v ? 'On' : 'Off';
|
||||
b.classList.toggle('on', !!v);
|
||||
b.setAttribute('aria-pressed', v ? 'true' : 'false');
|
||||
}
|
||||
paint(value);
|
||||
b.addEventListener('click', function () {
|
||||
value = !value;
|
||||
paint(value);
|
||||
onChange(value);
|
||||
});
|
||||
b.setValue = paint;
|
||||
return b;
|
||||
}
|
||||
|
||||
// `options` is [{ value, label }].
|
||||
function choice(options, value, onChange) {
|
||||
var b = el('button', 'focusable btn choice');
|
||||
b.type = 'button';
|
||||
|
||||
function indexOf(v) {
|
||||
for (var i = 0; i < options.length; i++) {
|
||||
if (options[i].value === v) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
var index = indexOf(value);
|
||||
function paint() {
|
||||
b.textContent = options.length ? options[index].label : '—';
|
||||
b.title = options.length ? String(options[index].value) : '';
|
||||
}
|
||||
paint();
|
||||
|
||||
b.addEventListener('click', function () {
|
||||
if (!options.length) {
|
||||
return;
|
||||
}
|
||||
index = (index + 1) % options.length;
|
||||
paint();
|
||||
onChange(options[index].value);
|
||||
});
|
||||
|
||||
b.setValue = function (v) {
|
||||
index = indexOf(v);
|
||||
paint();
|
||||
};
|
||||
b.setOptions = function (list, v) {
|
||||
options = list;
|
||||
index = indexOf(v);
|
||||
paint();
|
||||
};
|
||||
return b;
|
||||
}
|
||||
|
||||
function text(value, onChange, opts) {
|
||||
opts = opts || {};
|
||||
var input = el('input', 'focusable input');
|
||||
input.type = 'text';
|
||||
input.value = value === undefined || value === null ? '' : String(value);
|
||||
if (opts.placeholder) {
|
||||
input.placeholder = opts.placeholder;
|
||||
}
|
||||
if (opts.numeric) {
|
||||
input.inputMode = 'numeric';
|
||||
}
|
||||
|
||||
function commit() {
|
||||
var raw = input.value.trim();
|
||||
onChange(opts.numeric ? (raw === '' ? 0 : parseFloat(raw)) : raw);
|
||||
}
|
||||
|
||||
input.addEventListener('change', commit);
|
||||
input.addEventListener('blur', commit);
|
||||
input.addEventListener('keydown', function (e) {
|
||||
// Let the arrow keys move the caret inside a focused field, but hand Up
|
||||
// and Down back to the navigator so the user can leave it.
|
||||
if (e.keyCode === 37 || e.keyCode === 39) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
if (e.keyCode === 13) {
|
||||
commit();
|
||||
}
|
||||
});
|
||||
|
||||
input.setValue = function (v) {
|
||||
input.value = v === undefined || v === null ? '' : String(v);
|
||||
};
|
||||
return input;
|
||||
}
|
||||
|
||||
// A horizontal bar, 0..1.
|
||||
function bar(className) {
|
||||
var outer = el('div', 'bar ' + (className || ''));
|
||||
var fill = el('div', 'bar-fill');
|
||||
outer.appendChild(fill);
|
||||
outer.setValue = function (v) {
|
||||
var pct = Math.max(0, Math.min(1, v)) * 100;
|
||||
fill.style.width = pct.toFixed(1) + '%';
|
||||
};
|
||||
return outer;
|
||||
}
|
||||
|
||||
function clear(node) {
|
||||
while (node.firstChild) {
|
||||
node.removeChild(node.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
global.UI = {
|
||||
el: el,
|
||||
get: get,
|
||||
patchFor: patchFor,
|
||||
row: row,
|
||||
button: button,
|
||||
toggle: toggle,
|
||||
choice: choice,
|
||||
text: text,
|
||||
bar: bar,
|
||||
clear: clear,
|
||||
};
|
||||
})(window);
|
||||
Reference in New Issue
Block a user