kv-netflix/backend/static/scripts/keyboard-nav.js
Khoa.vo 00ccf95cae v1.0.6: Image optimization, navigation fixes, PWA improvements
- Optimized mobile image loading (180px vs 200px desktop)
- Fixed Install App navigation not working on desktop
- Fixed replaceChild null error in hero rendering
- Added PWA icon (512x512)
- Fixed back button navigation issues
- Added mobile bottom padding for nav bar
- Moved Get App FAB higher to avoid nav overlap
- Removed unnecessary pushState from video navigation
- Made Search/MyList tabs not scroll to top on mobile
- Removed duplicate Android TV section from download page
2025-12-24 12:21:34 +07:00

205 lines
7.2 KiB
JavaScript

/**
* TV-Style Keyboard Navigation
* Handles Arrow keys to navigate horizontally through sliders and vertically between rows.
*/
export class KeyboardNavigation {
constructor() {
this.currentFocus = null;
this.isEnabled = false;
// Selectors for focusable items
this.selectors = [
'.video-card',
'.hero__btn',
'.slider-btn',
'#topSearchBtn',
'.nav-item',
'.category-card',
'.tab-btn',
'.episode-row',
'.recommendation-card'
];
}
init() {
this.isEnabled = true;
document.addEventListener('keydown', this.handleKey.bind(this));
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
// Initial focus?
// Usually wait for user to press a key to enter "Keyboard Mode"
// so we don't show focus rings to mouse users.
}
handleMouseMove() {
// If mouse moves, likely user is using mouse.
// Optional: clear focus to avoid conflict?
// For now, let's keep them separate or just let hover take precedence.
if (this.currentFocus) {
this.currentFocus.blur();
this.currentFocus.classList.remove('keyboard-focused');
this.currentFocus = null;
}
}
handleKey(e) {
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') {
if (this.currentFocus) {
this.currentFocus.click();
}
}
}
focusFirstVisible() {
// Find first video card in viewport
const candidates = document.querySelectorAll('.video-card');
if (candidates.length > 0) {
this.setFocus(candidates[0]);
}
}
setFocus(el) {
if (this.currentFocus) {
this.currentFocus.classList.remove('keyboard-focused');
// Trigger mouseleave logic if needed to reset z-index?
}
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'
});
}
moveHorizontal(direction) {
// 1. Try siblings first (if in a list)
// If direction is 1 (Right), look for nextElementSibling
if (!this.currentFocus) return null;
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) {
// Simple DOM order check
// BUT for sliders, DOM order matches visual order usually.
// Check if they are in the same container?
// If dragging across rows, Horizontal arrow shouldn't jump rows if possible?
// But flattening functionality is easier: just go to next DOM element.
// Refinement: If next element is in a DIFFERENT slider row, only jump if it's logically close?
// Ideally Right Arrow should stay in row.
const currentRect = this.currentFocus.getBoundingClientRect();
const nextEl = allFocusable[nextIndex];
const nextRect = nextEl.getBoundingClientRect();
// Heuristic: If vertical distance is large, it's a new row.
// If delta Y > height/2, maybe block horizontal nav?
const verticalDist = Math.abs(currentRect.top - nextRect.top);
if (verticalDist > currentRect.height * 0.5) {
// New row. Should arrow keys wrap?
// User said "scrollable to the right". Usually means stay in row or wrap.
// Let's allow wrapping for now, or strict row logic?
// Strict Row Logic is better for TV.
// If I am at end of row, right arrow does nothing or goes to "Next" button?
// Let's rely on simple DOM order for now as "good enough" for v1
// except if the user specifically requested "scrollable right".
// If I press Right at end of row, and it jumps to next row, that's okay.
}
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.2); // permit slight overlap
} else { // Up
return rect.bottom <= currentRect.top + (currentRect.height * 0.2);
}
});
if (candidates.length === 0) return null;
// Find the one with minimum distance
// Distance = Vertical Diff + Horizontal Diff penalty
let bestCandidate = null;
let minDistance = Infinity;
candidates.forEach(el => {
const rect = el.getBoundingClientRect();
const elCenterX = rect.left + rect.width / 2;
const elCenterY = rect.top + rect.height / 2;
// Vertical distance (primary)
const vDist = Math.abs(rect.top - currentRect.top);
// Horizontal alignment penalty
const hDist = Math.abs(elCenterX - centerX);
// Weighted distance: Vertical matter, but horizontally closest is best within that band.
// Actually, we usually want the "row immediately below".
// So sort by Vertical distance first.
// Simple Euclidean distance?
const dist = Math.sqrt(Math.pow(vDist, 2) + Math.pow(hDist, 2));
if (dist < minDistance) {
minDistance = dist;
bestCandidate = el;
}
});
return bestCandidate;
}
}