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:
parent
7ec106acc0
commit
040b21d2a7
5 changed files with 613 additions and 45 deletions
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
{isTvMode && (
|
||||
<button
|
||||
type="button"
|
||||
className={`has-cinema-tv-mode-btn ${isTvMode ? "active" : ""}`}
|
||||
onClick={toggleTvMode}
|
||||
title={isTvMode ? "Выключить ТВ-режим (Для мыши)" : "Включить ТВ-режим (Для пульта ДУ Android TV)"}
|
||||
className={`has-cinema-tv-menu-pill ${isTvNavOpen ? "active" : ""}`}
|
||||
onClick={() => setIsTvNavOpen((prev) => !prev)}
|
||||
title="Меню навигации (Пульт: ◀ или Влево)"
|
||||
data-tv-focusable="true"
|
||||
>
|
||||
{isTvMode ? "📺 ТВ-Режим: ВКЛ" : "📺 ТВ-Режим"}
|
||||
☰ Разделы
|
||||
</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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue