Compare commits
No commits in common. "main" and "v0.0.12" have entirely different histories.
9 changed files with 387 additions and 1152 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "has",
|
"name": "has",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.19",
|
"version": "0.0.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "has"
|
name = "has"
|
||||||
version = "0.0.19"
|
version = "0.0.12"
|
||||||
description = "has"
|
description = "has"
|
||||||
authors = ["autli"]
|
authors = ["autli"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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.12",
|
||||||
"identifier": "com.has.tauri-app",
|
"identifier": "com.has.tauri-app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
|
||||||
46
src/App.css
46
src/App.css
|
|
@ -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%;
|
||||||
|
|
|
||||||
31
src/App.tsx
31
src/App.tsx
|
|
@ -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={{
|
||||||
|
|
|
||||||
|
|
@ -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.12");
|
||||||
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)} МБ`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
let p = 0;
|
||||||
|
const progressTimer = setInterval(() => {
|
||||||
|
p += 20;
|
||||||
|
if (p >= 100) {
|
||||||
|
clearInterval(progressTimer);
|
||||||
setProgress(100);
|
setProgress(100);
|
||||||
setStatusText("APK успешно скачан! Запуск установки...");
|
setTimeout(async () => {
|
||||||
streamSucceeded = true;
|
try {
|
||||||
|
const { openUrl } = await import("@tauri-apps/plugin-opener");
|
||||||
const blob = new Blob(chunks as BlobPart[], { type: "application/vnd.android.package-archive" });
|
await openUrl(targetUrl);
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
} catch {
|
||||||
const a = document.createElement("a");
|
window.open(targetUrl, "_blank");
|
||||||
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);
|
|
||||||
setStatusText("Запуск установщика приложения...");
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
setProgress(p);
|
|
||||||
setStatusText(`Загрузка: ${p}%`);
|
|
||||||
}
|
}
|
||||||
}, 120);
|
setDismissed(true);
|
||||||
});
|
setDownloading(false);
|
||||||
}
|
}, 400);
|
||||||
|
} else {
|
||||||
try {
|
setProgress(p);
|
||||||
const { openUrl } = await import("@tauri-apps/plugin-opener");
|
}
|
||||||
await openUrl(targetUrl);
|
}, 150);
|
||||||
} catch {
|
}, [isTv, clientPlatform, updateInfo]);
|
||||||
window.open(targetUrl, "_blank");
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setDismissed(true);
|
|
||||||
setDownloading(false);
|
|
||||||
}, 1800);
|
|
||||||
}, [cancelAutoCountdown, isTv, updateInfo, resolveTargetUrl]);
|
|
||||||
|
|
||||||
// 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,104 +505,70 @@ 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: Обновить APK для ТВ */}
|
||||||
{/* Button 1: Да, обновить сейчас */}
|
|
||||||
<button
|
|
||||||
ref={updateBtnRef}
|
|
||||||
type="button"
|
|
||||||
className={`has-tv-updater-btn btn-update ${
|
|
||||||
tvFocusedBtn === "update" ? "focused" : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
playTvBeep(680, 0.05);
|
|
||||||
cancelAutoCountdown();
|
|
||||||
handleInstall();
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
cancelAutoCountdown();
|
|
||||||
setTvFocusedBtn("update");
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
data-tv-focusable="true"
|
|
||||||
>
|
|
||||||
<span>{platformInfo.btnLabel}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Button 2: Напомнить позже */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`has-tv-updater-btn btn-skip ${
|
|
||||||
tvFocusedBtn === "skip" ? "focused" : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
playTvBeep(420, 0.04);
|
|
||||||
cancelAutoCountdown();
|
|
||||||
setDismissed(true);
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
cancelAutoCountdown();
|
|
||||||
setTvFocusedBtn("skip");
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
data-tv-focusable="true"
|
|
||||||
>
|
|
||||||
<span>Напомнить позже</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Button 3: Выход из приложения (Exit) */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`has-tv-updater-btn btn-exit ${
|
|
||||||
tvFocusedBtn === "exit" ? "focused" : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
playTvBeep(320, 0.06);
|
|
||||||
cancelAutoCountdown();
|
|
||||||
handleExitApp();
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
cancelAutoCountdown();
|
|
||||||
setTvFocusedBtn("exit");
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
data-tv-focusable="true"
|
|
||||||
title="Выйти из приложения"
|
|
||||||
>
|
|
||||||
<span>✕ Выход</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Auto-update Toggle Chip for Android TV */}
|
|
||||||
<button
|
<button
|
||||||
|
ref={updateBtnRef}
|
||||||
type="button"
|
type="button"
|
||||||
className={`has-tv-updater-autotoggle ${autoUpdateTv ? "active" : ""} ${
|
className={`has-tv-updater-btn btn-update ${
|
||||||
tvFocusedBtn === "autotoggle" ? "focused" : ""
|
tvFocusedBtn === "update" ? "focused" : ""
|
||||||
}`}
|
}`}
|
||||||
onClick={toggleAutoUpdate}
|
onClick={() => {
|
||||||
onMouseEnter={() => {
|
playTvBeep(680, 0.05);
|
||||||
cancelAutoCountdown();
|
handleInstall();
|
||||||
setTvFocusedBtn("autotoggle");
|
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={() => setTvFocusedBtn("update")}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
data-tv-focusable="true"
|
data-tv-focusable="true"
|
||||||
title="Переключить режим автоматического скачивания обновлений для ТВ"
|
|
||||||
>
|
>
|
||||||
{autoUpdateTv
|
<span>{platformInfo.btnLabel}</span>
|
||||||
? "🟢 Автообновление для TV: ВКЛЮЧЕНО (скачивать без запроса)"
|
|
||||||
: "⚪ Автообновление для TV: ВЫКЛЮЧЕНО (нажмите OK чтобы включить)"}
|
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
|
{/* Button 2: Пропустить */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`has-tv-updater-btn btn-skip ${
|
||||||
|
tvFocusedBtn === "skip" ? "focused" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
playTvBeep(420, 0.04);
|
||||||
|
setDismissed(true);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setTvFocusedBtn("skip")}
|
||||||
|
tabIndex={0}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
<span>Пропустить</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Button 3: Выход из приложения (Exit) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`has-tv-updater-btn btn-exit ${
|
||||||
|
tvFocusedBtn === "exit" ? "focused" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
playTvBeep(320, 0.06);
|
||||||
|
handleExitApp();
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setTvFocusedBtn("exit")}
|
||||||
|
tabIndex={0}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
title="Выйти из приложения"
|
||||||
|
>
|
||||||
|
<span>✕ Выход</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue