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:
parent
9d1d9bc741
commit
8253ff5b7a
7 changed files with 467 additions and 95 deletions
116
.agent/workflows/test-android-tv.md
Normal file
116
.agent/workflows/test-android-tv.md
Normal 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)
|
||||
31
README.md
31
README.md
|
|
@ -2,28 +2,23 @@
|
|||
|
||||
[](https://hub.docker.com/r/vndangkhoa/streamflix)
|
||||
[](https://github.com/vndangkhoa/Streamflow)
|
||||
[](https://github.com/vndangkhoa/Streamflow/releases)
|
||||
[](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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 "------------------------------------------------"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue