Compare commits

..

No commits in common. "main" and "v0.0.14" have entirely different histories.

9 changed files with 508 additions and 1049 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "has", "name": "has",
"private": true, "private": true,
"version": "0.0.19", "version": "0.0.14",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "has" name = "has"
version = "0.0.19" version = "0.0.14"
description = "has" description = "has"
authors = ["autli"] authors = ["autli"]
edition = "2024" edition = "2024"

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- AndroidTV support --> <!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" /> <uses-feature android:name="android.software.leanback" android:required="false" />

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "has", "productName": "has",
"version": "0.0.19", "version": "0.0.14",
"identifier": "com.has.tauri-app", "identifier": "com.has.tauri-app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View file

@ -443,52 +443,6 @@ svg {
color: #ffffff; color: #ffffff;
} }
/* TV Auto-update Toggle Chip & Countdown */
.has-tv-updater-autotoggle {
margin-top: 20px;
background: rgba(255, 255, 255, 0.06);
border: 1px dashed rgba(255, 255, 255, 0.25);
border-radius: 12px;
padding: 10px 20px;
font-size: 13px;
color: #94a3b8;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
outline: none;
}
.has-tv-updater-autotoggle.active {
background: rgba(16, 185, 129, 0.15);
border-color: rgba(16, 185, 129, 0.5);
color: #6ee7b7;
}
.has-tv-updater-autotoggle.focused {
transform: scale(1.05);
border-color: #ffffff !important;
box-shadow: 0 0 0 3px #ffffff, 0 0 25px rgba(99, 102, 241, 0.7) !important;
color: #ffffff !important;
background: rgba(99, 102, 241, 0.35);
}
.has-tv-updater-countdown {
margin-bottom: 14px;
font-size: 14px;
color: #38bdf8;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 8px;
background: rgba(56, 189, 248, 0.12);
border: 1px solid rgba(56, 189, 248, 0.3);
}
/* TV Progress View */ /* TV Progress View */
.has-tv-updater-progress-container { .has-tv-updater-progress-container {
width: 100%; width: 100%;

View file

@ -27,6 +27,8 @@ const isTauri = () => {
// Helper for Smart TV / Android TV detection // Helper for Smart TV / Android TV detection
const detectTvEnvironment = (): boolean => { const detectTvEnvironment = (): boolean => {
if (typeof window === "undefined") return false; 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 ua = (navigator.userAgent || "").toLowerCase();
const isTvUa = const isTvUa =
ua.includes("android tv") || ua.includes("android tv") ||
@ -53,17 +55,6 @@ const detectTvEnvironment = (): boolean => {
const isAndroid = ua.includes("android"); const isAndroid = ua.includes("android");
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0; const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
if (isAndroid && noTouch) return true; if (isAndroid && noTouch) return true;
// On pure desktop (macOS / Windows / Desktop Linux), default to desktop PC mode
const isDesktop =
(ua.includes("windows nt") || ua.includes("macintosh") || (ua.includes("linux") && !ua.includes("android"))) &&
!isTvUa;
if (isDesktop) {
return localStorage.getItem("has_tv_mode_manual") === "true";
}
const saved = localStorage.getItem("has_tv_mode");
if (saved !== null) return saved === "true";
return false; return false;
}; };
@ -153,6 +144,21 @@ export default function App() {
checkDatabase(); checkDatabase();
}, [isTvEnvironment]); }, [isTvEnvironment]);
// Activate TV mode immediately upon any remote control key press
useEffect(() => {
const handleRemoteKey = (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", handleRemoteKey);
return () => window.removeEventListener("keydown", handleRemoteKey);
}, [isTvEnvironment]);
// Countdown timer for OTP re-send in resetStage 2 // Countdown timer for OTP re-send in resetStage 2
useEffect(() => { useEffect(() => {
if (resetStage !== 2) return; if (resetStage !== 2) return;
@ -519,7 +525,6 @@ export default function App() {
dbInfo={dbInfo} dbInfo={dbInfo}
dbStatus={dbStatus} dbStatus={dbStatus}
onLogout={handleLogout} onLogout={handleLogout}
isTvEnvironment={isTvEnvironment}
/> />
</> </>
); );
@ -537,7 +542,6 @@ export default function App() {
onSwitchToDesktop={() => { onSwitchToDesktop={() => {
setIsTvEnvironment(false); setIsTvEnvironment(false);
localStorage.setItem("has_tv_mode", "false"); localStorage.setItem("has_tv_mode", "false");
localStorage.setItem("has_tv_mode_manual", "false");
}} }}
/> />
</> </>
@ -808,7 +812,6 @@ export default function App() {
onClick={() => { onClick={() => {
setIsTvEnvironment(true); setIsTvEnvironment(true);
localStorage.setItem("has_tv_mode", "true"); localStorage.setItem("has_tv_mode", "true");
localStorage.setItem("has_tv_mode_manual", "true");
}} }}
title="Перейти в режим Android TV (выбор профилей)" title="Перейти в режим Android TV (выбор профилей)"
style={{ style={{

View file

@ -22,6 +22,8 @@ interface UpdaterBannerProps {
// Helper for Smart TV / Android TV detection // Helper for Smart TV / Android TV detection
const detectTvEnvironment = (): boolean => { const detectTvEnvironment = (): boolean => {
if (typeof window === "undefined") return false; 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 ua = (navigator.userAgent || "").toLowerCase();
const isTvUa = const isTvUa =
ua.includes("android tv") || ua.includes("android tv") ||
@ -48,17 +50,6 @@ const detectTvEnvironment = (): boolean => {
const isAndroid = ua.includes("android"); const isAndroid = ua.includes("android");
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0; const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
if (isAndroid && noTouch) return true; if (isAndroid && noTouch) return true;
// On pure desktop (macOS / Windows / Desktop Linux), default to desktop PC mode
const isDesktop =
(ua.includes("windows nt") || ua.includes("macintosh") || (ua.includes("linux") && !ua.includes("android"))) &&
!isTvUa;
if (isDesktop) {
return localStorage.getItem("has_tv_mode_manual") === "true";
}
const saved = localStorage.getItem("has_tv_mode");
if (saved !== null) return saved === "true";
return false; return false;
}; };
@ -97,24 +88,16 @@ const isNewerSemver = (remote: string, current: string): boolean => {
export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) { export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
const [isTv, setIsTv] = useState<boolean>(() => isTvEnvironment ?? detectTvEnvironment()); const [isTv, setIsTv] = useState<boolean>(() => isTvEnvironment ?? detectTvEnvironment());
const [currentAppVersion, setCurrentAppVersion] = useState<string>("0.0.19"); const [currentAppVersion, setCurrentAppVersion] = useState<string>("0.0.14");
const [updateAvailable, setUpdateAvailable] = useState(false); const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null); const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [statusText, setStatusText] = useState<string>("Загрузка обновления...");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
// Auto-update preferences for Android TV // TV remote navigation: "update" | "skip" | "exit"
const [autoUpdateTv, setAutoUpdateTv] = useState<boolean>(() => { const [tvFocusedBtn, setTvFocusedBtn] = useState<"update" | "skip" | "exit">("update");
if (typeof window === "undefined") return false;
return localStorage.getItem("has_auto_update_tv") === "true";
});
const [autoCountdown, setAutoCountdown] = useState<number | null>(null);
// TV remote navigation: "update" | "skip" | "exit" | "autotoggle"
const [tvFocusedBtn, setTvFocusedBtn] = useState<"update" | "skip" | "exit" | "autotoggle">("update");
const updateBtnRef = useRef<HTMLButtonElement | null>(null); const updateBtnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => { useEffect(() => {
@ -287,16 +270,16 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
case "android_tv": case "android_tv":
return { return {
badge: "📺 Android TV", badge: "📺 Android TV",
title: "Хотите ли вы обновить приложение HAS?", title: `Доступно обновление для Android TV (v${version})`,
desc: "Доступна новая оптимизированная версия для телевизоров и ТВ-приставок с поддержкой пульта ДУ, балансеров видео и повышенной стабильностью.", desc: "Специальная сборка для телевизоров и ТВ-приставок с поддержкой пульта ДУ (D-Pad), Leanback-интерфейса и плавного воспроизведения.",
btnLabel: "🚀 Да, обновить сейчас", btnLabel: "🚀 Обновить APK для ТВ",
icon: "📺", icon: "📺",
}; };
case "android_mobile": case "android_mobile":
return { return {
badge: "📱 Android", badge: "📱 Android",
title: "Хотите ли вы обновить приложение HAS?", title: `Доступно обновление для Android (v${version})`,
desc: "Новая версия приложения для мобильных устройств на Android.", desc: "Новая версия приложения для мобильных телефонов и планшетов на Android.",
btnLabel: "🚀 Скачать APK для Android", btnLabel: "🚀 Скачать APK для Android",
icon: "📱", icon: "📱",
}; };
@ -330,7 +313,7 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
const platformInfo = getPlatformDetails(); const platformInfo = getPlatformDetails();
// Smart download target selector // Smart download target selector
const resolveTargetUrl = useCallback((): string => { const resolveTargetUrl = (): string => {
if (!updateInfo) return "https://github.com/autli5/has/releases/latest"; if (!updateInfo) return "https://github.com/autli5/has/releases/latest";
switch (clientPlatform) { switch (clientPlatform) {
case "windows": case "windows":
@ -346,29 +329,12 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
default: default:
return updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest"; return updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
} }
}, [clientPlatform, updateInfo]); };
const cancelAutoCountdown = useCallback(() => {
setAutoCountdown(null);
}, []);
const toggleAutoUpdate = useCallback(() => {
setAutoUpdateTv((prev) => {
const next = !prev;
try {
localStorage.setItem("has_auto_update_tv", next ? "true" : "false");
} catch {}
return next;
});
cancelAutoCountdown();
}, [cancelAutoCountdown]);
const handleInstall = useCallback(async () => { const handleInstall = useCallback(async () => {
cancelAutoCountdown();
setDownloading(true); setDownloading(true);
setError(null); setError(null);
setProgress(0); setProgress(0);
setStatusText("Подготовка к установке обновления...");
// Desktop Windows / macOS / Linux Tauri updater plugin // Desktop Windows / macOS / Linux Tauri updater plugin
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window && !isTv) { if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window && !isTv) {
@ -380,7 +346,6 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
if (update?.available) { if (update?.available) {
let downloaded = 0; let downloaded = 0;
let total = 0; let total = 0;
setStatusText("Загрузка обновления...");
await update.downloadAndInstall((event: any) => { await update.downloadAndInstall((event: any) => {
if (event.event === "Started") { if (event.event === "Started") {
@ -389,12 +354,9 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
downloaded += event.data?.chunkLength ?? 0; downloaded += event.data?.chunkLength ?? 0;
} else if (event.event === "Finished") { } else if (event.event === "Finished") {
setProgress(100); setProgress(100);
setStatusText("Перезапуск программы...");
} }
if (total > 0) { if (total > 0) {
const p = Math.round((downloaded / total) * 100); setProgress(Math.round((downloaded / total) * 100));
setProgress(p);
setStatusText(`Загрузка: ${p}%`);
} }
}); });
@ -406,82 +368,30 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
} }
} }
// Android TV / Mobile / Direct APK Download & Installation // Direct download and open target platform installer/APK
const targetUrl = resolveTargetUrl(); const targetUrl = resolveTargetUrl();
setStatusText("Подключение к серверу загрузки APK...");
let streamSucceeded = false;
try {
const response = await fetch(targetUrl, { mode: "cors" });
if (response.ok && response.body) {
const contentLength = +(response.headers.get("Content-Length") || 0);
const reader = response.body.getReader();
let received = 0;
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
received += value.length;
if (contentLength > 0) {
const pct = Math.min(99, Math.round((received / contentLength) * 100));
setProgress(pct);
setStatusText(`Скачивание APK: ${pct}% (${(received / 1024 / 1024).toFixed(1)} МБ)`);
} else {
setStatusText(`Скачивание APK: ${(received / 1024 / 1024).toFixed(1)} МБ`);
}
}
}
setProgress(100);
setStatusText("APK успешно скачан! Запуск установки...");
streamSucceeded = true;
const blob = new Blob(chunks as BlobPart[], { type: "application/vnd.android.package-archive" });
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = `has-tv-v${updateInfo?.version || "update"}.apk`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
} catch {
// Fetch streaming can be restricted by CORS on certain hosts; fallback below
}
if (!streamSucceeded) {
let p = 0; let p = 0;
await new Promise<void>((resolve) => {
const progressTimer = setInterval(() => { const progressTimer = setInterval(() => {
p += 20; p += 20;
if (p >= 100) { if (p >= 100) {
clearInterval(progressTimer); clearInterval(progressTimer);
setProgress(100); setProgress(100);
setStatusText("Запуск установщика приложения..."); setTimeout(async () => {
resolve();
} else {
setProgress(p);
setStatusText(`Загрузка: ${p}%`);
}
}, 120);
});
}
try { try {
const { openUrl } = await import("@tauri-apps/plugin-opener"); const { openUrl } = await import("@tauri-apps/plugin-opener");
await openUrl(targetUrl); await openUrl(targetUrl);
} catch { } catch {
window.open(targetUrl, "_blank"); window.open(targetUrl, "_blank");
} }
setTimeout(() => {
setDismissed(true); setDismissed(true);
setDownloading(false); setDownloading(false);
}, 1800); }, 400);
}, [cancelAutoCountdown, isTv, updateInfo, resolveTargetUrl]); } else {
setProgress(p);
}
}, 150);
}, [isTv, clientPlatform, updateInfo]);
// Clean Exit from TV application // Clean Exit from TV application
const handleExitApp = useCallback(async () => { const handleExitApp = useCallback(async () => {
@ -493,30 +403,6 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
} }
}, []); }, []);
// Auto-update countdown for Android TV
useEffect(() => {
if (!updateAvailable || dismissed || !isTv || downloading) return;
if (!autoUpdateTv) {
setAutoCountdown(null);
return;
}
setAutoCountdown(5);
const interval = setInterval(() => {
setAutoCountdown((prev) => {
if (prev === null) return null;
if (prev <= 1) {
clearInterval(interval);
handleInstall();
return null;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [updateAvailable, dismissed, isTv, downloading, autoUpdateTv, handleInstall]);
// Strict Remote Control D-Pad Navigation for TV Update Modal // Strict Remote Control D-Pad Navigation for TV Update Modal
useEffect(() => { useEffect(() => {
if (!updateAvailable || dismissed || !isTv || downloading) return; if (!updateAvailable || dismissed || !isTv || downloading) return;
@ -529,49 +415,37 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
}, 50); }, 50);
const handleTvKeyDown = (e: KeyboardEvent) => { const handleTvKeyDown = (e: KeyboardEvent) => {
cancelAutoCountdown(); // Cycle between "update" -> "skip" -> "exit"
if (
// Down: switch to auto-update toggle e.key === "ArrowRight" ||
if (e.key === "ArrowDown" || e.keyCode === 20) { e.key === "ArrowDown" ||
e.keyCode === 22 ||
e.keyCode === 20 ||
e.key === "Tab"
) {
e.preventDefault(); e.preventDefault();
playTvBeep(520, 0.025);
if (tvFocusedBtn !== "autotoggle") {
setTvFocusedBtn("autotoggle");
}
return;
}
// Up: switch back from auto-update toggle to update button
if (e.key === "ArrowUp" || e.keyCode === 19) {
e.preventDefault();
playTvBeep(520, 0.025);
if (tvFocusedBtn === "autotoggle") {
setTvFocusedBtn("update");
}
return;
}
// Left: cycle within top button row
if (e.key === "ArrowLeft" || e.keyCode === 21) {
e.preventDefault();
playTvBeep(520, 0.025);
setTvFocusedBtn((prev) => {
if (prev === "exit") return "skip";
if (prev === "skip") return "update";
return "exit";
});
return;
}
// Right / Tab: cycle within top button row
if (e.key === "ArrowRight" || e.keyCode === 22 || e.key === "Tab") {
e.preventDefault();
playTvBeep(520, 0.025);
setTvFocusedBtn((prev) => { setTvFocusedBtn((prev) => {
if (prev === "update") return "skip"; if (prev === "update") return "skip";
if (prev === "skip") return "exit"; if (prev === "skip") return "exit";
return "update"; return "update";
}); });
playTvBeep(520, 0.025);
return;
}
if (
e.key === "ArrowLeft" ||
e.key === "ArrowUp" ||
e.keyCode === 21 ||
e.keyCode === 19
) {
e.preventDefault();
setTvFocusedBtn((prev) => {
if (prev === "exit") return "skip";
if (prev === "skip") return "update";
return "exit";
});
playTvBeep(520, 0.025);
return; return;
} }
@ -584,9 +458,6 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
} else if (tvFocusedBtn === "exit") { } else if (tvFocusedBtn === "exit") {
playTvBeep(320, 0.06); playTvBeep(320, 0.06);
handleExitApp(); handleExitApp();
} else if (tvFocusedBtn === "autotoggle") {
playTvBeep(560, 0.04);
toggleAutoUpdate();
} else { } else {
playTvBeep(420, 0.04); playTvBeep(420, 0.04);
setDismissed(true); setDismissed(true);
@ -604,7 +475,7 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
window.addEventListener("keydown", handleTvKeyDown); window.addEventListener("keydown", handleTvKeyDown);
return () => window.removeEventListener("keydown", handleTvKeyDown); return () => window.removeEventListener("keydown", handleTvKeyDown);
}, [updateAvailable, dismissed, isTv, downloading, tvFocusedBtn, handleInstall, handleExitApp, cancelAutoCountdown, toggleAutoUpdate]); }, [updateAvailable, dismissed, isTv, downloading, tvFocusedBtn, handleInstall, handleExitApp]);
if (!updateAvailable || dismissed) return null; if (!updateAvailable || dismissed) return null;
@ -616,21 +487,15 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
<div className="has-tv-updater-overlay" role="dialog" aria-modal="true"> <div className="has-tv-updater-overlay" role="dialog" aria-modal="true">
<div className="has-tv-updater-card"> <div className="has-tv-updater-card">
<div className="has-tv-updater-icon-ring">{platformInfo.icon}</div> <div className="has-tv-updater-icon-ring">{platformInfo.icon}</div>
<h2 className="has-tv-updater-title">Хотите ли вы обновить приложение HAS?</h2> <h2 className="has-tv-updater-title">{platformInfo.title}</h2>
<div className="has-tv-updater-version-tag"> <div className="has-tv-updater-version-tag">
<span>📺 Android TV • v{currentAppVersion} ➔ v{updateInfo?.version || "новейшая"}</span> <span>{platformInfo.badge}</span>
</div> </div>
<p className="has-tv-updater-desc"> <p className="has-tv-updater-desc">
{updateInfo?.body || platformInfo.desc} {updateInfo?.body || platformInfo.desc}
</p> </p>
{autoCountdown !== null && !downloading && (
<div className="has-tv-updater-countdown">
⚡ Автообновление активно: начнется через {autoCountdown} сек... (нажмите любую кнопку для отмены)
</div>
)}
{downloading ? ( {downloading ? (
<div className="has-tv-updater-progress-container"> <div className="has-tv-updater-progress-container">
<div className="has-tv-updater-progress-track"> <div className="has-tv-updater-progress-track">
@ -640,13 +505,12 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
/> />
</div> </div>
<span className="has-tv-updater-progress-text"> <span className="has-tv-updater-progress-text">
{statusText} Загрузка и установка обновления: {progress}%
</span> </span>
</div> </div>
) : ( ) : (
<>
<div className="has-tv-updater-buttons"> <div className="has-tv-updater-buttons">
{/* Button 1: Да, обновить сейчас */} {/* Button 1: Обновить APK для ТВ */}
<button <button
ref={updateBtnRef} ref={updateBtnRef}
type="button" type="button"
@ -655,20 +519,16 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
}`} }`}
onClick={() => { onClick={() => {
playTvBeep(680, 0.05); playTvBeep(680, 0.05);
cancelAutoCountdown();
handleInstall(); handleInstall();
}} }}
onMouseEnter={() => { onMouseEnter={() => setTvFocusedBtn("update")}
cancelAutoCountdown();
setTvFocusedBtn("update");
}}
tabIndex={0} tabIndex={0}
data-tv-focusable="true" data-tv-focusable="true"
> >
<span>{platformInfo.btnLabel}</span> <span>{platformInfo.btnLabel}</span>
</button> </button>
{/* Button 2: Напомнить позже */} {/* Button 2: Пропустить */}
<button <button
type="button" type="button"
className={`has-tv-updater-btn btn-skip ${ className={`has-tv-updater-btn btn-skip ${
@ -676,17 +536,13 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
}`} }`}
onClick={() => { onClick={() => {
playTvBeep(420, 0.04); playTvBeep(420, 0.04);
cancelAutoCountdown();
setDismissed(true); setDismissed(true);
}} }}
onMouseEnter={() => { onMouseEnter={() => setTvFocusedBtn("skip")}
cancelAutoCountdown();
setTvFocusedBtn("skip");
}}
tabIndex={0} tabIndex={0}
data-tv-focusable="true" data-tv-focusable="true"
> >
<span>Напомнить позже</span> <span>Пропустить</span>
</button> </button>
{/* Button 3: Выход из приложения (Exit) */} {/* Button 3: Выход из приложения (Exit) */}
@ -697,13 +553,9 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
}`} }`}
onClick={() => { onClick={() => {
playTvBeep(320, 0.06); playTvBeep(320, 0.06);
cancelAutoCountdown();
handleExitApp(); handleExitApp();
}} }}
onMouseEnter={() => { onMouseEnter={() => setTvFocusedBtn("exit")}
cancelAutoCountdown();
setTvFocusedBtn("exit");
}}
tabIndex={0} tabIndex={0}
data-tv-focusable="true" data-tv-focusable="true"
title="Выйти из приложения" title="Выйти из приложения"
@ -711,33 +563,12 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
<span>✕ Выход</span> <span>✕ Выход</span>
</button> </button>
</div> </div>
{/* Auto-update Toggle Chip for Android TV */}
<button
type="button"
className={`has-tv-updater-autotoggle ${autoUpdateTv ? "active" : ""} ${
tvFocusedBtn === "autotoggle" ? "focused" : ""
}`}
onClick={toggleAutoUpdate}
onMouseEnter={() => {
cancelAutoCountdown();
setTvFocusedBtn("autotoggle");
}}
tabIndex={0}
data-tv-focusable="true"
title="Переключить режим автоматического скачивания обновлений для ТВ"
>
{autoUpdateTv
? "🟢 Автообновление для TV: ВКЛЮЧЕНО (скачивать без запроса)"
: "⚪ Автообновление для TV: ВЫКЛЮЧЕНО (нажмите OK чтобы включить)"}
</button>
</>
)} )}
{error && <div className="updater-error" style={{ marginTop: 16 }}>{error}</div>} {error && <div className="updater-error" style={{ marginTop: 16 }}>{error}</div>}
<div className="has-tv-updater-remote-hint"> <div className="has-tv-updater-remote-hint">
◄ ► Стрелки на пульте: выбор • ▲ ▼ Вверх/Вниз: переключение опций • OK: подтвердить • Back: позже ◄ ► Стрелки на пульте: выбор • OK: подтвердить • Back: пропустить
</div> </div>
</div> </div>
</div> </div>

View file

@ -493,6 +493,8 @@ const formatMediaImg = (path: string | undefined, size: "w500" | "original" = "w
// Detect Android TV / Smart TV user agent or previous user preference // Detect Android TV / Smart TV user agent or previous user preference
const detectTvEnvironment = (): boolean => { const detectTvEnvironment = (): boolean => {
if (typeof window === "undefined") return false; 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 ua = (navigator.userAgent || "").toLowerCase();
const isTvUa = const isTvUa =
ua.includes("android tv") || ua.includes("android tv") ||
@ -519,17 +521,6 @@ const detectTvEnvironment = (): boolean => {
const isAndroid = ua.includes("android"); const isAndroid = ua.includes("android");
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0; const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
if (isAndroid && noTouch) return true; if (isAndroid && noTouch) return true;
// On pure desktop (macOS / Windows / Desktop Linux), default to desktop PC mode
const isDesktop =
(ua.includes("windows nt") || ua.includes("macintosh") || (ua.includes("linux") && !ua.includes("android"))) &&
!isTvUa;
if (isDesktop) {
return localStorage.getItem("has_tv_mode_manual") === "true";
}
const saved = localStorage.getItem("has_tv_mode");
if (saved !== null) return saved === "true";
return false; return false;
}; };
@ -549,10 +540,10 @@ export default function Cinema({
const lastFocusedCardRef = useRef<HTMLElement | null>(null); const lastFocusedCardRef = useRef<HTMLElement | null>(null);
useEffect(() => { useEffect(() => {
if (isTvEnvironment !== isTvMode) { if (isTvEnvironment && !isTvMode) {
setIsTvMode(isTvEnvironment); setIsTvMode(true);
} }
}, [isTvEnvironment]); }, [isTvEnvironment, isTvMode]);
const [tvToast, setTvToast] = useState<{ text: string; icon?: string } | null>(null); const [tvToast, setTvToast] = useState<{ text: string; icon?: string } | null>(null);
const tvToastTimerRef = useRef<any>(null); const tvToastTimerRef = useRef<any>(null);
@ -572,7 +563,6 @@ export default function Cinema({
setIsTvMode((prev) => { setIsTvMode((prev) => {
const next = !prev; const next = !prev;
localStorage.setItem("has_tv_mode", String(next)); localStorage.setItem("has_tv_mode", String(next));
localStorage.setItem("has_tv_mode_manual", String(next));
showTvToast(next ? "ТВ-Режим включен (10-Foot UI)" : "ПК-Режим включен", "📺", 2500); showTvToast(next ? "ТВ-Режим включен (10-Foot UI)" : "ПК-Режим включен", "📺", 2500);
playTvBeep(next ? 750 : 400, 0.05); playTvBeep(next ? 750 : 400, 0.05);
return next; return next;
@ -664,171 +654,65 @@ export default function Cinema({
const [isPlayingState, setIsPlayingState] = useState(true); const [isPlayingState, setIsPlayingState] = useState(true);
// Robust multi-level recursive window broadcast to reach nested balancer iframes // Dedicated multi-protocol command dispatcher to balancer iframes
const broadcastToWindowTree = useCallback((payload: any) => { const sendIframePlaybackCommand = useCallback((command: "play" | "pause" | "toggle") => {
const iframe = document.querySelector<HTMLIFrameElement>(".has-cinema-iframe-elem"); const iframe = document.querySelector<HTMLIFrameElement>(".has-cinema-iframe-elem");
if (!iframe || !iframe.contentWindow) return; if (!iframe || !iframe.contentWindow) return;
const send = (target: Window) => { const win = iframe.contentWindow;
// Never focus iframe directly — focus must strictly remain on our app controls for TV remote
const post = (payload: any) => {
try { try {
target.postMessage(payload, "*"); win.postMessage(payload, "*");
} catch {} } catch {}
try { try {
if (typeof payload === "object") { win.postMessage(JSON.stringify(payload), "*");
target.postMessage(JSON.stringify(payload), "*");
}
} catch {} } catch {}
}; };
const rootWin = iframe.contentWindow;
send(rootWin);
// Broadcast down into subframes (VidLink, Alloha, Playerjs, AutoEmbed nested wrappers)
try {
if (rootWin.frames && rootWin.frames.length > 0) {
for (let i = 0; i < rootWin.frames.length; i++) {
try {
send(rootWin.frames[i]);
if (rootWin.frames[i].frames && rootWin.frames[i].frames.length > 0) {
for (let j = 0; j < rootWin.frames[i].frames.length; j++) {
try {
send(rootWin.frames[i].frames[j]);
} catch {}
}
}
} catch {}
}
}
} catch {}
}, []);
// Dedicated multi-protocol command dispatcher to balancer iframes
const sendIframePlaybackCommand = useCallback((command: "play" | "pause" | "toggle") => {
const isPlay = command === "play"; const isPlay = command === "play";
const isToggle = command === "toggle"; const isToggle = command === "toggle";
// Отправляем команды многократно для надежности (разные плееры реагируют в разное время)
for (let i = 0; i < 3; i++) {
setTimeout(() => {
// 1. Kodik Player API (Supports both object and stringified commands) // 1. Kodik Player API (Supports both object and stringified commands)
broadcastToWindowTree({ key: "kodik_player_api", value: { method: isToggle ? "toggle" : (isPlay ? "play" : "pause") } }); post({ key: "kodik_player_api", value: { method: isToggle ? "toggle" : (isPlay ? "play" : "pause") } });
if (!isToggle) { if (!isToggle) {
broadcastToWindowTree({ key: "kodik_player_api", value: { method: isPlay ? "play" : "pause" } }); post({ key: "kodik_player_api", value: { method: isPlay ? "play" : "pause" } });
} }
// 2. Playerjs API (Used by Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka) // 2. Playerjs API (Used by Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka)
broadcastToWindowTree({ api: isToggle ? "toggle" : (isPlay ? "play" : "pause") }); post({ api: isToggle ? "toggle" : (isPlay ? "play" : "pause") });
if (!isToggle) { if (!isToggle) {
broadcastToWindowTree({ api: isPlay ? "play" : "pause" }); post({ api: isPlay ? "play" : "pause" });
} }
// 3. Alloha API // 3. Alloha API
broadcastToWindowTree({ action: isPlay ? "play" : "pause" }); post({ action: isPlay ? "play" : "pause" });
broadcastToWindowTree({ method: isPlay ? "play" : "pause" }); post({ method: isPlay ? "play" : "pause" });
// 4. VidLink Pro / Video.js API // 4. VidLink Pro / Video.js API
broadcastToWindowTree({ type: isPlay ? "PLAYER_PLAY" : "PLAYER_PAUSE" }); post({ type: isPlay ? "PLAYER_PLAY" : "PLAYER_PAUSE" });
broadcastToWindowTree({ type: isPlay ? "play" : "pause" }); post({ type: isPlay ? "play" : "pause" });
broadcastToWindowTree({ action: isPlay ? "play" : "pause" }); post({ action: isPlay ? "play" : "pause" });
broadcastToWindowTree({ event: isPlay ? "play" : "pause" });
// 5. YouTube / VidSrc / AutoEmbed HTML5 API // 5. YouTube / VidSrc / AutoEmbed HTML5 API
broadcastToWindowTree({ event: "command", func: isPlay ? "playVideo" : "pauseVideo", args: "" }); post({ event: "command", func: isPlay ? "playVideo" : "pauseVideo", args: "" });
// 6. Generic HTML5 postMessage // 6. Generic HTML5 postMessage
broadcastToWindowTree({ method: isPlay ? "play" : "pause" }); post({ method: isPlay ? "play" : "pause" });
broadcastToWindowTree({ command: isPlay ? "play" : "pause" }); post({ command: isPlay ? "play" : "pause" });
}, i * 100); }, []);
}
}, [broadcastToWindowTree]);
const setPlayerVolumeMax = useCallback((showToast: boolean = false) => {
// 1. Native HTML5 Video Element
if (videoRef.current) {
try {
videoRef.current.volume = 1.0;
videoRef.current.muted = false;
} catch {}
}
// 2. Kodik API
broadcastToWindowTree({ key: "kodik_player_api", value: { method: "volume", volume: 1 } });
broadcastToWindowTree({ key: "kodik_player_api", value: { method: "volume", volume: 100 } });
broadcastToWindowTree({ key: "kodik_player_api", value: { method: "unmute" } });
// 3. Playerjs API (Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka)
broadcastToWindowTree({ api: "volume", set: 100 });
broadcastToWindowTree({ api: "volume", set: 1 });
broadcastToWindowTree({ api: "volume", volume: 100 });
broadcastToWindowTree({ api: "volume", volume: 1 });
broadcastToWindowTree({ api: "unmute" });
// 4. Alloha API
broadcastToWindowTree({ action: "volume", volume: 1 });
broadcastToWindowTree({ action: "volume", value: 100 });
broadcastToWindowTree({ action: "volume", set: 100 });
broadcastToWindowTree({ action: "unmute" });
broadcastToWindowTree({ method: "volume", value: 1 });
broadcastToWindowTree({ method: "setVolume", value: 100 });
broadcastToWindowTree({ method: "setVolume", volume: 100 });
broadcastToWindowTree({ method: "unmute" });
broadcastToWindowTree({ method: "unMute" });
// 5. JW Player / VidLink Pro / Video.js API
broadcastToWindowTree({ method: "setMute", value: false });
broadcastToWindowTree({ method: "setMute", mute: false });
broadcastToWindowTree({ method: "setVolume", value: 100 });
broadcastToWindowTree({ method: "setVolume", volume: 100 });
broadcastToWindowTree({ action: "setMute", value: false });
broadcastToWindowTree({ action: "setVolume", value: 100 });
broadcastToWindowTree({ command: "setMute", value: false });
broadcastToWindowTree({ type: "SET_VOLUME", volume: 100 });
broadcastToWindowTree({ type: "SET_VOLUME", data: { volume: 100 } });
broadcastToWindowTree({ type: "UNMUTE" });
broadcastToWindowTree({ type: "unmute" });
broadcastToWindowTree({ action: "volume", value: 1 });
broadcastToWindowTree({ action: "unmute" });
// 6. YouTube / VidSrc / AutoEmbed HTML5 API
broadcastToWindowTree({ event: "command", func: "setVolume", args: [100] });
broadcastToWindowTree({ event: "command", func: "unMute", args: "" });
// 7. Generic HTML5 postMessage
broadcastToWindowTree({ method: "setVolume", volume: 100 });
broadcastToWindowTree({ method: "setVolume", value: 100 });
broadcastToWindowTree({ method: "unmute" });
broadcastToWindowTree({ method: "unMute" });
broadcastToWindowTree({ command: "setVolume", volume: 100 });
broadcastToWindowTree({ command: "unmute" });
if (showToast) {
showTvToast("Громкость плеера: 100%", "🔊", 1500);
}
}, [broadcastToWindowTree, showTvToast]);
const triggerPlayerPlay = useCallback(() => { const triggerPlayerPlay = useCallback(() => {
if (videoRef.current) { if (videoRef.current) {
try {
videoRef.current.muted = false;
videoRef.current.volume = 1.0;
} catch {}
videoRef.current.play().catch(() => {}); videoRef.current.play().catch(() => {});
} else { } else {
sendIframePlaybackCommand("play"); sendIframePlaybackCommand("play");
const iframe = document.querySelector<HTMLIFrameElement>(".has-cinema-iframe-elem");
if (iframe) {
try {
iframe.focus();
} catch {}
} }
}
// Установка громкости 100% каждый раз при воспроизведении
setPlayerVolumeMax(false);
setTimeout(() => setPlayerVolumeMax(false), 400);
setTimeout(() => setPlayerVolumeMax(false), 1200);
setIsPlayingState(true); setIsPlayingState(true);
showTvToast("Воспроизведение", "▶", 1200); showTvToast("Воспроизведение", "▶", 1200);
}, [sendIframePlaybackCommand, setPlayerVolumeMax, showTvToast]); }, [sendIframePlaybackCommand, showTvToast]);
const triggerPlayerPause = useCallback(() => { const triggerPlayerPause = useCallback(() => {
if (videoRef.current) { if (videoRef.current) {
@ -853,9 +737,8 @@ export default function Cinema({
} else { } else {
triggerPlayerPlay(); triggerPlayerPlay();
} }
sendIframePlaybackCommand("toggle");
} }
}, [isPlayingState, triggerPlayerPlay, triggerPlayerPause, sendIframePlaybackCommand]); }, [isPlayingState, triggerPlayerPlay, triggerPlayerPause]);
const triggerPlayerSeek = useCallback((secondsDelta: number) => { const triggerPlayerSeek = useCallback((secondsDelta: number) => {
if (videoRef.current) { if (videoRef.current) {
@ -865,59 +748,53 @@ export default function Cinema({
); );
currentPlaybackRef.current.currentTime = videoRef.current.currentTime; currentPlaybackRef.current.currentTime = videoRef.current.currentTime;
} else { } else {
const iframe = document.querySelector<HTMLIFrameElement>(".has-cinema-iframe-elem");
if (iframe && iframe.contentWindow) {
const win = iframe.contentWindow;
const post = (payload: any) => {
try { win.postMessage(payload, "*"); } catch {}
try { win.postMessage(JSON.stringify(payload), "*"); } catch {}
};
const curTime = currentPlaybackRef.current.currentTime || 0; const curTime = currentPlaybackRef.current.currentTime || 0;
const targetTime = Math.max(0, curTime + secondsDelta); const targetTime = Math.max(0, curTime + secondsDelta);
currentPlaybackRef.current.currentTime = targetTime; currentPlaybackRef.current.currentTime = targetTime;
// Отправляем команды перемотки несколько раз для надежности
for (let i = 0; i < 3; i++) {
setTimeout(() => {
// 1. Kodik API (Seek to absolute seconds & relative seek delta) // 1. Kodik API (Seek to absolute seconds & relative seek delta)
broadcastToWindowTree({ key: "kodik_player_api", value: { method: "seek", time: targetTime } }); post({ key: "kodik_player_api", value: { method: "seek", time: targetTime } });
broadcastToWindowTree({ key: "kodik_player_api", value: { method: "seek_delta", time: secondsDelta } }); post({ key: "kodik_player_api", value: { method: "seek_delta", time: secondsDelta } });
broadcastToWindowTree({ key: "kodik_player_api", value: { method: secondsDelta > 0 ? "forward" : "rewind", time: Math.abs(secondsDelta) } }); post({ key: "kodik_player_api", value: { method: secondsDelta > 0 ? "forward" : "rewind", time: Math.abs(secondsDelta) } });
// 2. Playerjs API (Used by Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka) // 2. Playerjs API (Used by Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka)
broadcastToWindowTree({ api: "seek", set: targetTime }); post({ api: "seek", time: targetTime });
broadcastToWindowTree({ api: "seek", set: secondsDelta > 0 ? `+${secondsDelta}` : `${secondsDelta}` }); post({ api: "seek", delta: secondsDelta });
broadcastToWindowTree({ api: "seek", time: targetTime }); post({ api: "seek_delta", time: secondsDelta });
broadcastToWindowTree({ api: "seek", delta: secondsDelta }); post({ api: "seek", time: secondsDelta > 0 ? `+${secondsDelta}` : `${secondsDelta}` });
broadcastToWindowTree({ api: "seek_delta", time: secondsDelta });
// 3. Alloha API // 3. Alloha API
broadcastToWindowTree({ action: "seek", time: targetTime }); post({ action: "seek", time: targetTime });
broadcastToWindowTree({ action: "seek", delta: secondsDelta }); post({ action: "seek", delta: secondsDelta });
broadcastToWindowTree({ method: "seek", time: targetTime }); post({ method: "seek", time: targetTime });
broadcastToWindowTree({ method: "seekTo", value: targetTime }); post({ method: "seekTo", value: targetTime });
// 4. VidLink Pro / Video.js API // 4. VidLink Pro / Video.js API
broadcastToWindowTree({ type: "SEEK", data: { time: targetTime } }); post({ type: "SEEK", data: { time: targetTime } });
broadcastToWindowTree({ type: "PLAYER_SEEK", time: targetTime }); post({ type: "PLAYER_SEEK", time: targetTime });
broadcastToWindowTree({ type: "seek", time: targetTime }); post({ type: "seek", time: targetTime });
broadcastToWindowTree({ action: "seek", time: targetTime }); post({ action: "seek", time: targetTime });
broadcastToWindowTree({ action: "seek", delta: secondsDelta }); post({ action: "seek", delta: secondsDelta });
// 5. YouTube / VidSrc / AutoEmbed HTML5 API // 5. YouTube / VidSrc / AutoEmbed HTML5 API
broadcastToWindowTree({ event: "command", func: "seekTo", args: [targetTime, true] }); post({ event: "command", func: "seekTo", args: [targetTime, true] });
broadcastToWindowTree({ event: "command", func: "seek", args: [targetTime] }); post({ event: "command", func: "seek", args: [targetTime] });
broadcastToWindowTree({ action: "seekTo", value: targetTime }); post({ action: "seekTo", value: targetTime });
// 6. Generic HTML5 postMessage // 6. Generic HTML5 postMessage
broadcastToWindowTree({ method: "seek", time: targetTime }); post({ method: "seek", time: targetTime });
broadcastToWindowTree({ command: "seek", time: targetTime }); post({ command: "seek", time: targetTime });
}, i * 100);
} }
} }
showTvToast(secondsDelta > 0 ? `+${secondsDelta} сек` : `${secondsDelta} сек`, secondsDelta > 0 ? "⏩" : "⏪", 1000); showTvToast(secondsDelta > 0 ? `+${secondsDelta} сек` : `${secondsDelta} сек`, secondsDelta > 0 ? "⏩" : "⏪", 1000);
}, [broadcastToWindowTree, showTvToast]);
// Give TV remote control directly to iframe if a user needs to interact with in-player controls
const triggerIframeDirectFocus = useCallback(() => {
const iframe = document.querySelector<HTMLIFrameElement>(".has-cinema-iframe-elem");
if (iframe) {
iframe.focus();
showTvToast("Управление передано плееру. Нажмите [Back] на пульте для возврата в меню HAS", "🎮", 3000);
}
}, [showTvToast]); }, [showTvToast]);
// Universal helper to convert a TMDB item into CinemaMediaItem // Universal helper to convert a TMDB item into CinemaMediaItem
@ -1141,22 +1018,17 @@ export default function Cinema({
} }
} }
// Also query Russian media info for Kinopoisk ID / rating & pre-resolve stream // Also query Russian media info for Kinopoisk ID / rating
if (isTauri && item.tmdb_id) { if (isTauri && item.tmdb_id) {
invoke<RussianMediaInfo>("get_russian_media_info", { invoke<RussianMediaInfo>("get_russian_media_info", {
tmdbId: item.tmdb_id, tmdbId: item.tmdb_id,
isTv: item.media_type === "tv", isTv: item.media_type === "tv",
title: item.title || item.original_title, title: item.title || item.original_title,
}).then((info) => { }).then((info) => {
if (info) { if (info?.kp_id) {
if (info.kp_id) setCurrentKpId(Number(info.kp_id));
if (info.imdb_id) setCurrentImdbId(info.imdb_id);
if (info.iframe_url) setCurrentAllohaUrl(info.iframe_url);
if (info.kp_id) {
setDetailsMedia((prev) => (prev && prev.id === item.id ? { ...prev, kinopoisk_rating: info.kp_id ? "КП" : undefined } : prev)); setDetailsMedia((prev) => (prev && prev.id === item.id ? { ...prev, kinopoisk_rating: info.kp_id ? "КП" : undefined } : prev));
setSelectedMedia((prev) => (prev && prev.id === item.id ? { ...prev, kinopoisk_rating: info.kp_id ? "КП" : undefined } : prev)); setSelectedMedia((prev) => (prev && prev.id === item.id ? { ...prev, kinopoisk_rating: info.kp_id ? "КП" : undefined } : prev));
} }
}
}).catch(() => {}); }).catch(() => {});
} }
} catch (e) { } catch (e) {
@ -1595,10 +1467,6 @@ export default function Cinema({
hls.loadSource(anilibriaStreamUrl); hls.loadSource(anilibriaStreamUrl);
hls.attachMedia(video); hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => { hls.on(Hls.Events.MANIFEST_PARSED, () => {
try {
video.volume = 1.0;
video.muted = false;
} catch {}
video.play().catch(() => {}); video.play().catch(() => {});
}); });
hls.on(Hls.Events.ERROR, (_event, data) => { hls.on(Hls.Events.ERROR, (_event, data) => {
@ -1702,27 +1570,14 @@ export default function Cinema({
mirrorWatchdogTimerRef.current = null; mirrorWatchdogTimerRef.current = null;
} }
setIsMirrorConnecting(false); setIsMirrorConnecting(false);
// Auto-trigger play and max volume once iframe scripts initialize // Auto-trigger play once iframe scripts initialize
setTimeout(() => { setTimeout(() => {
sendIframePlaybackCommand("play"); sendIframePlaybackCommand("play");
setPlayerVolumeMax(false);
}, 500); }, 500);
setTimeout(() => { setTimeout(() => {
sendIframePlaybackCommand("play"); sendIframePlaybackCommand("play");
setPlayerVolumeMax(false);
}, 1500); }, 1500);
setTimeout(() => { }, [sendIframePlaybackCommand]);
setPlayerVolumeMax(false);
}, 2800);
setTimeout(() => {
setPlayerVolumeMax(false);
}, 4500);
// Агрессивная установка громкости каждые 3 секунды (первые 15 секунд)
const volumeInterval = setInterval(() => {
setPlayerVolumeMax(false);
}, 3000);
setTimeout(() => clearInterval(volumeInterval), 15000);
}, [sendIframePlaybackCommand, setPlayerVolumeMax]);
// Start playback for a media item (movie or specific series episode) // Start playback for a media item (movie or specific series episode)
const startPlayback = (media: CinemaMediaItem, seasonNum: number = 1, episode?: CinemaEpisode) => { const startPlayback = (media: CinemaMediaItem, seasonNum: number = 1, episode?: CinemaEpisode) => {
@ -1737,13 +1592,6 @@ export default function Cinema({
setShowPlayerEpisodes(false); setShowPlayerEpisodes(false);
resetHudTimer(); resetHudTimer();
if (videoRef.current) {
try {
videoRef.current.volume = 1.0;
videoRef.current.muted = false;
} catch {}
}
const ep = const ep =
episode || episode ||
media.seasons.find((s) => s.season_number === seasonNum)?.episodes[0] || media.seasons.find((s) => s.season_number === seasonNum)?.episodes[0] ||
@ -1782,7 +1630,11 @@ export default function Cinema({
const knownKp = media.tmdb_id ? KNOWN_KP_MAP[media.tmdb_id] : null; const knownKp = media.tmdb_id ? KNOWN_KP_MAP[media.tmdb_id] : null;
if (knownKp) { if (knownKp) {
setCurrentKpId(knownKp); setCurrentKpId(knownKp);
} else {
setCurrentKpId(null);
} }
setCurrentImdbId(null);
setCurrentAllohaUrl(null);
// Reset mirror tracking and watchdog for fresh playback session // Reset mirror tracking and watchdog for fresh playback session
triedMirrorsRef.current.clear(); triedMirrorsRef.current.clear();
@ -1796,27 +1648,20 @@ export default function Cinema({
handleAutoSwitchNextMirror("Сервер не ответил за 8 секунд (автопереключение)"); handleAutoSwitchNextMirror("Сервер не ответил за 8 секунд (автопереключение)");
}, 8000); }, 8000);
// Prioritize reliable Russian servers with full remote control and working audio: // Default to verified reliable high-speed 1080p stream:
if (isAnime) { if (isAnime) {
if (anilibriaStreamUrl) { if (anilibriaStreamUrl) {
setSelectedMirror("anilibria"); setSelectedMirror("anilibria");
} else if (kodikResults.length > 0) { } else if (kodikResults.length > 0) {
setSelectedMirror("kodik_ru"); setSelectedMirror("kodik_ru");
} else { } else {
setSelectedMirror("kodik_ru"); setSelectedMirror("vidlink_pro");
} }
showTvToast("🌸 Сервер: Kodik (Аниме / Озвучка)", "🎙️", 1800); showTvToast("🌸 Сервер: Kodik (Аниме / Озвучка)", "🎙️", 1800);
} else { } else {
if (currentAllohaUrl || knownKp || currentKpId) { // Default to VidLink Pro (ultra reliable, resolves all TMDB movies/series in 1080p)
setSelectedMirror("alloha_ru"); setSelectedMirror("vidlink_pro");
showTvToast("🔊 Сервер: Alloha (Русский дубляж)", "🎬", 1800); showTvToast("⚡ Запущен Сервер 1 (Full HD 1080p)", "🎬", 1800);
} else if (kodikResults.length > 0) {
setSelectedMirror("kodik_ru");
showTvToast("🎙️ Сервер: Kodik (Студии дубляжа)", "🎬", 1800);
} else {
setSelectedMirror("alloha_ru");
showTvToast("⚡ Запуск плеера Full HD", "🎬", 1800);
}
} }
// Resolve Kinopoisk ID & Russian media data asynchronously via Rust backend or web fetch // Resolve Kinopoisk ID & Russian media data asynchronously via Rust backend or web fetch
@ -1838,13 +1683,6 @@ export default function Cinema({
clearTimeout(mirrorWatchdogTimerRef.current); clearTimeout(mirrorWatchdogTimerRef.current);
mirrorWatchdogTimerRef.current = null; mirrorWatchdogTimerRef.current = null;
} }
} else if (info.kp_id) {
setSelectedMirror("alloha_ru");
setIsMirrorConnecting(false);
if (mirrorWatchdogTimerRef.current) {
clearTimeout(mirrorWatchdogTimerRef.current);
mirrorWatchdogTimerRef.current = null;
}
} }
if (!isAnime) { if (!isAnime) {
setRussianDubNotice( setRussianDubNotice(
@ -1876,8 +1714,8 @@ export default function Cinema({
.catch(() => {}); .catch(() => {});
} }
// Search Kodik in background and auto-switch to clean stream if Russian dubbing is found // Search Kodik in background for anime or studio translations
searchKodikDubbings(media.original_title || media.title, true); searchKodikDubbings(media.original_title || media.title, isAnime);
}; };
// Card Click Action: Opens rich media details modal (DOES NOT start player immediately) // Card Click Action: Opens rich media details modal (DOES NOT start player immediately)
@ -2024,16 +1862,6 @@ export default function Cinema({
const activeMedia = playingMedia || selectedMedia; const activeMedia = playingMedia || selectedMedia;
currentPlaybackRef.current = { currentTime, duration }; currentPlaybackRef.current = { currentTime, duration };
// Постоянно поддерживаем громкость на 100% для нативного видео
try {
if (videoRef.current.volume !== 1.0) {
videoRef.current.volume = 1.0;
}
if (videoRef.current.muted) {
videoRef.current.muted = false;
}
} catch {}
if (duration > 0 && isTauri) { if (duration > 0 && isTauri) {
const payload: CinemaPlayback = { const payload: CinemaPlayback = {
title_id: activeMedia.id, title_id: activeMedia.id,
@ -2177,18 +2005,6 @@ export default function Cinema({
return () => clearInterval(interval); return () => clearInterval(interval);
}, [playingEpisode, playerMode, allMirrorsFailed, playingMedia, selectedMedia, playingSeasonNumber, isTauri, currentUser?.email]); }, [playingEpisode, playerMode, allMirrorsFailed, playingMedia, selectedMedia, playingSeasonNumber, isTauri, currentUser?.email]);
// Постоянный мониторинг и поддержание громкости на 100% во время воспроизведения
useEffect(() => {
if (!playingEpisode) return;
// Устанавливаем громкость каждые 2 секунды во время воспроизведения
const volumeKeeper = setInterval(() => {
setPlayerVolumeMax(false);
}, 2000);
return () => clearInterval(volumeKeeper);
}, [playingEpisode, setPlayerVolumeMax]);
// Listen to postMessage from embedded players (VidLink, Kodik, Alloha) // Listen to postMessage from embedded players (VidLink, Kodik, Alloha)
useEffect(() => { useEffect(() => {
const handleWindowMessage = (e: MessageEvent) => { const handleWindowMessage = (e: MessageEvent) => {
@ -2260,18 +2076,40 @@ export default function Cinema({
// D-Pad Spatial Navigation & TV Remote Key Handler // D-Pad Spatial Navigation & TV Remote Key Handler
const handleRemoteKeyDown = useCallback( const handleRemoteKeyDown = useCallback(
(e: KeyboardEvent) => { (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 = const isInput =
document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLInputElement ||
document.activeElement instanceof HTMLTextAreaElement; document.activeElement instanceof HTMLTextAreaElement;
// Remote Menu key (ONLY in TV Mode, and NEVER for the 'R' key on PC keyboard!) // Android TV / Remote key mappings
const isMenuKey = const isBack =
!isInput && e.key === "Escape" ||
(e.key === "Menu" || e.key === "GoBack" ||
e.key === "ContextMenu" || e.key === "BrowserBack" ||
(isTvMode && e.keyCode === 82 && e.key !== "r" && e.key !== "R" && e.code !== "KeyR")); e.keyCode === 4 ||
e.keyCode === 27;
const isOk =
e.key === "Enter" ||
e.key === "Select" ||
e.key === "Accept" ||
e.keyCode === 13 ||
e.keyCode === 23 ||
e.keyCode === 66;
const isPlayPause =
e.key === "MediaPlayPause" || e.keyCode === 85 || e.keyCode === 179;
const isFastForward = e.key === "MediaFastForward" || e.keyCode === 90;
const isRewind = e.key === "MediaRewind" || e.keyCode === 89;
if (isTvMode && isMenuKey) { // Remote Menu key (Android TV remote Menu button / ContextMenu)
if (e.key === "Menu" || e.keyCode === 82 || e.key === "ContextMenu") {
e.preventDefault(); e.preventDefault();
setIsTvNavOpen((prev) => { setIsTvNavOpen((prev) => {
const next = !prev; const next = !prev;
@ -2298,29 +2136,6 @@ export default function Cinema({
return; return;
} }
// Android TV / Remote key mappings
const isBack =
e.key === "Escape" ||
e.key === "GoBack" ||
e.key === "BrowserBack" ||
(isTvMode && e.keyCode === 4) ||
e.keyCode === 27;
const isOk =
e.key === "Enter" ||
e.key === "Select" ||
e.key === "Accept" ||
e.keyCode === 13 ||
(isTvMode && (e.keyCode === 23 || (e.keyCode === 66 && e.code !== "KeyB")));
const isPlayPause =
e.key === "MediaPlayPause" ||
(isTvMode && (e.keyCode === 85 || e.keyCode === 179) && e.code !== "KeyU");
const isFastForward =
e.key === "MediaFastForward" ||
(isTvMode && e.keyCode === 90 && e.code !== "KeyZ");
const isRewind =
e.key === "MediaRewind" ||
(isTvMode && e.keyCode === 89 && e.code !== "KeyY");
// 1. In-player remote control // 1. In-player remote control
if (playingEpisode !== null) { if (playingEpisode !== null) {
resetHudTimer(); resetHudTimer();
@ -2346,24 +2161,6 @@ export default function Cinema({
return; return;
} }
// 1.1.1 If Settings or Episodes drawer is open in player:
if (showPlayerSettings || showPlayerEpisodes) {
if (isOk) {
const active = document.activeElement as HTMLElement | null;
if (
active &&
(active.tagName === "BUTTON" ||
active.getAttribute("role") === "button" ||
active.hasAttribute("data-tv-focusable"))
) {
e.preventDefault();
playTvSelectBeep();
active.click();
return;
}
}
// Do NOT run HUD navigation! Skip HUD logic so spatial navigation below handles drawer chips and buttons!
} else {
// 1.2 When HUD is HIDDEN: // 1.2 When HUD is HIDDEN:
if (!isHudVisible) { if (!isHudVisible) {
// OK / Space / PlayPause: Toggle Play/Pause and show HUD // OK / Space / PlayPause: Toggle Play/Pause and show HUD
@ -2554,7 +2351,6 @@ export default function Cinema({
} }
} }
} }
}
// 2. OK / Select Button outside player (Immediate activation of focused card/button/tab/chip) // 2. OK / Select Button outside player (Immediate activation of focused card/button/tab/chip)
if (isOk && !isInput) { if (isOk && !isInput) {
@ -2871,38 +2667,28 @@ export default function Cinema({
}; };
}, [handleRemoteKeyDown]); }, [handleRemoteKeyDown]);
// Auto-focus first interactive element when player settings drawer opens // Guard TV remote focus: Never let iframe capture or trap remote control keys
useEffect(() => { useEffect(() => {
if (showPlayerSettings) { if (playingEpisode !== null) {
const timer = setTimeout(() => { const handleWindowBlur = () => {
const firstBtn = setTimeout(() => {
document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet .has-cinema-drawer-chip.primary") || if (document.activeElement?.tagName === "IFRAME") {
document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet .has-cinema-drawer-chip") || (document.activeElement as HTMLElement).blur();
document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet [data-tv-focusable='true']");
if (firstBtn) {
firstBtn.focus();
} }
}, 50); window.focus();
return () => clearTimeout(timer); const target =
document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary") ||
document.querySelector<HTMLElement>(".has-cinema-player-btn-back");
if (target) {
target.focus();
} }
}, [showPlayerSettings]); }, 30);
};
// Auto-focus current episode when episodes drawer opens window.addEventListener("blur", handleWindowBlur);
useEffect(() => { return () => window.removeEventListener("blur", handleWindowBlur);
if (showPlayerEpisodes) {
const timer = setTimeout(() => {
const activeEp =
document.querySelector<HTMLElement>(".has-cinema-player-episodes-sheet .has-cinema-drawer-ep-btn.active") ||
document.querySelector<HTMLElement>(".has-cinema-player-episodes-sheet .has-cinema-drawer-ep-btn") ||
document.querySelector<HTMLElement>(".has-cinema-player-episodes-sheet [data-tv-focusable='true']");
if (activeEp) {
activeEp.focus();
activeEp.scrollIntoView({ behavior: "auto", block: "center", inline: "nearest" });
} }
}, 50); }, [playingEpisode]);
return () => clearTimeout(timer);
}
}, [showPlayerEpisodes]);
// Handle Android TV hardware back & browser popstate to guarantee return to movie details card // Handle Android TV hardware back & browser popstate to guarantee return to movie details card
useEffect(() => { useEffect(() => {
@ -2935,35 +2721,14 @@ export default function Cinema({
useEffect(() => { useEffect(() => {
if (showPlayerSettings) { if (showPlayerSettings) {
const t = setTimeout(() => { const t = setTimeout(() => {
const firstFocus = const firstFocus = document.querySelector<HTMLElement>(
document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet .has-cinema-drawer-chip") || ".has-cinema-player-settings-sheet [data-tv-focusable='true']"
document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet .has-cinema-drawer-option-btn") || );
document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet [data-tv-focusable='true']"); if (firstFocus) firstFocus.focus();
if (firstFocus) { }, 50);
firstFocus.focus();
playTvBeep(640, 0.025);
}
}, 60);
return () => clearTimeout(t); return () => clearTimeout(t);
} }
}, [showPlayerSettings, playTvBeep]); }, [showPlayerSettings]);
// Auto-focus current or first episode when episodes drawer opens
useEffect(() => {
if (showPlayerEpisodes) {
const t = setTimeout(() => {
const targetEp =
document.querySelector<HTMLElement>(".has-cinema-player-episodes-sheet .has-cinema-drawer-chip.active") ||
document.querySelector<HTMLElement>(".has-cinema-player-episodes-sheet .has-cinema-drawer-chip") ||
document.querySelector<HTMLElement>(".has-cinema-player-episodes-sheet [data-tv-focusable='true']");
if (targetEp) {
targetEp.focus();
playTvBeep(640, 0.025);
}
}, 60);
return () => clearTimeout(t);
}
}, [showPlayerEpisodes, playTvBeep]);
// Handle Android TV hardware back & browser popstate for details modal // Handle Android TV hardware back & browser popstate for details modal
useEffect(() => { useEffect(() => {
@ -3129,16 +2894,24 @@ export default function Cinema({
setKodikResults(finalDubbings); setKodikResults(finalDubbings);
setSelectedKodikLink(finalDubbings[0].link); setSelectedKodikLink(finalDubbings[0].link);
if (autoSwitch) { if (autoSwitch) {
setSelectedMirror((prev) => (prev !== "alloha_ru" && prev !== "anilibria" ? "kodik_ru" : prev)); setSelectedMirror("kodik_ru");
setRussianDubNotice(`🇷🇺 Найдено ${finalDubbings.length} студий русской озвучки для «${targetTitle}».`); setRussianDubNotice(`🇷🇺 Найдено ${finalDubbings.length} студий русской озвучки для «${targetTitle}».`);
} }
} else { } else {
setKodikResults([]); setKodikResults([]);
setSelectedKodikLink(null); setSelectedKodikLink(null);
if (autoSwitch) {
setSelectedMirror("vidlink_pro");
setRussianDubNotice("🇷🇺 Для данного фильма русская аудиодорожка встроена в плеер VidLink Pro (нажмите на шестеренку ⚙️ или значок аудио в правом нижнем углу плеера).");
}
} }
} catch { } catch {
setKodikResults([]); setKodikResults([]);
setSelectedKodikLink(null); setSelectedKodikLink(null);
if (autoSwitch) {
setSelectedMirror("vidlink_pro");
setRussianDubNotice("🌍 Переключено на основной плеер VidLink Pro.");
}
} finally { } finally {
setIsSearchingKodik(false); setIsSearchingKodik(false);
} }
@ -3170,8 +2943,8 @@ export default function Cinema({
switch (selectedMirror) { switch (selectedMirror) {
case "vidlink_pro": case "vidlink_pro":
return isTv return isTv
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=1&muted=0&volume=100&primaryColor=6366f1` ? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=true&primaryColor=6366f1`
: `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=1&muted=0&volume=100&primaryColor=6366f1`; : `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=true&primaryColor=6366f1`;
case "kodik_ru": case "kodik_ru":
if (selectedKodikLink) { if (selectedKodikLink) {
@ -3182,16 +2955,9 @@ export default function Cinema({
const l = kodikResults[0].link.startsWith("//") ? `https:${kodikResults[0].link}` : kodikResults[0].link; const l = kodikResults[0].link.startsWith("//") ? `https:${kodikResults[0].link}` : kodikResults[0].link;
return appendAutoplay(l); return appendAutoplay(l);
} }
if (currentAllohaUrl) {
const l = currentAllohaUrl.startsWith("//") ? `https:${currentAllohaUrl}` : currentAllohaUrl;
return appendAutoplay(l);
}
if (kpId) {
return `https://theatre.stravers.live/?kp=${kpId}&autoplay=1`;
}
return isTv return isTv
? `https://player.autoembed.co/embed/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=1` ? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=true&primaryColor=6366f1`
: `https://player.autoembed.co/embed/movie/${tmdb || 693134}?autoplay=1`; : `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=true&primaryColor=6366f1`;
case "autoembed": case "autoembed":
return isTv return isTv
@ -3224,24 +2990,14 @@ export default function Cinema({
if (imdbId) { if (imdbId) {
return `https://theatre.stravers.live/?imdb=${imdbId}&autoplay=1`; return `https://theatre.stravers.live/?imdb=${imdbId}&autoplay=1`;
} }
if (tmdb) {
return `https://theatre.stravers.live/?tmdb=${tmdb}&autoplay=1`;
}
return isTv return isTv
? `https://player.autoembed.co/embed/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=1` ? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=true&primaryColor=6366f1`
: `https://player.autoembed.co/embed/movie/${tmdb || 693134}?autoplay=1`; : `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=true&primaryColor=6366f1`;
default: default:
if (currentAllohaUrl) {
const l = currentAllohaUrl.startsWith("//") ? `https:${currentAllohaUrl}` : currentAllohaUrl;
return appendAutoplay(l);
}
if (kpId) {
return `https://theatre.stravers.live/?kp=${kpId}&autoplay=1`;
}
return isTv return isTv
? `https://player.autoembed.co/embed/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=1` ? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=true&primaryColor=6366f1`
: `https://player.autoembed.co/embed/movie/${tmdb || 693134}?autoplay=1`; : `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=true&primaryColor=6366f1`;
} }
}; };
@ -4934,33 +4690,6 @@ export default function Cinema({
⏩ +10с ⏩ +10с
</button> </button>
<button
type="button"
className="has-cinema-player-ctrl-btn"
onClick={() => {
setPlayerVolumeMax(true);
resetHudTimer();
}}
title="Максимальная громкость 100%"
data-tv-focusable="true"
data-tv-row="bottom"
>
🔊 100%
</button>
<button
type="button"
className="has-cinema-player-ctrl-btn"
onClick={() => {
triggerIframeDirectFocus();
}}
title="Управление пультом напрямую внутри плеера"
data-tv-focusable="true"
data-tv-row="bottom"
>
🎮 Плеер
</button>
{(playingMedia || selectedMedia).media_type !== "movie" && ( {(playingMedia || selectedMedia).media_type !== "movie" && (
<button <button
type="button" type="button"
@ -5037,7 +4766,7 @@ export default function Cinema({
onClick={() => { onClick={() => {
triedMirrorsRef.current.clear(); triedMirrorsRef.current.clear();
setAllMirrorsFailed(false); setAllMirrorsFailed(false);
handleSelectMirror("alloha_ru"); handleSelectMirror("vidlink_pro");
}} }}
data-tv-focusable="true" data-tv-focusable="true"
> >
@ -5079,7 +4808,7 @@ export default function Cinema({
allow="autoplay *; fullscreen *; encrypted-media *; picture-in-picture *; clipboard-write *" allow="autoplay *; fullscreen *; encrypted-media *; picture-in-picture *; clipboard-write *"
referrerPolicy="no-referrer-when-downgrade" referrerPolicy="no-referrer-when-downgrade"
allowFullScreen allowFullScreen
tabIndex={0} tabIndex={-1}
onLoad={handleIframeLoaded} onLoad={handleIframeLoaded}
onError={() => handleAutoSwitchNextMirror("Сервер вернул ошибку")} onError={() => handleAutoSwitchNextMirror("Сервер вернул ошибку")}
{...{ webkitallowfullscreen: "true", mozallowfullscreen: "true" }} {...{ webkitallowfullscreen: "true", mozallowfullscreen: "true" }}
@ -5135,84 +4864,30 @@ export default function Cinema({
⏩ Быстрое управление и экран ⏩ Быстрое управление и экран
</h4> </h4>
<div className="has-cinema-drawer-chips-wrap"> <div className="has-cinema-drawer-chips-wrap">
<button
type="button"
className="has-cinema-drawer-chip primary"
onClick={() => {
triggerPlayerPlayPause();
}}
data-tv-focusable="true"
title="Воспроизведение / Пауза"
>
{isPlayingState ? "⏸ Пауза" : "▶ Воспроизведение"}
</button>
<button
type="button"
className="has-cinema-drawer-chip"
onClick={() => {
triggerPlayerSeek(-10);
}}
data-tv-focusable="true"
title="Перемотать на 10 секунд назад"
>
⏪ -10с
</button>
<button
type="button"
className="has-cinema-drawer-chip"
onClick={() => {
triggerPlayerSeek(10);
}}
data-tv-focusable="true"
title="Перемотать на 10 секунд вперед"
>
⏩ +10с
</button>
<button
type="button"
className="has-cinema-drawer-chip"
onClick={() => {
setPlayerVolumeMax(true);
}}
data-tv-focusable="true"
title="Выставить громкость плеера на максимум (100%)"
>
🔊 Громкость 100%
</button>
<button <button
type="button" type="button"
className="has-cinema-drawer-chip" className="has-cinema-drawer-chip"
onClick={() => { onClick={() => {
triggerPlayerSeek(20); triggerPlayerSeek(20);
setShowPlayerSettings(false);
}} }}
data-tv-focusable="true" data-tv-focusable="true"
title="Перемотать заставку на 20 секунд вперед" title="Перемотать заставку на 20 секунд вперед"
> >
⏩ Пропуск рекламы (+20с) ⏩ Пропуск рекламы / заставки (+20с)
</button> </button>
<button <button
type="button" type="button"
className="has-cinema-drawer-chip" className="has-cinema-drawer-chip"
onClick={() => { onClick={() => {
triggerPlayerSeek(85); triggerPlayerSeek(85);
setShowPlayerSettings(false);
}} }}
data-tv-focusable="true" data-tv-focusable="true"
title="Перемотать опенинг / интро на 85 секунд вперед" title="Перемотать опенинг / интро на 85 секунд вперед"
> >
⏩ Пропуск опенинга (+85с) ⏩ Пропуск опенинга (+85с)
</button> </button>
<button
type="button"
className="has-cinema-drawer-chip"
onClick={() => {
triggerIframeDirectFocus();
setShowPlayerSettings(false);
}}
data-tv-focusable="true"
title="Передать управление с пульта напрямую в плеер"
>
🎮 Управление в плеере
</button>
<button <button
type="button" type="button"
className="has-cinema-drawer-chip" className="has-cinema-drawer-chip"
@ -5278,16 +4953,16 @@ export default function Cinema({
<button <button
type="button" type="button"
className={`has-cinema-drawer-option-btn ${ className={`has-cinema-drawer-option-btn ${
selectedMirror === "alloha_ru" ? "active" : "" selectedMirror === "vidlink_pro" ? "active" : ""
}`} }`}
onClick={() => { onClick={() => {
handleSelectMirror("alloha_ru"); handleSelectMirror("vidlink_pro");
showTvToast("Включен Сервер 1: Alloha (Русский дубляж)", "🔊", 1500); showTvToast("Включен Сервер 1: Full HD (1080p)", "⚡", 1500);
}} }}
data-tv-focusable="true" data-tv-focusable="true"
> >
<span className="option-name">🔊 Сервер 1: Alloha (Русский дубляж)</span> <span className="option-name">⚡ Сервер 1: VidLink Pro (1080p)</span>
<span className="option-desc">Полная поддержка пульта, автозапуск, русская озвучка</span> <span className="option-desc">Высокая скорость, Full HD качество, стабильный поток</span>
</button> </button>
<button <button
@ -5297,7 +4972,7 @@ export default function Cinema({
}`} }`}
onClick={() => { onClick={() => {
handleSelectMirror("kodik_ru"); handleSelectMirror("kodik_ru");
showTvToast("Включен Сервер 2: Kodik (Студии дубляжа)", "🎙️", 1500); showTvToast("Включен Сервер 2: Kodik (Дубляж)", "🎙️", 1500);
}} }}
data-tv-focusable="true" data-tv-focusable="true"
> >
@ -5305,20 +4980,22 @@ export default function Cinema({
<span className="option-desc">Профессиональный русский дубляж (LostFilm, Red Head Sound, HDRezka)</span> <span className="option-desc">Профессиональный русский дубляж (LostFilm, Red Head Sound, HDRezka)</span>
</button> </button>
{currentAllohaUrl && (
<button <button
type="button" type="button"
className={`has-cinema-drawer-option-btn ${ className={`has-cinema-drawer-option-btn ${
selectedMirror === "vidlink_pro" ? "active" : "" selectedMirror === "alloha_ru" ? "active" : ""
}`} }`}
onClick={() => { onClick={() => {
handleSelectMirror("vidlink_pro"); handleSelectMirror("alloha_ru");
showTvToast("Включен Сервер 3: VidLink Pro (1080p)", "⚡", 1500); showTvToast("Включен Сервер 3: Alloha", "🔊", 1500);
}} }}
data-tv-focusable="true" data-tv-focusable="true"
> >
<span className="option-name">⚡ Сервер 3: VidLink Pro (1080p)</span> <span className="option-name">🔊 Сервер 3: Alloha Player</span>
<span className="option-desc">Высокая скорость, Full HD качество, стабильный поток</span> <span className="option-desc">Российский балансер кинолент</span>
</button> </button>
)}
{/* Anime-specific AniLibria HLS stream */} {/* Anime-specific AniLibria HLS stream */}
{((playingMedia || selectedMedia).genre.toLowerCase().includes("аниме") || {((playingMedia || selectedMedia).genre.toLowerCase().includes("аниме") ||

View file

@ -55,7 +55,6 @@ interface DashboardProps {
dbInfo: SafeDbInfo | null; dbInfo: SafeDbInfo | null;
dbStatus: DbStatus | null; dbStatus: DbStatus | null;
onLogout: () => void; onLogout: () => void;
isTvEnvironment?: boolean;
} }
type NavTab = "cinema" | "home" | "users" | "settings" | "profile" | "help"; type NavTab = "cinema" | "home" | "users" | "settings" | "profile" | "help";
@ -63,6 +62,8 @@ type NavTab = "cinema" | "home" | "users" | "settings" | "profile" | "help";
// Helper for Smart TV / Android TV detection // Helper for Smart TV / Android TV detection
const detectTvEnvironment = (): boolean => { const detectTvEnvironment = (): boolean => {
if (typeof window === "undefined") return false; 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 ua = (navigator.userAgent || "").toLowerCase();
const isTvUa = const isTvUa =
ua.includes("android tv") || ua.includes("android tv") ||
@ -89,17 +90,6 @@ const detectTvEnvironment = (): boolean => {
const isAndroid = ua.includes("android"); const isAndroid = ua.includes("android");
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0; const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
if (isAndroid && noTouch) return true; if (isAndroid && noTouch) return true;
// On pure desktop (macOS / Windows / Desktop Linux), default to desktop PC mode
const isDesktop =
(ua.includes("windows nt") || ua.includes("macintosh") || (ua.includes("linux") && !ua.includes("android"))) &&
!isTvUa;
if (isDesktop) {
return localStorage.getItem("has_tv_mode_manual") === "true";
}
const saved = localStorage.getItem("has_tv_mode");
if (saved !== null) return saved === "true";
return false; return false;
}; };
@ -108,23 +98,13 @@ export default function Dashboard({
dbInfo, dbInfo,
dbStatus, dbStatus,
onLogout, onLogout,
isTvEnvironment: propIsTv,
}: DashboardProps) { }: DashboardProps) {
const [currentUser, setCurrentUser] = useState<UserDto>(user); const [currentUser, setCurrentUser] = useState<UserDto>(user);
useEffect(() => { useEffect(() => {
setCurrentUser(user); setCurrentUser(user);
}, [user]); }, [user]);
const [isTvEnvironment, setIsTvEnvironment] = useState<boolean>(() => { const [isTvEnvironment, setIsTvEnvironment] = useState<boolean>(detectTvEnvironment);
return propIsTv !== undefined ? propIsTv : detectTvEnvironment();
});
useEffect(() => {
if (propIsTv !== undefined && propIsTv !== isTvEnvironment) {
setIsTvEnvironment(propIsTv);
}
}, [propIsTv, isTvEnvironment]);
const [activeTab, setActiveTab] = useState<NavTab>("cinema"); const [activeTab, setActiveTab] = useState<NavTab>("cinema");
const [serverUsers, setServerUsers] = useState<ServerUserDto[]>([]); const [serverUsers, setServerUsers] = useState<ServerUserDto[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false); const [loadingUsers, setLoadingUsers] = useState(false);
@ -133,6 +113,21 @@ export default function Dashboard({
}); });
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); 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) => { const handleNavClick = (tab: NavTab) => {
setActiveTab(tab); setActiveTab(tab);
setMobileMenuOpen(false); setMobileMenuOpen(false);