Files
Rene KievitsandClaude Opus 5 7529a60650 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>
2026-08-26 10:21:00 +02:00

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