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>
172 lines
4.0 KiB
JavaScript
172 lines
4.0 KiB
JavaScript
// 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);
|