OpenCode AI e9899d2e6d
Some checks failed
Android CI Build / check (push) Has been cancelled
Android CI Build / package-apks (push) Has been cancelled
Docs Deploy / build (push) Has been cancelled
Module Market Check / Validate modules/ (push) Has been cancelled
Module Market Publish / Generate submissions.json (push) Has been cancelled
Docs Deploy / deploy (push) Has been cancelled
Initial DruksCode V2: fresh fork of web-to-app (f3b488e) with DruksCode identity
- Package rename com.webtoapp -> br.com.drukstech.codeapp
- Product rename WebToApp/web-to-app -> DruksCode/drukscode
- Design system Wta* -> Dkc*
- applicationId br.com.drukstech.codeapp (.play for gplay flavor)
- rootProject.name DruksCode
- Fresh git history (upstream kept as remote for sync)
2026-09-12 02:42:02 +02:00

596 lines
21 KiB
JavaScript

// TV D-Pad Cursor — virtual mouse cursor for Android TV / Fire TV / TV boxes.
//
// Maps D-pad / arrow-key input to a movable on-screen cursor and simulates
// mouse + pointer events at the cursor location. Designed for DOCUMENT_START
// injection: all DOM access is deferred until document.body is available.
//
// Controls:
// Arrow keys / D-Pad = Move cursor (hold to accelerate)
// Enter (short) = Left click
// Enter (long ~0.8s) = Teleport cursor to the settings gear
// Back / Esc = Close menu / hide cursor
// M / Menu = Toggle settings menu
//
// In the settings menu:
// Up / Down = Select row
// Left / Right = Adjust value
// Enter = Toggle / activate
(function () {
// ── Configuration from configItems ────────────────────────────
var S = {
cursorSpeed: parseInt(getConfig('cursorSpeed', '10'), 10) || 10,
fastSpeed: parseInt(getConfig('fastSpeed', '24'), 10) || 24,
accelFrames: parseInt(getConfig('accelFrames', '18'), 10) || 18,
scrollSpeed: parseInt(getConfig('scrollSpeed', '14'), 10) || 14,
scrollZone: parseInt(getConfig('scrollZone', '80'), 10) || 80,
idleHideMs: parseInt(getConfig('idleHideMs', '5000'), 10),
highlight: String(getConfig('highlight', 'false')) === 'true',
pageZoom: parseInt(getConfig('pageZoom', '100'), 10) || 100
};
// ── Constants ─────────────────────────────────────────────────
var CURSOR_SIZE = 24;
var LONG_PRESS_MS = 800;
var LS_KEY = 'dkc-tv-cursor-settings';
var ZOOM_ID = 'dkc-tv-zoom-style';
// ── Runtime state ─────────────────────────────────────────────
var x = window.innerWidth / 2;
var y = window.innerHeight / 2;
var heldKeys = {};
var frameCount = {};
var idleTimer = null;
var cursorEl = null;
var gearEl = null;
var lastTarget = null;
var menuOpen = false;
var menuIndex = 0;
var menuRows = [];
var longPressTimer = null;
var didLongPress = false;
var rafId = null;
var scrollTick = 0;
var ready = false;
// ── Persistence (localStorage only — custom modules get no GM_*) ──
function loadOverrides() {
try {
var raw = localStorage.getItem(LS_KEY);
if (raw) {
var saved = JSON.parse(raw);
if (saved && typeof saved === 'object') {
for (var k in saved) {
if (saved.hasOwnProperty(k) && S.hasOwnProperty(k)) {
S[k] = saved[k];
}
}
}
}
} catch (e) {}
}
function saveOverrides() {
try {
localStorage.setItem(LS_KEY, JSON.stringify(S));
} catch (e) {}
}
loadOverrides();
// ── Page zoom via CSS transform (GeckoView-compatible) ────────
function applyZoom() {
var existing = document.getElementById(ZOOM_ID);
if (!existing) {
existing = document.createElement('style');
existing.id = ZOOM_ID;
(document.head || document.documentElement).appendChild(existing);
}
var z = S.pageZoom / 100;
existing.textContent =
'html { transform: scale(' + z + '); transform-origin: 0 0; ' +
'width: ' + (100 / z) + '%; height: ' + (100 / z) + '%; }';
}
// ── DOM readiness: defer all body-dependent init ──────────────
function onReady(fn) {
if (document.body) {
fn();
} else {
var observer = new MutationObserver(function () {
if (document.body) {
observer.disconnect();
fn();
}
});
observer.observe(document.documentElement, { childList: true });
}
}
// ── Cursor element ────────────────────────────────────────────
function buildCursor() {
if (cursorEl) return;
cursorEl = document.createElement('div');
cursorEl.id = 'dkc-tv-cursor';
document.body.appendChild(cursorEl);
updateTransform();
}
function buildGear() {
if (gearEl) return;
gearEl = document.createElement('div');
gearEl.id = 'dkc-tv-gear';
gearEl.textContent = '\u2699';
document.body.appendChild(gearEl);
}
function clampPos() {
var half = CURSOR_SIZE / 2;
x = Math.max(half, Math.min(window.innerWidth - half, x));
y = Math.max(half, Math.min(window.innerHeight - half, y));
}
function updateTransform() {
if (!cursorEl) return;
clampPos();
cursorEl.style.transform = 'translate(' + x + 'px, ' + y + 'px)';
cursorEl.classList.remove('idle');
if (idleTimer) clearTimeout(idleTimer);
if (S.idleHideMs > 0) {
idleTimer = setTimeout(function () {
if (cursorEl) cursorEl.classList.add('idle');
}, S.idleHideMs);
}
if (!menuOpen && S.highlight) {
checkHover();
} else {
clearHighlights();
}
checkGearHover();
}
function clearHighlights() {
if (lastTarget) {
lastTarget.classList.remove('dkc-tv-hover-target');
lastTarget = null;
}
}
// ── Clickable detection ───────────────────────────────────────
function isClickable(el) {
if (!el || el === document) return false;
var s = window.getComputedStyle(el);
if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) {
return false;
}
var tag = el.tagName;
if (tag === 'A' || tag === 'BUTTON' || tag === 'INPUT' || tag === 'SELECT' ||
tag === 'TEXTAREA' || tag === 'VIDEO' || tag === 'LABEL') {
return true;
}
if (el.closest('a') || el.closest('button') || el.closest('[role="button"]') ||
el.closest('label') || el.closest('video') || el.closest('[onclick]')) {
return true;
}
return false;
}
function checkHover() {
var el = document.elementFromPoint(x, y);
if (el === lastTarget) return;
if (lastTarget) {
lastTarget.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: el }));
lastTarget.classList.remove('dkc-tv-hover-target');
}
lastTarget = el;
if (el) {
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
if (isClickable(el)) el.classList.add('dkc-tv-hover-target');
}
}
function checkGearHover() {
if (!gearEl) return;
var r = gearEl.getBoundingClientRect();
var over = x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
gearEl.classList.toggle('focused', over);
}
// ── Event simulation: Mouse + Pointer Events for framework compat ──
function fireEvents(type, button) {
var el = document.elementFromPoint(x, y) || document.documentElement;
var opts = {
bubbles: true,
cancelable: true,
view: window,
detail: 1,
screenX: x + window.screenX,
screenY: y + window.screenY,
clientX: x,
clientY: y,
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
button: button,
buttons: (type === 'mousedown') ? (1 << button) : 0,
relatedTarget: null
};
// Pointer Events first — many modern frameworks (React 17+,
// Vue 3) rely on pointerdown rather than mousedown.
if (typeof PointerEvent !== 'undefined') {
try {
el.dispatchEvent(new PointerEvent(
type === 'click' ? 'click' : 'pointer' + type,
opts
));
} catch (e) {}
}
// Mouse Events as the universal fallback.
el.dispatchEvent(new MouseEvent(type, opts));
return el;
}
function click(button) {
if (!cursorEl) return;
button = button || 0;
// Check if cursor is over the gear button — toggle menu instead
// of dispatching a DOM click.
if (gearEl && !menuOpen) {
var r = gearEl.getBoundingClientRect();
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
cursorEl.classList.add('clicking');
toggleMenu(true);
setTimeout(function () { cursorEl.classList.remove('clicking'); }, 180);
return;
}
}
cursorEl.classList.add('clicking');
var el = fireEvents('mousedown', button);
fireEvents('mouseup', button);
fireEvents('click', button);
if (button === 0 && el && el.click) {
try { el.click(); } catch (e) {}
}
if (button === 2) {
fireEvents('contextmenu', 2);
}
setTimeout(function () { cursorEl.classList.remove('clicking'); }, 180);
}
// ── Teleport cursor to gear button ────────────────────────────
function teleportToGear() {
if (!gearEl || !cursorEl) return;
var r = gearEl.getBoundingClientRect();
x = r.left + r.width / 2;
y = r.top + r.height / 2;
cursorEl.classList.add('teleporting');
updateTransform();
setTimeout(function () {
if (cursorEl) cursorEl.classList.remove('teleporting');
}, 300);
}
// ── Movement loop (requestAnimationFrame, deterministic) ──────
function cursorLoop() {
if (!ready || !cursorEl) {
rafId = requestAnimationFrame(cursorLoop);
return;
}
var dx = 0, dy = 0, fast = false;
for (var key in heldKeys) {
if (!heldKeys.hasOwnProperty(key) || !heldKeys[key]) continue;
frameCount[key] = (frameCount[key] || 0) + 1;
if (frameCount[key] > S.accelFrames) fast = true;
}
var spd = fast ? S.fastSpeed : S.cursorSpeed;
if (heldKeys['ArrowUp']) dy -= spd;
if (heldKeys['ArrowDown']) dy += spd;
if (heldKeys['ArrowLeft']) dx -= spd;
if (heldKeys['ArrowRight']) dx += spd;
if (dx !== 0 || dy !== 0) {
x += dx;
y += dy;
// Edge scrolling — check BEFORE clamping so the cursor can
// push into the scroll zone to trigger scrolling.
scrollTick++;
if (scrollTick % 3 === 0) {
var M = S.scrollZone;
var ss = S.scrollSpeed;
if (dy > 0 && y > window.innerHeight - M) {
if (window.scrollY + window.innerHeight < document.documentElement.scrollHeight) {
window.scrollBy({ top: ss, behavior: 'auto' });
}
}
if (dy < 0 && y < M) {
if (window.scrollY > 0) {
window.scrollBy({ top: -ss, behavior: 'auto' });
}
}
if (dx > 0 && x > window.innerWidth - M) {
if (window.scrollX + window.innerWidth < document.documentElement.scrollWidth) {
window.scrollBy({ left: ss, behavior: 'auto' });
}
}
if (dx < 0 && x < M) {
if (window.scrollX > 0) {
window.scrollBy({ left: -ss, behavior: 'auto' });
}
}
}
updateTransform();
}
rafId = requestAnimationFrame(cursorLoop);
}
// ── Settings menu ─────────────────────────────────────────────
function buildMenu() {
var existing = document.getElementById('dkc-tv-menu');
if (existing) existing.remove();
var menu = document.createElement('div');
menu.id = 'dkc-tv-menu';
var items = [
{ id: 'cursorSpeed', label: 'Cursor Speed', type: 'range', min: 1, max: 40, step: 1 },
{ id: 'fastSpeed', label: 'Fast Speed', type: 'range', min: 10, max: 60, step: 1 },
{ id: 'accelFrames', label: 'Accel Delay', type: 'range', min: 0, max: 60, step: 1 },
{ id: 'scrollSpeed', label: 'Scroll Speed', type: 'range', min: 0, max: 40, step: 1 },
{ id: 'highlight', label: 'Highlight', type: 'toggle' },
{ id: 'pageZoom', label: 'Page Zoom', type: 'range', min: 50, max: 200, step: 10, suffix: '%' },
{ id: 'close', label: 'Close Menu', type: 'action' }
];
var html = '<h2>TV Cursor Settings</h2>';
for (var i = 0; i < items.length; i++) {
var it = items[i];
var val;
if (it.type === 'toggle') {
val = S[it.id] ? 'ON' : 'OFF';
} else if (it.type === 'action') {
val = '';
} else {
val = S[it.id] + (it.suffix || '');
}
var hint = it.type === 'toggle' ? '\u23ce toggle' :
it.type === 'action' ? '\u23ce or Back' :
'\u25c0 \u25b6 adjust';
html += '<div class="dkc-tv-row' + (i === 0 ? ' active' : '') +
'" data-idx="' + i + '" data-type="' + it.type + '" data-id="' + it.id + '">' +
'<span class="label">' + it.label +
' <span class="dkc-tv-arrows">\u25c0 \u25b6</span></span>' +
'<span class="hint">' + hint + '</span>' +
'<span class="value" id="dkc-tv-val-' + it.id + '">' + val + '</span>' +
'</div>';
}
menu.innerHTML = html;
document.body.appendChild(menu);
menuRows = Array.prototype.slice.call(menu.querySelectorAll('.dkc-tv-row'));
}
function updateMenuHighlight() {
for (var i = 0; i < menuRows.length; i++) {
menuRows[i].classList.toggle('active', i === menuIndex);
}
}
function toggleMenu(show) {
if (show) {
buildMenu();
menuOpen = true;
menuIndex = 0;
var m = document.getElementById('dkc-tv-menu');
if (m) m.classList.add('open');
if (gearEl) gearEl.classList.add('active');
if (cursorEl) cursorEl.classList.add('hidden');
clearHighlights();
} else {
menuOpen = false;
var m2 = document.getElementById('dkc-tv-menu');
if (m2) m2.classList.remove('open');
if (gearEl) gearEl.classList.remove('active', 'focused');
if (cursorEl) cursorEl.classList.remove('hidden');
}
}
function adjustMenuValue(dir) {
var row = menuRows[menuIndex];
if (!row) return;
var type = row.dataset.type;
var id = row.dataset.id;
if (type !== 'range') return;
var ranges = {
cursorSpeed: { min: 1, max: 40, step: 1 },
fastSpeed: { min: 10, max: 60, step: 1 },
accelFrames: { min: 0, max: 60, step: 1 },
scrollSpeed: { min: 0, max: 40, step: 1 },
pageZoom: { min: 50, max: 200, step: 10 }
};
var r = ranges[id];
if (!r) return;
S[id] = Math.max(r.min, Math.min(r.max, S[id] + dir * r.step));
var valEl = document.getElementById('dkc-tv-val-' + id);
if (valEl) valEl.textContent = S[id] + (id === 'pageZoom' ? '%' : '');
saveOverrides();
if (id === 'pageZoom') applyZoom();
}
function activateMenuItem() {
var row = menuRows[menuIndex];
if (!row) return;
var id = row.dataset.id;
if (id === 'close') {
toggleMenu(false);
return;
}
if (row.dataset.type === 'toggle') {
S[id] = !S[id];
var valEl = document.getElementById('dkc-tv-val-' + id);
if (valEl) valEl.textContent = S[id] ? 'ON' : 'OFF';
saveOverrides();
}
}
// ── Keyboard routing ──────────────────────────────────────────
function onKeyDown(e) {
var k = e.key;
// Menu / M toggles settings.
if (k === 'Menu' || k === 'ContextMenu' || k === 'm' || k === 'M') {
e.preventDefault();
e.stopPropagation();
toggleMenu(!menuOpen);
return;
}
if (menuOpen) {
// ── Menu navigation ──
if (k === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
menuIndex = (menuIndex - 1 + menuRows.length) % menuRows.length;
updateMenuHighlight();
return;
}
if (k === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
menuIndex = (menuIndex + 1) % menuRows.length;
updateMenuHighlight();
return;
}
if (k === 'ArrowLeft') {
e.preventDefault();
e.stopPropagation();
adjustMenuValue(-1);
return;
}
if (k === 'ArrowRight') {
e.preventDefault();
e.stopPropagation();
adjustMenuValue(1);
return;
}
if (k === 'Enter') {
e.preventDefault();
e.stopPropagation();
activateMenuItem();
return;
}
if (k === 'Back' || k === 'Escape') {
e.preventDefault();
e.stopPropagation();
toggleMenu(false);
return;
}
return;
}
// ── Cursor mode ──
if (k === 'ArrowUp' || k === 'ArrowDown' || k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
e.stopPropagation();
if (!heldKeys[k]) {
heldKeys[k] = true;
frameCount[k] = 0;
}
return;
}
if (k === 'Enter') {
e.preventDefault();
e.stopPropagation();
if (longPressTimer) clearTimeout(longPressTimer);
didLongPress = false;
longPressTimer = setTimeout(function () {
didLongPress = true;
teleportToGear();
}, LONG_PRESS_MS);
return;
}
if (k === 'Back' || k === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (cursorEl) {
if (cursorEl.classList.contains('hidden')) {
cursorEl.classList.remove('hidden');
} else {
cursorEl.classList.add('hidden');
}
}
return;
}
}
function onKeyUp(e) {
var k = e.key;
if (k === 'Enter') {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
if (!didLongPress) {
click(0);
}
didLongPress = false;
e.preventDefault();
e.stopPropagation();
return;
}
if (k === 'ArrowUp' || k === 'ArrowDown' || k === 'ArrowLeft' || k === 'ArrowRight') {
heldKeys[k] = false;
frameCount[k] = 0;
e.preventDefault();
e.stopPropagation();
return;
}
}
// ── Window resize: keep cursor in bounds ──────────────────────
function onResize() {
clampPos();
if (cursorEl) cursorEl.style.transform = 'translate(' + x + 'px, ' + y + 'px)';
}
// ── Initialization ────────────────────────────────────────────
onReady(function () {
buildCursor();
buildGear();
applyZoom();
ready = true;
});
// Keyboard listeners can be attached immediately — they don't
// depend on document.body.
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
window.addEventListener('resize', onResize);
// Start the movement loop right away; it waits for `ready` before
// doing anything visible.
rafId = requestAnimationFrame(cursorLoop);
})();