feat(tv): adapt app for Android TV remote D-Pad, dedicated navigation drawer, rename catalog & server tabs, add dedicated TV APK

This commit is contained in:
Nikita 2026-10-26 21:53:53 +05:00
parent 7ec106acc0
commit 040b21d2a7
5 changed files with 613 additions and 45 deletions

View file

@ -163,6 +163,7 @@ jobs:
cp "$apk" "dist-apk/Home.Autli.Server_${RELEASE_TAG}_${filename}"
if [[ "$filename" == *"universal"* ]] || [[ "$filename" == *"release"* ]]; then
cp "$apk" "dist-apk/Home.Autli.Server_${RELEASE_TAG}.apk"
cp "$apk" "dist-apk/Home.Autli.Server_TV_${RELEASE_TAG}.apk"
fi
done
@ -170,8 +171,12 @@ jobs:
first_apk=$(find dist-apk -type f -name "*.apk" | head -n 1)
if [ -n "$first_apk" ]; then
cp "$first_apk" "dist-apk/Home.Autli.Server_${RELEASE_TAG}.apk"
cp "$first_apk" "dist-apk/Home.Autli.Server_TV_${RELEASE_TAG}.apk"
fi
fi
if [ ! -f "dist-apk/Home.Autli.Server_TV_${RELEASE_TAG}.apk" ] && [ -f "dist-apk/Home.Autli.Server_${RELEASE_TAG}.apk" ]; then
cp "dist-apk/Home.Autli.Server_${RELEASE_TAG}.apk" "dist-apk/Home.Autli.Server_TV_${RELEASE_TAG}.apk"
fi
echo "Uploading APK assets to GitHub release $RELEASE_TAG..."
if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then

View file

