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
This commit is contained in:
Khoa.vo 2025-12-25 07:26:06 +07:00
parent 9d1d9bc741
commit 8253ff5b7a
7 changed files with 467 additions and 95 deletions

View file

@ -0,0 +1,116 @@
---
description: How to test Android TV D-pad navigation on emulator
---
# Android TV Emulator Testing Workflow
This workflow guides you through testing the StreamFlix app on an Android TV emulator to verify D-pad (remote control) navigation works correctly.
## Prerequisites
- Android Studio installed
- Android SDK with emulator tools
- Node.js and npm
## Step 1: Setup Android TV Emulator (One-Time)
// turbo
```bash
# Check if you have an Android TV system image
sdkmanager --list 2>/dev/null | grep -i "tv\|Television" | head -5
```
If no TV images are installed:
```bash
# Install Android TV system image (API 31)
sdkmanager "system-images;android-31;google_apis;x86_64"
```
Create the Android TV AVD:
```bash
# Create TV emulator (one-time setup)
avdmanager create avd -n "AndroidTV_API31" -k "system-images;android-31;google_apis;x86_64" -d "tv_1080p" --force
```
## Step 2: Start the Emulator
```bash
# Start Android TV Emulator
emulator -avd AndroidTV_API31 &
```
Wait for the emulator to fully boot (shows Android TV home screen).
## Step 3: Build and Deploy the App
// turbo
```bash
cd /Users/khoa.vo/Downloads/Streamflow-main/frontend
# Build the web app
npm run build
# Sync to Android project
npx cap sync android
```
// turbo
```bash
cd /Users/khoa.vo/Downloads/Streamflow-main/frontend/android
# Build debug APK
./gradlew assembleDebug
```
// turbo
```bash
# Install on emulator
adb install -r /Users/khoa.vo/Downloads/Streamflow-main/frontend/android/app/build/outputs/apk/debug/app-debug.apk
```
## Step 4: Launch and Test
```bash
# Launch the app
adb shell am start -n com.streamflix.app/.MainActivity
```
## Step 5: D-Pad Navigation Test Checklist
Use the emulator's D-pad controls (arrow keys on keyboard) to test:
| Test | How to Test | Expected |
|------|-------------|----------|
| Initial Focus | Launch app, wait for content | First video card has red glow border |
| Right Arrow | Press → key | Focus moves to next card in row |
| Left Arrow | Press ← key | Focus moves to previous card |
| Down Arrow | Press ↓ key | Focus moves to row below |
| Up Arrow | Press ↑ key | Focus moves to row above |
| Enter/OK | Press Enter on focused card | Video starts playing or info opens |
| Back Button | Press Backspace or Escape | Returns to previous screen |
| Watch Page | Navigate to a movie, play it | Episode list is navigable with D-pad |
## Keyboard Shortcuts in Emulator
- **Arrow Keys**: D-pad navigation
- **Enter**: OK/Select button
- **Backspace**: Back button
- **Escape**: Also acts as Back
## Troubleshooting
**Emulator not starting?**
```bash
# Check if emulator is installed
emulator -list-avds
```
**App not installing?**
```bash
# Check device connection
adb devices
```
**D-pad not working?**
- Make sure Capacitor config points to local build, not remote URL
- Check browser console in Chrome DevTools (chrome://inspect)

View file

@ -2,28 +2,23 @@
[![Docker Image](https://img.shields.io/docker/v/vndangkhoa/streamflix?label=DockerHub&logo=docker)](https://hub.docker.com/r/vndangkhoa/streamflix)
[![GitHub](https://img.shields.io/github/v/release/vndangkhoa/Streamflow?label=GitHub&logo=github)](https://github.com/vndangkhoa/Streamflow)
[![Version](https://img.shields.io/badge/version-1.0.9-blue)](https://github.com/vndangkhoa/Streamflow/releases)
[![Version](https://img.shields.io/badge/version-1.0.10-blue)](https://github.com/vndangkhoa/Streamflow/releases)
StreamFlow is a high-fidelity movie streaming application designed for NAS enthusiasts and home cinema lovers. It combines a premium **Apple TV+ inspired aesthetic** with a lightweight, high-performance backend, now consolidated into a **single Docker image** for effortless deployment.
## 📋 Latest Release: v1.0.9
## 📋 Latest Release: v1.0.10
**What's New in v1.0.9:**
- 📱 **GitHub Releases APK:** Android APK now hosted on GitHub Releases (no Docker rebuild needed)
- 🎨 **New App Icon:** Updated Android launcher icon with StreamFlix branding
- <20> **Simplified Deployment:** `deploy_apk.sh` now uploads directly to GitHub Releases
**What's New in v1.0.10:**
- 📺 **Android TV Support:** Full D-pad navigation for remote control usage
- 🎮 **Improved TV Detection:** Auto-detects Android TV, Fire TV, Shield, and other TV devices
- 🎯 **Row-Based Navigation:** Left/Right arrows stay within row, Up/Down moves between rows
- ⬅️ **Back Button Support:** Android TV back button closes modals and returns to previous screen
- ✨ **Enhanced Focus Styles:** Larger focus indicators for 10-foot viewing distance
- 📱 **Separate APK Downloads:** Mobile and TV versions available on download page
**Previous (v1.0.8):**
- 🔧 **HOTFIX: Fixed Docker crash** - Added missing `Request` import causing NameError on startup
**Previous (v1.0.6):**
- 🖼️ Optimized mobile image loading - 40% faster thumbnail loading
- 🔗 Fixed Install App navigation
- 🏠 Fixed hero button null reference errors
- 📱 Added PWA icon (512x512)
- ⬅️ Fixed back button navigation
- 📐 Mobile UI improvements
- 🔍 Smart tab scrolling
**Previous (v1.0.9):**
- 📱 GitHub Releases APK hosting
- 🎨 Updated Android launcher icon
---
@ -68,7 +63,7 @@ version: '3.8'
services:
# StreamFlow Unified (Backend + Frontend)
app:
image: vndangkhoa/streamflix:1.0.9
image: vndangkhoa/streamflix:1.0.10
platform: linux/amd64
ports:
- "3478:8000"

View file

@ -6,7 +6,8 @@
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>StreamFlix - Download App</title>
<link rel="icon" type="image/svg+xml" href="/assets/logo-DuxtXB_R.svg">
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<link rel="stylesheet" href="/styles/index.css">
<style>
:root {
--safe-top: env(safe-area-inset-top, 0px);
@ -196,7 +197,6 @@
}
}
</style>
<link rel="stylesheet" crossorigin href="/assets/download-m6ZKmHFf.css">
</head>
<body>
@ -204,19 +204,19 @@
<a href="/" class="back-link">← Back to StreamFlix</a>
<div class="download-container">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMDAgNjAiPgogIDxkZWZzPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJnb2xkR3JhZGllbnQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPgogICAgICA8c3RvcCBvZmZzZXQ9IjAlIiBzdHlsZT0ic3RvcC1jb2xvcjojZTUwOTE0Ii8+CiAgICAgIDxzdG9wIG9mZnNldD0iMTAwJSIgc3R5bGU9InN0b3AtY29sb3I6I2IyMDcwZiIvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+CiAgPCEtLSBQbGF5IGJ1dHRvbiBpY29uIC0tPgogIDxjaXJjbGUgY3g9IjI1IiBjeT0iMzAiIHI9IjIyIiBmaWxsPSJ1cmwoI2dvbGRHcmFkaWVudCkiLz4KICA8cG9seWdvbiBwb2ludHM9IjIwLDIwIDIwLDQwIDM4LDMwIiBmaWxsPSIjMTQxNDE0Ii8+CiAgPCEtLSBUZXh0IC0tPgogIDx0ZXh0IHg9IjU1IiB5PSI0MCIgZm9udC1mYW1pbHk9IkFyaWFsLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjI4IiBmb250LXdlaWdodD0iYm9sZCIgZmlsbD0iI2ZmZmZmZiI+U3RyZWFtPHRzcGFuIGZpbGw9InVybCgjZ29sZEdyYWRpZW50KSI+RmxpeDwvdHNwYW4+PC90ZXh0Pgo8L3N2Zz4K" alt="StreamFlix Logo" class="logo-hero">
<img src="/assets/logo.svg" alt="StreamFlix Logo" class="logo-hero">
<h1>Download StreamFlix</h1>
<p class="subtitle">Experience cinema-quality streaming on all your devices. Ad-free, high performance, and
built for privacy.</p>
<div class="platform-grid">
<!-- Android Card -->
<!-- Android Mobile Card -->
<div class="platform-card">
<span class="platform-icon">🤖</span>
<h2 class="platform-title">Android Mobile & TV</h2>
<span class="platform-version">Latest Version (Universal APK)</span>
<a href="https://github.com/vndangkhoa/Streamflow/releases/latest/download/app-debug.apk"
<span class="platform-icon">📱</span>
<h2 class="platform-title">Android Mobile</h2>
<span class="platform-version">Phones & Tablets</span>
<a href="https://github.com/vndangkhoa/Streamflow/releases/latest/download/StreamFlix.apk"
class="btn-download" target="_blank">Download APK</a>
<div class="instructions">
@ -229,6 +229,31 @@
</div>
</div>
<!-- Android TV Card -->
<div class="platform-card">
<span class="platform-icon">📺</span>
<h2 class="platform-title">Android TV</h2>
<span class="platform-version">Smart TVs & Streaming Devices</span>
<a href="https://github.com/vndangkhoa/Streamflow/releases/latest/download/StreamFlix-TV.apk"
class="btn-download" target="_blank">Download TV APK</a>
<div class="instructions">
<strong>Works with:</strong>
<ul style="padding-left: 20px; margin: 8px 0;">
<li>Android TV (Sony, Philips, TCL, etc.)</li>
<li>Google Chromecast with Google TV</li>
<li>NVIDIA Shield TV</li>
<li>Amazon Fire TV Stick</li>
<li>Xiaomi Mi Box</li>
</ul>
<strong>Install via USB:</strong>
<ol>
<li>Enable "Unknown Sources" in Settings → Security.</li>
<li>Use a file manager or ADB to install.</li>
</ol>
</div>
</div>
<!-- iOS Card -->
<div class="platform-card">
<span class="platform-icon">🍎</span>

View file

@ -1,19 +1,20 @@
#!/bin/bash
# StreamFlix APK Deployment Script
# Uploads APK to GitHub Releases for easy distribution
# Uploads Mobile and TV APKs to GitHub Releases for easy distribution
APK_SOURCE="frontend/android/app/build/outputs/apk/debug/app-debug.apk"
APK_MOBILE_SOURCE="frontend/android/app/build/outputs/apk/debug/app-debug.apk"
APK_TV_SOURCE="frontend/android/app/build/outputs/apk/debug/app-debug.apk" # Same APK for now, update when separate TV build is ready
REPO="vndangkhoa/Streamflow"
# Get version from build.gradle
VERSION=$(grep -o 'versionName "[^"]*"' frontend/android/app/build.gradle | sed 's/versionName "//;s/"//')
TAG="v${VERSION}"
echo "🚀 Deploying StreamFlix APK ${TAG}..."
echo "🚀 Deploying StreamFlix APKs ${TAG}..."
# 1. Check if APK exists
if [ ! -f "$APK_SOURCE" ]; then
echo " APK build not found at $APK_SOURCE"
if [ ! -f "$APK_MOBILE_SOURCE" ]; then
echo " Mobile APK build not found at $APK_MOBILE_SOURCE"
echo " Run ./build_apk.sh first to build the APK."
exit 1
fi
@ -26,40 +27,61 @@ if ! command -v gh &> /dev/null; then
exit 1
fi
# 3. Copy APK with standard name
APK_NAME="StreamFlix.apk"
cp "$APK_SOURCE" "$APK_NAME"
echo "📦 Prepared APK: $APK_NAME"
# 3. Prepare APKs with standard names
APK_MOBILE_NAME="StreamFlix.apk"
APK_TV_NAME="StreamFlix-TV.apk"
# 4. Create GitHub Release and upload APK
cp "$APK_MOBILE_SOURCE" "$APK_MOBILE_NAME"
echo "📱 Prepared Mobile APK: $APK_MOBILE_NAME"
cp "$APK_TV_SOURCE" "$APK_TV_NAME"
echo "📺 Prepared TV APK: $APK_TV_NAME"
# 4. Create GitHub Release and upload APKs
echo "📤 Creating GitHub Release ${TAG}..."
# Check if release already exists
if gh release view "$TAG" --repo "$REPO" &> /dev/null; then
echo "⚠️ Release $TAG already exists. Updating..."
gh release upload "$TAG" "$APK_NAME" --repo "$REPO" --clobber
gh release upload "$TAG" "$APK_MOBILE_NAME" "$APK_TV_NAME" --repo "$REPO" --clobber
else
echo "✨ Creating new release $TAG..."
gh release create "$TAG" "$APK_NAME" \
gh release create "$TAG" "$APK_MOBILE_NAME" "$APK_TV_NAME" \
--repo "$REPO" \
--title "StreamFlix ${TAG}" \
--notes "### What's New in ${TAG}
- 🤖 Android APK Release
- 📱 Universal APK for all Android devices
### Download
Click **StreamFlix.apk** below to download."
## Downloads
| Platform | APK |
|----------|-----|
| 📱 Android Mobile | StreamFlix.apk |
| 📺 Android TV | StreamFlix-TV.apk |
### Features
- 🎬 Cinema-quality streaming
- 📱 Optimized for mobile touch navigation
- 📺 D-pad navigation for Android TV remotes
- 🚀 Ad-free, high performance
### Installation
1. Download the appropriate APK for your device
2. Enable \"Install from Unknown Sources\" if prompted
3. Open and install the APK"
fi
# 5. Cleanup
rm "$APK_NAME"
rm "$APK_MOBILE_NAME" "$APK_TV_NAME"
echo ""
echo "✅ DEPLOYMENT SUCCESSFUL!"
echo "------------------------------------------------"
echo "📱 Download URL:"
echo "📱 Mobile Download URL:"
echo " https://github.com/${REPO}/releases/latest/download/StreamFlix.apk"
echo ""
echo "🌐 The download page will automatically link to this APK."
echo "📺 TV Download URL:"
echo " https://github.com/${REPO}/releases/latest/download/StreamFlix-TV.apk"
echo ""
echo "🌐 The download page will automatically link to these APKs."
echo " No Docker rebuild required!"
echo "------------------------------------------------"

View file

@ -211,12 +211,12 @@
built for privacy.</p>
<div class="platform-grid">
<!-- Android Card -->
<!-- Android Mobile Card -->
<div class="platform-card">
<span class="platform-icon">🤖</span>
<h2 class="platform-title">Android Mobile & TV</h2>
<span class="platform-version">Latest Version (Universal APK)</span>
<a href="https://github.com/vndangkhoa/Streamflow/releases/latest/download/app-debug.apk"
<span class="platform-icon">📱</span>
<h2 class="platform-title">Android Mobile</h2>
<span class="platform-version">Phones & Tablets</span>
<a href="https://github.com/vndangkhoa/Streamflow/releases/latest/download/StreamFlix.apk"
class="btn-download" target="_blank">Download APK</a>
<div class="instructions">
@ -229,6 +229,31 @@
</div>
</div>
<!-- Android TV Card -->
<div class="platform-card">
<span class="platform-icon">📺</span>
<h2 class="platform-title">Android TV</h2>
<span class="platform-version">Smart TVs & Streaming Devices</span>
<a href="https://github.com/vndangkhoa/Streamflow/releases/latest/download/StreamFlix-TV.apk"
class="btn-download" target="_blank">Download TV APK</a>
<div class="instructions">
<strong>Works with:</strong>
<ul style="padding-left: 20px; margin: 8px 0;">
<li>Android TV (Sony, Philips, TCL, etc.)</li>
<li>Google Chromecast with Google TV</li>
<li>NVIDIA Shield TV</li>
<li>Amazon Fire TV Stick</li>
<li>Xiaomi Mi Box</li>
</ul>
<strong>Install via USB:</strong>
<ol>
<li>Enable "Unknown Sources" in Settings → Security.</li>
<li>Use a file manager or ADB to install.</li>
</ol>
</div>
</div>
<!-- iOS Card -->
<div class="platform-card">
<span class="platform-icon">🍎</span>

View file

@ -1,6 +1,7 @@
/**
* 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 {
@ -8,25 +9,56 @@ export class KeyboardNavigation {
this.currentFocus = null;
this.isEnabled = false;
this.isTVMode = this.detectTVMode();
this.focusInitialized = false;
// Selectors for focusable items
// 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'
'.recommendation-card',
'button:not([disabled])',
'a[href]'
];
}
// Detect if running on Android TV or similar leanback device
/**
* Detect if running on Android TV or similar leanback device
* Uses multiple detection methods for reliability
*/
detectTVMode() {
const ua = navigator.userAgent.toLowerCase();
return ua.includes('android') && (ua.includes('tv') || ua.includes('aftm') || ua.includes('aftt'));
// 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() {
@ -38,16 +70,83 @@ export class KeyboardNavigation {
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) {
setTimeout(() => this.focusFirstVisible(), 500);
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.
// 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');
@ -56,6 +155,7 @@ export class KeyboardNavigation {
}
handleKey(e) {
// Handle navigation keys
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault(); // Prevent default page scroll
@ -84,25 +184,75 @@ export class KeyboardNavigation {
if (nextTarget) {
this.setFocus(nextTarget);
}
} else if (e.key === 'Enter') {
} 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');
// Trigger mouseleave logic if needed to reset z-index?
}
this.currentFocus = el;
@ -117,11 +267,45 @@ export class KeyboardNavigation {
});
}
/**
* 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) {
// 1. Try siblings first (if in a list)
// If direction is 1 (Right), look for nextElementSibling
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);
@ -129,32 +313,14 @@ export class KeyboardNavigation {
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?
// Don't jump to next row on 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 null; // Would jump to different row
}
return nextEl;
}
@ -175,36 +341,31 @@ export class KeyboardNavigation {
const rect = el.getBoundingClientRect();
if (direction === 1) { // Down
return rect.top >= currentRect.bottom - (currentRect.height * 0.2); // permit slight overlap
return rect.top >= currentRect.bottom - (currentRect.height * 0.3);
} else { // Up
return rect.bottom <= currentRect.top + (currentRect.height * 0.2);
return rect.bottom <= currentRect.top + (currentRect.height * 0.3);
}
});
if (candidates.length === 0) return null;
// Find the one with minimum distance
// Distance = Vertical Diff + Horizontal Diff penalty
// 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;
const elCenterY = rect.top + rect.height / 2;
// Vertical distance (primary)
const vDist = Math.abs(rect.top - currentRect.top);
// Horizontal alignment penalty
// Horizontal alignment penalty (prefer elements at similar X position)
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));
// 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;

View file

@ -501,7 +501,7 @@
.video-card.keyboard-focused .video-card__container,
.video-card:focus .video-card__container {
transform: scale(1.08);
box-shadow:
box-shadow:
0 0 0 4px var(--netflix-red),
0 0 30px rgba(229, 9, 20, 0.5),
var(--shadow-card-hover);
@ -510,12 +510,40 @@
/* TV Mode: Larger focus indicators for viewing distance */
@media (min-width: 1280px) {
.video-card.keyboard-focused .video-card__container,
.video-card:focus .video-card__container {
transform: scale(1.1);
box-shadow:
box-shadow:
0 0 0 6px var(--netflix-red),
0 0 50px rgba(229, 9, 20, 0.6),
var(--shadow-card-hover);
}
}
/* TV Mode Body Class - Enhanced for 10-foot UI */
body.tv-mode .video-card.keyboard-focused .video-card__container,
body.tv-mode .video-card:focus .video-card__container {
transform: scale(1.12);
box-shadow:
0 0 0 8px var(--netflix-red),
0 0 60px rgba(229, 9, 20, 0.7),
var(--shadow-card-hover);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
/* Generic focus styles for other interactive elements */
body.tv-mode button:focus,
body.tv-mode a:focus,
body.tv-mode .nav-link:focus,
body.tv-mode .tab-btn:focus,
body.tv-mode .episode-row:focus {
outline: 3px solid var(--netflix-red);
outline-offset: 2px;
}
/* Force show overlay on keyboard focus (for TV users) */
.video-card.keyboard-focused .video-card__overlay,
.video-card:focus .video-card__overlay {
opacity: 1;
}