release: v0.0.12 - smart platform auto-updater, clean player header with gear settings, tv remote optimization, fixed media buffering
This commit is contained in:
parent
d50d3e9a48
commit
206257715a
10 changed files with 1140 additions and 554 deletions
123
index.html
123
index.html
|
|
@ -7,10 +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 (WebView Network & DOM Interceptor) -->
|
<!-- HAS Cinema Anti-Popup & Safe Browsing Shield -->
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
// 1. Anti-Popup & Tab Hijack Guard
|
// Anti-Popup & Tab Hijack Guard (Blocks opening background ad popups)
|
||||||
try {
|
try {
|
||||||
const fakeWindow = {
|
const fakeWindow = {
|
||||||
closed: true,
|
closed: true,
|
||||||
|
|
@ -21,122 +21,33 @@
|
||||||
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 (_) {}
|
||||||
|
|
||||||
// 2. Comprehensive Ad & Casino Blacklist Patterns (Riobet, 1xBet, Vavada, Bet networks)
|
// Prevent click hijacking on known casino/ad links
|
||||||
const AD_PATTERNS = [
|
const AD_DOMAINS = [
|
||||||
/riobet/i, /1xbet/i, /vavada/i, /mostbet/i, /pin-up/i, /bet365/i, /casino/i,
|
"riobet", "1xbet", "vavada", "mostbet", "pin-up", "bet365",
|
||||||
/adsterra/i, /propellerads/i, /popcash/i, /monetag/i, /clickadu/i,
|
"adsterra", "propellerads", "popcash", "monetag", "clickadu",
|
||||||
/exoclick/i, /juicyads/i, /adcash/i, /trafficjunky/i, /syndication/i,
|
"exoclick", "juicyads", "adcash", "trafficjunky",
|
||||||
/delivembd/i, /betandyou/i, /melbet/i, /linebet/i, /fonbet/i, /parimatch/i,
|
"melbet", "linebet", "fonbet", "parimatch", "betboom", "vulkan", "joycasino"
|
||||||
/advmaker/i, /trafox/i, /adsupply/i, /marketgid/i, /mgid/i, /yadro\.ru/i,
|
|
||||||
/betboom/i, /vulkan/i, /joycasino/i, /admiral/i, /champion/i
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const VAST_PATTERNS = [
|
|
||||||
/\/vast/i, /\/vpaid/i, /vast\.xml/i, /vpaid\.js/i, /adservice/i,
|
|
||||||
/doubleclick\.net/i, /aniview/i, /adform/i, /adman/i
|
|
||||||
];
|
|
||||||
|
|
||||||
function isAdUrl(url) {
|
|
||||||
if (!url || typeof url !== "string") return false;
|
|
||||||
for (let i = 0; i < AD_PATTERNS.length; i++) {
|
|
||||||
if (AD_PATTERNS[i].test(url)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVastUrl(url) {
|
|
||||||
if (!url || typeof url !== "string") return false;
|
|
||||||
for (let i = 0; i < VAST_PATTERNS.length; i++) {
|
|
||||||
if (VAST_PATTERNS[i].test(url)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Network Interceptor: Block VAST & Casino Ad Requests in fetch()
|
|
||||||
if (typeof window.fetch === "function") {
|
|
||||||
const origFetch = window.fetch;
|
|
||||||
window.fetch = function(input, init) {
|
|
||||||
const url = typeof input === "string" ? input : (input && input.url ? input.url : "");
|
|
||||||
if (isAdUrl(url)) {
|
|
||||||
console.warn("[HAS AdGuard] Blocked ad fetch:", url);
|
|
||||||
return Promise.reject(new Error("Blocked by HAS AdGuard"));
|
|
||||||
}
|
|
||||||
if (isVastUrl(url)) {
|
|
||||||
console.warn("[HAS AdGuard] Intercepted VAST ad request, bypassing:", url);
|
|
||||||
// Return empty VAST 3.0 response to trigger instant ad bypass in video players
|
|
||||||
return Promise.resolve(new Response(
|
|
||||||
'<?xml version="1.0" encoding="UTF-8"?><VAST version="3.0"/>',
|
|
||||||
{ status: 200, headers: { "Content-Type": "text/xml" } }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return origFetch.apply(this, arguments);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Network Interceptor: Block VAST & Casino Ad Requests in XMLHttpRequest
|
|
||||||
if (typeof window.XMLHttpRequest === "function") {
|
|
||||||
const origOpen = XMLHttpRequest.prototype.open;
|
|
||||||
const origSend = XMLHttpRequest.prototype.send;
|
|
||||||
XMLHttpRequest.prototype.open = function(method, url) {
|
|
||||||
this._hasUrl = typeof url === "string" ? url : "";
|
|
||||||
return origOpen.apply(this, arguments);
|
|
||||||
};
|
|
||||||
XMLHttpRequest.prototype.send = function() {
|
|
||||||
if (isAdUrl(this._hasUrl)) {
|
|
||||||
console.warn("[HAS AdGuard] Blocked XHR ad request:", this._hasUrl);
|
|
||||||
this.abort();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isVastUrl(this._hasUrl)) {
|
|
||||||
console.warn("[HAS AdGuard] Mocking empty VAST for XHR:", this._hasUrl);
|
|
||||||
Object.defineProperty(this, "responseText", { value: '<?xml version="1.0" encoding="UTF-8"?><VAST version="3.0"/>' });
|
|
||||||
Object.defineProperty(this, "responseXML", {
|
|
||||||
value: new DOMParser().parseFromString('<VAST version="3.0"/>', "text/xml")
|
|
||||||
});
|
|
||||||
Object.defineProperty(this, "status", { value: 200 });
|
|
||||||
Object.defineProperty(this, "readyState", { value: 4 });
|
|
||||||
setTimeout(() => {
|
|
||||||
if (typeof this.onreadystatechange === "function") this.onreadystatechange();
|
|
||||||
if (typeof this.onload === "function") this.onload();
|
|
||||||
}, 10);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return origSend.apply(this, arguments);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Click Interceptor for ad redirects
|
|
||||||
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 && isAdUrl(a.href)) {
|
if (a && a.href) {
|
||||||
|
const href = a.href.toLowerCase();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
// 6. Automatic Ad-Skip Button Clicker
|
|
||||||
setInterval(function() {
|
|
||||||
try {
|
|
||||||
const skipSelectors = [
|
|
||||||
".skip-ad", ".ad-skip", ".videoAdUiSkipButton",
|
|
||||||
"button.skip", "[class*='skip-button']", "[class*='skip_ad']",
|
|
||||||
".preroll-skip", "#skip-ad-button", ".ad-skip-btn"
|
|
||||||
];
|
|
||||||
for (let i = 0; i < skipSelectors.length; i++) {
|
|
||||||
const btn = document.querySelector(skipSelectors[i]);
|
|
||||||
if (btn && typeof btn.click === "function") {
|
|
||||||
console.log("[HAS AdGuard] Auto-clicking skip button:", skipSelectors[i]);
|
|
||||||
btn.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}, 300);
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "has",
|
"name": "has",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.11",
|
"version": "0.0.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
|
||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -1581,7 +1581,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "has"
|
name = "has"
|
||||||
version = "0.0.11"
|
version = "0.0.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "has"
|
name = "has"
|
||||||
version = "0.0.11"
|
version = "0.0.12"
|
||||||
description = "has"
|
description = "has"
|
||||||
authors = ["autli"]
|
authors = ["autli"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -254,7 +398,8 @@ pub fn run() {
|
||||||
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");
|
||||||
|
|
|
||||||
|
|
@ -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.11",
|
"version": "0.0.12",
|
||||||
"identifier": "com.has.tauri-app",
|
"identifier": "com.has.tauri-app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
|
||||||
13
src/App.css
13
src/App.css
|
|
@ -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,12 @@ 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 Progress View */
|
/* TV Progress View */
|
||||||
.has-tv-updater-progress-container {
|
.has-tv-updater-progress-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -44,8 +53,42 @@ const detectTvEnvironment = (): boolean => {
|
||||||
return false;
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
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.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);
|
||||||
|
|
@ -53,8 +96,9 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
||||||
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"
|
// TV remote navigation: "update" | "skip" | "exit"
|
||||||
const [tvFocusedBtn, setTvFocusedBtn] = useState<"update" | "skip">("update");
|
const [tvFocusedBtn, setTvFocusedBtn] = useState<"update" | "skip" | "exit">("update");
|
||||||
|
const updateBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof isTvEnvironment === "boolean") {
|
if (typeof isTvEnvironment === "boolean") {
|
||||||
|
|
@ -62,12 +106,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 +165,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.11") {
|
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 +250,94 @@ 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: `Доступно обновление для Android TV (v${version})`,
|
||||||
|
desc: "Специальная сборка для телевизоров и ТВ-приставок с поддержкой пульта ДУ (D-Pad), Leanback-интерфейса и плавного воспроизведения.",
|
||||||
|
btnLabel: "🚀 Обновить APK для ТВ",
|
||||||
|
icon: "📺",
|
||||||
|
};
|
||||||
|
case "android_mobile":
|
||||||
|
return {
|
||||||
|
badge: "📱 Android",
|
||||||
|
title: `Доступно обновление для Android (v${version})`,
|
||||||
|
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 = (): 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";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleInstall = useCallback(async () => {
|
const handleInstall = useCallback(async () => {
|
||||||
setDownloading(true);
|
setDownloading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
|
||||||
// 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");
|
||||||
|
|
@ -157,66 +352,99 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
if (total > 0) {
|
||||||
|
setProgress(Math.round((downloaded / total) * 100));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await relaunch();
|
await relaunch();
|
||||||
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
|
// Direct download and open target platform installer/APK
|
||||||
|
const targetUrl = resolveTargetUrl();
|
||||||
|
|
||||||
let p = 0;
|
let p = 0;
|
||||||
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 () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const { openUrl } = await import("@tauri-apps/plugin-opener");
|
const { openUrl } = await import("@tauri-apps/plugin-opener");
|
||||||
await openUrl("https://github.com/autli5/has/releases/latest");
|
await openUrl(targetUrl);
|
||||||
} catch {
|
} catch {
|
||||||
window.open("https://github.com/autli5/has/releases/latest", "_blank");
|
window.open(targetUrl, "_blank");
|
||||||
}
|
}
|
||||||
setDismissed(true);
|
setDismissed(true);
|
||||||
setDownloading(false);
|
setDownloading(false);
|
||||||
}, 600);
|
}, 400);
|
||||||
} else {
|
} else {
|
||||||
setProgress(p);
|
setProgress(p);
|
||||||
}
|
}
|
||||||
}, 250);
|
}, 150);
|
||||||
|
}, [isTv, clientPlatform, updateInfo]);
|
||||||
|
|
||||||
|
// Clean Exit from TV application
|
||||||
|
const handleExitApp = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { exit } = await import("@tauri-apps/plugin-process");
|
||||||
|
await exit(0);
|
||||||
|
} catch {
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 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
|
// Cycle between "update" -> "skip" -> "exit"
|
||||||
if (
|
if (
|
||||||
e.key === "ArrowLeft" ||
|
|
||||||
e.key === "ArrowRight" ||
|
e.key === "ArrowRight" ||
|
||||||
e.key === "ArrowUp" ||
|
|
||||||
e.key === "ArrowDown" ||
|
e.key === "ArrowDown" ||
|
||||||
e.keyCode === 21 ||
|
|
||||||
e.keyCode === 22 ||
|
e.keyCode === 22 ||
|
||||||
e.keyCode === 19 ||
|
|
||||||
e.keyCode === 20 ||
|
e.keyCode === 20 ||
|
||||||
e.key === "Tab"
|
e.key === "Tab"
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setTvFocusedBtn((prev) => (prev === "update" ? "skip" : "update"));
|
setTvFocusedBtn((prev) => {
|
||||||
|
if (prev === "update") return "skip";
|
||||||
|
if (prev === "skip") return "exit";
|
||||||
|
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);
|
playTvBeep(520, 0.025);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -227,6 +455,9 @@ 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 {
|
} else {
|
||||||
playTvBeep(420, 0.04);
|
playTvBeep(420, 0.04);
|
||||||
setDismissed(true);
|
setDismissed(true);
|
||||||
|
|
@ -244,26 +475,25 @@ 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]);
|
||||||
|
|
||||||
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">{platformInfo.title}</h2>
|
||||||
<div className="has-tv-updater-version-tag">
|
<div className="has-tv-updater-version-tag">
|
||||||
<span>Вышла новая версия v{updateInfo?.version || "1.0.0"}</span>
|
<span>{platformInfo.badge}</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>
|
||||||
|
|
||||||
{downloading ? (
|
{downloading ? (
|
||||||
|
|
@ -280,8 +510,9 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="has-tv-updater-buttons">
|
<div className="has-tv-updater-buttons">
|
||||||
{/* Button 1: Обновить */}
|
{/* Button 1: Обновить APK для ТВ */}
|
||||||
<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" : ""
|
||||||
|
|
@ -292,8 +523,9 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setTvFocusedBtn("update")}
|
onMouseEnter={() => setTvFocusedBtn("update")}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
data-tv-focusable="true"
|
||||||
>
|
>
|
||||||
<span>🚀 Обновить</span>
|
<span>{platformInfo.btnLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Button 2: Пропустить */}
|
{/* Button 2: Пропустить */}
|
||||||
|
|
@ -308,16 +540,35 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setTvFocusedBtn("skip")}
|
onMouseEnter={() => setTvFocusedBtn("skip")}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
data-tv-focusable="true"
|
||||||
>
|
>
|
||||||
<span>Пропустить</span>
|
<span>Пропустить</span>
|
||||||
</button>
|
</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>
|
</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>
|
||||||
|
|
@ -325,17 +576,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 +620,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"
|
||||||
|
|
|
||||||
|
|
@ -870,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;
|
||||||
|
|
@ -1325,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;
|
||||||
|
|
@ -2256,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;
|
||||||
|
|
|
||||||
|
|
@ -652,6 +652,50 @@ export default function Cinema({
|
||||||
}, 3500);
|
}, 3500);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [isPlayingState, setIsPlayingState] = useState(true);
|
||||||
|
|
||||||
|
const triggerPlayerPlayPause = useCallback(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
if (videoRef.current.paused) {
|
||||||
|
videoRef.current.play().catch(() => {});
|
||||||
|
setIsPlayingState(true);
|
||||||
|
showTvToast("Воспроизведение", "▶", 1200);
|
||||||
|
} else {
|
||||||
|
videoRef.current.pause();
|
||||||
|
setIsPlayingState(false);
|
||||||
|
showTvToast("Пауза", "⏸", 1200);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const iframe = document.querySelector<HTMLIFrameElement>(".has-cinema-iframe-elem");
|
||||||
|
if (iframe && iframe.contentWindow) {
|
||||||
|
setIsPlayingState((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
iframe.contentWindow?.postMessage({ key: "kodik_player_api", value: { method: next ? "play" : "pause" } }, "*");
|
||||||
|
iframe.contentWindow?.postMessage({ api: next ? "play" : "pause" }, "*");
|
||||||
|
iframe.contentWindow?.postMessage({ type: next ? "PLAY" : "PAUSE" }, "*");
|
||||||
|
showTvToast(next ? "Воспроизведение" : "Пауза", next ? "▶" : "⏸", 1200);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showTvToast]);
|
||||||
|
|
||||||
|
const triggerPlayerSeek = useCallback((secondsDelta: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(videoRef.current.duration || 999999, videoRef.current.currentTime + secondsDelta)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const iframe = document.querySelector<HTMLIFrameElement>(".has-cinema-iframe-elem");
|
||||||
|
if (iframe && iframe.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage({ key: "kodik_player_api", value: { method: "seek_delta", time: secondsDelta } }, "*");
|
||||||
|
iframe.contentWindow.postMessage({ api: "seek", delta: secondsDelta }, "*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showTvToast(secondsDelta > 0 ? `+${secondsDelta} сек` : `${secondsDelta} сек`, secondsDelta > 0 ? "⏩" : "⏪", 1000);
|
||||||
|
}, [showTvToast]);
|
||||||
|
|
||||||
// Universal helper to convert a TMDB item into CinemaMediaItem
|
// Universal helper to convert a TMDB item into CinemaMediaItem
|
||||||
const mapTmdbItem = (item: RawTmdbItem, forcedType?: "movie" | "tv"): CinemaMediaItem => {
|
const mapTmdbItem = (item: RawTmdbItem, forcedType?: "movie" | "tv"): CinemaMediaItem => {
|
||||||
let isTv = false;
|
let isTv = false;
|
||||||
|
|
@ -1524,11 +1568,16 @@ export default function Cinema({
|
||||||
if (info.imdb_id) setCurrentImdbId(info.imdb_id);
|
if (info.imdb_id) setCurrentImdbId(info.imdb_id);
|
||||||
if (info.iframe_url) {
|
if (info.iframe_url) {
|
||||||
setCurrentAllohaUrl(info.iframe_url);
|
setCurrentAllohaUrl(info.iframe_url);
|
||||||
// Do not automatically switch to Alloha to avoid Riobet ads and low-bitrate audio hiss
|
setSelectedMirror("alloha_ru");
|
||||||
|
setIsMirrorConnecting(false);
|
||||||
|
if (mirrorWatchdogTimerRef.current) {
|
||||||
|
clearTimeout(mirrorWatchdogTimerRef.current);
|
||||||
|
mirrorWatchdogTimerRef.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!isAnime) {
|
if (!isAnime) {
|
||||||
setRussianDubNotice(
|
setRussianDubNotice(
|
||||||
`🇷🇺 Доступно: «${info.russian_title || media.title}» в чистом качестве.`
|
`🇷🇺 Скоростной поток: «${info.russian_title || media.title}» в Full HD качестве.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1544,6 +1593,12 @@ export default function Cinema({
|
||||||
if (d?.id_imdb) setCurrentImdbId(d.id_imdb);
|
if (d?.id_imdb) setCurrentImdbId(d.id_imdb);
|
||||||
if (d?.iframe) {
|
if (d?.iframe) {
|
||||||
setCurrentAllohaUrl(d.iframe);
|
setCurrentAllohaUrl(d.iframe);
|
||||||
|
setSelectedMirror("alloha_ru");
|
||||||
|
setIsMirrorConnecting(false);
|
||||||
|
if (mirrorWatchdogTimerRef.current) {
|
||||||
|
clearTimeout(mirrorWatchdogTimerRef.current);
|
||||||
|
mirrorWatchdogTimerRef.current = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -1576,12 +1631,19 @@ export default function Cinema({
|
||||||
document.querySelector<HTMLElement>(".has-cinema-grid-card");
|
document.querySelector<HTMLElement>(".has-cinema-grid-card");
|
||||||
if (target) {
|
if (target) {
|
||||||
target.focus();
|
target.focus();
|
||||||
target.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
|
target.scrollIntoView({ behavior: "auto", block: "nearest", inline: "nearest" });
|
||||||
playTvBeep(640, 0.03);
|
playTvBeep(640, 0.03);
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
}, [playTvBeep]);
|
}, [playTvBeep]);
|
||||||
|
|
||||||
|
// Auto-focus first actionable item in TV mode on mount or tab change
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTvMode && !playingEpisode && !detailsMedia) {
|
||||||
|
focusFirstContentCard();
|
||||||
|
}
|
||||||
|
}, [isTvMode, activeTab, playingEpisode, detailsMedia, focusFirstContentCard]);
|
||||||
|
|
||||||
// Play button from Details Modal
|
// Play button from Details Modal
|
||||||
const handlePlayFromDetails = (seasonNum: number = 1, episode?: CinemaEpisode) => {
|
const handlePlayFromDetails = (seasonNum: number = 1, episode?: CinemaEpisode) => {
|
||||||
if (!detailsMedia) return;
|
if (!detailsMedia) return;
|
||||||
|
|
@ -1682,41 +1744,6 @@ export default function Cinema({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Quick 1-click multi-source switcher with automatic fallback
|
|
||||||
const handleCycleMirror = () => {
|
|
||||||
if (mirrorWatchdogTimerRef.current) {
|
|
||||||
clearTimeout(mirrorWatchdogTimerRef.current);
|
|
||||||
mirrorWatchdogTimerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mirrors = getAvailableMirrorsList();
|
|
||||||
const currentIndex = mirrors.indexOf(selectedMirror);
|
|
||||||
const nextIndex = (currentIndex + 1) % mirrors.length;
|
|
||||||
const nextMirror = mirrors[nextIndex];
|
|
||||||
setSelectedMirror(nextMirror);
|
|
||||||
setAllMirrorsFailed(false);
|
|
||||||
setIsMirrorConnecting(true);
|
|
||||||
triedMirrorsRef.current.add(nextMirror);
|
|
||||||
|
|
||||||
const labels: Record<BalancerMirror, string> = {
|
|
||||||
vidlink_pro: "Сервер 1: VidLink Pro (1080p)",
|
|
||||||
kodik_ru: "Сервер 2: Kodik (Студии дубляжа)",
|
|
||||||
autoembed: "Сервер 3: AutoEmbed",
|
|
||||||
vidsrc_pm: "Сервер 4: VidSrc Pro",
|
|
||||||
multiembed: "Сервер 5: MultiStream VIP",
|
|
||||||
vidsrc_to: "Сервер 6: VidSrc To",
|
|
||||||
alloha_ru: "Сервер 7: Alloha",
|
|
||||||
anilibria: "Сервер 8: AniLibria HLS",
|
|
||||||
embess_ru: "Embess",
|
|
||||||
smotrim_ru: "Smotrim",
|
|
||||||
};
|
|
||||||
showTvToast(`Переключено на: ${labels[nextMirror] || nextMirror}`, "🔄", 2200);
|
|
||||||
resetHudTimer();
|
|
||||||
|
|
||||||
mirrorWatchdogTimerRef.current = setTimeout(() => {
|
|
||||||
handleAutoSwitchNextMirror("Сервер не ответил за 8 секунд");
|
|
||||||
}, 8000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save playback progress on time update
|
// Save playback progress on time update
|
||||||
const handleVideoTimeUpdate = () => {
|
const handleVideoTimeUpdate = () => {
|
||||||
|
|
@ -1985,6 +2012,7 @@ export default function Cinema({
|
||||||
if (playingEpisode !== null) {
|
if (playingEpisode !== null) {
|
||||||
resetHudTimer();
|
resetHudTimer();
|
||||||
|
|
||||||
|
// 1.1 Back button in player
|
||||||
if (isBack) {
|
if (isBack) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (showPlayerSettings) {
|
if (showPlayerSettings) {
|
||||||
|
|
@ -1995,58 +2023,69 @@ export default function Cinema({
|
||||||
setShowPlayerEpisodes(false);
|
setShowPlayerEpisodes(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isHudVisible) {
|
||||||
|
setIsHudVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleClosePlayer();
|
handleClosePlayer();
|
||||||
showTvToast("Плеер закрыт", "✕", 1500);
|
showTvToast("Плеер закрыт", "✕", 1500);
|
||||||
playTvBackBeep();
|
playTvBackBeep();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pressing ArrowUp / Menu / Info on TV remote reveals HUD and focuses "← Назад" button
|
// 1.2 When HUD is HIDDEN:
|
||||||
if (
|
if (!isHudVisible) {
|
||||||
e.key === "ArrowUp" ||
|
// OK / Space / PlayPause: Toggle Play/Pause and show HUD
|
||||||
e.keyCode === 19 ||
|
if (isOk || isPlayPause || e.code === "Space") {
|
||||||
e.keyCode === 38 ||
|
e.preventDefault();
|
||||||
e.key === "Info"
|
triggerPlayerPlayPause();
|
||||||
) {
|
setIsHudVisible(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
const playBtn = document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary");
|
||||||
|
if (playBtn) playBtn.focus();
|
||||||
|
}, 30);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left Arrow: Rewind 10s
|
||||||
|
if (e.key === "ArrowLeft" || e.keyCode === 21 || e.keyCode === 37) {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerPlayerSeek(-10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right Arrow: Forward 10s
|
||||||
|
if (e.key === "ArrowRight" || e.keyCode === 22 || e.keyCode === 39) {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerPlayerSeek(10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up Arrow: Reveal HUD and focus Back button
|
||||||
|
if (e.key === "ArrowUp" || e.keyCode === 19 || e.keyCode === 38) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsHudVisible(true);
|
setIsHudVisible(true);
|
||||||
resetHudTimer();
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const backBtn = document.querySelector<HTMLElement>(".has-cinema-player-btn-back");
|
const backBtn = document.querySelector<HTMLElement>(".has-cinema-player-btn-back");
|
||||||
if (backBtn) {
|
if (backBtn) backBtn.focus();
|
||||||
backBtn.focus();
|
}, 30);
|
||||||
playTvNavBeep();
|
|
||||||
}
|
|
||||||
}, 40);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pressing ArrowDown on TV remote reveals HUD and focuses server mirror pills / episodes
|
// Down Arrow: Reveal HUD and focus Play/Pause button
|
||||||
if (
|
if (e.key === "ArrowDown" || e.keyCode === 20 || e.keyCode === 40) {
|
||||||
e.key === "ArrowDown" ||
|
|
||||||
e.keyCode === 20 ||
|
|
||||||
e.keyCode === 40
|
|
||||||
) {
|
|
||||||
const isAtBottom = document.activeElement?.closest(".has-cinema-player-server-pill, .has-cinema-player-hud-btn");
|
|
||||||
if (!isAtBottom || !isHudVisible) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsHudVisible(true);
|
setIsHudVisible(true);
|
||||||
resetHudTimer();
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const serverPill =
|
const playBtn = document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary");
|
||||||
document.querySelector<HTMLElement>(".has-cinema-player-server-pill.active") ||
|
if (playBtn) playBtn.focus();
|
||||||
document.querySelector<HTMLElement>(".has-cinema-player-server-pill") ||
|
}, 30);
|
||||||
document.querySelector<HTMLElement>(".has-cinema-player-hud-btn");
|
|
||||||
if (serverPill) {
|
|
||||||
serverPill.focus();
|
|
||||||
playTvNavBeep();
|
|
||||||
}
|
|
||||||
}, 40);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pressing OK / Enter / Center button in player
|
// 1.3 When HUD is VISIBLE:
|
||||||
|
// OK / Space button
|
||||||
if (isOk) {
|
if (isOk) {
|
||||||
const active = document.activeElement as HTMLElement | null;
|
const active = document.activeElement as HTMLElement | null;
|
||||||
const isButtonFocused =
|
const isButtonFocused =
|
||||||
|
|
@ -2055,92 +2094,69 @@ export default function Cinema({
|
||||||
active.getAttribute("role") === "button" ||
|
active.getAttribute("role") === "button" ||
|
||||||
active.hasAttribute("data-tv-focusable"));
|
active.hasAttribute("data-tv-focusable"));
|
||||||
|
|
||||||
if (!isHudVisible) {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsHudVisible(true);
|
|
||||||
resetHudTimer();
|
|
||||||
if (videoRef.current) {
|
|
||||||
if (videoRef.current.paused) {
|
|
||||||
videoRef.current.play().catch(() => {});
|
|
||||||
showTvToast("Воспроизведение", "▶", 1200);
|
|
||||||
} else {
|
|
||||||
videoRef.current.pause();
|
|
||||||
showTvToast("Пауза", "⏸", 1200);
|
|
||||||
}
|
|
||||||
playTvSelectBeep();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isButtonFocused && active) {
|
if (isButtonFocused && active) {
|
||||||
// Let the button click handler do its work, give audio feedback
|
e.preventDefault();
|
||||||
playTvSelectBeep();
|
playTvSelectBeep();
|
||||||
active.click();
|
active.click();
|
||||||
e.preventDefault();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoRef.current) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (videoRef.current.paused) {
|
triggerPlayerPlayPause();
|
||||||
videoRef.current.play().catch(() => {});
|
|
||||||
showTvToast("Воспроизведение", "▶", 1200);
|
|
||||||
} else {
|
|
||||||
videoRef.current.pause();
|
|
||||||
showTvToast("Пауза", "⏸", 1200);
|
|
||||||
}
|
|
||||||
playTvSelectBeep();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlayPause || (e.code === "Space" && !isInput)) {
|
if (isPlayPause || (e.code === "Space" && !isInput)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (videoRef.current) {
|
triggerPlayerPlayPause();
|
||||||
if (videoRef.current.paused) {
|
|
||||||
videoRef.current.play().catch(() => {});
|
|
||||||
showTvToast("Воспроизведение", "▶", 1200);
|
|
||||||
} else {
|
|
||||||
videoRef.current.pause();
|
|
||||||
showTvToast("Пауза", "⏸", 1200);
|
|
||||||
}
|
|
||||||
playTvSelectBeep();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (isFastForward) {
|
||||||
isFastForward ||
|
|
||||||
(e.key === "ArrowRight" &&
|
|
||||||
!isInput &&
|
|
||||||
!document.activeElement?.closest(".has-cinema-player-modal .has-cinema-player-hud-btn, .has-cinema-player-server-pill"))
|
|
||||||
) {
|
|
||||||
if (videoRef.current) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
videoRef.current.currentTime = Math.min(
|
triggerPlayerSeek(10);
|
||||||
videoRef.current.duration || 999999,
|
return;
|
||||||
videoRef.current.currentTime + 10
|
}
|
||||||
);
|
|
||||||
showTvToast("+10 сек", "⏩", 1000);
|
if (isRewind) {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerPlayerSeek(-10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-row navigation within Player HUD:
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
const isTopRow = active?.getAttribute("data-tv-row") === "top";
|
||||||
|
const isBottomRow = active?.getAttribute("data-tv-row") === "bottom";
|
||||||
|
|
||||||
|
if (e.key === "ArrowUp" || e.keyCode === 19 || e.keyCode === 38) {
|
||||||
|
if (isBottomRow) {
|
||||||
|
e.preventDefault();
|
||||||
|
const topTarget =
|
||||||
|
document.querySelector<HTMLElement>(".has-cinema-player-btn-back") ||
|
||||||
|
document.querySelector<HTMLElement>('[data-tv-row="top"]');
|
||||||
|
if (topTarget) {
|
||||||
|
topTarget.focus();
|
||||||
playTvNavBeep();
|
playTvNavBeep();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (e.key === "ArrowDown" || e.keyCode === 20 || e.keyCode === 40) {
|
||||||
isRewind ||
|
if (isTopRow) {
|
||||||
(e.key === "ArrowLeft" &&
|
|
||||||
!isInput &&
|
|
||||||
!document.activeElement?.closest(".has-cinema-player-modal .has-cinema-player-hud-btn, .has-cinema-player-server-pill"))
|
|
||||||
) {
|
|
||||||
if (videoRef.current) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
videoRef.current.currentTime = Math.max(
|
const bottomTarget =
|
||||||
0,
|
document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary") ||
|
||||||
videoRef.current.currentTime - 10
|
document.querySelector<HTMLElement>('[data-tv-row="bottom"]');
|
||||||
);
|
if (bottomTarget) {
|
||||||
showTvToast("-10 сек", "⏪", 1000);
|
bottomTarget.focus();
|
||||||
playTvNavBeep();
|
playTvNavBeep();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if (isBottomRow) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsHudVisible(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2304,7 +2320,7 @@ export default function Cinema({
|
||||||
|
|
||||||
let focusables = Array.from(
|
let focusables = Array.from(
|
||||||
document.querySelectorAll<HTMLElement>(
|
document.querySelectorAll<HTMLElement>(
|
||||||
'[data-tv-focusable="true"], .has-cinema-nav-item, .has-cinema-tv-nav-item, .has-cinema-btn-play, .has-cinema-btn-bookmark, .has-cinema-media-card, .has-cinema-grid-card, .has-cinema-episode-card, .has-cinema-player-hud-btn, .has-cinema-player-btn-back, .has-cinema-drawer-option-btn, .has-cinema-drawer-chip, .has-cinema-drawer-ep-btn, .has-cinema-drawer-close-btn, .has-cinema-drawer-btn-primary, .has-cinema-drawer-btn-secondary, .has-cinema-player-pill-nav, .has-cinema-season-tab, .has-cinema-details-btn-play, .has-cinema-details-close-btn, .has-cinema-details-btn-bookmark, .has-cinema-details-btn-trailer, .has-cinema-actor-card, .has-cinema-btn-info-wide'
|
'[data-tv-focusable="true"], .has-cinema-nav-item, .has-cinema-tv-nav-item, .has-cinema-tv-tab-btn, .has-cinema-tv-menu-btn, .has-cinema-player-ctrl-btn, .has-cinema-btn-play, .has-cinema-btn-bookmark, .has-cinema-media-card, .has-cinema-grid-card, .has-cinema-episode-card, .has-cinema-player-hud-btn, .has-cinema-player-btn-back, .has-cinema-drawer-option-btn, .has-cinema-drawer-chip, .has-cinema-drawer-ep-btn, .has-cinema-drawer-close-btn, .has-cinema-drawer-btn-primary, .has-cinema-drawer-btn-secondary, .has-cinema-player-pill-nav, .has-cinema-season-tab, .has-cinema-details-btn-play, .has-cinema-details-close-btn, .has-cinema-details-btn-bookmark, .has-cinema-details-btn-trailer, .has-cinema-actor-card, .has-cinema-btn-info-wide'
|
||||||
)
|
)
|
||||||
).filter((el) => {
|
).filter((el) => {
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
|
|
@ -2328,7 +2344,7 @@ export default function Cinema({
|
||||||
focusables = focusables.filter((el) => !el.closest(".has-cinema-tv-nav-drawer"));
|
focusables = focusables.filter((el) => !el.closest(".has-cinema-tv-nav-drawer"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// In TV mode, top header is hidden, so never include header elements
|
// In TV mode, desktop header is hidden, so never include desktop header elements
|
||||||
if (isTvMode) {
|
if (isTvMode) {
|
||||||
focusables = focusables.filter((el) => !el.closest(".has-cinema-header"));
|
focusables = focusables.filter((el) => !el.closest(".has-cinema-header"));
|
||||||
}
|
}
|
||||||
|
|
@ -2352,15 +2368,16 @@ export default function Cinema({
|
||||||
if (!current || !focusables.includes(current)) {
|
if (!current || !focusables.includes(current)) {
|
||||||
const initial =
|
const initial =
|
||||||
focusables.find((el) => el.classList.contains("has-cinema-btn-play")) ||
|
focusables.find((el) => el.classList.contains("has-cinema-btn-play")) ||
|
||||||
|
focusables.find((el) => el.classList.contains("has-cinema-tv-tab-btn.active")) ||
|
||||||
focusables.find((el) => el.classList.contains("has-cinema-nav-item")) ||
|
focusables.find((el) => el.classList.contains("has-cinema-nav-item")) ||
|
||||||
focusables[0];
|
focusables[0];
|
||||||
if (initial) {
|
if (initial) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
initial.focus();
|
initial.focus();
|
||||||
initial.scrollIntoView({
|
initial.scrollIntoView({
|
||||||
behavior: "smooth",
|
behavior: "auto",
|
||||||
block: "center",
|
block: "nearest",
|
||||||
inline: "center",
|
inline: "nearest",
|
||||||
});
|
});
|
||||||
playTvBeep(580, 0.03);
|
playTvBeep(580, 0.03);
|
||||||
}
|
}
|
||||||
|
|
@ -2388,6 +2405,11 @@ export default function Cinema({
|
||||||
if (direction === "left" && dx >= -8) continue;
|
if (direction === "left" && dx >= -8) continue;
|
||||||
if (direction === "right" && dx <= 8) continue;
|
if (direction === "right" && dx <= 8) continue;
|
||||||
|
|
||||||
|
// When moving vertically, only consider elements visible horizontally in viewport
|
||||||
|
if ((direction === "up" || direction === "down") && (cRect.right < 0 || cRect.left > window.innerWidth)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let score = Infinity;
|
let score = Infinity;
|
||||||
if (direction === "up" || direction === "down") {
|
if (direction === "up" || direction === "down") {
|
||||||
score = Math.abs(dy) + Math.abs(dx) * 1.7;
|
score = Math.abs(dy) + Math.abs(dx) * 1.7;
|
||||||
|
|
@ -2405,28 +2427,25 @@ export default function Cinema({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
bestEl.focus();
|
bestEl.focus();
|
||||||
bestEl.scrollIntoView({
|
bestEl.scrollIntoView({
|
||||||
behavior: "smooth",
|
behavior: "auto",
|
||||||
block: "center",
|
block: "nearest",
|
||||||
inline: "center",
|
inline: "nearest",
|
||||||
});
|
});
|
||||||
playTvBeep(640, 0.025);
|
playTvBeep(640, 0.025);
|
||||||
} else {
|
} else {
|
||||||
// If no element found in this direction outside modals:
|
// Outside modals: when at the top and pressing UP, focus Leanback TV Top Bar
|
||||||
// When moving LEFT from the leftmost card, summon the TV Navigation Drawer!
|
|
||||||
if (!detailsEl && !playerEl && !trailerEl && !settingsSheet && !episodesSheet) {
|
if (!detailsEl && !playerEl && !trailerEl && !settingsSheet && !episodesSheet) {
|
||||||
if (direction === "left") {
|
if (direction === "up" && isTvMode) {
|
||||||
|
const topTarget =
|
||||||
|
document.querySelector<HTMLElement>(".has-cinema-tv-tab-btn.active") ||
|
||||||
|
document.querySelector<HTMLElement>(".has-cinema-tv-menu-btn") ||
|
||||||
|
document.querySelector<HTMLElement>(".has-cinema-tv-tab-btn");
|
||||||
|
if (topTarget && topTarget !== current) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
lastFocusedCardRef.current = current;
|
topTarget.focus();
|
||||||
setIsTvNavOpen(true);
|
topTarget.scrollIntoView({ behavior: "auto", block: "nearest", inline: "nearest" });
|
||||||
setTimeout(() => {
|
playTvBeep(640, 0.025);
|
||||||
const targetNav =
|
|
||||||
document.querySelector<HTMLElement>(".has-cinema-tv-nav-item.active") ||
|
|
||||||
document.querySelector<HTMLElement>(".has-cinema-tv-nav-item");
|
|
||||||
if (targetNav) {
|
|
||||||
targetNav.focus();
|
|
||||||
playTvBeep(640, 0.03);
|
|
||||||
}
|
}
|
||||||
}, 40);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2485,6 +2504,19 @@ export default function Cinema({
|
||||||
}
|
}
|
||||||
}, [playingEpisode, showPlayerSettings, showPlayerEpisodes, handleClosePlayer]);
|
}, [playingEpisode, showPlayerSettings, showPlayerEpisodes, handleClosePlayer]);
|
||||||
|
|
||||||
|
// Auto-focus first interactive element when player settings drawer opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (showPlayerSettings) {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
const firstFocus = document.querySelector<HTMLElement>(
|
||||||
|
".has-cinema-player-settings-sheet [data-tv-focusable='true']"
|
||||||
|
);
|
||||||
|
if (firstFocus) firstFocus.focus();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [showPlayerSettings]);
|
||||||
|
|
||||||
// Handle Android TV hardware back & browser popstate for details modal
|
// Handle Android TV hardware back & browser popstate for details modal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (detailsMedia !== null && playingEpisode === null) {
|
if (detailsMedia !== null && playingEpisode === null) {
|
||||||
|
|
@ -2947,6 +2979,103 @@ export default function Cinema({
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ========================================================
|
||||||
|
0. SLEEK LEANBACK TV NAVIGATION HEADER (Visible in TV Mode)
|
||||||
|
======================================================== */}
|
||||||
|
{isTvMode && (
|
||||||
|
<header className="has-cinema-tv-top-bar" aria-label="ТВ Навигация">
|
||||||
|
<div className="has-cinema-tv-top-brand">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-tv-menu-btn"
|
||||||
|
onClick={() => setIsTvNavOpen((prev) => !prev)}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
title="Открыть меню"
|
||||||
|
>
|
||||||
|
☰ Меню
|
||||||
|
</button>
|
||||||
|
<span className="has-cinema-tv-brand-title">HAS Кинотеатр</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="has-cinema-tv-top-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`has-cinema-tv-tab-btn ${activeTab === "home" && !searchQuery ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("home");
|
||||||
|
setSearchQuery("");
|
||||||
|
focusFirstContentCard();
|
||||||
|
}}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
🏠 Каталог
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`has-cinema-tv-tab-btn ${activeTab === "movies" && !searchQuery ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("movies");
|
||||||
|
setSearchQuery("");
|
||||||
|
focusFirstContentCard();
|
||||||
|
}}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
🎬 Фильмы
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`has-cinema-tv-tab-btn ${activeTab === "series" && !searchQuery ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("series");
|
||||||
|
setSearchQuery("");
|
||||||
|
focusFirstContentCard();
|
||||||
|
}}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
📺 Сериалы
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`has-cinema-tv-tab-btn ${activeTab === "history" ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("history");
|
||||||
|
setSearchQuery("");
|
||||||
|
focusFirstContentCard();
|
||||||
|
}}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
🕒 История {cinemaState.history.length > 0 && `(${cinemaState.history.length})`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`has-cinema-tv-tab-btn ${activeTab === "bookmarks" && !searchQuery ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("bookmarks");
|
||||||
|
setSearchQuery("");
|
||||||
|
focusFirstContentCard();
|
||||||
|
}}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
⭐ Мой список {cinemaState.bookmarks.length > 0 && `(${cinemaState.bookmarks.length})`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`has-cinema-tv-tab-btn ${activeTab === "search" || searchQuery ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("search");
|
||||||
|
setTimeout(() => {
|
||||||
|
const s = document.querySelector<HTMLInputElement>(".has-cinema-tv-search-input-field") || document.querySelector<HTMLInputElement>(".has-cinema-search-input");
|
||||||
|
if (s) s.focus();
|
||||||
|
}, 50);
|
||||||
|
}}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
🔍 Поиск
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ========================================================
|
{/* ========================================================
|
||||||
1. TOP NAVIGATION BAR (For Desktop / Mobile only; hidden in TV Mode)
|
1. TOP NAVIGATION BAR (For Desktop / Mobile only; hidden in TV Mode)
|
||||||
======================================================== */}
|
======================================================== */}
|
||||||
|
|
@ -4170,6 +4299,7 @@ export default function Cinema({
|
||||||
}`}
|
}`}
|
||||||
onMouseMove={resetHudTimer}
|
onMouseMove={resetHudTimer}
|
||||||
onClick={resetHudTimer}
|
onClick={resetHudTimer}
|
||||||
|
onDoubleClick={handleToggleFullscreen}
|
||||||
>
|
>
|
||||||
{/* Top Edge Sensor: triggers HUD immediately when cursor moves to top 90px of screen */}
|
{/* Top Edge Sensor: triggers HUD immediately when cursor moves to top 90px of screen */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -4210,8 +4340,9 @@ export default function Cinema({
|
||||||
setIsHudVisible(true);
|
setIsHudVisible(true);
|
||||||
resetHudTimer();
|
resetHudTimer();
|
||||||
}}
|
}}
|
||||||
title="Назад к информации о фильме (Esc / Back / ▲)"
|
title="Назад к информации о фильме (Esc / Back)"
|
||||||
data-tv-focusable="true"
|
data-tv-focusable="true"
|
||||||
|
data-tv-row="top"
|
||||||
>
|
>
|
||||||
← Назад
|
← Назад
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -4228,176 +4359,22 @@ export default function Cinema({
|
||||||
<span className="has-cinema-player-hud-subtitle">Полный фильм</span>
|
<span className="has-cinema-player-hud-subtitle">Полный фильм</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(playingMedia || selectedMedia).media_type !== "movie" && (
|
|
||||||
<div className="has-cinema-player-quick-ep-nav">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="has-cinema-player-pill-nav"
|
|
||||||
disabled={playingEpisode.number <= 1}
|
|
||||||
onClick={handlePrevEpisode}
|
|
||||||
title="Предыдущая серия"
|
|
||||||
data-tv-focusable="true"
|
|
||||||
>
|
|
||||||
◀ Пред.
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="has-cinema-player-pill-nav"
|
|
||||||
onClick={handleNextEpisode}
|
|
||||||
title="Следующая серия"
|
|
||||||
data-tv-focusable="true"
|
|
||||||
>
|
|
||||||
След. ▶
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="has-cinema-player-hud-right">
|
<div className="has-cinema-player-hud-right">
|
||||||
{/* Episodes button for Series only */}
|
{/* Single Gear Button: Contains ALL player functions */}
|
||||||
{(playingMedia || selectedMedia).media_type !== "movie" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`has-cinema-player-hud-btn ${showPlayerEpisodes ? "active" : ""}`}
|
|
||||||
onClick={() => {
|
|
||||||
setShowPlayerEpisodes((prev) => !prev);
|
|
||||||
setShowPlayerSettings(false);
|
|
||||||
resetHudTimer();
|
|
||||||
}}
|
|
||||||
title="Список серий"
|
|
||||||
data-tv-focusable="true"
|
|
||||||
>
|
|
||||||
📺 Серии
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Server selector buttons right in the HUD */}
|
|
||||||
<div className="has-cinema-player-hud-servers">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`has-cinema-player-server-pill ${selectedMirror === "vidlink_pro" ? "active" : ""}`}
|
|
||||||
onClick={() => handleSelectMirror("vidlink_pro")}
|
|
||||||
title="Сервер 1: Скоростной Full HD 1080p"
|
|
||||||
data-tv-focusable="true"
|
|
||||||
>
|
|
||||||
⚡ Сервер 1 (1080p)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`has-cinema-player-server-pill ${selectedMirror === "kodik_ru" ? "active" : ""}`}
|
|
||||||
onClick={() => handleSelectMirror("kodik_ru")}
|
|
||||||
title="Сервер 2: Студии дубляжа (Kodik)"
|
|
||||||
data-tv-focusable="true"
|
|
||||||
>
|
|
||||||
🎙️ Сервер 2 (Дубляж)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`has-cinema-player-server-pill ${selectedMirror === "autoembed" ? "active" : ""}`}
|
|
||||||
onClick={() => handleSelectMirror("autoembed")}
|
|
||||||
title="Сервер 3: AutoEmbed"
|
|
||||||
data-tv-focusable="true"
|
|
||||||
>
|
|
||||||
🌐 Сервер 3
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`has-cinema-player-server-pill ${selectedMirror === "vidsrc_pm" ? "active" : ""}`}
|
|
||||||
onClick={() => handleSelectMirror("vidsrc_pm")}
|
|
||||||
title="Сервер 4: VidSrc"
|
|
||||||
data-tv-focusable="true"
|
|
||||||
>
|
|
||||||
🎬 Сервер 4
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`has-cinema-player-server-pill ${selectedMirror === "multiembed" ? "active" : ""}`}
|
|
||||||
onClick={() => handleSelectMirror("multiembed")}
|
|
||||||
title="Сервер 5: MultiStream"
|
|
||||||
data-tv-focusable="true"
|
|
||||||
>
|
|
||||||
🚀 Сервер 5
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick mirror switcher */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="has-cinema-player-hud-btn"
|
|
||||||
onClick={handleCycleMirror}
|
|
||||||
title="Переключить на следующий сервер (если текущий заблокирован или не загружается)"
|
|
||||||
data-tv-focusable="true"
|
|
||||||
>
|
|
||||||
🔄 Сменить источник
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* AdBlock Guard toggle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`has-cinema-player-hud-btn adblock-pill ${isAdBlockEnabled ? "active" : ""}`}
|
|
||||||
onClick={() => {
|
|
||||||
setIsAdBlockEnabled((prev) => !prev);
|
|
||||||
showTvToast(
|
|
||||||
!isAdBlockEnabled
|
|
||||||
? "🛡️ Блокировка рекламы и казино ВКЛ"
|
|
||||||
: "⚠️ Блокировка рекламы ВЫКЛ",
|
|
||||||
"🛡️",
|
|
||||||
1800
|
|
||||||
);
|
|
||||||
resetHudTimer();
|
|
||||||
}}
|
|
||||||
title={isAdBlockEnabled ? "Блокировщик рекламы активен (всплывающие окна и казино заблокированы)" : "Включить блокировку рекламы"}
|
|
||||||
data-tv-focusable="true"
|
|
||||||
>
|
|
||||||
🛡️ {isAdBlockEnabled ? "Без рекламы" : "Реклама: Вкл"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Quick Skip Ad / Intro Pill */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="has-cinema-player-hud-btn ad-skip-pill"
|
|
||||||
onClick={() => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.currentTime = Math.min(
|
|
||||||
videoRef.current.duration || 999999,
|
|
||||||
videoRef.current.currentTime + 20
|
|
||||||
);
|
|
||||||
}
|
|
||||||
showTvToast("Пропуск рекламы (+20 сек)", "⏩", 1500);
|
|
||||||
playTvSelectBeep();
|
|
||||||
resetHudTimer();
|
|
||||||
}}
|
|
||||||
title="Пропустить рекламу казино или вступительную заставку (+20 сек)"
|
|
||||||
data-tv-focusable="true"
|
|
||||||
>
|
|
||||||
⏩ Пропуск (+20с)
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Single Settings button (Contains voiceover studios & mirrors) */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`has-cinema-player-hud-btn ${showPlayerSettings ? "active" : ""}`}
|
className={`has-cinema-player-hud-btn ${showPlayerSettings ? "active" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowPlayerSettings((prev) => !prev);
|
setShowPlayerSettings((prev) => !prev);
|
||||||
setShowPlayerEpisodes(false);
|
|
||||||
resetHudTimer();
|
resetHudTimer();
|
||||||
}}
|
}}
|
||||||
title="Озвучка, качество и серверы"
|
title="Все настройки: озвучка, серии, серверы, пропуск заставки"
|
||||||
data-tv-focusable="true"
|
data-tv-focusable="true"
|
||||||
|
data-tv-row="top"
|
||||||
>
|
>
|
||||||
⚙️ Озвучка / Сервер
|
⚙️ Настройки
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Fullscreen toggle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="has-cinema-player-hud-btn icon-only"
|
|
||||||
onClick={handleToggleFullscreen}
|
|
||||||
title={isFullscreen ? "Выйти из полноэкранного режима" : "Развернуть во весь экран"}
|
|
||||||
data-tv-focusable="true"
|
|
||||||
>
|
|
||||||
{isFullscreen ? "🗗" : "⛶"}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Close */}
|
{/* Close */}
|
||||||
|
|
@ -4414,24 +4391,98 @@ export default function Cinema({
|
||||||
}}
|
}}
|
||||||
title="Закрыть плеер"
|
title="Закрыть плеер"
|
||||||
data-tv-focusable="true"
|
data-tv-focusable="true"
|
||||||
|
data-tv-row="top"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Fallback Switcher (Always accessible if video fails to load) */}
|
{/* Leanback Bottom Playback Controls Bar */}
|
||||||
<div className={`has-cinema-player-bottom-failover ${!isHudVisible ? "hud-hidden" : ""}`}>
|
<div
|
||||||
<span className="failover-hint">Не работает плеер?</span>
|
className={`has-cinema-player-bottom-hud ${!isHudVisible ? "hud-hidden" : ""}`}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setIsHudVisible(true);
|
||||||
|
resetHudTimer();
|
||||||
|
}}
|
||||||
|
onMouseMove={resetHudTimer}
|
||||||
|
>
|
||||||
|
<div className="has-cinema-player-controls-row">
|
||||||
|
{(playingMedia || selectedMedia).media_type !== "movie" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="failover-btn"
|
className="has-cinema-player-ctrl-btn"
|
||||||
onClick={handleCycleMirror}
|
disabled={playingEpisode.number <= 1}
|
||||||
|
onClick={() => {
|
||||||
|
handlePrevEpisode();
|
||||||
|
resetHudTimer();
|
||||||
|
}}
|
||||||
|
title="Предыдущая серия"
|
||||||
data-tv-focusable="true"
|
data-tv-focusable="true"
|
||||||
title="Переключить на следующий доступный сервер"
|
data-tv-row="bottom"
|
||||||
>
|
>
|
||||||
🔄 Сменить сервер
|
⏮ Пред.
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-player-ctrl-btn"
|
||||||
|
onClick={() => {
|
||||||
|
triggerPlayerSeek(-10);
|
||||||
|
resetHudTimer();
|
||||||
|
}}
|
||||||
|
title="Перемотка назад (-10 сек)"
|
||||||
|
data-tv-focusable="true"
|
||||||
|
data-tv-row="bottom"
|
||||||
|
>
|
||||||
|
⏪ -10с
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-player-ctrl-btn primary"
|
||||||
|
onClick={() => {
|
||||||
|
triggerPlayerPlayPause();
|
||||||
|
resetHudTimer();
|
||||||
|
}}
|
||||||
|
title="Воспроизведение / Пауза (Space / OK)"
|
||||||
|
data-tv-focusable="true"
|
||||||
|
data-tv-row="bottom"
|
||||||
|
>
|
||||||
|
{isPlayingState ? "⏸ Пауза" : "▶ Воспроизведение"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-player-ctrl-btn"
|
||||||
|
onClick={() => {
|
||||||
|
triggerPlayerSeek(10);
|
||||||
|
resetHudTimer();
|
||||||
|
}}
|
||||||
|
title="Перемотка вперед (+10 сек)"
|
||||||
|
data-tv-focusable="true"
|
||||||
|
data-tv-row="bottom"
|
||||||
|
>
|
||||||
|
⏩ +10с
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(playingMedia || selectedMedia).media_type !== "movie" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-player-ctrl-btn"
|
||||||
|
onClick={() => {
|
||||||
|
handleNextEpisode();
|
||||||
|
resetHudTimer();
|
||||||
|
}}
|
||||||
|
title="Следующая серия"
|
||||||
|
data-tv-focusable="true"
|
||||||
|
data-tv-row="bottom"
|
||||||
|
>
|
||||||
|
След. ⏭
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clean 100% Video Canvas */}
|
{/* Clean 100% Video Canvas */}
|
||||||
|
|
@ -4527,15 +4578,10 @@ export default function Cinema({
|
||||||
)
|
)
|
||||||
) : playerMode === "balancer" ? (
|
) : playerMode === "balancer" ? (
|
||||||
<iframe
|
<iframe
|
||||||
key={`${selectedMirror}-${balancerEmbedUrl}-${isAdBlockEnabled}`}
|
key={`${selectedMirror}-${balancerEmbedUrl}`}
|
||||||
src={balancerEmbedUrl}
|
src={balancerEmbedUrl}
|
||||||
title={(playingMedia || selectedMedia).title}
|
title={(playingMedia || selectedMedia).title}
|
||||||
className="has-cinema-iframe-elem"
|
className="has-cinema-iframe-elem"
|
||||||
sandbox={
|
|
||||||
isAdBlockEnabled
|
|
||||||
? "allow-scripts allow-same-origin allow-forms allow-presentation"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
allow="autoplay *; fullscreen *; encrypted-media *; picture-in-picture *; clipboard-write *"
|
allow="autoplay *; fullscreen *; encrypted-media *; picture-in-picture *; clipboard-write *"
|
||||||
referrerPolicy="no-referrer-when-downgrade"
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
|
|
@ -4588,6 +4634,80 @@ export default function Cinema({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="has-cinema-drawer-body">
|
<div className="has-cinema-drawer-body">
|
||||||
|
{/* Quick Controls Section */}
|
||||||
|
<div className="has-cinema-drawer-section">
|
||||||
|
<h4 className="has-cinema-drawer-section-title">
|
||||||
|
⏩ Быстрое управление и экран
|
||||||
|
</h4>
|
||||||
|
<div className="has-cinema-drawer-chips-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-drawer-chip"
|
||||||
|
onClick={() => {
|
||||||
|
triggerPlayerSeek(20);
|
||||||
|
setShowPlayerSettings(false);
|
||||||
|
}}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
title="Перемотать заставку на 20 секунд вперед"
|
||||||
|
>
|
||||||
|
⏩ Пропуск рекламы / заставки (+20с)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-drawer-chip"
|
||||||
|
onClick={() => {
|
||||||
|
triggerPlayerSeek(85);
|
||||||
|
setShowPlayerSettings(false);
|
||||||
|
}}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
title="Перемотать опенинг / интро на 85 секунд вперед"
|
||||||
|
>
|
||||||
|
⏩ Пропуск опенинга (+85с)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-drawer-chip"
|
||||||
|
onClick={() => {
|
||||||
|
handleToggleFullscreen();
|
||||||
|
setShowPlayerSettings(false);
|
||||||
|
}}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
title="Переключить полноэкранный режим"
|
||||||
|
>
|
||||||
|
{isFullscreen ? "🗗 Оконный режим" : "⛶ Во весь экран"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Series & Episode selection section */}
|
||||||
|
{(playingMedia || selectedMedia).media_type !== "movie" && (
|
||||||
|
<div className="has-cinema-drawer-section">
|
||||||
|
<h4 className="has-cinema-drawer-section-title">
|
||||||
|
📺 Выбор серии (Сезон {playingSeasonNumber})
|
||||||
|
</h4>
|
||||||
|
<div className="has-cinema-drawer-chips-wrap">
|
||||||
|
{((playingMedia || selectedMedia).seasons.find(
|
||||||
|
(s) => s.season_number === playingSeasonNumber
|
||||||
|
)?.episodes || []).map((ep) => (
|
||||||
|
<button
|
||||||
|
key={ep.id}
|
||||||
|
type="button"
|
||||||
|
className={`has-cinema-drawer-chip ${
|
||||||
|
playingEpisode?.number === ep.number ? "active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
startPlayback(playingMedia || selectedMedia, playingSeasonNumber, ep);
|
||||||
|
setShowPlayerSettings(false);
|
||||||
|
}}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
Серия {ep.number}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Voiceover / Audio Track Section */}
|
{/* Voiceover / Audio Track Section */}
|
||||||
<div className="has-cinema-drawer-section">
|
<div className="has-cinema-drawer-section">
|
||||||
<h4 className="has-cinema-drawer-section-title">
|
<h4 className="has-cinema-drawer-section-title">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue