- Added full D-pad navigation for Android TV remote control - Improved TV device detection (Fire TV, Shield, Google TV, etc.) - Row-based navigation: Left/Right stay in row, Up/Down moves between rows - Back button support for closing modals - Enhanced focus styles for 10-foot viewing distance - Separate Mobile and TV download options on download page - Updated deploy script for dual APK releases
378 lines
13 KiB
JavaScript
378 lines
13 KiB
JavaScript
/**
|
|
* TV-Style Keyboard Navigation
|
|
* Handles Arrow keys to navigate horizontally through sliders and vertically between rows.
|
|
* Optimized for Android TV D-pad remote control navigation.
|
|
*/
|
|
|
|
export class KeyboardNavigation {
|
|
constructor() {
|
|
this.currentFocus = null;
|
|
this.isEnabled = false;
|
|
this.isTVMode = this.detectTVMode();
|
|
this.focusInitialized = false;
|
|
|
|
// Selectors for focusable items (in priority order)
|
|
this.selectors = [
|
|
'.video-card',
|
|
'.hero__btn',
|
|
'.slider-btn',
|
|
'#topSearchBtn',
|
|
'.nav-item',
|
|
'.nav-link',
|
|
'.category-card',
|
|
'.tab-btn',
|
|
'.episode-row',
|
|
'.recommendation-card',
|
|
'button:not([disabled])',
|
|
'a[href]'
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Detect if running on Android TV or similar leanback device
|
|
* Uses multiple detection methods for reliability
|
|
*/
|
|
detectTVMode() {
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
|
|
// Check UA for known TV strings
|
|
const tvPatterns = [
|
|
'tv', 'aftm', 'aftt', 'aft', 'shield', 'googletv',
|
|
'chromecast', 'firetv', 'bravia', 'philipstv', 'samsungtv',
|
|
'lgtv', 'webos', 'tizen', 'vizio', 'roku', 'appletv'
|
|
];
|
|
const isAndroid = ua.includes('android');
|
|
const hasTV = tvPatterns.some(p => ua.includes(p));
|
|
|
|
// Fallback: No fine pointer (mouse) likely means D-pad/remote
|
|
const noMouse = window.matchMedia && !window.matchMedia('(pointer: fine)').matches;
|
|
|
|
// Fallback: Large screen without touch is likely TV
|
|
const isBigScreen = window.innerWidth >= 1280 && window.innerHeight >= 720;
|
|
const noTouch = !('ontouchstart' in window);
|
|
|
|
const detected = (isAndroid && hasTV) || (isAndroid && noMouse) || (isBigScreen && noTouch && noMouse);
|
|
|
|
if (detected) {
|
|
console.log('[KeyboardNav] TV Mode detected');
|
|
document.body.classList.add('tv-mode');
|
|
}
|
|
|
|
return detected;
|
|
}
|
|
|
|
init() {
|
|
this.isEnabled = true;
|
|
document.addEventListener('keydown', this.handleKey.bind(this));
|
|
|
|
// Only add mouse handler on non-TV devices
|
|
if (!this.isTVMode) {
|
|
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
|
}
|
|
|
|
// Add tabindex to all focusable elements for D-pad navigation
|
|
this.ensureTabIndexes();
|
|
|
|
// Auto-focus first card for TV mode (helps D-pad users start navigating)
|
|
if (this.isTVMode) {
|
|
this.waitForFocusableElement();
|
|
}
|
|
|
|
// Re-apply tabindex when DOM changes (e.g., new content loaded)
|
|
this.observeDOM();
|
|
}
|
|
|
|
/**
|
|
* Ensure all interactive elements have tabindex for focus
|
|
*/
|
|
ensureTabIndexes() {
|
|
const elements = document.querySelectorAll(this.selectors.join(','));
|
|
elements.forEach(el => {
|
|
if (!el.hasAttribute('tabindex')) {
|
|
el.setAttribute('tabindex', '0');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Observe DOM for new elements and add tabindex
|
|
*/
|
|
observeDOM() {
|
|
const observer = new MutationObserver((mutations) => {
|
|
let needsUpdate = false;
|
|
for (const mutation of mutations) {
|
|
if (mutation.addedNodes.length > 0) {
|
|
needsUpdate = true;
|
|
break;
|
|
}
|
|
}
|
|
if (needsUpdate) {
|
|
// Debounce updates
|
|
clearTimeout(this._tabindexTimeout);
|
|
this._tabindexTimeout = setTimeout(() => {
|
|
this.ensureTabIndexes();
|
|
// Try to focus if not yet focused
|
|
if (this.isTVMode && !this.focusInitialized) {
|
|
this.focusFirstVisible();
|
|
}
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
observer.observe(document.body, {
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Wait for focusable elements to appear, then focus the first one
|
|
*/
|
|
waitForFocusableElement() {
|
|
const tryFocus = (attempt = 0) => {
|
|
const candidates = document.querySelectorAll('.video-card');
|
|
if (candidates.length > 0) {
|
|
this.setFocus(candidates[0]);
|
|
this.focusInitialized = true;
|
|
console.log('[KeyboardNav] Initial focus set');
|
|
} else if (attempt < 10) {
|
|
// Retry with exponential backoff (100ms, 200ms, 400ms, ...)
|
|
setTimeout(() => tryFocus(attempt + 1), 100 * Math.pow(2, attempt));
|
|
}
|
|
};
|
|
|
|
// Initial delay to let page settle
|
|
setTimeout(() => tryFocus(0), 300);
|
|
}
|
|
|
|
handleMouseMove() {
|
|
// If mouse moves, likely user is using mouse.
|
|
if (this.currentFocus) {
|
|
this.currentFocus.blur();
|
|
this.currentFocus.classList.remove('keyboard-focused');
|
|
this.currentFocus = null;
|
|
}
|
|
}
|
|
|
|
handleKey(e) {
|
|
// Handle navigation keys
|
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
|
e.preventDefault(); // Prevent default page scroll
|
|
|
|
if (!this.currentFocus) {
|
|
this.focusFirstVisible();
|
|
return;
|
|
}
|
|
|
|
let nextTarget = null;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowRight':
|
|
nextTarget = this.moveHorizontal(1);
|
|
break;
|
|
case 'ArrowLeft':
|
|
nextTarget = this.moveHorizontal(-1);
|
|
break;
|
|
case 'ArrowUp':
|
|
nextTarget = this.moveVertical(-1);
|
|
break;
|
|
case 'ArrowDown':
|
|
nextTarget = this.moveVertical(1);
|
|
break;
|
|
}
|
|
|
|
if (nextTarget) {
|
|
this.setFocus(nextTarget);
|
|
}
|
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
// Select/activate focused element
|
|
if (this.currentFocus) {
|
|
e.preventDefault();
|
|
this.currentFocus.click();
|
|
}
|
|
} else if (e.key === 'Backspace' || e.key === 'Escape' || e.key === 'XF86Back') {
|
|
// Back button handling for Android TV
|
|
this.handleBack(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle back button for Android TV
|
|
*/
|
|
handleBack(e) {
|
|
// Check for open modals/overlays first
|
|
const searchModal = document.getElementById('searchModal');
|
|
const playerModal = document.getElementById('playerModal');
|
|
const infoModal = document.querySelector('.info-modal.active, .info-modal:not(.hidden)');
|
|
const videoPlayerContainer = document.getElementById('videoPlayerContainer');
|
|
|
|
// Close modals in priority order
|
|
if (searchModal?.classList.contains('active')) {
|
|
e.preventDefault();
|
|
searchModal.classList.remove('active');
|
|
return;
|
|
}
|
|
|
|
if (infoModal) {
|
|
e.preventDefault();
|
|
infoModal.classList.add('hidden');
|
|
infoModal.classList.remove('active');
|
|
return;
|
|
}
|
|
|
|
if (videoPlayerContainer && !videoPlayerContainer.classList.contains('hidden')) {
|
|
e.preventDefault();
|
|
// Trigger close player - the page's own handler should catch this
|
|
const closeBtn = document.getElementById('closePlayer') || document.getElementById('playerBackButton');
|
|
if (closeBtn) closeBtn.click();
|
|
return;
|
|
}
|
|
|
|
if (playerModal?.classList.contains('active')) {
|
|
e.preventDefault();
|
|
const closePlayerBtn = document.getElementById('closePlayer');
|
|
if (closePlayerBtn) closePlayerBtn.click();
|
|
return;
|
|
}
|
|
|
|
// If no modal is open, let default back behavior happen
|
|
// (e.g., browser back or Capacitor's back handling)
|
|
}
|
|
|
|
focusFirstVisible() {
|
|
// Find first video card in viewport
|
|
const candidates = document.querySelectorAll('.video-card');
|
|
if (candidates.length > 0) {
|
|
this.setFocus(candidates[0]);
|
|
this.focusInitialized = true;
|
|
}
|
|
}
|
|
|
|
setFocus(el) {
|
|
if (!el) return;
|
|
|
|
if (this.currentFocus) {
|
|
this.currentFocus.classList.remove('keyboard-focused');
|
|
}
|
|
|
|
this.currentFocus = el;
|
|
el.classList.add('keyboard-focused');
|
|
el.focus({ preventScroll: true }); // Native focus
|
|
|
|
// Smooth scroll into view
|
|
el.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'center',
|
|
inline: 'center'
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the row container of an element
|
|
*/
|
|
getRowContainer(el) {
|
|
return el.closest('.video-row, .slider-row, .row-content, .grid, .episodes-grid, .recommendations-container');
|
|
}
|
|
|
|
/**
|
|
* Get all focusable elements within a container (or document)
|
|
*/
|
|
getFocusableInContainer(container) {
|
|
const selector = this.selectors.slice(0, 10).join(','); // Primary interactive elements
|
|
return container
|
|
? Array.from(container.querySelectorAll(selector))
|
|
: Array.from(document.querySelectorAll(selector));
|
|
}
|
|
|
|
moveHorizontal(direction) {
|
|
if (!this.currentFocus) return null;
|
|
|
|
// Try to stay within the same row/container
|
|
const row = this.getRowContainer(this.currentFocus);
|
|
|
|
if (row) {
|
|
// Get siblings in the same row
|
|
const siblings = this.getFocusableInContainer(row);
|
|
const currentIndex = siblings.indexOf(this.currentFocus);
|
|
|
|
if (currentIndex !== -1) {
|
|
const nextIndex = currentIndex + direction;
|
|
if (nextIndex >= 0 && nextIndex < siblings.length) {
|
|
return siblings[nextIndex];
|
|
}
|
|
}
|
|
// At edge of row - don't wrap to next row on horizontal nav
|
|
return null;
|
|
}
|
|
|
|
// Fallback: flat DOM order navigation
|
|
const allFocusable = Array.from(document.querySelectorAll(this.selectors.join(',')));
|
|
const currentIndex = allFocusable.indexOf(this.currentFocus);
|
|
|
|
if (currentIndex === -1) return null;
|
|
|
|
const nextIndex = currentIndex + direction;
|
|
if (nextIndex >= 0 && nextIndex < allFocusable.length) {
|
|
const currentRect = this.currentFocus.getBoundingClientRect();
|
|
const nextEl = allFocusable[nextIndex];
|
|
const nextRect = nextEl.getBoundingClientRect();
|
|
|
|
// Don't jump to next row on horizontal nav
|
|
const verticalDist = Math.abs(currentRect.top - nextRect.top);
|
|
if (verticalDist > currentRect.height * 0.5) {
|
|
return null; // Would jump to different row
|
|
}
|
|
return nextEl;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
moveVertical(direction) {
|
|
// Find closest element in the visual direction
|
|
if (!this.currentFocus) return null;
|
|
|
|
const currentRect = this.currentFocus.getBoundingClientRect();
|
|
const centerX = currentRect.left + currentRect.width / 2;
|
|
const allFocusable = Array.from(document.querySelectorAll(this.selectors.join(',')));
|
|
|
|
// Filter elements that are strictly Above/Below
|
|
const candidates = allFocusable.filter(el => {
|
|
if (el === this.currentFocus) return false;
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
if (direction === 1) { // Down
|
|
return rect.top >= currentRect.bottom - (currentRect.height * 0.3);
|
|
} else { // Up
|
|
return rect.bottom <= currentRect.top + (currentRect.height * 0.3);
|
|
}
|
|
});
|
|
|
|
if (candidates.length === 0) return null;
|
|
|
|
// Find the one with minimum distance, prioritizing horizontal alignment
|
|
let bestCandidate = null;
|
|
let minDistance = Infinity;
|
|
|
|
candidates.forEach(el => {
|
|
const rect = el.getBoundingClientRect();
|
|
const elCenterX = rect.left + rect.width / 2;
|
|
|
|
// Vertical distance (primary)
|
|
const vDist = Math.abs(rect.top - currentRect.top);
|
|
|
|
// Horizontal alignment penalty (prefer elements at similar X position)
|
|
const hDist = Math.abs(elCenterX - centerX);
|
|
|
|
// Weight: prefer closer rows, then prefer horizontal alignment
|
|
// Vertical distance matters more for row-based navigation
|
|
const dist = vDist * 2 + hDist;
|
|
|
|
if (dist < minDistance) {
|
|
minDistance = dist;
|
|
bestCandidate = el;
|
|
}
|
|
});
|
|
|
|
return bestCandidate;
|
|
}
|
|
}
|