kv-netflix/frontend/scripts/keyboard-nav.js
Khoa.vo 8253ff5b7a v1.0.10: Android TV D-pad navigation support
- 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
2025-12-25 07:26:06 +07:00

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