@ -1814,33 +1814,194 @@
6. ANDROID TV & REMOTE CONTROL (10-FOOT UI) STYLES
======================================================== */
/* Header TV Mode Switcher Button */
.has-cinema-tv-mode-btn {
/* Header TV Mode Pill Button & Navigation Drawer */
.has-cinema-tv-menu-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.18);
padding: 7px 16px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.25), rgba(139, 92, 246, 0.25));
border: 1px solid rgba(129, 140, 248, 0.5);
border-radius: 9999px;
color: #cbd5e1;
font-size: 13px;
color: #e0e7ff;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.has-cinema-tv-mode-btn:hover {
background: rgba(255, 255, 255, 0.16);
color: #ffffff;
border-color: rgba(255, 255, 255, 0.35);
}
.has-cinema-tv-mode-btn.active {
.has-cinema-tv-menu-pill:hover,
.has-cinema-tv-menu-pill:focus {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
border-color: #818cf8;
color: #ffffff;
box-shadow: 0 0 16px rgba(99, 102, 241, 0.6);
outline: 3px solid #818cf8 !important;
box-shadow: 0 0 20px rgba(129, 140, 248, 0.8) !important;
transform: scale(1.05);
}
/* Android TV Leanback Navigation Rail & Drawer */
.has-cinema-tv-nav-drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
display: flex;
flex-direction: column;
z-index: 1000;
transition: width 0.28s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.28s ease, background 0.28s ease;
}
.has-cinema-tv-nav-drawer.collapsed {
width: 66px;
background: rgba(10, 15, 28, 0.9);
backdrop-filter: blur(20px);
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.has-cinema-tv-nav-drawer.open {
width: 270px;
background: rgba(8, 12, 24, 0.97);
backdrop-filter: blur(24px);
border-right: 1px solid rgba(129, 140, 248, 0.4);
box-shadow: 15px 0 50px rgba(0, 0, 0, 0.9);
}
.has-cinema-tv-nav-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 12px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.has-cinema-tv-nav-logo {
display: flex;
align-items: center;
gap: 10px;
overflow: hidden;
}
.has-cinema-tv-nav-logo-badge {
background: linear-gradient(135deg, #6366f1, #a855f7);
color: #fff;
font-weight: 800;
font-size: 13px;
padding: 4px 8px;
border-radius: 8px;
letter-spacing: 1px;
}
.has-cinema-tv-nav-logo-text {
font-size: 16px;
font-weight: 700;
color: #f8fafc;
white-space: nowrap;
}
.has-cinema-tv-nav-toggle-btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
color: #cbd5e1;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
font-size: 12px;
}
.has-cinema-tv-nav-toggle-btn:focus,
.has-cinema-tv-nav-toggle-btn:hover {
background: #6366f1;
color: #ffffff;
outline: 3px solid #818cf8 !important;
box-shadow: 0 0 16px rgba(129, 140, 248, 0.7) !important;
}
.has-cinema-tv-nav-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 8px;
flex: 1;
overflow-y: auto;
}
.has-cinema-tv-nav-item {
width: 100%;
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
background: transparent;
border: 1px solid transparent;
border-radius: 12px;
color: #94a3b8;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
text-align: left;
}
.has-cinema-tv-nav-item:hover,
.has-cinema-tv-nav-item:focus {
background: rgba(99, 102, 241, 0.25) !important;
border-color: #818cf8 !important;
color: #ffffff !important;
outline: 3px solid #818cf8 !important;
box-shadow: 0 0 24px rgba(129, 140, 248, 0.8) !important;
transform: scale(1.04) !important;
}
.has-cinema-tv-nav-item.active {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.35), rgba(139, 92, 246, 0.35));
border-color: rgba(129, 140, 248, 0.6);
color: #ffffff;
}
.has-cinema-tv-nav-item.logout {
margin-top: auto;
color: #f87171;
}
.has-cinema-tv-nav-item.logout:hover,
.has-cinema-tv-nav-item.logout:focus {
background: rgba(239, 68, 68, 0.25) !important;
border-color: #f87171 !important;
outline-color: #f87171 !important;
box-shadow: 0 0 24px rgba(239, 68, 68, 0.8) !important;
}
.has-cinema-tv-nav-icon {
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
flex-shrink: 0;
}
.has-cinema-tv-nav-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.has-cinema-tv-nav-footer {
padding: 12px;
font-size: 11.5px;
color: #64748b;
text-align: center;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.has-tv-mode {
padding-left: 74px !important;
}
/* Universal Glowing Focus Indicators for D-Pad / Keyboard Navigation */

View file

@ -16,6 +16,9 @@ import "./Cinema.css";
interface CinemaProps {
currentUser: UserDto;
onNavigateTab?: (tab: string) => void;
onLogout?: () => void;
isTvEnvironment?: boolean;
}
const buildAnimeSeasons = (seasonConfigs: { season_number: number; count: number }[], thumb: string): CinemaSeason[] => {
@ -485,7 +488,7 @@ const detectTvEnvironment = (): boolean => {
const saved = localStorage.getItem("has_tv_mode");
if (saved !== null) return saved === "true";
const ua = (navigator.userAgent || "").toLowerCase();
return (
const isTvUa =
ua.includes("android tv") ||
ua.includes("smarttv") ||
ua.includes("googletv") ||
@ -495,15 +498,44 @@ const detectTvEnvironment = (): boolean => {
ua.includes("crkey") ||
ua.includes("bravia") ||
ua.includes("aftt") ||
ua.includes("aftm")
);
ua.includes("aftm") ||
ua.includes("mibox") ||
ua.includes("mi box") ||
ua.includes("shield") ||
ua.includes("dtv") ||
ua.includes("smart-tv") ||
ua.includes("hbbtv") ||
ua.includes("netcast") ||
ua.includes("viera") ||
ua.includes("roku") ||
ua.includes("firetv");
if (isTvUa) return true;
const isAndroid = ua.includes("android");
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
if (isAndroid && noTouch) return true;
return false;
};
export default function Cinema({ currentUser }: CinemaProps) {
export default function Cinema({
currentUser,
onNavigateTab,
onLogout,
isTvEnvironment = false,
}: CinemaProps) {
const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
// Android TV / Remote Control (10-Foot UI) State
const [isTvMode, setIsTvMode] = useState<boolean>(detectTvEnvironment);
const [isTvMode, setIsTvMode] = useState<boolean>(() => {
return isTvEnvironment || detectTvEnvironment();
});
const [isTvNavOpen, setIsTvNavOpen] = useState(false);
const lastFocusedCardRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isTvEnvironment && !isTvMode) {
setIsTvMode(true);
}
}, [isTvEnvironment, isTvMode]);
const [tvToast, setTvToast] = useState<{ text: string; icon?: string } | null>(null);
const tvToastTimerRef = useRef<any>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
@ -1307,6 +1339,7 @@ export default function Cinema({ currentUser }: CinemaProps) {
// Card Click Action: Opens rich media details modal (DOES NOT start player immediately)
const handleMediaCardClick = (item: CinemaMediaItem) => {
lastFocusedCardRef.current = document.activeElement as HTMLElement | null;
setSelectedMedia(item);
setDetailsMedia(item);
setSelectedSeason(1);
@ -1318,6 +1351,20 @@ export default function Cinema({ currentUser }: CinemaProps) {
playTvBeep(520, 0.035);
};
const focusFirstContentCard = useCallback(() => {
setTimeout(() => {
const target =
document.querySelector<HTMLElement>(".has-cinema-btn-play") ||
document.querySelector<HTMLElement>(".has-cinema-media-card") ||
document.querySelector<HTMLElement>(".has-cinema-grid-card");
if (target) {
target.focus();
target.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
playTvBeep(640, 0.03);
}
}, 50);
}, [playTvBeep]);
// Play button from Details Modal
const handlePlayFromDetails = (seasonNum: number = 1, episode?: CinemaEpisode) => {
if (!detailsMedia) return;
@ -1537,6 +1584,15 @@ export default function Cinema({ currentUser }: CinemaProps) {
// D-Pad Spatial Navigation & TV Remote Key Handler
const handleRemoteKeyDown = useCallback(
(e: KeyboardEvent) => {
// Auto-activate TV mode immediately on ANY remote control button press
const isRemoteKey =
[19, 20, 21, 22, 23, 4, 82, 85, 179].includes(e.keyCode) ||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key);
if (isRemoteKey && !isTvMode) {
setIsTvMode(true);
localStorage.setItem("has_tv_mode", "true");
}
const isInput =
document.activeElement instanceof HTMLInputElement ||
document.activeElement instanceof HTMLTextAreaElement;
@ -1553,7 +1609,28 @@ export default function Cinema({ currentUser }: CinemaProps) {
const isFastForward = e.key === "MediaFastForward" || e.keyCode === 90;
const isRewind = e.key === "MediaRewind" || e.keyCode === 89;
// F10 key toggles TV mode
// Remote Menu key (Android TV remote Menu button / ContextMenu)
if (e.key === "Menu" || e.keyCode === 82 || e.key === "ContextMenu") {
e.preventDefault();
setIsTvNavOpen((prev) => {
const next = !prev;
if (next) {
setTimeout(() => {
const navBtn =
document.querySelector<HTMLElement>(".has-cinema-tv-nav-item.active") ||
document.querySelector<HTMLElement>(".has-cinema-tv-nav-item");
if (navBtn) {
navBtn.focus();
playTvBeep(640, 0.03);
}
}, 30);
}
return next;
});
return;
}
// F10 key toggles TV mode fallback
if (e.key === "F10") {
e.preventDefault();
toggleTvMode();
@ -1585,7 +1662,6 @@ export default function Cinema({ currentUser }: CinemaProps) {
e.key === "ArrowUp" ||
e.keyCode === 19 ||
e.keyCode === 38 ||
e.key === "Menu" ||
e.key === "Info"
) {
e.preventDefault();
@ -1667,6 +1743,12 @@ export default function Cinema({ currentUser }: CinemaProps) {
// 2. Global Back button outside player
if (isBack) {
if (isTvNavOpen) {
e.preventDefault();
setIsTvNavOpen(false);
focusFirstContentCard();
return;
}
if (showTrailerModal) {
e.preventDefault();
setShowTrailerModal(false);
@ -1679,6 +1761,16 @@ export default function Cinema({ currentUser }: CinemaProps) {
setDetailsMedia(null);
showTvToast("Возврат к списку", "↩", 1000);
playTvBeep(450, 0.04);
setTimeout(() => {
if (lastFocusedCardRef.current && document.contains(lastFocusedCardRef.current)) {
lastFocusedCardRef.current.focus();
lastFocusedCardRef.current.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "center",
});
}
}, 40);
return;
}
if (searchQuery.trim().length > 0) {
@ -1686,13 +1778,15 @@ export default function Cinema({ currentUser }: CinemaProps) {
setSearchQuery("");
showTvToast("Поиск очищен", "🔍", 1200);
playTvBeep(450, 0.04);
focusFirstContentCard();
return;
}
if (activeTab !== "home") {
e.preventDefault();
setActiveTab("home");
showTvToast("Главный экран", "🏠", 1200);
showTvToast("Каталог", "🏠", 1200);
playTvBeep(450, 0.04);
focusFirstContentCard();
return;
}
}
@ -1735,9 +1829,20 @@ export default function Cinema({ currentUser }: CinemaProps) {
const settingsSheet = document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet");
const episodesSheet = document.querySelector<HTMLElement>(".has-cinema-player-episodes-sheet");
// If focus is currently inside the TV Navigation drawer
const currentActive = document.activeElement as HTMLElement | null;
if (currentActive && currentActive.closest(".has-cinema-tv-nav-drawer")) {
if (direction === "right") {
e.preventDefault();
setIsTvNavOpen(false);
focusFirstContentCard();
return;
}
}
let focusables = Array.from(
document.querySelectorAll<HTMLElement>(
'[data-tv-focusable="true"], .has-cinema-nav-item, .has-cinema-btn-play, .has-cinema-btn-bookmark, .has-cinema-search-input, .has-cinema-media-card, .has-cinema-grid-card, .has-cinema-episode-card, .has-cinema-player-hud-btn, .has-cinema-player-btn-back, .has-cinema-drawer-option-btn, .has-cinema-drawer-chip, .has-cinema-drawer-ep-btn, .has-cinema-drawer-close-btn, .has-cinema-drawer-btn-primary, .has-cinema-drawer-btn-secondary, .has-cinema-player-pill-nav, .has-cinema-season-tab, .has-cinema-tv-mode-btn, .has-cinema-details-btn-play, .has-cinema-details-close-btn, .has-cinema-details-btn-bookmark, .has-cinema-details-btn-trailer, .has-cinema-actor-card, .has-cinema-btn-info-wide'
'[data-tv-focusable="true"], .has-cinema-nav-item, .has-cinema-tv-nav-item, .has-cinema-tv-menu-pill, .has-cinema-btn-play, .has-cinema-btn-bookmark, .has-cinema-search-input, .has-cinema-media-card, .has-cinema-grid-card, .has-cinema-episode-card, .has-cinema-player-hud-btn, .has-cinema-player-btn-back, .has-cinema-drawer-option-btn, .has-cinema-drawer-chip, .has-cinema-drawer-ep-btn, .has-cinema-drawer-close-btn, .has-cinema-drawer-btn-primary, .has-cinema-drawer-btn-secondary, .has-cinema-player-pill-nav, .has-cinema-season-tab, .has-cinema-details-btn-play, .has-cinema-details-close-btn, .has-cinema-details-btn-bookmark, .has-cinema-details-btn-trailer, .has-cinema-actor-card, .has-cinema-btn-info-wide'
)
).filter((el) => {
const r = el.getBoundingClientRect();
@ -1823,14 +1928,34 @@ export default function Cinema({ currentUser }: CinemaProps) {
bestEl.focus();
bestEl.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
block: "center",
inline: "center",
});
playTvBeep(640, 0.025);
} else {
// If no element found in this direction outside modals:
// Trigger the TV Navigation Drawer on Left (from leftmost card) or Right (from rightmost edge)
if (!detailsEl && !playerEl && !trailerEl && !settingsSheet && !episodesSheet) {
if (direction === "left" || direction === "right") {
e.preventDefault();
setIsTvNavOpen(true);
setTimeout(() => {
const targetNav =
document.querySelector<HTMLElement>(".has-cinema-tv-nav-item.active") ||
document.querySelector<HTMLElement>(".has-cinema-tv-nav-item");
if (targetNav) {
targetNav.focus();
playTvBeep(640, 0.03);
}
}, 40);
}
}
}
}
},
[
isTvMode,
isTvNavOpen,
playingEpisode,
detailsMedia,
showTrailerModal,
@ -1843,6 +1968,7 @@ export default function Cinema({ currentUser }: CinemaProps) {
playTvBeep,
toggleTvMode,
resetHudTimer,
focusFirstContentCard,
]
);
@ -2125,6 +2251,174 @@ export default function Cinema({ currentUser }: CinemaProps) {
return (
<div className={`has-cinema-container ${isTvMode ? "has-tv-mode" : ""}`} ref={topRef}>
{/* ========================================================
ANDROID TV LEANBACK NAVIGATION DRAWER
(Opens when pressing Left from cards, remote Menu, or Menu pill)
======================================================== */}
{isTvMode && (
<aside
className={`has-cinema-tv-nav-drawer ${isTvNavOpen ? "open" : "collapsed"}`}
aria-label="Навигация для ТВ"
>
<div className="has-cinema-tv-nav-header">
<div className="has-cinema-tv-nav-logo">
<span className="has-cinema-tv-nav-logo-badge">HAS</span>
{isTvNavOpen && <span className="has-cinema-tv-nav-logo-text">Кинотеатр</span>}
</div>
<button
type="button"
className="has-cinema-tv-nav-toggle-btn"
onClick={() => setIsTvNavOpen((prev) => !prev)}
title={isTvNavOpen ? "Свернуть меню (Вправо)" : "Развернуть меню"}
data-tv-focusable="true"
>
{isTvNavOpen ? "◀" : "▶"}
</button>
</div>
<div className="has-cinema-tv-nav-list">
<button
type="button"
className={`has-cinema-tv-nav-item ${searchQuery ? "active" : ""}`}
onClick={() => {
setIsTvNavOpen(false);
const searchInput = document.querySelector<HTMLInputElement>(".has-cinema-search-input");
if (searchInput) {
searchInput.focus();
}
}}
data-tv-focusable="true"
title="Поиск"
>
<span className="has-cinema-tv-nav-icon">🔍</span>
{isTvNavOpen && <span className="has-cinema-tv-nav-label">Поиск</span>}
</button>
<button
type="button"
className={`has-cinema-tv-nav-item ${activeTab === "home" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("home");
setSearchQuery("");
setIsTvNavOpen(false);
focusFirstContentCard();
}}
data-tv-focusable="true"
title="Каталог"
>
<span className="has-cinema-tv-nav-icon">🎬</span>
{isTvNavOpen && <span className="has-cinema-tv-nav-label">Каталог</span>}
</button>
<button
type="button"
className={`has-cinema-tv-nav-item ${activeTab === "movies" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("movies");
setSearchQuery("");
setIsTvNavOpen(false);
focusFirstContentCard();
}}
data-tv-focusable="true"
title="Фильмы"
>
<span className="has-cinema-tv-nav-icon">🍿</span>
{isTvNavOpen && <span className="has-cinema-tv-nav-label">Фильмы</span>}
</button>
<button
type="button"
className={`has-cinema-tv-nav-item ${activeTab === "series" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("series");
setSearchQuery("");
setIsTvNavOpen(false);
focusFirstContentCard();
}}
data-tv-focusable="true"
title="Сериалы"
>
<span className="has-cinema-tv-nav-icon">📺</span>
{isTvNavOpen && <span className="has-cinema-tv-nav-label">Сериалы</span>}
</button>
<button
type="button"
className={`has-cinema-tv-nav-item ${activeTab === "anime" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("anime");
setSearchQuery("");
setIsTvNavOpen(false);
focusFirstContentCard();
}}
data-tv-focusable="true"
title="Аниме"
>
<span className="has-cinema-tv-nav-icon">⛩️</span>
{isTvNavOpen && <span className="has-cinema-tv-nav-label">Аниме</span>}
</button>
<button
type="button"
className={`has-cinema-tv-nav-item ${activeTab === "bookmarks" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("bookmarks");
setSearchQuery("");
setIsTvNavOpen(false);
focusFirstContentCard();
}}
data-tv-focusable="true"
title="Мой список"
>
<span className="has-cinema-tv-nav-icon">⭐</span>
{isTvNavOpen && (
<span className="has-cinema-tv-nav-label">
Мой список {cinemaState.bookmarks.length > 0 && `(${cinemaState.bookmarks.length})`}
</span>
)}
</button>
{onNavigateTab && (
<button
type="button"
className="has-cinema-tv-nav-item"
onClick={() => {
setIsTvNavOpen(false);
onNavigateTab("home");
}}
data-tv-focusable="true"
title="Панель сервера"
>
<span className="has-cinema-tv-nav-icon">🖥️</span>
{isTvNavOpen && <span className="has-cinema-tv-nav-label">Панель сервера</span>}
</button>
)}
{onLogout && (
<button
type="button"
className="has-cinema-tv-nav-item logout"
onClick={() => {
setIsTvNavOpen(false);
onLogout();
}}
data-tv-focusable="true"
title="Выйти"
>
<span className="has-cinema-tv-nav-icon">🚪</span>
{isTvNavOpen && <span className="has-cinema-tv-nav-label">Выйти</span>}
</button>
)}
</div>
{isTvNavOpen && (
<div className="has-cinema-tv-nav-footer">
<span>▶ Нажмите Вправо для перехода к фильмам</span>
</div>
)}
</aside>
)}
{/* ========================================================
1. TOP NAVIGATION BAR (All categories & search)
======================================================== */}
@ -2139,7 +2433,7 @@ export default function Cinema({ currentUser }: CinemaProps) {
}}
data-tv-focusable="true"
>
Главная
Каталог
</button>
<button
type="button"
@ -2188,16 +2482,17 @@ export default function Cinema({ currentUser }: CinemaProps) {
</nav>
<div className="has-cinema-header-right">
{/* Android TV 10-Foot Mode Switcher Button */}
<button
type="button"
className={`has-cinema-tv-mode-btn ${isTvMode ? "active" : ""}`}
onClick={toggleTvMode}
title={isTvMode ? "Выключить ТВ-режим (Для мыши)" : "Включить ТВ-режим (Для пульта ДУ Android TV)"}
data-tv-focusable="true"
>
{isTvMode ? "📺 ТВ-Режим: ВКЛ" : "📺 ТВ-Режим"}
</button>
{isTvMode && (
<button
type="button"
className={`has-cinema-tv-menu-pill ${isTvNavOpen ? "active" : ""}`}
onClick={() => setIsTvNavOpen((prev) => !prev)}
title="Меню навигации (Пульт: ◀ или Влево)"
data-tv-focusable="true"
>
☰ Разделы
</button>
)}
{/* Universal Search Box */}
<div className="has-cinema-search-wrapper">
@ -3672,8 +3967,8 @@ export default function Cinema({ currentUser }: CinemaProps) {
<span>Назад</span>
</div>
<div className="has-cinema-tv-guide-item">
<span className="has-cinema-tv-key">F10</span>
<span>ТВ-режим</span>
<span className="has-cinema-tv-key">◀ Меню</span>
<span>Разделы</span>
</div>
</div>
)}

View file

@ -704,3 +704,25 @@
}
}
/* Smart TV / Android TV Environment Rules */
.has-dashboard-wrapper.has-tv-mode-root .has-sidebar {
display: none !important;
}
.has-dashboard-wrapper.has-tv-mode-root .has-mobile-top-bar {
display: none !important;
}
.has-dashboard-wrapper.has-tv-mode-root .has-main-content {
margin-left: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
padding: 0 !important;
}
.has-dashboard-wrapper.has-tv-mode-root .has-tv-back-to-cinema-btn:focus {
outline: 4px solid #818cf8 !important;
box-shadow: 0 0 25px rgba(129, 140, 248, 0.9) !important;
transform: scale(1.05);
}

View file

@ -32,6 +32,40 @@ interface DashboardProps {
type NavTab = "home" | "storage" | "cinema" | "users" | "settings" | "profile" | "help";
// Helper for Smart TV / Android TV detection
const detectTvEnvironment = (): boolean => {
if (typeof window === "undefined") return false;
const saved = localStorage.getItem("has_tv_mode");
if (saved !== null) return saved === "true";
const ua = (navigator.userAgent || "").toLowerCase();
const isTvUa =
ua.includes("android tv") ||
ua.includes("smarttv") ||
ua.includes("googletv") ||
ua.includes("tizen") ||
ua.includes("webos") ||
ua.includes("appletv") ||
ua.includes("crkey") ||
ua.includes("bravia") ||
ua.includes("aftt") ||
ua.includes("aftm") ||
ua.includes("mibox") ||
ua.includes("mi box") ||
ua.includes("shield") ||
ua.includes("dtv") ||
ua.includes("smart-tv") ||
ua.includes("hbbtv") ||
ua.includes("netcast") ||
ua.includes("viera") ||
ua.includes("roku") ||
ua.includes("firetv");
if (isTvUa) return true;
const isAndroid = ua.includes("android");
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
if (isAndroid && noTouch) return true;
return false;
};
export default function Dashboard({
user,
dbInfo,
@ -43,7 +77,10 @@ export default function Dashboard({
setCurrentUser(user);
}, [user]);
const [activeTab, setActiveTab] = useState<NavTab>("home");
const [isTvEnvironment, setIsTvEnvironment] = useState<boolean>(detectTvEnvironment);
const [activeTab, setActiveTab] = useState<NavTab>(() => {
return detectTvEnvironment() ? "cinema" : "home";
});
const [serverUsers, setServerUsers] = useState<ServerUserDto[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
@ -51,6 +88,21 @@ export default function Dashboard({
});
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
// Dynamic D-Pad Remote Control Key Listener (Auto-detects TV when user presses remote buttons)
useEffect(() => {
const handleRemoteDpadKey = (e: KeyboardEvent) => {
const isRemoteKey =
[19, 20, 21, 22, 23, 4, 82, 85, 179].includes(e.keyCode) ||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key);
if (isRemoteKey && !isTvEnvironment) {
setIsTvEnvironment(true);
localStorage.setItem("has_tv_mode", "true");
}
};
window.addEventListener("keydown", handleRemoteDpadKey);
return () => window.removeEventListener("keydown", handleRemoteDpadKey);
}, [isTvEnvironment]);
const handleNavClick = (tab: NavTab) => {
setActiveTab(tab);
setMobileMenuOpen(false);
@ -151,7 +203,7 @@ export default function Dashboard({
}, [activeTab, loadServerUsers]);
return (
<div className="has-dashboard-wrapper">
<div className={`has-dashboard-wrapper ${isTvEnvironment ? "has-tv-mode-root" : ""}`}>
{/* Mobile Top Header (Visible on Mobile / Small screens) */}
<header className="has-mobile-top-bar">
<div
@ -233,7 +285,7 @@ export default function Dashboard({
type="button"
className={`has-nav-item ${activeTab === "home" ? "active" : ""}`}
onClick={() => handleNavClick("home")}
title={isCollapsed ? (isUserAdmin ? "Главная" : "Кинотеатр") : undefined}
title={isCollapsed ? (isUserAdmin ? "Панель сервера" : "Кинотеатр") : undefined}
>
<div className="has-nav-item-left">
{isUserAdmin ? (
@ -241,7 +293,7 @@ export default function Dashboard({
) : (
<CinemaNavIcon className="w-5 h-5" width={20} height={20} />
)}
{!isCollapsed && <span>{isUserAdmin ? "Главная" : "Кинотеатр"}</span>}
{!isCollapsed && <span>{isUserAdmin ? "Панель сервера" : "Кинотеатр"}</span>}
</div>
</button>
@ -389,6 +441,27 @@ export default function Dashboard({
<span className="has-status-indicator" />
<span>{dbStatus?.connected ? "Система активна" : "Проверка связи..."}</span>
</div>
{isTvEnvironment && (
<button
type="button"
className="has-tv-back-to-cinema-btn"
onClick={() => handleNavClick("cinema")}
data-tv-focusable="true"
style={{
padding: "10px 20px",
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
color: "#fff",
fontWeight: 600,
fontSize: "14px",
borderRadius: "8px",
border: "none",
cursor: "pointer",
}}
>
🎬 Вернуться в Кинотеатр
</button>
)}
</header>
)}
@ -404,13 +477,25 @@ export default function Dashboard({
usersCount={serverUsers.length}
/>
) : (
<Cinema currentUser={currentUser} />
<Cinema
currentUser={currentUser}
onNavigateTab={(tab) => handleNavClick(tab as NavTab)}
onLogout={onLogout}
isTvEnvironment={isTvEnvironment}
/>
)
)}
{activeTab === "storage" && <Storage currentUser={currentUser} />}
{activeTab === "cinema" && isUserAdmin && <Cinema currentUser={currentUser} />}
{activeTab === "cinema" && isUserAdmin && (
<Cinema
currentUser={currentUser}
onNavigateTab={(tab) => handleNavClick(tab as NavTab)}
onLogout={onLogout}
isTvEnvironment={isTvEnvironment}
/>
)}
{activeTab === "users" && isUserAdmin && (
<Users