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://hub.docker.com/r/vndangkhoa/streamflix)
|
||||||
[](https://github.com/vndangkhoa/Streamflow)
|
[](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.
|
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:**
|
**What's New in v1.0.10:**
|
||||||
- 📱 **GitHub Releases APK:** Android APK now hosted on GitHub Releases (no Docker rebuild needed)
|
- 📺 **Android TV Support:** Full D-pad navigation for remote control usage
|
||||||
- 🎨 **New App Icon:** Updated Android launcher icon with StreamFlix branding
|
- 🎮 **Improved TV Detection:** Auto-detects Android TV, Fire TV, Shield, and other TV devices
|
||||||
- <20> **Simplified Deployment:** `deploy_apk.sh` now uploads directly to GitHub Releases
|
- 🎯 **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):**
|
**Previous (v1.0.9):**
|
||||||
- 🔧 **HOTFIX: Fixed Docker crash** - Added missing `Request` import causing NameError on startup
|
- 📱 GitHub Releases APK hosting
|
||||||
|
- 🎨 Updated Android launcher icon
|
||||||
**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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -68,7 +63,7 @@ version: '3.8'
|
||||||
services:
|
services:
|
||||||
# StreamFlow Unified (Backend + Frontend)
|
# StreamFlow Unified (Backend + Frontend)
|
||||||
app:
|
app:
|
||||||
image: vndangkhoa/streamflix:1.0.9
|
image: vndangkhoa/streamflix:1.0.10
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
ports:
|
ports:
|
||||||
- "3478:8000"
|
- "3478:8000"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
<meta name="viewport"
|
<meta name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>StreamFlix - Download App</title>
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--safe-top: env(safe-area-inset-top, 0px);
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
|
@ -196,7 +197,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/download-m6ZKmHFf.css">
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -204,19 +204,19 @@
|
||||||
<a href="/" class="back-link">← Back to StreamFlix</a>
|
<a href="/" class="back-link">← Back to StreamFlix</a>
|
||||||
|
|
||||||
<div class="download-container">
|
<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>
|
<h1>Download StreamFlix</h1>
|
||||||
<p class="subtitle">Experience cinema-quality streaming on all your devices. Ad-free, high performance, and
|
<p class="subtitle">Experience cinema-quality streaming on all your devices. Ad-free, high performance, and
|
||||||
built for privacy.</p>
|
built for privacy.</p>
|
||||||
|
|
||||||
<div class="platform-grid">
|
<div class="platform-grid">
|
||||||
<!-- Android Card -->
|
<!-- Android Mobile Card -->
|
||||||
<div class="platform-card">
|
<div class="platform-card">
|
||||||
<span class="platform-icon">🤖</span>
|
<span class="platform-icon">📱</span>
|
||||||
<h2 class="platform-title">Android Mobile & TV</h2>
|
<h2 class="platform-title">Android Mobile</h2>
|
||||||
<span class="platform-version">Latest Version (Universal APK)</span>
|
<span class="platform-version">Phones & Tablets</span>
|
||||||
<a href="https://github.com/vndangkhoa/Streamflow/releases/latest/download/app-debug.apk"
|
<a href="https://github.com/vndangkhoa/Streamflow/releases/latest/download/StreamFlix.apk"
|
||||||
class="btn-download" target="_blank">Download APK</a>
|
class="btn-download" target="_blank">Download APK</a>
|
||||||
|
|
||||||
<div class="instructions">
|
<div class="instructions">
|
||||||
|
|
@ -229,6 +229,31 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- iOS Card -->
|
||||||
<div class="platform-card">
|
<div class="platform-card">
|
||||||
<span class="platform-icon">🍎</span>
|
<span class="platform-icon">🍎</span>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# StreamFlix APK Deployment Script
|
# 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"
|
REPO="vndangkhoa/Streamflow"
|
||||||
|
|
||||||
# Get version from build.gradle
|
# Get version from build.gradle
|
||||||
VERSION=$(grep -o 'versionName "[^"]*"' frontend/android/app/build.gradle | sed 's/versionName "//;s/"//')
|
VERSION=$(grep -o 'versionName "[^"]*"' frontend/android/app/build.gradle | sed 's/versionName "//;s/"//')
|
||||||
TAG="v${VERSION}"
|
TAG="v${VERSION}"
|
||||||
|
|
||||||
echo "🚀 Deploying StreamFlix APK ${TAG}..."
|
echo "🚀 Deploying StreamFlix APKs ${TAG}..."
|
||||||
|
|
||||||
# 1. Check if APK exists
|
# 1. Check if APK exists
|
||||||
if [ ! -f "$APK_SOURCE" ]; then
|
if [ ! -f "$APK_MOBILE_SOURCE" ]; then
|
||||||
echo "❌ APK build not found at $APK_SOURCE"
|
echo "❌ Mobile APK build not found at $APK_MOBILE_SOURCE"
|
||||||
echo " Run ./build_apk.sh first to build the APK."
|
echo " Run ./build_apk.sh first to build the APK."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
@ -26,40 +27,61 @@ if ! command -v gh &> /dev/null; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Copy APK with standard name
|
# 3. Prepare APKs with standard names
|
||||||
APK_NAME="StreamFlix.apk"
|
APK_MOBILE_NAME="StreamFlix.apk"
|
||||||
cp "$APK_SOURCE" "$APK_NAME"
|
APK_TV_NAME="StreamFlix-TV.apk"
|
||||||
echo "📦 Prepared APK: $APK_NAME"
|
|
||||||
|
|
||||||
# 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}..."
|
echo "📤 Creating GitHub Release ${TAG}..."
|
||||||
|
|
||||||
# Check if release already exists
|
# Check if release already exists
|
||||||
if gh release view "$TAG" --repo "$REPO" &> /dev/null; then
|
if gh release view "$TAG" --repo "$REPO" &> /dev/null; then
|
||||||
echo "⚠️ Release $TAG already exists. Updating..."
|
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
|
else
|
||||||
echo "✨ Creating new release $TAG..."
|
echo "✨ Creating new release $TAG..."
|
||||||
gh release create "$TAG" "$APK_NAME" \
|
gh release create "$TAG" "$APK_MOBILE_NAME" "$APK_TV_NAME" \
|
||||||
--repo "$REPO" \
|
--repo "$REPO" \
|
||||||
--title "StreamFlix ${TAG}" \
|
--title "StreamFlix ${TAG}" \
|
||||||
--notes "### What's New in ${TAG}
|
--notes "### What's New in ${TAG}
|
||||||
- 🤖 Android APK Release
|
|
||||||
- 📱 Universal APK for all Android devices
|
|
||||||
|
|
||||||
### Download
|
## Downloads
|
||||||
Click **StreamFlix.apk** below to download."
|
|
||||||
|
| 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
|
fi
|
||||||
|
|
||||||
# 5. Cleanup
|
# 5. Cleanup
|
||||||
rm "$APK_NAME"
|
rm "$APK_MOBILE_NAME" "$APK_TV_NAME"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ DEPLOYMENT SUCCESSFUL!"
|
echo "✅ DEPLOYMENT SUCCESSFUL!"
|
||||||
echo "------------------------------------------------"
|
echo "------------------------------------------------"
|
||||||
echo "📱 Download URL:"
|
echo "📱 Mobile Download URL:"
|
||||||
echo " https://github.com/${REPO}/releases/latest/download/StreamFlix.apk"
|
echo " https://github.com/${REPO}/releases/latest/download/StreamFlix.apk"
|
||||||
echo ""
|
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 " No Docker rebuild required!"
|
||||||
echo "------------------------------------------------"
|
echo "------------------------------------------------"
|
||||||
|
|
|
||||||
|
|
@ -211,12 +211,12 @@
|
||||||
built for privacy.</p>
|
built for privacy.</p>
|
||||||
|
|
||||||
<div class="platform-grid">
|
<div class="platform-grid">
|
||||||
<!-- Android Card -->
|
<!-- Android Mobile Card -->
|
||||||
<div class="platform-card">
|
<div class="platform-card">
|
||||||
<span class="platform-icon">🤖</span>
|
<span class="platform-icon">📱</span>
|
||||||
<h2 class="platform-title">Android Mobile & TV</h2>
|
<h2 class="platform-title">Android Mobile</h2>
|
||||||
<span class="platform-version">Latest Version (Universal APK)</span>
|
<span class="platform-version">Phones & Tablets</span>
|
||||||
<a href="https://github.com/vndangkhoa/Streamflow/releases/latest/download/app-debug.apk"
|
<a href="https://github.com/vndangkhoa/Streamflow/releases/latest/download/StreamFlix.apk"
|
||||||
class="btn-download" target="_blank">Download APK</a>
|
class="btn-download" target="_blank">Download APK</a>
|
||||||
|
|
||||||
<div class="instructions">
|
<div class="instructions">
|
||||||
|
|
@ -229,6 +229,31 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- iOS Card -->
|
||||||
<div class="platform-card">
|
<div class="platform-card">
|
||||||
<span class="platform-icon">🍎</span>
|
<span class="platform-icon">🍎</span>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* TV-Style Keyboard Navigation
|
* TV-Style Keyboard Navigation
|
||||||
* Handles Arrow keys to navigate horizontally through sliders and vertically between rows.
|
* Handles Arrow keys to navigate horizontally through sliders and vertically between rows.
|
||||||
|
* Optimized for Android TV D-pad remote control navigation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class KeyboardNavigation {
|
export class KeyboardNavigation {
|
||||||
|
|
@ -8,25 +9,56 @@ export class KeyboardNavigation {
|
||||||
this.currentFocus = null;
|
this.currentFocus = null;
|
||||||
this.isEnabled = false;
|
this.isEnabled = false;
|
||||||
this.isTVMode = this.detectTVMode();
|
this.isTVMode = this.detectTVMode();
|
||||||
|
this.focusInitialized = false;
|
||||||
|
|
||||||
// Selectors for focusable items
|
// Selectors for focusable items (in priority order)
|
||||||
this.selectors = [
|
this.selectors = [
|
||||||
'.video-card',
|
'.video-card',
|
||||||
'.hero__btn',
|
'.hero__btn',
|
||||||
'.slider-btn',
|
'.slider-btn',
|
||||||
'#topSearchBtn',
|
'#topSearchBtn',
|
||||||
'.nav-item',
|
'.nav-item',
|
||||||
|
'.nav-link',
|
||||||
'.category-card',
|
'.category-card',
|
||||||
'.tab-btn',
|
'.tab-btn',
|
||||||
'.episode-row',
|
'.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() {
|
detectTVMode() {
|
||||||
const ua = navigator.userAgent.toLowerCase();
|
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() {
|
init() {
|
||||||
|
|
@ -38,16 +70,83 @@ export class KeyboardNavigation {
|
||||||
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
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)
|
// Auto-focus first card for TV mode (helps D-pad users start navigating)
|
||||||
if (this.isTVMode) {
|
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() {
|
handleMouseMove() {
|
||||||
// If mouse moves, likely user is using mouse.
|
// 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) {
|
if (this.currentFocus) {
|
||||||
this.currentFocus.blur();
|
this.currentFocus.blur();
|
||||||
this.currentFocus.classList.remove('keyboard-focused');
|
this.currentFocus.classList.remove('keyboard-focused');
|
||||||
|
|
@ -56,6 +155,7 @@ export class KeyboardNavigation {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKey(e) {
|
handleKey(e) {
|
||||||
|
// Handle navigation keys
|
||||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||||
e.preventDefault(); // Prevent default page scroll
|
e.preventDefault(); // Prevent default page scroll
|
||||||
|
|
||||||
|
|
@ -84,25 +184,75 @@ export class KeyboardNavigation {
|
||||||
if (nextTarget) {
|
if (nextTarget) {
|
||||||
this.setFocus(nextTarget);
|
this.setFocus(nextTarget);
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
// Select/activate focused element
|
||||||
if (this.currentFocus) {
|
if (this.currentFocus) {
|
||||||
|
e.preventDefault();
|
||||||
this.currentFocus.click();
|
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() {
|
focusFirstVisible() {
|
||||||
// Find first video card in viewport
|
// Find first video card in viewport
|
||||||
const candidates = document.querySelectorAll('.video-card');
|
const candidates = document.querySelectorAll('.video-card');
|
||||||
if (candidates.length > 0) {
|
if (candidates.length > 0) {
|
||||||
this.setFocus(candidates[0]);
|
this.setFocus(candidates[0]);
|
||||||
|
this.focusInitialized = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setFocus(el) {
|
setFocus(el) {
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
if (this.currentFocus) {
|
if (this.currentFocus) {
|
||||||
this.currentFocus.classList.remove('keyboard-focused');
|
this.currentFocus.classList.remove('keyboard-focused');
|
||||||
// Trigger mouseleave logic if needed to reset z-index?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentFocus = el;
|
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) {
|
moveHorizontal(direction) {
|
||||||
// 1. Try siblings first (if in a list)
|
|
||||||
// If direction is 1 (Right), look for nextElementSibling
|
|
||||||
if (!this.currentFocus) return null;
|
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 allFocusable = Array.from(document.querySelectorAll(this.selectors.join(',')));
|
||||||
const currentIndex = allFocusable.indexOf(this.currentFocus);
|
const currentIndex = allFocusable.indexOf(this.currentFocus);
|
||||||
|
|
||||||
|
|
@ -129,32 +313,14 @@ export class KeyboardNavigation {
|
||||||
|
|
||||||
const nextIndex = currentIndex + direction;
|
const nextIndex = currentIndex + direction;
|
||||||
if (nextIndex >= 0 && nextIndex < allFocusable.length) {
|
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 currentRect = this.currentFocus.getBoundingClientRect();
|
||||||
const nextEl = allFocusable[nextIndex];
|
const nextEl = allFocusable[nextIndex];
|
||||||
const nextRect = nextEl.getBoundingClientRect();
|
const nextRect = nextEl.getBoundingClientRect();
|
||||||
|
|
||||||
// Heuristic: If vertical distance is large, it's a new row.
|
// Don't jump to next row on horizontal nav
|
||||||
// If delta Y > height/2, maybe block horizontal nav?
|
|
||||||
const verticalDist = Math.abs(currentRect.top - nextRect.top);
|
const verticalDist = Math.abs(currentRect.top - nextRect.top);
|
||||||
if (verticalDist > currentRect.height * 0.5) {
|
if (verticalDist > currentRect.height * 0.5) {
|
||||||
// New row. Should arrow keys wrap?
|
return null; // Would jump to different row
|
||||||
// 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 nextEl;
|
||||||
}
|
}
|
||||||
|
|
@ -175,36 +341,31 @@ export class KeyboardNavigation {
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
|
|
||||||
if (direction === 1) { // Down
|
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
|
} 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;
|
if (candidates.length === 0) return null;
|
||||||
|
|
||||||
// Find the one with minimum distance
|
// Find the one with minimum distance, prioritizing horizontal alignment
|
||||||
// Distance = Vertical Diff + Horizontal Diff penalty
|
|
||||||
let bestCandidate = null;
|
let bestCandidate = null;
|
||||||
let minDistance = Infinity;
|
let minDistance = Infinity;
|
||||||
|
|
||||||
candidates.forEach(el => {
|
candidates.forEach(el => {
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
const elCenterX = rect.left + rect.width / 2;
|
const elCenterX = rect.left + rect.width / 2;
|
||||||
const elCenterY = rect.top + rect.height / 2;
|
|
||||||
|
|
||||||
// Vertical distance (primary)
|
// Vertical distance (primary)
|
||||||
const vDist = Math.abs(rect.top - currentRect.top);
|
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);
|
const hDist = Math.abs(elCenterX - centerX);
|
||||||
|
|
||||||
// Weighted distance: Vertical matter, but horizontally closest is best within that band.
|
// Weight: prefer closer rows, then prefer horizontal alignment
|
||||||
// Actually, we usually want the "row immediately below".
|
// Vertical distance matters more for row-based navigation
|
||||||
// So sort by Vertical distance first.
|
const dist = vDist * 2 + hDist;
|
||||||
|
|
||||||
// Simple Euclidean distance?
|
|
||||||
const dist = Math.sqrt(Math.pow(vDist, 2) + Math.pow(hDist, 2));
|
|
||||||
|
|
||||||
if (dist < minDistance) {
|
if (dist < minDistance) {
|
||||||
minDistance = dist;
|
minDistance = dist;
|
||||||
|
|
|
||||||
|
|
@ -501,7 +501,7 @@
|
||||||
.video-card.keyboard-focused .video-card__container,
|
.video-card.keyboard-focused .video-card__container,
|
||||||
.video-card:focus .video-card__container {
|
.video-card:focus .video-card__container {
|
||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 4px var(--netflix-red),
|
0 0 0 4px var(--netflix-red),
|
||||||
0 0 30px rgba(229, 9, 20, 0.5),
|
0 0 30px rgba(229, 9, 20, 0.5),
|
||||||
var(--shadow-card-hover);
|
var(--shadow-card-hover);
|
||||||
|
|
@ -510,12 +510,40 @@
|
||||||
|
|
||||||
/* TV Mode: Larger focus indicators for viewing distance */
|
/* TV Mode: Larger focus indicators for viewing distance */
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 1280px) {
|
||||||
|
|
||||||
.video-card.keyboard-focused .video-card__container,
|
.video-card.keyboard-focused .video-card__container,
|
||||||
.video-card:focus .video-card__container {
|
.video-card:focus .video-card__container {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 6px var(--netflix-red),
|
0 0 0 6px var(--netflix-red),
|
||||||
0 0 50px rgba(229, 9, 20, 0.6),
|
0 0 50px rgba(229, 9, 20, 0.6),
|
||||||
var(--shadow-card-hover);
|
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