Compare commits

..

10 commits

Author SHA1 Message Date
e616eec246 feat(release): v0.0.19 - fix PC keyboard R key triggering TV navigation drawer, disable TV mode auto-activation on desktop 2026-11-02 14:54:23 +05:00
205678b3d7 feat(release): v0.0.18 - fix autoplay, 100% volume unmute, prioritize Russian balancers and complete TV remote navigation 2026-10-29 20:29:50 +05:00
f41430984c chore(release): bump version to 0.0.17 2026-10-29 17:00:33 +05:00
7da9ab00b1 Улучшить управление видеоплеером и громкостью
- Автоматическая установка громкости на 100% при запуске плеера
- Постоянный мониторинг громкости каждые 2 секунды во время воспроизведения
- Усиление надежности команд Play/Pause/Seek (тройная отправка с задержками)
- Улучшенная перемотка ±10 секунд для всех типов плееров
- Поддержка громкости для нативного HTML5 видео (AniLibria)
- Агрессивная установка громкости в первые 15 секунд после загрузки iframe

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-29 15:48:39 +05:00
2ab0457568 feat(release): v0.0.16 - deep recursive iframe window tree broadcast, fix remote D-pad settings drawer navigation, auto-switch Kodik for movies 2026-10-28 23:12:57 +05:00
9e9694ed19 feat(release): v0.0.15 - 100% player volume boost script, Android TV auto-update APK downloader & install modal 2026-10-28 22:17:50 +05:00
ef8b7037c1 release: v0.0.14 - fix remote focus trap, deterministic hud navigation, robust +10s/-10s seek 2026-10-28 18:15:02 +05:00
308b03a6ea release: v0.0.13 - fix player play/pause script, multi-protocol postMessage dispatcher, and balancer autoplay 2026-10-28 17:04:51 +05:00
4bc0a2c541 release: v0.0.12 - smart platform auto-updater, clean player header with gear settings, tv remote optimization, fixed media buffering 2026-10-28 11:05:33 +05:00
d50d3e9a48 feat(release): v0.0.11 - watch history, webview adblock, clean studio audio priority & kettle noise elimination 2026-10-27 22:17:17 +05:00
14 changed files with 2670 additions and 733 deletions

View file

