Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e616eec246 | |||
| 205678b3d7 | |||
| f41430984c | |||
| 7da9ab00b1 | |||
| 2ab0457568 | |||
| 9e9694ed19 |
9 changed files with 1032 additions and 491 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "has",
|
||||
"private": true,
|
||||
"version": "0.0.14",
|
||||
"version": "0.0.19",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "has"
|
||||
version = "0.0.14"
|
||||
version = "0.0.19"
|
||||
description = "has"
|
||||
authors = ["autli"]
|
||||
edition = "2024"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "has",
|
||||
"version": "0.0.14",
|
||||
"version": "0.0.19",
|
||||
"identifier": "com.has.tauri-app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
|
|
|||
46
src/App.css
46
src/App.css
|
|
@ -443,6 +443,52 @@ svg {
|
|||
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 */
|
||||
.has-tv-updater-progress-container {
|
||||
width: 100%;
|
||||
|
|
|
|||
31
src/App.tsx
31
src/App.tsx
|
|
@ -27,8 +27,6 @@ const isTauri = () => {
|
|||
// 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") ||
|
||||
|
|
@ -55,6 +53,17 @@ const detectTvEnvironment = (): boolean => {
|
|||
const isAndroid = ua.includes("android");
|
||||
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
|
||||
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;
|
||||
};
|
||||
|
||||
|
|
@ -144,21 +153,6 @@ export default function App() {
|
|||
checkDatabase();
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (resetStage !== 2) return;
|
||||
|
|
@ -525,6 +519,7 @@ export default function App() {
|
|||
dbInfo={dbInfo}
|
||||
dbStatus={dbStatus}
|
||||
onLogout={handleLogout}
|
||||
isTvEnvironment={isTvEnvironment}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
@ -542,6 +537,7 @@ export default function App() {
|
|||
onSwitchToDesktop={() => {
|
||||
setIsTvEnvironment(false);
|
||||
localStorage.setItem("has_tv_mode", "false");
|
||||
localStorage.setItem("has_tv_mode_manual", "false");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
@ -812,6 +808,7 @@ export default function App() {
|
|||
onClick={() => {
|
||||
setIsTvEnvironment(true);
|
||||
localStorage.setItem("has_tv_mode", "true");
|
||||
localStorage.setItem("has_tv_mode_manual", "true");
|
||||
}}
|
||||
title="Перейти в режим Android TV (выбор профилей)"
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ interface UpdaterBannerProps {
|
|||
// 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") ||
|
||||
|
|
@ -50,6 +48,17 @@ const detectTvEnvironment = (): boolean => {
|
|||
const isAndroid = ua.includes("android");
|
||||
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
|
||||
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;
|
||||
};
|
||||
|
||||
|
|
@ -88,16 +97,24 @@ const isNewerSemver = (remote: string, current: string): boolean => {
|
|||
|
||||
export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
||||
const [isTv, setIsTv] = useState<boolean>(() => isTvEnvironment ?? detectTvEnvironment());
|
||||
const [currentAppVersion, setCurrentAppVersion] = useState<string>("0.0.14");
|
||||
const [currentAppVersion, setCurrentAppVersion] = useState<string>("0.0.19");
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [statusText, setStatusText] = useState<string>("Загрузка обновления...");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
// TV remote navigation: "update" | "skip" | "exit"
|
||||
const [tvFocusedBtn, setTvFocusedBtn] = useState<"update" | "skip" | "exit">("update");
|
||||
// Auto-update preferences for Android TV
|
||||
const [autoUpdateTv, setAutoUpdateTv] = useState<boolean>(() => {
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -270,16 +287,16 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
case "android_tv":
|
||||
return {
|
||||
badge: "📺 Android TV",
|
||||
title: `Доступно обновление для Android TV (v${version})`,
|
||||
desc: "Специальная сборка для телевизоров и ТВ-приставок с поддержкой пульта ДУ (D-Pad), Leanback-интерфейса и плавного воспроизведения.",
|
||||
btnLabel: "🚀 Обновить APK для ТВ",
|
||||
title: "Хотите ли вы обновить приложение HAS?",
|
||||
desc: "Доступна новая оптимизированная версия для телевизоров и ТВ-приставок с поддержкой пульта ДУ, балансеров видео и повышенной стабильностью.",
|
||||
btnLabel: "🚀 Да, обновить сейчас",
|
||||
icon: "📺",
|
||||
};
|
||||
case "android_mobile":
|
||||
return {
|
||||
badge: "📱 Android",
|
||||
title: `Доступно обновление для Android (v${version})`,
|
||||
desc: "Новая версия приложения для мобильных телефонов и планшетов на Android.",
|
||||
title: "Хотите ли вы обновить приложение HAS?",
|
||||
desc: "Новая версия приложения для мобильных устройств на Android.",
|
||||
btnLabel: "🚀 Скачать APK для Android",
|
||||
icon: "📱",
|
||||
};
|
||||
|
|
@ -313,7 +330,7 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
const platformInfo = getPlatformDetails();
|
||||
|
||||
// Smart download target selector
|
||||
const resolveTargetUrl = (): string => {
|
||||
const resolveTargetUrl = useCallback((): string => {
|
||||
if (!updateInfo) return "https://github.com/autli5/has/releases/latest";
|
||||
switch (clientPlatform) {
|
||||
case "windows":
|
||||
|
|
@ -329,12 +346,29 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
default:
|
||||
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 () => {
|
||||
cancelAutoCountdown();
|
||||
setDownloading(true);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setStatusText("Подготовка к установке обновления...");
|
||||
|
||||
// Desktop Windows / macOS / Linux Tauri updater plugin
|
||||
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window && !isTv) {
|
||||
|
|
@ -346,6 +380,7 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
if (update?.available) {
|
||||
let downloaded = 0;
|
||||
let total = 0;
|
||||
setStatusText("Загрузка обновления...");
|
||||
|
||||
await update.downloadAndInstall((event: any) => {
|
||||
if (event.event === "Started") {
|
||||
|
|
@ -354,9 +389,12 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
downloaded += event.data?.chunkLength ?? 0;
|
||||
} else if (event.event === "Finished") {
|
||||
setProgress(100);
|
||||
setStatusText("Перезапуск программы...");
|
||||
}
|
||||
if (total > 0) {
|
||||
setProgress(Math.round((downloaded / total) * 100));
|
||||
const p = Math.round((downloaded / total) * 100);
|
||||
setProgress(p);
|
||||
setStatusText(`Загрузка: ${p}%`);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -368,30 +406,82 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
// Direct download and open target platform installer/APK
|
||||
// Android TV / Mobile / Direct APK Download & Installation
|
||||
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;
|
||||
await new Promise<void>((resolve) => {
|
||||
const progressTimer = setInterval(() => {
|
||||
p += 20;
|
||||
if (p >= 100) {
|
||||
clearInterval(progressTimer);
|
||||
setProgress(100);
|
||||
setTimeout(async () => {
|
||||
setStatusText("Запуск установщика приложения...");
|
||||
resolve();
|
||||
} else {
|
||||
setProgress(p);
|
||||
setStatusText(`Загрузка: ${p}%`);
|
||||
}
|
||||
}, 120);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { openUrl } = await import("@tauri-apps/plugin-opener");
|
||||
await openUrl(targetUrl);
|
||||
} catch {
|
||||
window.open(targetUrl, "_blank");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setDismissed(true);
|
||||
setDownloading(false);
|
||||
}, 400);
|
||||
} else {
|
||||
setProgress(p);
|
||||
}
|
||||
}, 150);
|
||||
}, [isTv, clientPlatform, updateInfo]);
|
||||
}, 1800);
|
||||
}, [cancelAutoCountdown, isTv, updateInfo, resolveTargetUrl]);
|
||||
|
||||
// Clean Exit from TV application
|
||||
const handleExitApp = useCallback(async () => {
|
||||
|
|
@ -403,6 +493,30 @@ 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
|
||||
useEffect(() => {
|
||||
if (!updateAvailable || dismissed || !isTv || downloading) return;
|
||||
|
|
@ -415,37 +529,49 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
}, 50);
|
||||
|
||||
const handleTvKeyDown = (e: KeyboardEvent) => {
|
||||
// Cycle between "update" -> "skip" -> "exit"
|
||||
if (
|
||||
e.key === "ArrowRight" ||
|
||||
e.key === "ArrowDown" ||
|
||||
e.keyCode === 22 ||
|
||||
e.keyCode === 20 ||
|
||||
e.key === "Tab"
|
||||
) {
|
||||
cancelAutoCountdown();
|
||||
|
||||
// Down: switch to auto-update toggle
|
||||
if (e.key === "ArrowDown" || e.keyCode === 20) {
|
||||
e.preventDefault();
|
||||
setTvFocusedBtn((prev) => {
|
||||
if (prev === "update") return "skip";
|
||||
if (prev === "skip") return "exit";
|
||||
return "update";
|
||||
});
|
||||
playTvBeep(520, 0.025);
|
||||
if (tvFocusedBtn !== "autotoggle") {
|
||||
setTvFocusedBtn("autotoggle");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
e.key === "ArrowLeft" ||
|
||||
e.key === "ArrowUp" ||
|
||||
e.keyCode === 21 ||
|
||||
e.keyCode === 19
|
||||
) {
|
||||
// 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) => {
|
||||
if (prev === "update") return "skip";
|
||||
if (prev === "skip") return "exit";
|
||||
return "update";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -458,6 +584,9 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
} else if (tvFocusedBtn === "exit") {
|
||||
playTvBeep(320, 0.06);
|
||||
handleExitApp();
|
||||
} else if (tvFocusedBtn === "autotoggle") {
|
||||
playTvBeep(560, 0.04);
|
||||
toggleAutoUpdate();
|
||||
} else {
|
||||
playTvBeep(420, 0.04);
|
||||
setDismissed(true);
|
||||
|
|
@ -475,7 +604,7 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
|
||||
window.addEventListener("keydown", handleTvKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleTvKeyDown);
|
||||
}, [updateAvailable, dismissed, isTv, downloading, tvFocusedBtn, handleInstall, handleExitApp]);
|
||||
}, [updateAvailable, dismissed, isTv, downloading, tvFocusedBtn, handleInstall, handleExitApp, cancelAutoCountdown, toggleAutoUpdate]);
|
||||
|
||||
if (!updateAvailable || dismissed) return null;
|
||||
|
||||
|
|
@ -487,15 +616,21 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
<div className="has-tv-updater-overlay" role="dialog" aria-modal="true">
|
||||
<div className="has-tv-updater-card">
|
||||
<div className="has-tv-updater-icon-ring">{platformInfo.icon}</div>
|
||||
<h2 className="has-tv-updater-title">{platformInfo.title}</h2>
|
||||
<h2 className="has-tv-updater-title">Хотите ли вы обновить приложение HAS?</h2>
|
||||
<div className="has-tv-updater-version-tag">
|
||||
<span>{platformInfo.badge}</span>
|
||||
<span>📺 Android TV • v{currentAppVersion} ➔ v{updateInfo?.version || "новейшая"}</span>
|
||||
</div>
|
||||
|
||||
<p className="has-tv-updater-desc">
|
||||
{updateInfo?.body || platformInfo.desc}
|
||||
</p>
|
||||
|
||||
{autoCountdown !== null && !downloading && (
|
||||
<div className="has-tv-updater-countdown">
|
||||
⚡ Автообновление активно: начнется через {autoCountdown} сек... (нажмите любую кнопку для отмены)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{downloading ? (
|
||||
<div className="has-tv-updater-progress-container">
|
||||
<div className="has-tv-updater-progress-track">
|
||||
|
|
@ -505,12 +640,13 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
/>
|
||||
</div>
|
||||
<span className="has-tv-updater-progress-text">
|
||||
Загрузка и установка обновления: {progress}%
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="has-tv-updater-buttons">
|
||||
{/* Button 1: Обновить APK для ТВ */}
|
||||
{/* Button 1: Да, обновить сейчас */}
|
||||
<button
|
||||
ref={updateBtnRef}
|
||||
type="button"
|
||||
|
|
@ -519,16 +655,20 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
}`}
|
||||
onClick={() => {
|
||||
playTvBeep(680, 0.05);
|
||||
cancelAutoCountdown();
|
||||
handleInstall();
|
||||
}}
|
||||
onMouseEnter={() => setTvFocusedBtn("update")}
|
||||
onMouseEnter={() => {
|
||||
cancelAutoCountdown();
|
||||
setTvFocusedBtn("update");
|
||||
}}
|
||||
tabIndex={0}
|
||||
data-tv-focusable="true"
|
||||
>
|
||||
<span>{platformInfo.btnLabel}</span>
|
||||
</button>
|
||||
|
||||
{/* Button 2: Пропустить */}
|
||||
{/* Button 2: Напомнить позже */}
|
||||
<button
|
||||
type="button"
|
||||
className={`has-tv-updater-btn btn-skip ${
|
||||
|
|
@ -536,13 +676,17 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
}`}
|
||||
onClick={() => {
|
||||
playTvBeep(420, 0.04);
|
||||
cancelAutoCountdown();
|
||||
setDismissed(true);
|
||||
}}
|
||||
onMouseEnter={() => setTvFocusedBtn("skip")}
|
||||
onMouseEnter={() => {
|
||||
cancelAutoCountdown();
|
||||
setTvFocusedBtn("skip");
|
||||
}}
|
||||
tabIndex={0}
|
||||
data-tv-focusable="true"
|
||||
>
|
||||
<span>Пропустить</span>
|
||||
<span>Напомнить позже</span>
|
||||
</button>
|
||||
|
||||
{/* Button 3: Выход из приложения (Exit) */}
|
||||
|
|
@ -553,9 +697,13 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
}`}
|
||||
onClick={() => {
|
||||
playTvBeep(320, 0.06);
|
||||
cancelAutoCountdown();
|
||||
handleExitApp();
|
||||
}}
|
||||
onMouseEnter={() => setTvFocusedBtn("exit")}
|
||||
onMouseEnter={() => {
|
||||
cancelAutoCountdown();
|
||||
setTvFocusedBtn("exit");
|
||||
}}
|
||||
tabIndex={0}
|
||||
data-tv-focusable="true"
|
||||
title="Выйти из приложения"
|
||||
|
|
@ -563,12 +711,33 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
|||
<span>✕ Выход</span>
|
||||
</button>
|
||||
</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>}
|
||||
|
||||
<div className="has-tv-updater-remote-hint">
|
||||
◄ ► Стрелки на пульте: выбор • OK: подтвердить • Back: пропустить
|
||||
◄ ► Стрелки на пульте: выбор • ▲ ▼ Вверх/Вниз: переключение опций • OK: подтвердить • Back: позже
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -493,8 +493,6 @@ const formatMediaImg = (path: string | undefined, size: "w500" | "original" = "w
|
|||
// Detect Android TV / Smart TV user agent or previous user preference
|
||||
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") ||
|
||||
|
|
@ -521,6 +519,17 @@ const detectTvEnvironment = (): boolean => {
|
|||
const isAndroid = ua.includes("android");
|
||||
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
|
||||
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;
|
||||
};
|
||||
|
||||
|
|
@ -540,10 +549,10 @@ export default function Cinema({
|
|||
const lastFocusedCardRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTvEnvironment && !isTvMode) {
|
||||
setIsTvMode(true);
|
||||
if (isTvEnvironment !== isTvMode) {
|
||||
setIsTvMode(isTvEnvironment);
|
||||
}
|
||||
}, [isTvEnvironment, isTvMode]);
|
||||
}, [isTvEnvironment]);
|
||||
const [tvToast, setTvToast] = useState<{ text: string; icon?: string } | null>(null);
|
||||
const tvToastTimerRef = useRef<any>(null);
|
||||
|
||||
|
|
@ -563,6 +572,7 @@ export default function Cinema({
|
|||
setIsTvMode((prev) => {
|
||||
const next = !prev;
|
||||
localStorage.setItem("has_tv_mode", String(next));
|
||||
localStorage.setItem("has_tv_mode_manual", String(next));
|
||||
showTvToast(next ? "ТВ-Режим включен (10-Foot UI)" : "ПК-Режим включен", "📺", 2500);
|
||||
playTvBeep(next ? 750 : 400, 0.05);
|
||||
return next;
|
||||
|
|
@ -654,65 +664,171 @@ export default function Cinema({
|
|||
|
||||
const [isPlayingState, setIsPlayingState] = useState(true);
|
||||
|
||||
// Dedicated multi-protocol command dispatcher to balancer iframes
|
||||
const sendIframePlaybackCommand = useCallback((command: "play" | "pause" | "toggle") => {
|
||||
// Robust multi-level recursive window broadcast to reach nested balancer iframes
|
||||
const broadcastToWindowTree = useCallback((payload: any) => {
|
||||
const iframe = document.querySelector<HTMLIFrameElement>(".has-cinema-iframe-elem");
|
||||
if (!iframe || !iframe.contentWindow) return;
|
||||
|
||||
const win = iframe.contentWindow;
|
||||
|
||||
// Never focus iframe directly — focus must strictly remain on our app controls for TV remote
|
||||
|
||||
const post = (payload: any) => {
|
||||
const send = (target: Window) => {
|
||||
try {
|
||||
win.postMessage(payload, "*");
|
||||
target.postMessage(payload, "*");
|
||||
} catch {}
|
||||
try {
|
||||
win.postMessage(JSON.stringify(payload), "*");
|
||||
if (typeof payload === "object") {
|
||||
target.postMessage(JSON.stringify(payload), "*");
|
||||
}
|
||||
} 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 isToggle = command === "toggle";
|
||||
|
||||
// Отправляем команды многократно для надежности (разные плееры реагируют в разное время)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
setTimeout(() => {
|
||||
// 1. Kodik Player API (Supports both object and stringified commands)
|
||||
post({ key: "kodik_player_api", value: { method: isToggle ? "toggle" : (isPlay ? "play" : "pause") } });
|
||||
broadcastToWindowTree({ key: "kodik_player_api", value: { method: isToggle ? "toggle" : (isPlay ? "play" : "pause") } });
|
||||
if (!isToggle) {
|
||||
post({ key: "kodik_player_api", value: { method: isPlay ? "play" : "pause" } });
|
||||
broadcastToWindowTree({ key: "kodik_player_api", value: { method: isPlay ? "play" : "pause" } });
|
||||
}
|
||||
|
||||
// 2. Playerjs API (Used by Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka)
|
||||
post({ api: isToggle ? "toggle" : (isPlay ? "play" : "pause") });
|
||||
broadcastToWindowTree({ api: isToggle ? "toggle" : (isPlay ? "play" : "pause") });
|
||||
if (!isToggle) {
|
||||
post({ api: isPlay ? "play" : "pause" });
|
||||
broadcastToWindowTree({ api: isPlay ? "play" : "pause" });
|
||||
}
|
||||
|
||||
// 3. Alloha API
|
||||
post({ action: isPlay ? "play" : "pause" });
|
||||
post({ method: isPlay ? "play" : "pause" });
|
||||
broadcastToWindowTree({ action: isPlay ? "play" : "pause" });
|
||||
broadcastToWindowTree({ method: isPlay ? "play" : "pause" });
|
||||
|
||||
// 4. VidLink Pro / Video.js API
|
||||
post({ type: isPlay ? "PLAYER_PLAY" : "PLAYER_PAUSE" });
|
||||
post({ type: isPlay ? "play" : "pause" });
|
||||
post({ action: isPlay ? "play" : "pause" });
|
||||
broadcastToWindowTree({ type: isPlay ? "PLAYER_PLAY" : "PLAYER_PAUSE" });
|
||||
broadcastToWindowTree({ type: isPlay ? "play" : "pause" });
|
||||
broadcastToWindowTree({ action: isPlay ? "play" : "pause" });
|
||||
broadcastToWindowTree({ event: isPlay ? "play" : "pause" });
|
||||
|
||||
// 5. YouTube / VidSrc / AutoEmbed HTML5 API
|
||||
post({ event: "command", func: isPlay ? "playVideo" : "pauseVideo", args: "" });
|
||||
broadcastToWindowTree({ event: "command", func: isPlay ? "playVideo" : "pauseVideo", args: "" });
|
||||
|
||||
// 6. Generic HTML5 postMessage
|
||||
post({ method: isPlay ? "play" : "pause" });
|
||||
post({ command: isPlay ? "play" : "pause" });
|
||||
}, []);
|
||||
broadcastToWindowTree({ method: isPlay ? "play" : "pause" });
|
||||
broadcastToWindowTree({ 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(() => {
|
||||
if (videoRef.current) {
|
||||
try {
|
||||
videoRef.current.muted = false;
|
||||
videoRef.current.volume = 1.0;
|
||||
} catch {}
|
||||
videoRef.current.play().catch(() => {});
|
||||
} else {
|
||||
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);
|
||||
showTvToast("Воспроизведение", "▶", 1200);
|
||||
}, [sendIframePlaybackCommand, showTvToast]);
|
||||
}, [sendIframePlaybackCommand, setPlayerVolumeMax, showTvToast]);
|
||||
|
||||
const triggerPlayerPause = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
|
|
@ -737,8 +853,9 @@ export default function Cinema({
|
|||
} else {
|
||||
triggerPlayerPlay();
|
||||
}
|
||||
sendIframePlaybackCommand("toggle");
|
||||
}
|
||||
}, [isPlayingState, triggerPlayerPlay, triggerPlayerPause]);
|
||||
}, [isPlayingState, triggerPlayerPlay, triggerPlayerPause, sendIframePlaybackCommand]);
|
||||
|
||||
const triggerPlayerSeek = useCallback((secondsDelta: number) => {
|
||||
if (videoRef.current) {
|
||||
|
|
@ -748,53 +865,59 @@ export default function Cinema({
|
|||
);
|
||||
currentPlaybackRef.current.currentTime = videoRef.current.currentTime;
|
||||
} 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 targetTime = Math.max(0, curTime + secondsDelta);
|
||||
currentPlaybackRef.current.currentTime = targetTime;
|
||||
|
||||
// Отправляем команды перемотки несколько раз для надежности
|
||||
for (let i = 0; i < 3; i++) {
|
||||
setTimeout(() => {
|
||||
// 1. Kodik API (Seek to absolute seconds & relative seek delta)
|
||||
post({ key: "kodik_player_api", value: { method: "seek", time: targetTime } });
|
||||
post({ key: "kodik_player_api", value: { method: "seek_delta", time: secondsDelta } });
|
||||
post({ key: "kodik_player_api", value: { method: secondsDelta > 0 ? "forward" : "rewind", time: Math.abs(secondsDelta) } });
|
||||
broadcastToWindowTree({ key: "kodik_player_api", value: { method: "seek", time: targetTime } });
|
||||
broadcastToWindowTree({ 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) } });
|
||||
|
||||
// 2. Playerjs API (Used by Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka)
|
||||
post({ api: "seek", time: targetTime });
|
||||
post({ api: "seek", delta: secondsDelta });
|
||||
post({ api: "seek_delta", time: secondsDelta });
|
||||
post({ api: "seek", time: secondsDelta > 0 ? `+${secondsDelta}` : `${secondsDelta}` });
|
||||
broadcastToWindowTree({ api: "seek", set: targetTime });
|
||||
broadcastToWindowTree({ api: "seek", set: secondsDelta > 0 ? `+${secondsDelta}` : `${secondsDelta}` });
|
||||
broadcastToWindowTree({ api: "seek", time: targetTime });
|
||||
broadcastToWindowTree({ api: "seek", delta: secondsDelta });
|
||||
broadcastToWindowTree({ api: "seek_delta", time: secondsDelta });
|
||||
|
||||
// 3. Alloha API
|
||||
post({ action: "seek", time: targetTime });
|
||||
post({ action: "seek", delta: secondsDelta });
|
||||
post({ method: "seek", time: targetTime });
|
||||
post({ method: "seekTo", value: targetTime });
|
||||
broadcastToWindowTree({ action: "seek", time: targetTime });
|
||||
broadcastToWindowTree({ action: "seek", delta: secondsDelta });
|
||||
broadcastToWindowTree({ method: "seek", time: targetTime });
|
||||
broadcastToWindowTree({ method: "seekTo", value: targetTime });
|
||||
|
||||
// 4. VidLink Pro / Video.js API
|
||||
post({ type: "SEEK", data: { time: targetTime } });
|
||||
post({ type: "PLAYER_SEEK", time: targetTime });
|
||||
post({ type: "seek", time: targetTime });
|
||||
post({ action: "seek", time: targetTime });
|
||||
post({ action: "seek", delta: secondsDelta });
|
||||
broadcastToWindowTree({ type: "SEEK", data: { time: targetTime } });
|
||||
broadcastToWindowTree({ type: "PLAYER_SEEK", time: targetTime });
|
||||
broadcastToWindowTree({ type: "seek", time: targetTime });
|
||||
broadcastToWindowTree({ action: "seek", time: targetTime });
|
||||
broadcastToWindowTree({ action: "seek", delta: secondsDelta });
|
||||
|
||||
// 5. YouTube / VidSrc / AutoEmbed HTML5 API
|
||||
post({ event: "command", func: "seekTo", args: [targetTime, true] });
|
||||
post({ event: "command", func: "seek", args: [targetTime] });
|
||||
post({ action: "seekTo", value: targetTime });
|
||||
broadcastToWindowTree({ event: "command", func: "seekTo", args: [targetTime, true] });
|
||||
broadcastToWindowTree({ event: "command", func: "seek", args: [targetTime] });
|
||||
broadcastToWindowTree({ action: "seekTo", value: targetTime });
|
||||
|
||||
// 6. Generic HTML5 postMessage
|
||||
post({ method: "seek", time: targetTime });
|
||||
post({ command: "seek", time: targetTime });
|
||||
broadcastToWindowTree({ method: "seek", time: targetTime });
|
||||
broadcastToWindowTree({ command: "seek", time: targetTime });
|
||||
}, i * 100);
|
||||
}
|
||||
}
|
||||
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]);
|
||||
|
||||
// Universal helper to convert a TMDB item into CinemaMediaItem
|
||||
|
|
@ -1018,17 +1141,22 @@ export default function Cinema({
|
|||
}
|
||||
}
|
||||
|
||||
// Also query Russian media info for Kinopoisk ID / rating
|
||||
// Also query Russian media info for Kinopoisk ID / rating & pre-resolve stream
|
||||
if (isTauri && item.tmdb_id) {
|
||||
invoke<RussianMediaInfo>("get_russian_media_info", {
|
||||
tmdbId: item.tmdb_id,
|
||||
isTv: item.media_type === "tv",
|
||||
title: item.title || item.original_title,
|
||||
}).then((info) => {
|
||||
if (info?.kp_id) {
|
||||
if (info) {
|
||||
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));
|
||||
setSelectedMedia((prev) => (prev && prev.id === item.id ? { ...prev, kinopoisk_rating: info.kp_id ? "КП" : undefined } : prev));
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -1467,6 +1595,10 @@ export default function Cinema({
|
|||
hls.loadSource(anilibriaStreamUrl);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
try {
|
||||
video.volume = 1.0;
|
||||
video.muted = false;
|
||||
} catch {}
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
|
|
@ -1570,14 +1702,27 @@ export default function Cinema({
|
|||
mirrorWatchdogTimerRef.current = null;
|
||||
}
|
||||
setIsMirrorConnecting(false);
|
||||
// Auto-trigger play once iframe scripts initialize
|
||||
// Auto-trigger play and max volume once iframe scripts initialize
|
||||
setTimeout(() => {
|
||||
sendIframePlaybackCommand("play");
|
||||
setPlayerVolumeMax(false);
|
||||
}, 500);
|
||||
setTimeout(() => {
|
||||
sendIframePlaybackCommand("play");
|
||||
setPlayerVolumeMax(false);
|
||||
}, 1500);
|
||||
}, [sendIframePlaybackCommand]);
|
||||
setTimeout(() => {
|
||||
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)
|
||||
const startPlayback = (media: CinemaMediaItem, seasonNum: number = 1, episode?: CinemaEpisode) => {
|
||||
|
|
@ -1592,6 +1737,13 @@ export default function Cinema({
|
|||
setShowPlayerEpisodes(false);
|
||||
resetHudTimer();
|
||||
|
||||
if (videoRef.current) {
|
||||
try {
|
||||
videoRef.current.volume = 1.0;
|
||||
videoRef.current.muted = false;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const ep =
|
||||
episode ||
|
||||
media.seasons.find((s) => s.season_number === seasonNum)?.episodes[0] ||
|
||||
|
|
@ -1630,11 +1782,7 @@ export default function Cinema({
|
|||
const knownKp = media.tmdb_id ? KNOWN_KP_MAP[media.tmdb_id] : null;
|
||||
if (knownKp) {
|
||||
setCurrentKpId(knownKp);
|
||||
} else {
|
||||
setCurrentKpId(null);
|
||||
}
|
||||
setCurrentImdbId(null);
|
||||
setCurrentAllohaUrl(null);
|
||||
|
||||
// Reset mirror tracking and watchdog for fresh playback session
|
||||
triedMirrorsRef.current.clear();
|
||||
|
|
@ -1648,20 +1796,27 @@ export default function Cinema({
|
|||
handleAutoSwitchNextMirror("Сервер не ответил за 8 секунд (автопереключение)");
|
||||
}, 8000);
|
||||
|
||||
// Default to verified reliable high-speed 1080p stream:
|
||||
// Prioritize reliable Russian servers with full remote control and working audio:
|
||||
if (isAnime) {
|
||||
if (anilibriaStreamUrl) {
|
||||
setSelectedMirror("anilibria");
|
||||
} else if (kodikResults.length > 0) {
|
||||
setSelectedMirror("kodik_ru");
|
||||
} else {
|
||||
setSelectedMirror("vidlink_pro");
|
||||
setSelectedMirror("kodik_ru");
|
||||
}
|
||||
showTvToast("🌸 Сервер: Kodik (Аниме / Озвучка)", "🎙️", 1800);
|
||||
} else {
|
||||
// Default to VidLink Pro (ultra reliable, resolves all TMDB movies/series in 1080p)
|
||||
setSelectedMirror("vidlink_pro");
|
||||
showTvToast("⚡ Запущен Сервер 1 (Full HD 1080p)", "🎬", 1800);
|
||||
if (currentAllohaUrl || knownKp || currentKpId) {
|
||||
setSelectedMirror("alloha_ru");
|
||||
showTvToast("🔊 Сервер: Alloha (Русский дубляж)", "🎬", 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
|
||||
|
|
@ -1683,6 +1838,13 @@ export default function Cinema({
|
|||
clearTimeout(mirrorWatchdogTimerRef.current);
|
||||
mirrorWatchdogTimerRef.current = null;
|
||||
}
|
||||
} else if (info.kp_id) {
|
||||
setSelectedMirror("alloha_ru");
|
||||
setIsMirrorConnecting(false);
|
||||
if (mirrorWatchdogTimerRef.current) {
|
||||
clearTimeout(mirrorWatchdogTimerRef.current);
|
||||
mirrorWatchdogTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
if (!isAnime) {
|
||||
setRussianDubNotice(
|
||||
|
|
@ -1714,8 +1876,8 @@ export default function Cinema({
|
|||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Search Kodik in background for anime or studio translations
|
||||
searchKodikDubbings(media.original_title || media.title, isAnime);
|
||||
// Search Kodik in background and auto-switch to clean stream if Russian dubbing is found
|
||||
searchKodikDubbings(media.original_title || media.title, true);
|
||||
};
|
||||
|
||||
// Card Click Action: Opens rich media details modal (DOES NOT start player immediately)
|
||||
|
|
@ -1862,6 +2024,16 @@ export default function Cinema({
|
|||
const activeMedia = playingMedia || selectedMedia;
|
||||
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) {
|
||||
const payload: CinemaPlayback = {
|
||||
title_id: activeMedia.id,
|
||||
|
|
@ -2005,6 +2177,18 @@ export default function Cinema({
|
|||
return () => clearInterval(interval);
|
||||
}, [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)
|
||||
useEffect(() => {
|
||||
const handleWindowMessage = (e: MessageEvent) => {
|
||||
|
|
@ -2076,40 +2260,18 @@ export default function Cinema({
|
|||
// 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;
|
||||
|
||||
// Android TV / Remote key mappings
|
||||
const isBack =
|
||||
e.key === "Escape" ||
|
||||
e.key === "GoBack" ||
|
||||
e.key === "BrowserBack" ||
|
||||
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;
|
||||
// Remote Menu key (ONLY in TV Mode, and NEVER for the 'R' key on PC keyboard!)
|
||||
const isMenuKey =
|
||||
!isInput &&
|
||||
(e.key === "Menu" ||
|
||||
e.key === "ContextMenu" ||
|
||||
(isTvMode && e.keyCode === 82 && e.key !== "r" && e.key !== "R" && e.code !== "KeyR"));
|
||||
|
||||
// Remote Menu key (Android TV remote Menu button / ContextMenu)
|
||||
if (e.key === "Menu" || e.keyCode === 82 || e.key === "ContextMenu") {
|
||||
if (isTvMode && isMenuKey) {
|
||||
e.preventDefault();
|
||||
setIsTvNavOpen((prev) => {
|
||||
const next = !prev;
|
||||
|
|
@ -2136,6 +2298,29 @@ export default function Cinema({
|
|||
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
|
||||
if (playingEpisode !== null) {
|
||||
resetHudTimer();
|
||||
|
|
@ -2161,6 +2346,24 @@ export default function Cinema({
|
|||
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:
|
||||
if (!isHudVisible) {
|
||||
// OK / Space / PlayPause: Toggle Play/Pause and show HUD
|
||||
|
|
@ -2351,6 +2554,7 @@ export default function Cinema({
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. OK / Select Button outside player (Immediate activation of focused card/button/tab/chip)
|
||||
if (isOk && !isInput) {
|
||||
|
|
@ -2667,28 +2871,38 @@ export default function Cinema({
|
|||
};
|
||||
}, [handleRemoteKeyDown]);
|
||||
|
||||
// Guard TV remote focus: Never let iframe capture or trap remote control keys
|
||||
// Auto-focus first interactive element when player settings drawer opens
|
||||
useEffect(() => {
|
||||
if (playingEpisode !== null) {
|
||||
const handleWindowBlur = () => {
|
||||
setTimeout(() => {
|
||||
if (document.activeElement?.tagName === "IFRAME") {
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
if (showPlayerSettings) {
|
||||
const timer = setTimeout(() => {
|
||||
const firstBtn =
|
||||
document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet .has-cinema-drawer-chip.primary") ||
|
||||
document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet .has-cinema-drawer-chip") ||
|
||||
document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet [data-tv-focusable='true']");
|
||||
if (firstBtn) {
|
||||
firstBtn.focus();
|
||||
}
|
||||
window.focus();
|
||||
const target =
|
||||
document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary") ||
|
||||
document.querySelector<HTMLElement>(".has-cinema-player-btn-back");
|
||||
if (target) {
|
||||
target.focus();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, 30);
|
||||
};
|
||||
}, [showPlayerSettings]);
|
||||
|
||||
window.addEventListener("blur", handleWindowBlur);
|
||||
return () => window.removeEventListener("blur", handleWindowBlur);
|
||||
// Auto-focus current episode when episodes drawer opens
|
||||
useEffect(() => {
|
||||
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" });
|
||||
}
|
||||
}, [playingEpisode]);
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [showPlayerEpisodes]);
|
||||
|
||||
// Handle Android TV hardware back & browser popstate to guarantee return to movie details card
|
||||
useEffect(() => {
|
||||
|
|
@ -2721,14 +2935,35 @@ export default function Cinema({
|
|||
useEffect(() => {
|
||||
if (showPlayerSettings) {
|
||||
const t = setTimeout(() => {
|
||||
const firstFocus = document.querySelector<HTMLElement>(
|
||||
".has-cinema-player-settings-sheet [data-tv-focusable='true']"
|
||||
);
|
||||
if (firstFocus) firstFocus.focus();
|
||||
}, 50);
|
||||
const firstFocus =
|
||||
document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet .has-cinema-drawer-chip") ||
|
||||
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();
|
||||
playTvBeep(640, 0.025);
|
||||
}
|
||||
}, 60);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [showPlayerSettings]);
|
||||
}, [showPlayerSettings, playTvBeep]);
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
|
|
@ -2894,24 +3129,16 @@ export default function Cinema({
|
|||
setKodikResults(finalDubbings);
|
||||
setSelectedKodikLink(finalDubbings[0].link);
|
||||
if (autoSwitch) {
|
||||
setSelectedMirror("kodik_ru");
|
||||
setSelectedMirror((prev) => (prev !== "alloha_ru" && prev !== "anilibria" ? "kodik_ru" : prev));
|
||||
setRussianDubNotice(`🇷🇺 Найдено ${finalDubbings.length} студий русской озвучки для «${targetTitle}».`);
|
||||
}
|
||||
} else {
|
||||
setKodikResults([]);
|
||||
setSelectedKodikLink(null);
|
||||
if (autoSwitch) {
|
||||
setSelectedMirror("vidlink_pro");
|
||||
setRussianDubNotice("🇷🇺 Для данного фильма русская аудиодорожка встроена в плеер VidLink Pro (нажмите на шестеренку ⚙️ или значок аудио в правом нижнем углу плеера).");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setKodikResults([]);
|
||||
setSelectedKodikLink(null);
|
||||
if (autoSwitch) {
|
||||
setSelectedMirror("vidlink_pro");
|
||||
setRussianDubNotice("🌍 Переключено на основной плеер VidLink Pro.");
|
||||
}
|
||||
} finally {
|
||||
setIsSearchingKodik(false);
|
||||
}
|
||||
|
|
@ -2943,8 +3170,8 @@ export default function Cinema({
|
|||
switch (selectedMirror) {
|
||||
case "vidlink_pro":
|
||||
return isTv
|
||||
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=true&primaryColor=6366f1`
|
||||
: `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=true&primaryColor=6366f1`;
|
||||
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=1&muted=0&volume=100&primaryColor=6366f1`
|
||||
: `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=1&muted=0&volume=100&primaryColor=6366f1`;
|
||||
|
||||
case "kodik_ru":
|
||||
if (selectedKodikLink) {
|
||||
|
|
@ -2955,9 +3182,16 @@ export default function Cinema({
|
|||
const l = kodikResults[0].link.startsWith("//") ? `https:${kodikResults[0].link}` : kodikResults[0].link;
|
||||
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
|
||||
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=true&primaryColor=6366f1`
|
||||
: `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=true&primaryColor=6366f1`;
|
||||
? `https://player.autoembed.co/embed/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=1`
|
||||
: `https://player.autoembed.co/embed/movie/${tmdb || 693134}?autoplay=1`;
|
||||
|
||||
case "autoembed":
|
||||
return isTv
|
||||
|
|
@ -2990,14 +3224,24 @@ export default function Cinema({
|
|||
if (imdbId) {
|
||||
return `https://theatre.stravers.live/?imdb=${imdbId}&autoplay=1`;
|
||||
}
|
||||
if (tmdb) {
|
||||
return `https://theatre.stravers.live/?tmdb=${tmdb}&autoplay=1`;
|
||||
}
|
||||
return isTv
|
||||
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=true&primaryColor=6366f1`
|
||||
: `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=true&primaryColor=6366f1`;
|
||||
? `https://player.autoembed.co/embed/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=1`
|
||||
: `https://player.autoembed.co/embed/movie/${tmdb || 693134}?autoplay=1`;
|
||||
|
||||
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
|
||||
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=true&primaryColor=6366f1`
|
||||
: `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=true&primaryColor=6366f1`;
|
||||
? `https://player.autoembed.co/embed/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=1`
|
||||
: `https://player.autoembed.co/embed/movie/${tmdb || 693134}?autoplay=1`;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -4690,6 +4934,33 @@ export default function Cinema({
|
|||
⏩ +10с
|
||||
</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" && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -4766,7 +5037,7 @@ export default function Cinema({
|
|||
onClick={() => {
|
||||
triedMirrorsRef.current.clear();
|
||||
setAllMirrorsFailed(false);
|
||||
handleSelectMirror("vidlink_pro");
|
||||
handleSelectMirror("alloha_ru");
|
||||
}}
|
||||
data-tv-focusable="true"
|
||||
>
|
||||
|
|
@ -4808,7 +5079,7 @@ export default function Cinema({
|
|||
allow="autoplay *; fullscreen *; encrypted-media *; picture-in-picture *; clipboard-write *"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
allowFullScreen
|
||||
tabIndex={-1}
|
||||
tabIndex={0}
|
||||
onLoad={handleIframeLoaded}
|
||||
onError={() => handleAutoSwitchNextMirror("Сервер вернул ошибку")}
|
||||
{...{ webkitallowfullscreen: "true", mozallowfullscreen: "true" }}
|
||||
|
|
@ -4864,30 +5135,84 @@ export default function Cinema({
|
|||
⏩ Быстрое управление и экран
|
||||
</h4>
|
||||
<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
|
||||
type="button"
|
||||
className="has-cinema-drawer-chip"
|
||||
onClick={() => {
|
||||
triggerPlayerSeek(20);
|
||||
setShowPlayerSettings(false);
|
||||
}}
|
||||
data-tv-focusable="true"
|
||||
title="Перемотать заставку на 20 секунд вперед"
|
||||
>
|
||||
⏩ Пропуск рекламы / заставки (+20с)
|
||||
⏩ Пропуск рекламы (+20с)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="has-cinema-drawer-chip"
|
||||
onClick={() => {
|
||||
triggerPlayerSeek(85);
|
||||
setShowPlayerSettings(false);
|
||||
}}
|
||||
data-tv-focusable="true"
|
||||
title="Перемотать опенинг / интро на 85 секунд вперед"
|
||||
>
|
||||
⏩ Пропуск опенинга (+85с)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="has-cinema-drawer-chip"
|
||||
onClick={() => {
|
||||
triggerIframeDirectFocus();
|
||||
setShowPlayerSettings(false);
|
||||
}}
|
||||
data-tv-focusable="true"
|
||||
title="Передать управление с пульта напрямую в плеер"
|
||||
>
|
||||
🎮 Управление в плеере
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="has-cinema-drawer-chip"
|
||||
|
|
@ -4953,16 +5278,16 @@ export default function Cinema({
|
|||
<button
|
||||
type="button"
|
||||
className={`has-cinema-drawer-option-btn ${
|
||||
selectedMirror === "vidlink_pro" ? "active" : ""
|
||||
selectedMirror === "alloha_ru" ? "active" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
handleSelectMirror("vidlink_pro");
|
||||
showTvToast("Включен Сервер 1: Full HD (1080p)", "⚡", 1500);
|
||||
handleSelectMirror("alloha_ru");
|
||||
showTvToast("Включен Сервер 1: Alloha (Русский дубляж)", "🔊", 1500);
|
||||
}}
|
||||
data-tv-focusable="true"
|
||||
>
|
||||
<span className="option-name">⚡ Сервер 1: VidLink Pro (1080p)</span>
|
||||
<span className="option-desc">Высокая скорость, Full HD качество, стабильный поток</span>
|
||||
<span className="option-name">🔊 Сервер 1: Alloha (Русский дубляж)</span>
|
||||
<span className="option-desc">Полная поддержка пульта, автозапуск, русская озвучка</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
@ -4972,7 +5297,7 @@ export default function Cinema({
|
|||
}`}
|
||||
onClick={() => {
|
||||
handleSelectMirror("kodik_ru");
|
||||
showTvToast("Включен Сервер 2: Kodik (Дубляж)", "🎙️", 1500);
|
||||
showTvToast("Включен Сервер 2: Kodik (Студии дубляжа)", "🎙️", 1500);
|
||||
}}
|
||||
data-tv-focusable="true"
|
||||
>
|
||||
|
|
@ -4980,22 +5305,20 @@ export default function Cinema({
|
|||
<span className="option-desc">Профессиональный русский дубляж (LostFilm, Red Head Sound, HDRezka)</span>
|
||||
</button>
|
||||
|
||||
{currentAllohaUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className={`has-cinema-drawer-option-btn ${
|
||||
selectedMirror === "alloha_ru" ? "active" : ""
|
||||
selectedMirror === "vidlink_pro" ? "active" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
handleSelectMirror("alloha_ru");
|
||||
showTvToast("Включен Сервер 3: Alloha", "🔊", 1500);
|
||||
handleSelectMirror("vidlink_pro");
|
||||
showTvToast("Включен Сервер 3: VidLink Pro (1080p)", "⚡", 1500);
|
||||
}}
|
||||
data-tv-focusable="true"
|
||||
>
|
||||
<span className="option-name">🔊 Сервер 3: Alloha Player</span>
|
||||
<span className="option-desc">Российский балансер кинолент</span>
|
||||
<span className="option-name">⚡ Сервер 3: VidLink Pro (1080p)</span>
|
||||
<span className="option-desc">Высокая скорость, Full HD качество, стабильный поток</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Anime-specific AniLibria HLS stream */}
|
||||
{((playingMedia || selectedMedia).genre.toLowerCase().includes("аниме") ||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ interface DashboardProps {
|
|||
dbInfo: SafeDbInfo | null;
|
||||
dbStatus: DbStatus | null;
|
||||
onLogout: () => void;
|
||||
isTvEnvironment?: boolean;
|
||||
}
|
||||
|
||||
type NavTab = "cinema" | "home" | "users" | "settings" | "profile" | "help";
|
||||
|
|
@ -62,8 +63,6 @@ type NavTab = "cinema" | "home" | "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") ||
|
||||
|
|
@ -90,6 +89,17 @@ const detectTvEnvironment = (): boolean => {
|
|||
const isAndroid = ua.includes("android");
|
||||
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
|
||||
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;
|
||||
};
|
||||
|
||||
|
|
@ -98,13 +108,23 @@ export default function Dashboard({
|
|||
dbInfo,
|
||||
dbStatus,
|
||||
onLogout,
|
||||
isTvEnvironment: propIsTv,
|
||||
}: DashboardProps) {
|
||||
const [currentUser, setCurrentUser] = useState<UserDto>(user);
|
||||
useEffect(() => {
|
||||
setCurrentUser(user);
|
||||
}, [user]);
|
||||
|
||||
const [isTvEnvironment, setIsTvEnvironment] = useState<boolean>(detectTvEnvironment);
|
||||
const [isTvEnvironment, setIsTvEnvironment] = useState<boolean>(() => {
|
||||
return propIsTv !== undefined ? propIsTv : detectTvEnvironment();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (propIsTv !== undefined && propIsTv !== isTvEnvironment) {
|
||||
setIsTvEnvironment(propIsTv);
|
||||
}
|
||||
}, [propIsTv, isTvEnvironment]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<NavTab>("cinema");
|
||||
const [serverUsers, setServerUsers] = useState<ServerUserDto[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
|
|
@ -113,21 +133,6 @@ 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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue