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,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);
|
||||
Reference in New Issue
Block a user