@ -7,9 +7,10 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<title>has</title> <title>has</title>
<!-- HAS Cinema AdGuard & Anti-Popup Shield --> <!-- HAS Cinema Anti-Popup & Safe Browsing Shield -->
<script> <script>
(function() { (function() {
// Anti-Popup & Tab Hijack Guard (Blocks opening background ad popups)
try { try {
const fakeWindow = { const fakeWindow = {
closed: true, closed: true,
@ -20,26 +21,28 @@
location: { href: "about:blank" } location: { href: "about:blank" }
}; };
window.open = function(url) { window.open = function(url) {
console.warn("[HAS AdGuard] Blocked unauthorized window.open:", url); console.warn("[HAS AdGuard] Blocked popup window.open:", url);
return fakeWindow; return fakeWindow;
}; };
} catch (_) {} } catch (_) {}
const AD_PATTERNS = [ // Prevent click hijacking on known casino/ad links
/1xbet/i, /vavada/i, /mostbet/i, /pin-up/i, /bet365/i, /casino/i, const AD_DOMAINS = [
/adsterra/i, /propellerads/i, /popcash/i, /monetag/i, /clickadu/i, "riobet", "1xbet", "vavada", "mostbet", "pin-up", "bet365",
/exoclick/i, /juicyads/i, /adcash/i, /trafficjunky/i, /syndication/i, "adsterra", "propellerads", "popcash", "monetag", "clickadu",
/delivembd/i, /betandyou/i, /melbet/i, /linebet/i, /fonbet/i, /parimatch/i "exoclick", "juicyads", "adcash", "trafficjunky",
"melbet", "linebet", "fonbet", "parimatch", "betboom", "vulkan", "joycasino"
]; ];
document.addEventListener("click", function(e) { document.addEventListener("click", function(e) {
const a = e.target && e.target.closest ? e.target.closest("a") : null; const a = e.target && e.target.closest ? e.target.closest("a") : null;
if (a && a.href) { if (a && a.href) {
for (let i = 0; i < AD_PATTERNS.length; i++) { const href = a.href.toLowerCase();
if (AD_PATTERNS[i].test(a.href)) { for (let i = 0; i < AD_DOMAINS.length; i++) {
if (href.includes(AD_DOMAINS[i])) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
console.warn("[HAS AdGuard] Intercepted ad redirect:", a.href); console.warn("[HAS AdGuard] Intercepted ad click:", a.href);
return; return;
} }
} }

View file

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

2
src-tauri/Cargo.lock generated
View file

@ -1581,7 +1581,7 @@ dependencies = [
[[package]] [[package]]
name = "has" name = "has"
version = "0.0.10" version = "0.0.12"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bcrypt", "bcrypt",

View file

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

View file

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

View file

@ -252,6 +252,53 @@ pub async fn update_cinema_progress(
Ok(state) Ok(state)
} }
#[tauri::command]
pub async fn remove_cinema_history_item(
app: AppHandle,
title_id: String,
season: u32,
episode: u32,
user_email: Option<String>,
) -> Result<CinemaState, String> {
let mut state = read_local_cinema_state(&app);
state.history.retain(|h| !(h.title_id == title_id && h.season == season && h.episode == episode));
let _ = write_local_cinema_state(&app, &state);
if let Some(email) = user_email {
let email_clean = email.trim().to_lowercase();
if !email_clean.is_empty() {
let state_to_save = state.clone();
tokio::spawn(async move {
let _ = save_remote_cinema_state(&email_clean, &state_to_save).await;
});
}
}
Ok(state)
}
#[tauri::command]
pub async fn clear_cinema_history(
app: AppHandle,
user_email: Option<String>,
) -> Result<CinemaState, String> {
let mut state = read_local_cinema_state(&app);
state.history.clear();
let _ = write_local_cinema_state(&app, &state);
if let Some(email) = user_email {
let email_clean = email.trim().to_lowercase();
if !email_clean.is_empty() {
let state_to_save = state.clone();
tokio::spawn(async move {
let _ = save_remote_cinema_state(&email_clean, &state_to_save).await;
});
}
}
Ok(state)
}
#[tauri::command] #[tauri::command]
pub async fn toggle_cinema_bookmark( pub async fn toggle_cinema_bookmark(
app: AppHandle, app: AppHandle,

View file

@ -6,7 +6,7 @@ mod cinema;
use admin::DeviceSecurityInfo; use admin::DeviceSecurityInfo;
use auth::{AuthResponse, DbStatus, ResetRequestResponse}; use auth::{AuthResponse, DbStatus, ResetRequestResponse};
use serde::Serialize; use serde::{Deserialize, Serialize};
use storage::{ServerUserDto, StorageItemDto, StorageStats}; use storage::{ServerUserDto, StorageItemDto, StorageStats};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -201,6 +201,150 @@ async fn admin_delete_user(
admin::admin_delete_user(&admin_email, target_id, admin_pin).await admin::admin_delete_user(&admin_email, target_id, admin_pin).await
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppReleaseInfo {
pub version: String,
pub current_version: String,
pub body: String,
pub has_update: bool,
pub download_url: String,
pub platform: String,
pub windows_url: Option<String>,
pub tv_apk_url: Option<String>,
pub mobile_apk_url: Option<String>,
pub linux_url: Option<String>,
pub macos_url: Option<String>,
}
fn detect_current_platform() -> &'static str {
#[cfg(target_os = "android")]
{
"android"
}
#[cfg(target_os = "windows")]
{
"windows"
}
#[cfg(target_os = "macos")]
{
"macos"
}
#[cfg(target_os = "linux")]
{
"linux"
}
#[cfg(not(any(target_os = "android", target_os = "windows", target_os = "macos", target_os = "linux")))]
{
"unknown"
}
}
fn is_newer_semver(remote: &str, current: &str) -> bool {
let parse = |v: &str| -> Vec<u32> {
v.trim_start_matches('v')
.split('.')
.map(|s| s.parse::<u32>().unwrap_or(0))
.collect()
};
let r = parse(remote);
let c = parse(current);
for i in 0..r.len().max(c.len()) {
let rv = *r.get(i).unwrap_or(&0);
let cv = *c.get(i).unwrap_or(&0);
if rv > cv {
return true;
}
if rv < cv {
return false;
}
}
false
}
#[tauri::command]
async fn check_app_update(current_version: Option<String>) -> Result<AppReleaseInfo, String> {
let cur_ver = current_version.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
let client = reqwest::Client::builder()
.user_agent("HAS-App-Updater")
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let res = client
.get("https://api.github.com/repos/autli5/has/releases/latest")
.header("Accept", "application/vnd.github.v3+json")
.send()
.await
.map_err(|e| format!("Failed to reach GitHub: {}", e))?;
if !res.status().is_success() {
return Err(format!("GitHub API returned HTTP {}", res.status()));
}
let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
let raw_tag = json["tag_name"].as_str().unwrap_or("0.0.0");
let remote_version = raw_tag.trim_start_matches('v').to_string();
let body = json["body"].as_str().unwrap_or("Новое обновление доступно.").to_string();
let html_url = json["html_url"]
.as_str()
.unwrap_or("https://github.com/autli5/has/releases/latest")
.to_string();
let has_update = is_newer_semver(&remote_version, &cur_ver);
let mut tv_apk_url = None;
let mut mobile_apk_url = None;
let mut windows_url = None;
let mut linux_url = None;
let mut macos_url = None;
if let Some(assets) = json["assets"].as_array() {
for asset in assets {
if let (Some(name), Some(url)) = (asset["name"].as_str(), asset["browser_download_url"].as_str()) {
let lower = name.to_lowercase();
if (lower.contains("_tv_") || lower.contains("-tv-") || lower.contains(".tv.")) && lower.ends_with(".apk") {
tv_apk_url = Some(url.to_string());
} else if lower.ends_with(".apk") && mobile_apk_url.is_none() {
mobile_apk_url = Some(url.to_string());
} else if lower.ends_with(".exe") || lower.ends_with(".msi") {
if windows_url.is_none() || lower.ends_with(".exe") {
windows_url = Some(url.to_string());
}
} else if lower.ends_with(".deb") || lower.ends_with(".appimage") || lower.ends_with(".rpm") {
if linux_url.is_none() || lower.ends_with(".deb") {
linux_url = Some(url.to_string());
}
} else if lower.ends_with(".dmg") {
macos_url = Some(url.to_string());
}
}
}
}
if tv_apk_url.is_none() && mobile_apk_url.is_some() {
tv_apk_url = mobile_apk_url.clone();
}
if mobile_apk_url.is_none() && tv_apk_url.is_some() {
mobile_apk_url = tv_apk_url.clone();
}
let platform = detect_current_platform().to_string();
Ok(AppReleaseInfo {
version: remote_version,
current_version: cur_ver,
body,
has_update,
download_url: html_url,
platform,
windows_url,
tv_apk_url,
mobile_apk_url,
linux_url,
macos_url,
})
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
@ -249,10 +393,13 @@ pub fn run() {
cinema::get_cinema_state, cinema::get_cinema_state,
cinema::save_cinema_state, cinema::save_cinema_state,
cinema::update_cinema_progress, cinema::update_cinema_progress,
cinema::remove_cinema_history_item,
cinema::clear_cinema_history,
cinema::toggle_cinema_bookmark, cinema::toggle_cinema_bookmark,
cinema::tmdb_query, cinema::tmdb_query,
cinema::get_russian_media_info, cinema::get_russian_media_info,
cinema::filter_playable_media cinema::filter_playable_media,
check_app_update
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View file

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

View file

@ -417,6 +417,13 @@ svg {
color: #cbd5e1; color: #cbd5e1;
} }
/* Button 3: Выход из приложения (Exit) */
.has-tv-updater-btn.btn-exit {
background: rgba(239, 68, 68, 0.15);
border: 2px solid rgba(239, 68, 68, 0.35);
color: #fca5a5;
}
/* D-Pad Remote Control Focused Glow */ /* D-Pad Remote Control Focused Glow */
.has-tv-updater-btn.focused { .has-tv-updater-btn.focused {
transform: scale(1.08); transform: scale(1.08);
@ -430,6 +437,58 @@ svg {
box-shadow: 0 0 0 4px #ffffff, 0 0 30px rgba(255, 255, 255, 0.4) !important; box-shadow: 0 0 0 4px #ffffff, 0 0 30px rgba(255, 255, 255, 0.4) !important;
} }
.has-tv-updater-btn.btn-exit.focused {
background: rgba(220, 38, 38, 0.9);
box-shadow: 0 0 0 4px #ffffff, 0 0 35px rgba(239, 68, 68, 0.8) !important;
color: #ffffff;
}
/* TV Auto-update Toggle Chip & Countdown */
.has-tv-updater-autotoggle {
margin-top: 20px;
background: rgba(255, 255, 255, 0.06);
border: 1px dashed rgba(255, 255, 255, 0.25);
border-radius: 12px;
padding: 10px 20px;
font-size: 13px;
color: #94a3b8;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
outline: none;
}
.has-tv-updater-autotoggle.active {
background: rgba(16, 185, 129, 0.15);
border-color: rgba(16, 185, 129, 0.5);
color: #6ee7b7;
}
.has-tv-updater-autotoggle.focused {
transform: scale(1.05);
border-color: #ffffff !important;
box-shadow: 0 0 0 3px #ffffff, 0 0 25px rgba(99, 102, 241, 0.7) !important;
color: #ffffff !important;
background: rgba(99, 102, 241, 0.35);
}
.has-tv-updater-countdown {
margin-bottom: 14px;
font-size: 14px;
color: #38bdf8;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 8px;
background: rgba(56, 189, 248, 0.12);
border: 1px solid rgba(56, 189, 248, 0.3);
}
/* TV Progress View */ /* TV Progress View */
.has-tv-updater-progress-container { .has-tv-updater-progress-container {
width: 100%; width: 100%;

View file

@ -27,8 +27,6 @@ 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") ||
@ -55,6 +53,17 @@ 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;
}; };
@ -144,21 +153,6 @@ 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;
@ -525,6 +519,7 @@ export default function App() {
dbInfo={dbInfo} dbInfo={dbInfo}
dbStatus={dbStatus} dbStatus={dbStatus}
onLogout={handleLogout} onLogout={handleLogout}
isTvEnvironment={isTvEnvironment}
/> />
</> </>
); );
@ -542,6 +537,7 @@ 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");
}} }}
/> />
</> </>
@ -812,6 +808,7 @@ export default function App() {
onClick={() => { onClick={() => {
setIsTvEnvironment(true); setIsTvEnvironment(true);
localStorage.setItem("has_tv_mode", "true"); localStorage.setItem("has_tv_mode", "true");
localStorage.setItem("has_tv_mode_manual", "true");
}} }}
title="Перейти в режим Android TV (выбор профилей)" title="Перейти в режим Android TV (выбор профилей)"
style={{ style={{

View file

@ -1,9 +1,18 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { playTvBeep } from "../utils/tvAudio"; import { playTvBeep } from "../utils/tvAudio";
export type DetectedPlatform = "windows" | "android_tv" | "android_mobile" | "linux" | "macos" | "web";
interface UpdateInfo { interface UpdateInfo {
version: string; version: string;
body: string | null; body: string | null;
downloadUrl?: string;
platform?: string;
windowsUrl?: string | null;
tvApkUrl?: string | null;
mobileApkUrl?: string | null;
linuxUrl?: string | null;
macosUrl?: string | null;
} }
interface UpdaterBannerProps { interface UpdaterBannerProps {
@ -13,8 +22,6 @@ 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") ||
@ -41,20 +48,74 @@ 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;
};
// Detect client platform
const detectClientPlatform = (isTv: boolean, backendPlatform?: string): DetectedPlatform => {
if (isTv) return "android_tv";
if (backendPlatform === "android") return "android_mobile";
if (backendPlatform === "windows") return "windows";
if (backendPlatform === "macos") return "macos";
if (backendPlatform === "linux") return "linux";
const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "").toLowerCase();
const plat = (typeof navigator !== "undefined" ? navigator.platform : "").toLowerCase();
if (ua.includes("android")) return isTv ? "android_tv" : "android_mobile";
if (plat.includes("win") || ua.includes("windows")) return "windows";
if (plat.includes("mac") || ua.includes("macintosh")) return "macos";
if (plat.includes("linux") || ua.includes("linux")) return "linux";
return "web";
};
// Check if a remote version is strictly newer than the current version
const isNewerSemver = (remote: string, current: string): boolean => {
const cleanRemote = remote.replace(/^v/i, "").trim();
const cleanCurrent = current.replace(/^v/i, "").trim();
if (cleanRemote === cleanCurrent) return false;
const rParts = cleanRemote.split(".").map((n) => parseInt(n, 10) || 0);
const cParts = cleanCurrent.split(".").map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(rParts.length, cParts.length); i++) {
const r = rParts[i] ?? 0;
const c = cParts[i] ?? 0;
if (r > c) return true;
if (r < c) return false;
}
return false; return false;
}; };
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 [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);
// Strictly two buttons for TV remote navigation: "update" | "skip" // Auto-update preferences for Android TV
const [tvFocusedBtn, setTvFocusedBtn] = useState<"update" | "skip">("update"); 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(() => { useEffect(() => {
if (typeof isTvEnvironment === "boolean") { if (typeof isTvEnvironment === "boolean") {
@ -62,12 +123,52 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
} }
}, [isTvEnvironment]); }, [isTvEnvironment]);
// Read current running version from Tauri on startup
useEffect(() => {
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) {
import("@tauri-apps/api/app")
.then(({ getVersion }) => getVersion())
.then((ver) => {
if (ver) setCurrentAppVersion(ver);
})
.catch(() => {});
}
}, []);
// Check for updates // Check for updates
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const checkForUpdates = async () => { const checkForUpdates = async () => {
// 1. Try official Tauri updater plugin first (Desktop) // 1. Try Rust backend command first (Works on Android TV, Mobile & Desktop without CORS limits)
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) {
try {
const { invoke } = await import("@tauri-apps/api/core");
const res = await invoke<any>("check_app_update", {
currentVersion: currentAppVersion,
});
if (!cancelled && res && res.has_update) {
setUpdateAvailable(true);
setUpdateInfo({
version: res.version,
body: res.body || "Новое обновление с улучшенной стабильностью и функциями.",
downloadUrl: res.download_url,
platform: res.platform,
windowsUrl: res.windows_url,
tvApkUrl: res.tv_apk_url,
mobileApkUrl: res.mobile_apk_url,
linuxUrl: res.linux_url,
macosUrl: res.macos_url,
});
setDismissed(false);
return;
}
} catch (err) {
console.warn("[Updater] Rust backend check failed, trying fallback:", err);
}
}
// 2. Desktop Tauri updater plugin (macOS / Windows / Linux)
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) { if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) {
try { try {
const { check } = await import("@tauri-apps/plugin-updater"); const { check } = await import("@tauri-apps/plugin-updater");
@ -81,46 +182,78 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
setDismissed(false); setDismissed(false);
return; return;
} }
} catch (err) { } catch {
console.warn("[Updater] Plugin check fallback:", err); // Expected on mobile / TV webview
} }
} }
// 2. Fallback check for Android TV / browser via releases endpoint // 3. GitHub API check fallback
try { try {
const resp = await fetch("https://github.com/autli5/has/releases/latest/download/latest.json"); const resp = await fetch("https://api.github.com/repos/autli5/has/releases/latest", {
headers: { Accept: "application/vnd.github.v3+json" },
});
if (resp.ok) { if (resp.ok) {
const data = await resp.json(); const data = await resp.json();
if (!cancelled && data?.version && data.version !== "0.0.10") { const remoteTag = (data?.tag_name || "").replace(/^v/i, "").trim();
if (!cancelled && remoteTag && isNewerSemver(remoteTag, currentAppVersion)) {
let tvApk: string | null = null;
let mobileApk: string | null = null;
let winUrl: string | null = null;
let linUrl: string | null = null;
let macUrl: string | null = null;
if (Array.isArray(data?.assets)) {
for (const asset of data.assets) {
const name: string = (asset.name || "").toLowerCase();
const url: string = asset.browser_download_url || "";
if ((name.includes("_tv_") || name.includes("-tv-") || name.includes(".tv.")) && name.endsWith(".apk")) {
tvApk = url;
} else if (name.endsWith(".apk") && !mobileApk) {
mobileApk = url;
} else if (name.endsWith(".exe") || name.endsWith(".msi")) {
if (!winUrl || name.endsWith(".exe")) winUrl = url;
} else if (name.endsWith(".deb") || name.endsWith(".appimage") || name.endsWith(".rpm")) {
if (!linUrl || name.endsWith(".deb")) linUrl = url;
} else if (name.endsWith(".dmg")) {
macUrl = url;
}
}
}
if (!tvApk && mobileApk) tvApk = mobileApk;
if (!mobileApk && tvApk) mobileApk = tvApk;
setUpdateAvailable(true); setUpdateAvailable(true);
setUpdateInfo({ setUpdateInfo({
version: data.version, version: remoteTag,
body: data.notes || data.body || "Новое обновление с улучшенной стабильностью и функциями.", body: data.body || "Новое обновление с улучшенной стабильностью и функциями.",
downloadUrl: data.html_url || "https://github.com/autli5/has/releases/latest",
windowsUrl: winUrl,
tvApkUrl: tvApk,
mobileApkUrl: mobileApk,
linuxUrl: linUrl,
macosUrl: macUrl,
}); });
setDismissed(false); setDismissed(false);
} }
} }
} catch {} } catch (err) {
console.warn("[Updater] Fallback fetch error:", err);
}
}; };
// 1. Initial check after startup const timer = setTimeout(checkForUpdates, 1200);
const timer = setTimeout(checkForUpdates, 1000); const interval = setInterval(checkForUpdates, 90 * 1000);
// 2. Periodic check every 60 seconds
const interval = setInterval(checkForUpdates, 60 * 1000);
// 3. Check whenever window gains focus
const handleFocus = () => checkForUpdates(); const handleFocus = () => checkForUpdates();
window.addEventListener("focus", handleFocus); window.addEventListener("focus", handleFocus);
// 4. Custom event for manual checks from UI or simulation
window.addEventListener("has-check-update", handleFocus); window.addEventListener("has-check-update", handleFocus);
const handleSimulateUpdate = (e: any) => { const handleSimulateUpdate = (e: any) => {
setUpdateAvailable(true); setUpdateAvailable(true);
setUpdateInfo({ setUpdateInfo({
version: e.detail?.version || "1.0.0", version: e.detail?.version || "1.0.0",
body: e.detail?.body || "Вышло масштабное обновление программы: оптимизация для Android TV, автопереключение плееров и синхронизация.", body: e.detail?.body || "Вышло масштабное обновление программы.",
downloadUrl: "https://github.com/autli5/has/releases/latest",
}); });
setDismissed(false); setDismissed(false);
}; };
@ -134,15 +267,111 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
window.removeEventListener("has-check-update", handleFocus); window.removeEventListener("has-check-update", handleFocus);
window.removeEventListener("has-simulate-update", handleSimulateUpdate); window.removeEventListener("has-simulate-update", handleSimulateUpdate);
}; };
}, [currentAppVersion]);
// Determine current active platform
const clientPlatform = detectClientPlatform(isTv, updateInfo?.platform);
// Platform-tailored texts and assets
const getPlatformDetails = () => {
const version = updateInfo?.version || "1.0.0";
switch (clientPlatform) {
case "windows":
return {
badge: "🪟 Windows",
title: `Доступно обновление для Windows (v${version})`,
desc: "Вышла новая версия приложения для Windows. Нажмите «Обновить», чтобы установить последнюю версию программы.",
btnLabel: "🚀 Обновить для Windows (.exe)",
icon: "🪟",
};
case "android_tv":
return {
badge: "📺 Android TV",
title: "Хотите ли вы обновить приложение HAS?",
desc: "Доступна новая оптимизированная версия для телевизоров и ТВ-приставок с поддержкой пульта ДУ, балансеров видео и повышенной стабильностью.",
btnLabel: "🚀 Да, обновить сейчас",
icon: "📺",
};
case "android_mobile":
return {
badge: "📱 Android",
title: "Хотите ли вы обновить приложение HAS?",
desc: "Новая версия приложения для мобильных устройств на Android.",
btnLabel: "🚀 Скачать APK для Android",
icon: "📱",
};
case "linux":
return {
badge: "🐧 Linux",
title: `Доступно обновление для Linux (v${version})`,
desc: "Пакет обновления для дистрибутивов Linux (.deb / .AppImage / .rpm).",
btnLabel: "🚀 Скачать для Linux (.deb)",
icon: "🐧",
};
case "macos":
return {
badge: "🍏 macOS",
title: `Доступно обновление для macOS (v${version})`,
desc: "Новая версия приложения для macOS (.dmg).",
btnLabel: "🚀 Скачать образ (.dmg)",
icon: "🍏",
};
default:
return {
badge: "🚀 Обновление",
title: `Доступно обновление v${version}`,
desc: "Вышла новая версия программы с улучшенной стабильностью.",
btnLabel: "🚀 Обновить сейчас",
icon: "🚀",
};
}
};
const platformInfo = getPlatformDetails();
// Smart download target selector
const resolveTargetUrl = useCallback((): string => {
if (!updateInfo) return "https://github.com/autli5/has/releases/latest";
switch (clientPlatform) {
case "windows":
return updateInfo.windowsUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
case "android_tv":
return updateInfo.tvApkUrl || updateInfo.mobileApkUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
case "android_mobile":
return updateInfo.mobileApkUrl || updateInfo.tvApkUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
case "linux":
return updateInfo.linuxUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
case "macos":
return updateInfo.macosUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
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 () => { const handleInstall = useCallback(async () => {
cancelAutoCountdown();
setDownloading(true); setDownloading(true);
setError(null); setError(null);
setProgress(0); setProgress(0);
setStatusText("Подготовка к установке обновления...");
// Desktop Tauri plugin install // Desktop Windows / macOS / Linux Tauri updater plugin
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) { if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window && !isTv) {
try { try {
const { check } = await import("@tauri-apps/plugin-updater"); const { check } = await import("@tauri-apps/plugin-updater");
const { relaunch } = await import("@tauri-apps/plugin-process"); const { relaunch } = await import("@tauri-apps/plugin-process");
@ -151,17 +380,21 @@ 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") {
total = event.data?.contentLength ?? 0; total = event.data?.contentLength ?? 0;
} else if (event.event === "Progress") { } else if (event.event === "Progress") {
downloaded += event.data?.chunkLength ?? 0; downloaded += event.data?.chunkLength ?? 0;
if (total > 0) {
setProgress(Math.round((downloaded / total) * 100));
}
} else if (event.event === "Finished") { } else if (event.event === "Finished") {
setProgress(100); setProgress(100);
setStatusText("Перезапуск программы...");
}
if (total > 0) {
const p = Math.round((downloaded / total) * 100);
setProgress(p);
setStatusText(`Загрузка: ${p}%`);
} }
}); });
@ -169,55 +402,176 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
return; return;
} }
} catch (e: unknown) { } catch (e: unknown) {
console.warn("[Updater] Desktop updater failed, falling back to download:", e); console.warn("[Updater] Desktop plugin install skipped/fallback:", e);
} }
} }
// Fallback for Android TV / browser: animated progress and open download // 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; let p = 0;
await new Promise<void>((resolve) => {
const progressTimer = setInterval(() => { const progressTimer = setInterval(() => {
p += 15; p += 20;
if (p >= 100) { if (p >= 100) {
clearInterval(progressTimer); clearInterval(progressTimer);
setProgress(100); setProgress(100);
setTimeout(async () => { setStatusText("Запуск установщика приложения...");
try { resolve();
const { openUrl } = await import("@tauri-apps/plugin-opener");
await openUrl("https://github.com/autli5/has/releases/latest");
} catch {
window.open("https://github.com/autli5/has/releases/latest", "_blank");
}
setDismissed(true);
setDownloading(false);
}, 600);
} else { } else {
setProgress(p); 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);
}, 1800);
}, [cancelAutoCountdown, isTv, updateInfo, resolveTargetUrl]);
// Clean Exit from TV application
const handleExitApp = useCallback(async () => {
try {
const { exit } = await import("@tauri-apps/plugin-process");
await exit(0);
} catch {
window.close();
} }
}, 250);
}, []); }, []);
// 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;
// Auto-focus the "Update" button when TV modal appears
setTimeout(() => {
if (updateBtnRef.current) {
updateBtnRef.current.focus();
}
}, 50);
const handleTvKeyDown = (e: KeyboardEvent) => { const handleTvKeyDown = (e: KeyboardEvent) => {
// Toggle strictly between "update" and "skip" on Left / Right / Up / Down / Tab cancelAutoCountdown();
if (
e.key === "ArrowLeft" || // Down: switch to auto-update toggle
e.key === "ArrowRight" || if (e.key === "ArrowDown" || e.keyCode === 20) {
e.key === "ArrowUp" ||
e.key === "ArrowDown" ||
e.keyCode === 21 ||
e.keyCode === 22 ||
e.keyCode === 19 ||
e.keyCode === 20 ||
e.key === "Tab"
) {
e.preventDefault(); e.preventDefault();
setTvFocusedBtn((prev) => (prev === "update" ? "skip" : "update"));
playTvBeep(520, 0.025); 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) => {
if (prev === "update") return "skip";
if (prev === "skip") return "exit";
return "update";
});
return; return;
} }
@ -227,6 +581,12 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
if (tvFocusedBtn === "update") { if (tvFocusedBtn === "update") {
playTvBeep(680, 0.05); playTvBeep(680, 0.05);
handleInstall(); handleInstall();
} else if (tvFocusedBtn === "exit") {
playTvBeep(320, 0.06);
handleExitApp();
} else if (tvFocusedBtn === "autotoggle") {
playTvBeep(560, 0.04);
toggleAutoUpdate();
} else { } else {
playTvBeep(420, 0.04); playTvBeep(420, 0.04);
setDismissed(true); setDismissed(true);
@ -244,28 +604,33 @@ 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]); }, [updateAvailable, dismissed, isTv, downloading, tvFocusedBtn, handleInstall, handleExitApp, cancelAutoCountdown, toggleAutoUpdate]);
if (!updateAvailable || dismissed) return null; if (!updateAvailable || dismissed) return null;
// ========================================================================= // =========================================================================
// 1. ANDROID TV: Full-screen Centered Modal with EXACTLY Two Buttons // 1. ANDROID TV: Full-screen Centered Modal with D-Pad TV Navigation & Exit
// ========================================================================= // =========================================================================
if (isTv) { if (isTv) {
return ( return (
<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">🚀</div> <div className="has-tv-updater-icon-ring">{platformInfo.icon}</div>
<h2 className="has-tv-updater-title">Доступно обновление</h2> <h2 className="has-tv-updater-title">Хотите ли вы обновить приложение HAS?</h2>
<div className="has-tv-updater-version-tag"> <div className="has-tv-updater-version-tag">
<span>Вышла новая версия v{updateInfo?.version || "1.0.0"}</span> <span>📺 Android TV • v{currentAppVersion} ➔ v{updateInfo?.version || "новейшая"}</span>
</div> </div>
<p className="has-tv-updater-desc"> <p className="has-tv-updater-desc">
{updateInfo?.body || {updateInfo?.body || platformInfo.desc}
"Вышло новое обновление программы с оптимизацией для Android TV, автопереключением плееров и синхронизацией истории."}
</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">
@ -275,28 +640,35 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
/> />
</div> </div>
<span className="has-tv-updater-progress-text"> <span className="has-tv-updater-progress-text">
Загрузка и установка обновления: {progress}% {statusText}
</span> </span>
</div> </div>
) : ( ) : (
<>
<div className="has-tv-updater-buttons"> <div className="has-tv-updater-buttons">
{/* Button 1: Обновить */} {/* Button 1: Да, обновить сейчас */}
<button <button
ref={updateBtnRef}
type="button" type="button"
className={`has-tv-updater-btn btn-update ${ className={`has-tv-updater-btn btn-update ${
tvFocusedBtn === "update" ? "focused" : "" tvFocusedBtn === "update" ? "focused" : ""
}`} }`}
onClick={() => { onClick={() => {
playTvBeep(680, 0.05); playTvBeep(680, 0.05);
cancelAutoCountdown();
handleInstall(); handleInstall();
}} }}
onMouseEnter={() => setTvFocusedBtn("update")} onMouseEnter={() => {
cancelAutoCountdown();
setTvFocusedBtn("update");
}}
tabIndex={0} tabIndex={0}
data-tv-focusable="true"
> >
<span>🚀 Обновить</span> <span>{platformInfo.btnLabel}</span>
</button> </button>
{/* Button 2: Пропустить */} {/* Button 2: Напомнить позже */}
<button <button
type="button" type="button"
className={`has-tv-updater-btn btn-skip ${ className={`has-tv-updater-btn btn-skip ${
@ -304,20 +676,68 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
}`} }`}
onClick={() => { onClick={() => {
playTvBeep(420, 0.04); playTvBeep(420, 0.04);
cancelAutoCountdown();
setDismissed(true); setDismissed(true);
}} }}
onMouseEnter={() => setTvFocusedBtn("skip")} onMouseEnter={() => {
cancelAutoCountdown();
setTvFocusedBtn("skip");
}}
tabIndex={0} tabIndex={0}
data-tv-focusable="true"
> >
<span>Пропустить</span> <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> </button>
</div> </div>
{/* Auto-update Toggle Chip for Android TV */}
<button
type="button"
className={`has-tv-updater-autotoggle ${autoUpdateTv ? "active" : ""} ${
tvFocusedBtn === "autotoggle" ? "focused" : ""
}`}
onClick={toggleAutoUpdate}
onMouseEnter={() => {
cancelAutoCountdown();
setTvFocusedBtn("autotoggle");
}}
tabIndex={0}
data-tv-focusable="true"
title="Переключить режим автоматического скачивания обновлений для ТВ"
>
{autoUpdateTv
? "🟢 Автообновление для TV: ВКЛЮЧЕНО (скачивать без запроса)"
: "⚪ Автообновление для TV: ВЫКЛЮЧЕНО (нажмите OK чтобы включить)"}
</button>
</>
)} )}
{error && <div className="updater-error" style={{ marginTop: 16 }}>{error}</div>} {error && <div className="updater-error" style={{ marginTop: 16 }}>{error}</div>}
<div className="has-tv-updater-remote-hint"> <div className="has-tv-updater-remote-hint">
◄ ► Стрелки на пульте: переключение • OK: подтвердить выбор • Back: пропустить ◄ ► Стрелки на пульте: выбор • ▲ ▼ Вверх/Вниз: переключение опций • OK: подтвердить • Back: позже
</div> </div>
</div> </div>
</div> </div>
@ -325,17 +745,31 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
} }
// ========================================================================= // =========================================================================
// 2. DESKTOP: Classic Bottom-Right Popup Banner // 2. DESKTOP (WINDOWS / MAC / LINUX) & MOBILE: Smart Platform Banner
// ========================================================================= // =========================================================================
return ( return (
<div className="updater-banner"> <div className="updater-banner">
<div className="updater-banner-content"> <div className="updater-banner-content">
<div className="updater-icon">🚀</div> <div className="updater-icon">{platformInfo.icon}</div>
<div className="updater-text"> <div className="updater-text">
<strong>Доступно обновление v{updateInfo?.version}</strong> <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
{updateInfo?.body && ( <strong>{platformInfo.title}</strong>
<span className="updater-notes">{updateInfo.body}</span> <span
)} style={{
fontSize: 11,
padding: "2px 8px",
borderRadius: 6,
background: "rgba(99, 102, 241, 0.2)",
color: "#c7d2fe",
fontWeight: 700,
}}
>
{platformInfo.badge}
</span>
</div>
<span className="updater-notes">
{updateInfo?.body || platformInfo.desc}
</span>
</div> </div>
<div className="updater-actions"> <div className="updater-actions">
{downloading ? ( {downloading ? (
@ -355,7 +789,7 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
className="updater-btn-install" className="updater-btn-install"
onClick={handleInstall} onClick={handleInstall}
> >
Обновить сейчас {platformInfo.btnLabel}
</button> </button>
<button <button
type="button" type="button"

View file

@ -800,6 +800,28 @@
transform: scale(1.05); transform: scale(1.05);
} }
/* Quick Ad / Intro Skip Button */
.has-cinema-player-hud-btn.ad-skip-pill {
font-size: 12px;
padding: 7px 13px;
border-radius: 9999px;
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.4);
color: #fde68a;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.has-cinema-player-hud-btn.ad-skip-pill:hover,
.has-cinema-player-hud-btn.ad-skip-pill:focus-visible,
.has-cinema-player-hud-btn.ad-skip-pill[data-tv-focused="true"] {
background: rgba(245, 158, 11, 0.35) !important;
border-color: #f59e0b !important;
color: #ffffff !important;
outline: 3px solid #f59e0b !important;
box-shadow: 0 0 20px rgba(245, 158, 11, 0.8) !important;
transform: scale(1.05);
}
/* Server selector pills in top HUD */ /* Server selector pills in top HUD */
.has-cinema-player-hud-servers { .has-cinema-player-hud-servers {
display: flex; display: flex;
@ -848,62 +870,94 @@
transform: scale(1.06); transform: scale(1.06);
} }
/* Bottom failover floating helper */ /* Leanback Bottom Playback Controls Bar */
.has-cinema-player-bottom-failover { .has-cinema-player-bottom-hud {
position: absolute; position: absolute;
bottom: 24px; bottom: 0;
left: 50%; left: 0;
transform: translateX(-50%); right: 0;
z-index: 999; z-index: 1000;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 12px; justify-content: center;
background: rgba(15, 23, 42, 0.88); padding: 24px 30px calc(env(safe-area-inset-bottom, 0px) + 24px);
border: 1px solid rgba(255, 255, 255, 0.16); background: linear-gradient(0deg, rgba(6, 9, 18, 0.96) 0%, rgba(6, 9, 18, 0.75) 60%, transparent 100%);
backdrop-filter: blur(16px); pointer-events: none;
padding: 8px 18px; transition: opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
border-radius: 9999px; transform: translateY(0);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6); opacity: 1;
transition: opacity 0.3s ease, transform 0.3s ease;
pointer-events: auto;
} }
.has-cinema-player-bottom-failover.hud-hidden { .has-cinema-player-bottom-hud.hud-hidden {
opacity: 0; opacity: 0;
transform: translateX(-50%) translateY(20px); visibility: hidden;
transform: translateY(20px);
pointer-events: none; pointer-events: none;
} }
.failover-hint { .has-cinema-player-controls-row {
font-size: 12.5px; display: flex;
color: #94a3b8; align-items: center;
font-weight: 500; justify-content: center;
gap: 14px;
pointer-events: auto !important;
} }
.failover-btn { .has-cinema-player-ctrl-btn {
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%); background: rgba(255, 255, 255, 0.12);
border: none; border: 1px solid rgba(255, 255, 255, 0.22);
color: #ffffff; color: #ffffff;
padding: 6px 14px; padding: 10px 20px;
border-radius: 9999px; border-radius: 9999px;
font-size: 12px; font-size: 14px;
font-weight: 700; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease; display: inline-flex;
align-items: center;
gap: 8px;
backdrop-filter: blur(14px);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
} }
.failover-btn:hover { .has-cinema-player-ctrl-btn:hover,
transform: scale(1.05); .has-cinema-player-ctrl-btn:focus-visible,
box-shadow: 0 0 16px rgba(59, 130, 246, 0.6); .has-cinema-player-ctrl-btn:focus {
} background: rgba(255, 255, 255, 0.25) !important;
border-color: #818cf8 !important;
.failover-btn:focus-visible, outline: 3px solid #818cf8 !important;
.failover-btn[data-tv-focused="true"] { box-shadow: 0 0 20px rgba(129, 140, 248, 0.8) !important;
outline: none;
box-shadow: 0 0 0 3px #ffffff, 0 0 20px #3b82f6;
transform: scale(1.08); transform: scale(1.08);
} }
.has-cinema-player-ctrl-btn.primary {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
border-color: #818cf8;
padding: 12px 28px;
font-size: 15px;
font-weight: 700;
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.5);
}
.has-cinema-player-ctrl-btn.primary:hover,
.has-cinema-player-ctrl-btn.primary:focus {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
box-shadow: 0 0 25px rgba(129, 140, 248, 0.95) !important;
transform: scale(1.1);
}
.has-cinema-player-ctrl-btn.secondary {
background: rgba(30, 41, 59, 0.7);
border-color: rgba(148, 163, 184, 0.3);
color: #cbd5e1;
}
.has-cinema-player-ctrl-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.has-cinema-mirror-connecting-banner { .has-cinema-mirror-connecting-banner {
position: absolute; position: absolute;
top: 76px; top: 76px;
@ -1303,6 +1357,21 @@
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.6); box-shadow: 0 6px 20px rgba(79, 70, 229, 0.6);
} }
.has-cinema-drawer-option-btn:focus,
.has-cinema-drawer-option-btn:focus-visible,
.has-cinema-drawer-chip:focus,
.has-cinema-drawer-chip:focus-visible,
.has-cinema-drawer-ep-btn:focus,
.has-cinema-drawer-ep-btn:focus-visible,
.has-cinema-drawer-btn-primary:focus,
.has-cinema-drawer-btn-secondary:focus,
.has-cinema-drawer-close-btn:focus {
outline: 3px solid #818cf8 !important;
outline-offset: 2px !important;
box-shadow: 0 0 16px rgba(99, 102, 241, 0.6) !important;
transform: scale(1.03) !important;
}
/* Episodes Grid Drawer (Series only) */ /* Episodes Grid Drawer (Series only) */
.has-cinema-drawer-episodes-grid { .has-cinema-drawer-episodes-grid {
display: grid; display: grid;
@ -2234,6 +2303,91 @@
display: none !important; display: none !important;
} }
/* Sleek Leanback TV Top Navigation Bar */
.has-cinema-tv-top-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 40px;
background: rgba(10, 15, 30, 0.92);
backdrop-filter: blur(24px);
border-bottom: 1px solid rgba(129, 140, 248, 0.25);
margin-bottom: 8px;
position: relative;
z-index: 100;
}
.has-cinema-tv-top-brand {
display: flex;
align-items: center;
gap: 16px;
}
.has-cinema-tv-menu-btn {
background: rgba(99, 102, 241, 0.22);
border: 1px solid rgba(129, 140, 248, 0.45);
color: #ffffff;
padding: 8px 18px;
border-radius: 9999px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.has-cinema-tv-menu-btn:hover,
.has-cinema-tv-menu-btn:focus,
.has-cinema-tv-menu-btn:focus-visible {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
border-color: #ffffff;
outline: 3px solid #818cf8 !important;
box-shadow: 0 0 20px rgba(129, 140, 248, 0.85) !important;
transform: scale(1.05);
}
.has-cinema-tv-brand-title {
font-size: 16px;
font-weight: 800;
color: #f1f5f9;
letter-spacing: 0.5px;
}
.has-cinema-tv-top-tabs {
display: flex;
align-items: center;
gap: 10px;
}
.has-cinema-tv-tab-btn {
background: transparent;
border: 1px solid transparent;
color: #94a3b8;
padding: 8px 18px;
border-radius: 9999px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.has-cinema-tv-tab-btn:hover,
.has-cinema-tv-tab-btn:focus,
.has-cinema-tv-tab-btn:focus-visible {
color: #ffffff;
background: rgba(255, 255, 255, 0.14);
border-color: #818cf8;
outline: 3px solid #818cf8 !important;
box-shadow: 0 0 18px rgba(129, 140, 248, 0.75) !important;
transform: scale(1.05);
}
.has-cinema-tv-tab-btn.active {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: #ffffff;
border-color: #818cf8;
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.45);
}
.has-tv-mode .has-cinema-hero { .has-tv-mode .has-cinema-hero {
padding: 40px 54px 30px !important; padding: 40px 54px 30px !important;
min-height: 360px; min-height: 360px;
@ -3119,6 +3273,177 @@
} }
} }
/* ========================================================
WATCH HISTORY (ИСТОРИЯ ПРОСМОТРОВ) STYLES
======================================================== */
.has-cinema-history-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.has-cinema-history-subtitle {
color: #94a3b8;
font-size: 13.5px;
font-weight: 400;
margin-top: 4px;
display: block;
}
.has-cinema-clear-history-btn {
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.28);
color: #fca5a5;
font-size: 13px;
font-weight: 500;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.has-cinema-clear-history-btn:hover,
.has-cinema-clear-history-btn:focus-visible,
.has-tv-mode .has-cinema-clear-history-btn:focus {
background: rgba(239, 68, 68, 0.25) !important;
border-color: #ef4444 !important;
color: #fff !important;
outline: 2px solid #ef4444 !important;
outline-offset: 2px !important;
transform: translateY(-1px);
}
.has-cinema-row-more-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.14);
color: #cbd5e1;
font-size: 12.5px;
font-weight: 500;
padding: 6px 14px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.has-cinema-row-more-btn:hover,
.has-cinema-row-more-btn:focus-visible,
.has-tv-mode .has-cinema-row-more-btn:focus {
background: rgba(99, 102, 241, 0.25) !important;
border-color: #818cf8 !important;
color: #fff !important;
outline: 2px solid #818cf8 !important;
outline-offset: 2px !important;
}
/* History Card Enhancements */
.has-cinema-history-card {
position: relative;
}
.has-cinema-history-item-del-btn {
position: absolute;
top: 8px;
right: 8px;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.25);
color: #cbd5e1;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
cursor: pointer;
opacity: 0;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 25;
}
.has-cinema-media-card:hover .has-cinema-history-item-del-btn,
.has-cinema-grid-card:hover .has-cinema-history-item-del-btn,
.has-cinema-media-card:focus-within .has-cinema-history-item-del-btn,
.has-cinema-grid-card:focus-within .has-cinema-history-item-del-btn,
.has-tv-mode .has-cinema-media-card:focus .has-cinema-history-item-del-btn,
.has-tv-mode .has-cinema-grid-card:focus .has-cinema-history-item-del-btn {
opacity: 1;
}
.has-cinema-history-item-del-btn:hover,
.has-cinema-history-item-del-btn:focus-visible {
background: #ef4444;
color: #fff;
border-color: #ef4444;
transform: scale(1.15);
outline: none;
}
.has-cinema-history-status-badge {
position: absolute;
top: 8px;
left: 8px;
padding: 3px 8px;
border-radius: 6px;
background: rgba(15, 23, 42, 0.85);
backdrop-filter: blur(6px);
border: 1px solid rgba(129, 140, 248, 0.35);
color: #c7d2fe;
font-size: 11px;
font-weight: 600;
z-index: 10;
}
.has-cinema-history-status-badge.completed {
background: rgba(6, 78, 59, 0.85);
border-color: rgba(52, 211, 153, 0.4);
color: #6ee7b7;
}
.has-cinema-history-percent-badge {
position: absolute;
bottom: 8px;
right: 8px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
color: #e2e8f0;
font-size: 10.5px;
font-weight: 600;
z-index: 10;
}
.has-cinema-history-card-sub {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: #94a3b8;
margin-top: 2px;
}
.has-cinema-history-ep-info {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.has-cinema-history-date {
color: #64748b;
font-size: 11px;
white-space: nowrap;
}

File diff suppressed because it is too large Load diff

View file

@ -55,6 +55,7 @@ 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";
@ -62,8 +63,6 @@ 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") ||
@ -90,6 +89,17 @@ 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;
}; };
@ -98,13 +108,23 @@ 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>(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 [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);
@ -113,21 +133,6 @@ 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);