From 8253ff5b7a8ab1b6b73e90b8ee71a76fc041b8ac Mon Sep 17 00:00:00 2001 From: "Khoa.vo" Date: Thu, 25 Dec 2025 07:26:06 +0700 Subject: [PATCH] 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 --- .agent/workflows/test-android-tv.md | 116 +++++++++++++ README.md | 31 ++-- backend/static/download.html | 41 ++++- deploy_apk.sh | 60 ++++--- frontend/download.html | 35 +++- frontend/scripts/keyboard-nav.js | 247 ++++++++++++++++++++++----- frontend/styles/components/cards.css | 32 +++- 7 files changed, 467 insertions(+), 95 deletions(-) create mode 100644 .agent/workflows/test-android-tv.md diff --git a/.agent/workflows/test-android-tv.md b/.agent/workflows/test-android-tv.md new file mode 100644 index 0000000..9660f28 --- /dev/null +++ b/.agent/workflows/test-android-tv.md @@ -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) diff --git a/README.md b/README.md index 13a227b..0fdf494 100644 --- a/README.md +++ b/README.md @@ -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 -- � **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" diff --git a/backend/static/download.html b/backend/static/download.html index ee3b589..28e6d98 100644 --- a/backend/static/download.html +++ b/backend/static/download.html @@ -6,7 +6,8 @@ StreamFlix - Download App - + + - @@ -204,19 +204,19 @@ ← Back to StreamFlix
- StreamFlix Logo + StreamFlix Logo

Download StreamFlix

Experience cinema-quality streaming on all your devices. Ad-free, high performance, and built for privacy.

- +
- 🤖 -

Android Mobile & TV

- Latest Version (Universal APK) - 📱 +

Android Mobile

+ Phones & Tablets +
Download APK
@@ -229,6 +229,31 @@
+ +
+ 📺 +

Android TV

+ Smart TVs & Streaming Devices + Download TV APK + +
+ Works with: +
    +
  • Android TV (Sony, Philips, TCL, etc.)
  • +
  • Google Chromecast with Google TV
  • +
  • NVIDIA Shield TV
  • +
  • Amazon Fire TV Stick
  • +
  • Xiaomi Mi Box
  • +
+ Install via USB: +
    +
  1. Enable "Unknown Sources" in Settings → Security.
  2. +
  3. Use a file manager or ADB to install.
  4. +
+
+
+
🍎 diff --git a/deploy_apk.sh b/deploy_apk.sh index 13541d5..6f243e0 100755 --- a/deploy_apk.sh +++ b/deploy_apk.sh @@ -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 "------------------------------------------------" diff --git a/frontend/download.html b/frontend/download.html index 03f6fd1..28e6d98 100644 --- a/frontend/download.html +++ b/frontend/download.html @@ -211,12 +211,12 @@ built for privacy.

- +
- 🤖 -

Android Mobile & TV

- Latest Version (Universal APK) - 📱 +

Android Mobile

+ Phones & Tablets +
Download APK
@@ -229,6 +229,31 @@
+ +
+ 📺 +

Android TV

+ Smart TVs & Streaming Devices + Download TV APK + +
+ Works with: +
    +
  • Android TV (Sony, Philips, TCL, etc.)
  • +
  • Google Chromecast with Google TV
  • +
  • NVIDIA Shield TV
  • +
  • Amazon Fire TV Stick
  • +
  • Xiaomi Mi Box
  • +
+ Install via USB: +
    +
  1. Enable "Unknown Sources" in Settings → Security.
  2. +
  3. Use a file manager or ADB to install.
  4. +
+
+
+
🍎 diff --git a/frontend/scripts/keyboard-nav.js b/frontend/scripts/keyboard-nav.js index bf86674..3e8d4c7 100644 --- a/frontend/scripts/keyboard-nav.js +++ b/frontend/scripts/keyboard-nav.js @@ -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; diff --git a/frontend/styles/components/cards.css b/frontend/styles/components/cards.css index 6b00aff..7a48cbc 100644 --- a/frontend/styles/components/cards.css +++ b/frontend/styles/components/cards.css @@ -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; } \ No newline at end of file