Compare commits
17 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e616eec246 | |||
| 205678b3d7 | |||
| f41430984c | |||
| 7da9ab00b1 | |||
| 2ab0457568 | |||
| 9e9694ed19 | |||
| ef8b7037c1 | |||
| 308b03a6ea | |||
| 4bc0a2c541 | |||
| d50d3e9a48 | |||
| 4d7fab0166 | |||
| fd1fcffef7 | |||
| e7b926fd48 | |||
| 429b81e6ad | |||
| 2fafe24afa | |||
| f380fc2add | |||
| 6750d7d437 |
22 changed files with 6628 additions and 1047 deletions
43
index.html
43
index.html
|
|
@ -7,6 +7,49 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<title>has</title>
|
||||
<!-- HAS Cinema Anti-Popup & Safe Browsing Shield -->
|
||||
<script>
|
||||
(function() {
|
||||
// Anti-Popup & Tab Hijack Guard (Blocks opening background ad popups)
|
||||
try {
|
||||
const fakeWindow = {
|
||||
closed: true,
|
||||
close: function() {},
|
||||
focus: function() {},
|
||||
blur: function() {},
|
||||
postMessage: function() {},
|
||||
location: { href: "about:blank" }
|
||||
};
|
||||
window.open = function(url) {
|
||||
console.warn("[HAS AdGuard] Blocked popup window.open:", url);
|
||||
return fakeWindow;
|
||||
};
|
||||
} catch (_) {}
|
||||
|
||||
// Prevent click hijacking on known casino/ad links
|
||||
const AD_DOMAINS = [
|
||||
"riobet", "1xbet", "vavada", "mostbet", "pin-up", "bet365",
|
||||
"adsterra", "propellerads", "popcash", "monetag", "clickadu",
|
||||
"exoclick", "juicyads", "adcash", "trafficjunky",
|
||||
"melbet", "linebet", "fonbet", "parimatch", "betboom", "vulkan", "joycasino"
|
||||
];
|
||||
|
||||
document.addEventListener("click", function(e) {
|
||||
const a = e.target && e.target.closest ? e.target.closest("a") : null;
|
||||
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.stopPropagation();
|
||||
console.warn("[HAS AdGuard] Intercepted ad click:", a.href);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "has",
|
||||
"private": true,
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.19",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -1581,7 +1581,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "has"
|
||||
version = "0.0.1"
|
||||
version = "0.0.12"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bcrypt",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "has"
|
||||
version = "0.0.3"
|
||||
version = "0.0.19"
|
||||
description = "has"
|
||||
authors = ["autli"]
|
||||
edition = "2024"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
|
|
|||
|
|
@ -37,24 +37,21 @@ pub fn verify_admin_pin(input_pin: Option<&str>) -> Result<(), String> {
|
|||
}
|
||||
|
||||
pub fn get_current_device_name() -> String {
|
||||
if let Ok(name) = std::env::var("COMPUTERNAME") {
|
||||
if !name.trim().is_empty() {
|
||||
if let Ok(name) = std::env::var("COMPUTERNAME")
|
||||
&& !name.trim().is_empty() {
|
||||
return name.trim().to_string();
|
||||
}
|
||||
}
|
||||
if let Ok(name) = std::env::var("HOSTNAME") {
|
||||
if !name.trim().is_empty() {
|
||||
if let Ok(name) = std::env::var("HOSTNAME")
|
||||
&& !name.trim().is_empty() {
|
||||
return name.trim().to_string();
|
||||
}
|
||||
}
|
||||
if let Ok(output) = std::process::Command::new("hostname").output() {
|
||||
if output.status.success() {
|
||||
if let Ok(output) = std::process::Command::new("hostname").output()
|
||||
&& output.status.success() {
|
||||
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !s.is_empty() {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(user) = std::env::var("USER") {
|
||||
return format!("{}-workstation", user.trim());
|
||||
}
|
||||
|
|
@ -62,13 +59,11 @@ pub fn get_current_device_name() -> String {
|
|||
}
|
||||
|
||||
pub fn get_current_local_ip() -> String {
|
||||
if let Ok(socket) = std::net::UdpSocket::bind("0.0.0.0:0") {
|
||||
if socket.connect("8.8.8.8:80").is_ok() {
|
||||
if let Ok(addr) = socket.local_addr() {
|
||||
if let Ok(socket) = std::net::UdpSocket::bind("0.0.0.0:0")
|
||||
&& socket.connect("8.8.8.8:80").is_ok()
|
||||
&& let Ok(addr) = socket.local_addr() {
|
||||
return addr.ip().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
"127.0.0.1".to_string()
|
||||
}
|
||||
|
||||
|
|
@ -85,16 +80,17 @@ pub async fn init_admin_security_table(client: &tokio_postgres::Client) -> Resul
|
|||
VALUES (1, false, false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
";
|
||||
client
|
||||
.batch_execute(sql)
|
||||
.await
|
||||
.map_err(|e| format!("Ошибка создания таблицы безопасности: {}", e))
|
||||
// If has_app lacks DDL permissions on public schema, do not fail admin updates
|
||||
if let Err(e) = client.batch_execute(sql).await {
|
||||
eprintln!("[admin] admin_security_config table creation skipped: {}", e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_admin_security_info(admin_email: &str) -> Result<DeviceSecurityInfo, String> {
|
||||
let config = get_db_config();
|
||||
let client = connect_to_db(&config).await?;
|
||||
init_admin_security_table(&client).await?;
|
||||
let _ = init_admin_security_table(&client).await;
|
||||
|
||||
let clean_email = admin_email.trim().to_lowercase();
|
||||
let user_row = client
|
||||
|
|
@ -119,17 +115,18 @@ pub async fn get_admin_security_info(admin_email: &str) -> Result<DeviceSecurity
|
|||
};
|
||||
|
||||
let cfg_row = client
|
||||
.query_one(
|
||||
.query_opt(
|
||||
"SELECT device_binding_enabled, trusted_device_name, ip_binding_enabled, trusted_ip FROM admin_security_config WHERE id = 1",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Ошибка чтения параметров безопасности: {}", e))?;
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let device_binding_enabled: bool = cfg_row.get(0);
|
||||
let trusted_device_name: Option<String> = cfg_row.get(1);
|
||||
let ip_binding_enabled: bool = cfg_row.get(2);
|
||||
let trusted_ip: Option<String> = cfg_row.get(3);
|
||||
let (device_binding_enabled, trusted_device_name, ip_binding_enabled, trusted_ip): (bool, Option<String>, bool, Option<String>) = match cfg_row {
|
||||
Some(row) => (row.get(0), row.get(1), row.get(2), row.get(3)),
|
||||
None => (false, None, false, None),
|
||||
};
|
||||
|
||||
let current_device = get_current_device_name();
|
||||
let current_ip = get_current_local_ip();
|
||||
|
|
@ -138,7 +135,7 @@ pub async fn get_admin_security_info(admin_email: &str) -> Result<DeviceSecurity
|
|||
true
|
||||
} else {
|
||||
match &trusted_device_name {
|
||||
Some(t) => t.trim().eq_ignore_ascii_case(¤t_device.trim()),
|
||||
Some(t) => t.trim().eq_ignore_ascii_case(current_device.trim()),
|
||||
None => true,
|
||||
}
|
||||
};
|
||||
|
|
@ -147,7 +144,7 @@ pub async fn get_admin_security_info(admin_email: &str) -> Result<DeviceSecurity
|
|||
true
|
||||
} else {
|
||||
match &trusted_ip {
|
||||
Some(t) => t.trim().eq_ignore_ascii_case(¤t_ip.trim()),
|
||||
Some(t) => t.trim().eq_ignore_ascii_case(current_ip.trim()),
|
||||
None => true,
|
||||
}
|
||||
};
|
||||
|
|
@ -310,3 +307,27 @@ pub async fn admin_delete_user(
|
|||
|
||||
Ok("Пользователь успешно удален".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_init_admin_security() {
|
||||
let config = get_db_config();
|
||||
if let Ok(client) = connect_to_db(&config).await {
|
||||
let res = init_admin_security_table(&client).await;
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_admin_security_info() {
|
||||
let config = get_db_config();
|
||||
if let Ok(_) = connect_to_db(&config).await {
|
||||
let res = get_admin_security_info("dimatyilickiy0511@gmail.com").await;
|
||||
println!("get_admin_security_info result: {:?}", res);
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,8 +85,8 @@ pub fn load_env() {
|
|||
let _ = dotenvy::from_filename("../.env");
|
||||
|
||||
// 3. Executable directory .env (useful in production bundle)
|
||||
if let Ok(exe_path) = env::current_exe() {
|
||||
if let Some(exe_dir) = exe_path.parent() {
|
||||
if let Ok(exe_path) = env::current_exe()
|
||||
&& let Some(exe_dir) = exe_path.parent() {
|
||||
let _ = dotenvy::from_path(exe_dir.join(".env"));
|
||||
if let Some(parent) = exe_dir.parent() {
|
||||
let _ = dotenvy::from_path(parent.join(".env"));
|
||||
|
|
@ -95,7 +95,6 @@ pub fn load_env() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_clean_env(key: &str, default: &str) -> String {
|
||||
|
|
@ -132,7 +131,7 @@ pub fn get_db_config() -> DbConfig {
|
|||
None
|
||||
};
|
||||
let dbname = extract_clean_env("DB_NAME", "has");
|
||||
let ssl_mode = extract_clean_env("DB_SSL_MODE", "prefer").to_lowercase();
|
||||
let ssl_mode = extract_clean_env("DB_SSL_MODE", "disable").to_lowercase();
|
||||
|
||||
let users_table = extract_clean_env("DB_USERS_TABLE", "users");
|
||||
let email_column = extract_clean_env("DB_EMAIL_COLUMN", "email");
|
||||
|
|
@ -153,11 +152,10 @@ pub fn get_db_config() -> DbConfig {
|
|||
}
|
||||
|
||||
fn build_conn_string(config: &DbConfig) -> String {
|
||||
if let Some(url) = &config.database_url {
|
||||
if !url.trim().is_empty() {
|
||||
if let Some(url) = &config.database_url
|
||||
&& !url.trim().is_empty() {
|
||||
return url.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let mut parts = vec![
|
||||
format!("host={}", config.host),
|
||||
|
|
@ -166,11 +164,10 @@ fn build_conn_string(config: &DbConfig) -> String {
|
|||
format!("dbname={}", config.dbname),
|
||||
];
|
||||
|
||||
if let Some(ref pwd) = config.password {
|
||||
if !pwd.is_empty() {
|
||||
if let Some(ref pwd) = config.password
|
||||
&& !pwd.is_empty() {
|
||||
parts.push(format!("password={}", pwd));
|
||||
}
|
||||
}
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
|
@ -205,17 +202,20 @@ pub async fn connect_to_db(config: &DbConfig) -> Result<tokio_postgres::Client,
|
|||
.map_err(|e| format!("Семафор БД: {}", e))?;
|
||||
|
||||
let conn_str = build_conn_string(config);
|
||||
let connect_timeout = std::time::Duration::from_secs(4);
|
||||
let connect_timeout = std::time::Duration::from_secs(10);
|
||||
|
||||
// Try SSL if mode is 'require' or 'prefer'
|
||||
if config.ssl_mode == "require" || config.ssl_mode == "prefer" {
|
||||
// Try SSL ONLY if mode is 'require', or if 'prefer' and not known to be NoTls
|
||||
static KNOWN_NOTLS_SERVER: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
if config.ssl_mode == "require" || (config.ssl_mode == "prefer" && !KNOWN_NOTLS_SERVER.load(std::sync::atomic::Ordering::Relaxed)) {
|
||||
let mut builder = native_tls::TlsConnector::builder();
|
||||
builder.danger_accept_invalid_certs(true); // Allow self-signed certs for remote VPS
|
||||
|
||||
if let Ok(tls_connector) = builder.build() {
|
||||
let tls = postgres_native_tls::MakeTlsConnector::new(tls_connector);
|
||||
let conn_future = tokio_postgres::connect(&conn_str, tls);
|
||||
match tokio::time::timeout(connect_timeout, conn_future).await {
|
||||
let tls_timeout = if config.ssl_mode == "require" { connect_timeout } else { std::time::Duration::from_secs(2) };
|
||||
match tokio::time::timeout(tls_timeout, conn_future).await {
|
||||
Ok(Ok((client, connection))) => {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = connection.await {
|
||||
|
|
@ -228,12 +228,14 @@ pub async fn connect_to_db(config: &DbConfig) -> Result<tokio_postgres::Client,
|
|||
if config.ssl_mode == "require" {
|
||||
return Err(format!("TLS connection failed: {}", e));
|
||||
}
|
||||
KNOWN_NOTLS_SERVER.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
// If prefer, fallback to NoTls below
|
||||
}
|
||||
Err(_) => {
|
||||
if config.ssl_mode == "require" {
|
||||
return Err("Таймаут безопасного подключения (TLS). Сервер не отвечает.".to_string());
|
||||
}
|
||||
KNOWN_NOTLS_SERVER.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
// If prefer, fallback to NoTls below
|
||||
}
|
||||
}
|
||||
|
|
@ -257,7 +259,7 @@ pub async fn connect_to_db(config: &DbConfig) -> Result<tokio_postgres::Client,
|
|||
Err("Не удалось подключиться к серверу базы данных. Попробуйте позже.".to_string())
|
||||
}
|
||||
Err(_) => {
|
||||
Err("Таймаут подключения к серверу базы данных. Сервер не ответил за 4 сек.".to_string())
|
||||
Err("Таймаут подключения к серверу базы данных. Сервер не ответил за 10 сек.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -266,11 +268,10 @@ pub fn verify_password(provided: &str, stored: &str) -> bool {
|
|||
let stored = stored.trim();
|
||||
|
||||
// 1. Bcrypt hash check ($2a$, $2b$, $2y$)
|
||||
if stored.starts_with("$2a$") || stored.starts_with("$2b$") || stored.starts_with("$2y$") {
|
||||
if let Ok(valid) = bcrypt::verify(provided, stored) {
|
||||
if (stored.starts_with("$2a$") || stored.starts_with("$2b$") || stored.starts_with("$2y$"))
|
||||
&& let Ok(valid) = bcrypt::verify(provided, stored) {
|
||||
return valid;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. SHA-256 hash check (64 hex characters)
|
||||
if stored.len() == 64 && stored.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
|
|
@ -522,6 +523,284 @@ pub async fn authenticate_user(email: &str, password: &str) -> AuthResponse {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn validate_email_syntax(email: &str) -> Result<(&str, &str), String> {
|
||||
let clean = email.trim();
|
||||
if clean.is_empty() {
|
||||
return Err("Адрес электронной почты не может быть пустым".to_string());
|
||||
}
|
||||
if clean.len() > 254 {
|
||||
return Err("Слишком длинный адрес электронной почты".to_string());
|
||||
}
|
||||
let parts: Vec<&str> = clean.split('@').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err("Некорректный формат email: адрес должен содержать ровно один символ '@'".to_string());
|
||||
}
|
||||
let local = parts[0];
|
||||
let domain = parts[1];
|
||||
|
||||
if local.is_empty() || local.len() > 64 {
|
||||
return Err("Некорректная первая часть email адреса".to_string());
|
||||
}
|
||||
if domain.is_empty() || domain.len() > 253 {
|
||||
return Err("Некорректный домен email адреса".to_string());
|
||||
}
|
||||
|
||||
if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
|
||||
return Err("Email не должен начинаться/заканчиваться точкой или содержать две точки подряд".to_string());
|
||||
}
|
||||
if !local.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' || c == '+' || c == '%') {
|
||||
return Err("Email содержит недопустимые символы в имени пользователя".to_string());
|
||||
}
|
||||
|
||||
if domain.starts_with('.') || domain.ends_with('.') || domain.contains("..") || !domain.contains('.') {
|
||||
return Err("Некорректный домен почты (пример: name@mail.ru)".to_string());
|
||||
}
|
||||
|
||||
let domain_parts: Vec<&str> = domain.split('.').collect();
|
||||
let tld = match domain_parts.last() {
|
||||
Some(t) => *t,
|
||||
None => return Err("Отсутствует доменная зона почты".to_string()),
|
||||
};
|
||||
if tld.len() < 2 || !tld.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return Err("Некорректная доменная зона почты (например: .ru, .com)".to_string());
|
||||
}
|
||||
|
||||
for part in &domain_parts {
|
||||
if part.is_empty() || part.starts_with('-') || part.ends_with('-') {
|
||||
return Err("Некорректное имя почтового сервера".to_string());
|
||||
}
|
||||
if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
|
||||
return Err("Домен почты содержит недопустимые символы".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let typo_suggestion = match domain.to_lowercase().as_str() {
|
||||
"gmai.com" | "gamil.com" | "gmial.com" | "gmaill.com" => Some("gmail.com"),
|
||||
"yandx.ru" | "yandx.com" | "yadecs.ru" | "yadex.ru" => Some("yandex.ru"),
|
||||
"mil.ru" | "mai.ru" | "maill.ru" => Some("mail.ru"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(suggested) = typo_suggestion {
|
||||
return Err(format!("Возможно, опечатка в домене '{}'. Вы имели в виду '{}'?", domain, suggested));
|
||||
}
|
||||
|
||||
const DISPOSABLE_DOMAINS: &[&str] = &[
|
||||
"tempmail.com", "10minutemail.com", "guerrillamail.com", "mailinator.com",
|
||||
"trashmail.com", "yopmail.com", "temp-mail.org", "dispostable.com",
|
||||
"getnada.com", "throwawaymail.com", "fakeinbox.com", "sharklasers.com",
|
||||
"guerrillamailblock.com", "grr.la", "mohmal.com", "dropmail.me",
|
||||
"tempail.com", "burnermail.io", "crazymailing.com", "generator.email"
|
||||
];
|
||||
let domain_lower = domain.to_lowercase();
|
||||
if DISPOSABLE_DOMAINS.iter().any(|&d| domain_lower == d || domain_lower.ends_with(&format!(".{}", d))) {
|
||||
return Err("Временные и одноразовые почтовые ящики не поддерживаются. Пожалуйста, используйте действительный личный адрес.".to_string());
|
||||
}
|
||||
|
||||
Ok((local, domain))
|
||||
}
|
||||
|
||||
pub async fn verify_domain_exists(domain: &str) -> Result<(), String> {
|
||||
let domain = domain.trim();
|
||||
let resolves = match tokio::net::lookup_host(format!("{}:25", domain)).await {
|
||||
Ok(mut addrs) => addrs.next().is_some(),
|
||||
Err(_) => match tokio::net::lookup_host(format!("{}:443", domain)).await {
|
||||
Ok(mut addrs) => addrs.next().is_some(),
|
||||
Err(_) => match tokio::net::lookup_host(format!("{}:80", domain)).await {
|
||||
Ok(mut addrs) => addrs.next().is_some(),
|
||||
Err(_) => false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if !resolves {
|
||||
return Err(format!("Почтовый домен '{}' не найден в сети Интернет. Проверьте правильность адреса.", domain));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_mailru_api(email: &str) -> Result<bool, String> {
|
||||
let url = format!("https://account.mail.ru/api/v1/user/exists?email={}", email);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(4))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let res = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
|
||||
if let Some(exists) = json.get("body").and_then(|b| b.get("exists")).and_then(|e| e.as_bool()) {
|
||||
Ok(exists)
|
||||
} else {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_smtp_mailbox_rcpt(host_port: &str, email: &str) -> Result<bool, String> {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::timeout;
|
||||
|
||||
let fut = async {
|
||||
let stream = TcpStream::connect(host_port).await.map_err(|e| e.to_string())?;
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
async fn read_smtp_reply<R: AsyncBufReadExt + Unpin>(reader: &mut R) -> Result<(u16, String), String> {
|
||||
let mut last_line;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line).await.map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Err("Connection closed".to_string());
|
||||
}
|
||||
last_line = line.clone();
|
||||
if line.len() >= 4 && line[..3].chars().all(|c| c.is_ascii_digit()) && line.as_bytes()[3] == b' ' {
|
||||
break;
|
||||
}
|
||||
if line.len() >= 3 && line[..3].chars().all(|c| c.is_ascii_digit()) && line.len() < 4 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let code = last_line.get(..3).and_then(|c| c.parse::<u16>().ok()).unwrap_or(0);
|
||||
Ok((code, last_line))
|
||||
}
|
||||
|
||||
let (code, _) = read_smtp_reply(&mut reader).await?;
|
||||
if code != 220 {
|
||||
return Err("Invalid banner".to_string());
|
||||
}
|
||||
|
||||
writer.write_all(b"EHLO mail.ru\r\n").await.map_err(|e| e.to_string())?;
|
||||
writer.flush().await.map_err(|e| e.to_string())?;
|
||||
let (code, _) = read_smtp_reply(&mut reader).await?;
|
||||
if code != 250 {
|
||||
return Err("EHLO rejected".to_string());
|
||||
}
|
||||
|
||||
writer.write_all(b"MAIL FROM:<scorporation@internet.ru>\r\n").await.map_err(|e| e.to_string())?;
|
||||
writer.flush().await.map_err(|e| e.to_string())?;
|
||||
let (code, _) = read_smtp_reply(&mut reader).await?;
|
||||
if code != 250 {
|
||||
return Err("MAIL FROM rejected".to_string());
|
||||
}
|
||||
|
||||
let rcpt_cmd = format!("RCPT TO:<{}>\r\n", email);
|
||||
writer.write_all(rcpt_cmd.as_bytes()).await.map_err(|e| e.to_string())?;
|
||||
writer.flush().await.map_err(|e| e.to_string())?;
|
||||
let (code, line) = read_smtp_reply(&mut reader).await?;
|
||||
|
||||
let _ = writer.write_all(b"QUIT\r\n").await;
|
||||
let _ = writer.flush().await;
|
||||
|
||||
let line_lower = line.to_lowercase();
|
||||
if code == 550 || code == 551 || code == 552 || code == 553 || code == 554
|
||||
|| line_lower.contains("does not exist")
|
||||
|| line_lower.contains("user not found")
|
||||
|| line_lower.contains("no such user")
|
||||
|| line_lower.contains("nosuchuser")
|
||||
|| line_lower.contains("mailbox unavailable")
|
||||
|| line_lower.contains("recipient rejected")
|
||||
|| line_lower.contains("invalid recipient")
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if (200..300).contains(&code) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
};
|
||||
|
||||
match timeout(Duration::from_secs(4), fut).await {
|
||||
Ok(res) => res,
|
||||
Err(_) => Err("SMTP check timeout".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_mailbox_exists(email: &str) -> Result<(), String> {
|
||||
let clean = email.trim().to_lowercase();
|
||||
let parts: Vec<&str> = clean.split('@').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err("Некорректный адрес электронной почты".to_string());
|
||||
}
|
||||
let domain = parts[1];
|
||||
|
||||
// 1. Mail.ru group domains
|
||||
const MAILRU_DOMAINS: &[&str] = &["mail.ru", "inbox.ru", "list.ru", "bk.ru", "internet.ru", "xmail.ru"];
|
||||
if MAILRU_DOMAINS.contains(&domain) {
|
||||
match check_mailru_api(&clean).await {
|
||||
Ok(exists) => {
|
||||
if !exists {
|
||||
return Err(format!("Почтовый ящик '{}' не существует. Пожалуйста, укажите действительный почтовый ящик.", clean));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[verify_mailbox] Mail.ru API error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Google / Gmail
|
||||
if domain == "gmail.com" || domain == "googlemail.com" {
|
||||
match check_smtp_mailbox_rcpt("gmail-smtp-in.l.google.com:25", &clean).await {
|
||||
Ok(exists) => {
|
||||
if !exists {
|
||||
return Err(format!("Почтовый ящик '{}' не существует. Пожалуйста, укажите действительный почтовый ящик.", clean));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[verify_mailbox] Gmail check error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Yandex
|
||||
const YANDEX_DOMAINS: &[&str] = &["yandex.ru", "ya.ru", "yandex.com", "yandex.by", "yandex.kz"];
|
||||
if YANDEX_DOMAINS.contains(&domain) {
|
||||
match check_smtp_mailbox_rcpt("mx.yandex.ru:25", &clean).await {
|
||||
Ok(exists) => {
|
||||
if !exists {
|
||||
return Err(format!("Почтовый ящик '{}' не существует. Пожалуйста, укажите действительный почтовый ящик.", clean));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[verify_mailbox] Yandex check error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Other domains: lookup MX or primary host
|
||||
if let Ok(mut addrs) = tokio::net::lookup_host(format!("{}:25", domain)).await
|
||||
&& let Some(addr) = addrs.next()
|
||||
&& let Ok(exists) = check_smtp_mailbox_rcpt(&addr.to_string(), &clean).await
|
||||
&& !exists {
|
||||
return Err(format!("Почтовый ящик '{}' не существует. Пожалуйста, укажите действительный почтовый ящик.", clean));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_email_already_registered(email: &str) -> Result<bool, String> {
|
||||
let clean = email.trim().to_lowercase();
|
||||
let config = get_db_config();
|
||||
let client = connect_to_db(&config).await.map_err(|e| e.to_string())?;
|
||||
let check_query = format!(
|
||||
"SELECT 1 FROM {} WHERE LOWER({}) = LOWER($1) LIMIT 1",
|
||||
config.users_table, config.email_column
|
||||
);
|
||||
let row = client.query_opt(&check_query, &[&clean]).await.map_err(|e| e.to_string())?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
pub async fn register_user(email: &str, password: &str, username: Option<&str>) -> AuthResponse {
|
||||
let clean_email = email.trim();
|
||||
let clean_username = username.map(|u| u.trim()).filter(|u| !u.is_empty());
|
||||
|
|
@ -533,6 +812,33 @@ pub async fn register_user(email: &str, password: &str, username: Option<&str>)
|
|||
};
|
||||
}
|
||||
|
||||
let (_, domain) = match validate_email_syntax(clean_email) {
|
||||
Ok(d) => d,
|
||||
Err(err) => {
|
||||
return AuthResponse {
|
||||
success: false,
|
||||
message: err,
|
||||
user: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = verify_domain_exists(domain).await {
|
||||
return AuthResponse {
|
||||
success: false,
|
||||
message: err,
|
||||
user: None,
|
||||
};
|
||||
}
|
||||
|
||||
if let Err(err) = verify_mailbox_exists(clean_email).await {
|
||||
return AuthResponse {
|
||||
success: false,
|
||||
message: err,
|
||||
user: None,
|
||||
};
|
||||
}
|
||||
|
||||
let config = get_db_config();
|
||||
let client = match connect_to_db(&config).await {
|
||||
Ok(c) => c,
|
||||
|
|
@ -545,6 +851,19 @@ pub async fn register_user(email: &str, password: &str, username: Option<&str>)
|
|||
}
|
||||
};
|
||||
|
||||
// Strictly enforce 1 account per email address
|
||||
let check_existing = format!(
|
||||
"SELECT 1 FROM {} WHERE LOWER({}) = LOWER($1) LIMIT 1",
|
||||
config.users_table, config.email_column
|
||||
);
|
||||
if let Ok(Some(_)) = client.query_opt(&check_existing, &[&clean_email]).await {
|
||||
return AuthResponse {
|
||||
success: false,
|
||||
message: "На этот адрес электронной почты уже зарегистрирован аккаунт. На одну почту можно создать только 1 аккаунт.".to_string(),
|
||||
user: None,
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-create table if standard 'users' table
|
||||
if config.users_table == "users" && config.email_column == "email" && config.password_column == "password" {
|
||||
let create_table_sql = "
|
||||
|
|
@ -615,7 +934,7 @@ pub async fn register_user(email: &str, password: &str, username: Option<&str>)
|
|||
if err_str.contains("unique") || err_str.contains("duplicate") || err_str.contains("уже существует") {
|
||||
AuthResponse {
|
||||
success: false,
|
||||
message: "Пользователь с таким email уже существует".to_string(),
|
||||
message: "На этот адрес электронной почты уже зарегистрирован аккаунт. На одну почту можно создать только 1 аккаунт.".to_string(),
|
||||
user: None,
|
||||
}
|
||||
} else {
|
||||
|
|
@ -645,6 +964,7 @@ fn generate_reset_code() -> String {
|
|||
format!("{:06}", num)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn send_smtp_email(
|
||||
host: &str,
|
||||
port: u16,
|
||||
|
|
@ -971,10 +1291,224 @@ pub async fn reset_password_with_code(email: &str, code: &str, new_password: &st
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RegistrationEntry {
|
||||
code: String,
|
||||
expires_at: std::time::Instant,
|
||||
}
|
||||
|
||||
static REGISTRATION_CODES: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, RegistrationEntry>>> = std::sync::OnceLock::new();
|
||||
|
||||
fn get_registration_codes() -> &'static std::sync::Mutex<std::collections::HashMap<String, RegistrationEntry>> {
|
||||
REGISTRATION_CODES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
|
||||
}
|
||||
|
||||
pub async fn request_registration_code(email: &str, _username: Option<&str>) -> ResetRequestResponse {
|
||||
let clean_email = email.trim();
|
||||
let (_, domain) = match validate_email_syntax(clean_email) {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
return ResetRequestResponse {
|
||||
success: false,
|
||||
message: e,
|
||||
dev_code: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = verify_domain_exists(domain).await {
|
||||
return ResetRequestResponse {
|
||||
success: false,
|
||||
message: e,
|
||||
dev_code: None,
|
||||
};
|
||||
}
|
||||
|
||||
if let Err(e) = verify_mailbox_exists(clean_email).await {
|
||||
return ResetRequestResponse {
|
||||
success: false,
|
||||
message: e,
|
||||
dev_code: None,
|
||||
};
|
||||
}
|
||||
|
||||
let config = get_db_config();
|
||||
let client = match connect_to_db(&config).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return ResetRequestResponse {
|
||||
success: false,
|
||||
message: "Ошибка подключения к серверу. Попробуйте позже.".to_string(),
|
||||
dev_code: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let check_query = format!(
|
||||
"SELECT 1 FROM {} WHERE LOWER({}) = LOWER($1) LIMIT 1",
|
||||
config.users_table, config.email_column
|
||||
);
|
||||
|
||||
match client.query_opt(&check_query, &[&clean_email]).await {
|
||||
Ok(Some(_)) => ResetRequestResponse {
|
||||
success: false,
|
||||
message: "На этот адрес электронной почты уже зарегистрирован аккаунт. На одну почту можно создать только 1 аккаунт.".to_string(),
|
||||
dev_code: None,
|
||||
},
|
||||
Ok(None) => {
|
||||
let code = generate_reset_code();
|
||||
let expires_at = std::time::Instant::now() + std::time::Duration::from_secs(900); // 15 mins
|
||||
|
||||
{
|
||||
let mut map = get_registration_codes().lock().unwrap();
|
||||
map.insert(clean_email.to_lowercase(), RegistrationEntry {
|
||||
code: code.clone(),
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
let smtp_host = extract_clean_env("SMTP_HOST", "smtp.mail.ru");
|
||||
let smtp_port = extract_clean_env("SMTP_PORT", "465").parse::<u16>().unwrap_or(465);
|
||||
let smtp_user = extract_clean_env("SMTP_USER", "scorporation@internet.ru");
|
||||
let smtp_pass = extract_clean_env("SMTP_PASSWORD", "87AhqOfdlC5LPb5eIX3N");
|
||||
let smtp_from = extract_clean_env("SMTP_FROM", &smtp_user);
|
||||
|
||||
let dev_code;
|
||||
if !smtp_host.is_empty() {
|
||||
let subject = "Home Autli Server: Код подтверждения регистрации";
|
||||
let body = format!(
|
||||
"Здравствуйте!\n\nВаш проверочный код для подтверждения почты и завершения регистрации в Home Autli Server: {}\n\nКод действителен в течение 15 минут.\nЕсли вы не регистрировались в Home Autli Server, просто проигнорируйте это письмо.",
|
||||
code
|
||||
);
|
||||
match send_smtp_email(&smtp_host, smtp_port, &smtp_user, &smtp_pass, &smtp_from, clean_email, subject, &body).await {
|
||||
Ok(_) => {
|
||||
return ResetRequestResponse {
|
||||
success: true,
|
||||
message: format!("Код подтверждения успешно отправлен на почту {}", clean_email),
|
||||
dev_code: None,
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Не удалось отправить email через SMTP: {}. Тестовый код: {}", e, code);
|
||||
dev_code = Some(code.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dev_code = Some(code.clone());
|
||||
}
|
||||
|
||||
ResetRequestResponse {
|
||||
success: true,
|
||||
message: if dev_code.is_some() {
|
||||
format!("Проверочный код сгенерирован (тестовый режим: {})", code)
|
||||
} else {
|
||||
format!("Код подтверждения отправлен на {}", clean_email)
|
||||
},
|
||||
dev_code,
|
||||
}
|
||||
}
|
||||
Err(e) => ResetRequestResponse {
|
||||
success: false,
|
||||
message: format!("Ошибка проверки почты: {}", e),
|
||||
dev_code: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn register_with_code(
|
||||
email: &str,
|
||||
code: &str,
|
||||
password: &str,
|
||||
username: Option<&str>,
|
||||
) -> AuthResponse {
|
||||
let clean_email = email.trim();
|
||||
let clean_code = code.trim();
|
||||
|
||||
if clean_code.is_empty() {
|
||||
return AuthResponse {
|
||||
success: false,
|
||||
message: "Пожалуйста, введите проверочный код из письма".to_string(),
|
||||
user: None,
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
let mut map = get_registration_codes().lock().unwrap();
|
||||
let key = clean_email.to_lowercase();
|
||||
if let Some(entry) = map.get(&key) {
|
||||
if std::time::Instant::now() > entry.expires_at {
|
||||
return AuthResponse {
|
||||
success: false,
|
||||
message: "Срок действия кода подтверждения истек. Запросите код заново.".to_string(),
|
||||
user: None,
|
||||
};
|
||||
}
|
||||
if entry.code != clean_code {
|
||||
return AuthResponse {
|
||||
success: false,
|
||||
message: "Неверный проверочный код. Проверьте введенные цифры.".to_string(),
|
||||
user: None,
|
||||
};
|
||||
}
|
||||
map.remove(&key);
|
||||
} else {
|
||||
return AuthResponse {
|
||||
success: false,
|
||||
message: "Код подтверждения для данного адреса не найден или устарел. Запросите код снова.".to_string(),
|
||||
user: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
register_user(clean_email, password, username).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_email_syntax() {
|
||||
assert!(validate_email_syntax("user@gmail.com").is_ok());
|
||||
assert!(validate_email_syntax("test.user+tag@mail.ru").is_ok());
|
||||
assert!(validate_email_syntax("alex_99@yandex.ru").is_ok());
|
||||
|
||||
assert!(validate_email_syntax("").is_err());
|
||||
assert!(validate_email_syntax("invalid").is_err());
|
||||
assert!(validate_email_syntax("invalid@").is_err());
|
||||
assert!(validate_email_syntax("@domain.com").is_err());
|
||||
assert!(validate_email_syntax("user@.com").is_err());
|
||||
assert!(validate_email_syntax("user@domain..com").is_err());
|
||||
assert!(validate_email_syntax("user@tempmail.com").is_err());
|
||||
assert!(validate_email_syntax("user@mailinator.com").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_verify_domain_exists() {
|
||||
assert!(verify_domain_exists("gmail.com").await.is_ok());
|
||||
assert!(verify_domain_exists("mail.ru").await.is_ok());
|
||||
assert!(verify_domain_exists("this-domain-surely-does-not-exist-123456789.ru").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_verify_mailbox_exists() {
|
||||
// Real mailboxes should pass
|
||||
assert!(verify_mailbox_exists("scorporation@internet.ru").await.is_ok());
|
||||
|
||||
// Fake / non-existent mailboxes on Mail.ru HTTP API must fail!
|
||||
assert!(verify_mailbox_exists("ne60201@mail.ru").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_email_already_registered() {
|
||||
// Admin email exists in database
|
||||
assert_eq!(check_email_already_registered("dimatyilickiy0511@gmail.com").await.unwrap(), true);
|
||||
// Case-insensitive check
|
||||
assert_eq!(check_email_already_registered("DIMATYILICKIY0511@GMAIL.COM").await.unwrap(), true);
|
||||
// Unregistered email
|
||||
assert_eq!(check_email_already_registered("brand_new_unregistered_email_998877@mail.ru").await.unwrap(), false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_database_connection() {
|
||||
let config = get_db_config();
|
||||
|
|
|
|||
|
|
@ -54,43 +54,178 @@ pub fn get_cinema_config() -> CinemaConfig {
|
|||
CinemaConfig { tmdb_api_key: Some(key) }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_cinema_state(app: AppHandle) -> Result<CinemaState, String> {
|
||||
let path = get_storage_file_path(&app);
|
||||
pub fn read_local_cinema_state(app: &AppHandle) -> CinemaState {
|
||||
let path = get_storage_file_path(app);
|
||||
if !path.exists() {
|
||||
return Ok(CinemaState::default());
|
||||
return CinemaState::default();
|
||||
}
|
||||
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) => match serde_json::from_str::<CinemaState>(&content) {
|
||||
Ok(state) => Ok(state),
|
||||
Err(e) => {
|
||||
eprintln!("Error parsing cinema_state.json: {}", e);
|
||||
Ok(CinemaState::default())
|
||||
Ok(content) => serde_json::from_str::<CinemaState>(&content).unwrap_or_default(),
|
||||
Err(_) => CinemaState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_local_cinema_state(app: &AppHandle, state: &CinemaState) -> Result<(), String> {
|
||||
let path = get_storage_file_path(app);
|
||||
let json = serde_json::to_string_pretty(state).map_err(|e| format!("Ошибка сериализации данных: {}", e))?;
|
||||
fs::write(&path, json).map_err(|e| format!("Не удалось сохранить локальный файл кинотеатра: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_cinema_state(user_email: &str) -> Option<CinemaState> {
|
||||
let email = user_email.trim().to_lowercase();
|
||||
if email.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let config = crate::auth::get_db_config();
|
||||
let client = crate::auth::connect_to_db(&config).await.ok()?;
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT content FROM server_notes WHERE LOWER(user_email) = LOWER($1) AND title = '__cinema_state__' ORDER BY id DESC LIMIT 1",
|
||||
&[&email],
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if let Some(row) = row {
|
||||
let content: String = row.get(0);
|
||||
serde_json::from_str::<CinemaState>(&content).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_remote_cinema_state(user_email: &str, state: &CinemaState) -> Result<(), String> {
|
||||
let email = user_email.trim().to_lowercase();
|
||||
if email.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let json = serde_json::to_string(state).map_err(|e| e.to_string())?;
|
||||
let config = crate::auth::get_db_config();
|
||||
let client = crate::auth::connect_to_db(&config)
|
||||
.await
|
||||
.map_err(|e| format!("Не удалось подключиться к базе данных: {}", e))?;
|
||||
|
||||
let existing = client
|
||||
.query_opt(
|
||||
"SELECT id FROM server_notes WHERE LOWER(user_email) = LOWER($1) AND title = '__cinema_state__' LIMIT 1",
|
||||
&[&email],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(row) = existing {
|
||||
let id: i64 = row.get(0);
|
||||
client
|
||||
.execute(
|
||||
"UPDATE server_notes SET content = $1 WHERE id = $2",
|
||||
&[&json, &id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
client
|
||||
.execute(
|
||||
"INSERT INTO server_notes (user_email, title, folder, content, note_type, status) VALUES ($1, '__cinema_state__', 'system', $2, 'system', 'active')",
|
||||
&[&email, &json],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn merge_cinema_states(local: CinemaState, remote: Option<CinemaState>) -> CinemaState {
|
||||
let remote = match remote {
|
||||
Some(r) => r,
|
||||
None => return local,
|
||||
};
|
||||
|
||||
// 1. Merge bookmarks: union preserving unique items
|
||||
let mut bookmarks = local.bookmarks;
|
||||
for bm in remote.bookmarks {
|
||||
if !bookmarks.contains(&bm) {
|
||||
bookmarks.push(bm);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Merge history: key = (title_id, season, episode)
|
||||
let mut history_map: std::collections::HashMap<(String, u32, u32), CinemaPlayback> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for item in remote.history {
|
||||
history_map.insert((item.title_id.clone(), item.season, item.episode), item);
|
||||
}
|
||||
|
||||
for item in local.history {
|
||||
let key = (item.title_id.clone(), item.season, item.episode);
|
||||
if let Some(existing) = history_map.get_mut(&key) {
|
||||
if item.last_watched >= existing.last_watched || item.progress_seconds > existing.progress_seconds {
|
||||
*existing = item;
|
||||
}
|
||||
},
|
||||
Err(e) => Err(format!("Не удалось прочитать локальный файл истории кинотеатра: {}", e)),
|
||||
} else {
|
||||
history_map.insert(key, item);
|
||||
}
|
||||
}
|
||||
|
||||
let mut merged_history: Vec<CinemaPlayback> = history_map.into_values().collect();
|
||||
merged_history.sort_by(|a, b| b.last_watched.cmp(&a.last_watched));
|
||||
if merged_history.len() > 50 {
|
||||
merged_history.truncate(50);
|
||||
}
|
||||
|
||||
CinemaState {
|
||||
bookmarks,
|
||||
history: merged_history,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_cinema_state(app: AppHandle, state: CinemaState) -> Result<bool, String> {
|
||||
let path = get_storage_file_path(&app);
|
||||
match serde_json::to_string_pretty(&state) {
|
||||
Ok(json) => match fs::write(&path, json) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => Err(format!("Не удалось сохранить локальный файл кинотеатра: {}", e)),
|
||||
},
|
||||
Err(e) => Err(format!("Ошибка сериализации данных: {}", e)),
|
||||
pub async fn get_cinema_state(app: AppHandle, user_email: Option<String>) -> Result<CinemaState, String> {
|
||||
let local_state = read_local_cinema_state(&app);
|
||||
if let Some(email) = user_email {
|
||||
let email_clean = email.trim().to_lowercase();
|
||||
if !email_clean.is_empty()
|
||||
&& let Some(remote_state) = fetch_remote_cinema_state(&email_clean).await {
|
||||
let merged = merge_cinema_states(local_state, Some(remote_state));
|
||||
let _ = write_local_cinema_state(&app, &merged);
|
||||
let email_for_task = email_clean.clone();
|
||||
let merged_for_task = merged.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = save_remote_cinema_state(&email_for_task, &merged_for_task).await;
|
||||
});
|
||||
return Ok(merged);
|
||||
}
|
||||
}
|
||||
Ok(local_state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_cinema_progress(
|
||||
pub async fn save_cinema_state(
|
||||
app: AppHandle,
|
||||
state: CinemaState,
|
||||
user_email: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
write_local_cinema_state(&app, &state)?;
|
||||
if let Some(email) = user_email {
|
||||
let email_clean = email.trim().to_lowercase();
|
||||
if !email_clean.is_empty() {
|
||||
let state_to_save = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = save_remote_cinema_state(&email_clean, &state_to_save).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_cinema_progress(
|
||||
app: AppHandle,
|
||||
item: CinemaPlayback,
|
||||
user_email: Option<String>,
|
||||
) -> Result<CinemaState, String> {
|
||||
let mut state = get_cinema_state(app.clone()).unwrap_or_default();
|
||||
let mut state = read_local_cinema_state(&app);
|
||||
|
||||
// Remove older entry for the same title/season/episode if exists
|
||||
state.history.retain(|h| !(h.title_id == item.title_id && h.season == item.season && h.episode == item.episode));
|
||||
|
|
@ -102,20 +237,93 @@ pub fn update_cinema_progress(
|
|||
state.history.truncate(50);
|
||||
}
|
||||
|
||||
let _ = save_cinema_state(app, state.clone());
|
||||
let _ = write_local_cinema_state(&app, &state);
|
||||
|
||||
if let Some(email) = user_email {
|
||||
let email_clean = email.trim().to_lowercase();
|
||||
if !email_clean.is_empty() {
|
||||
let state_to_save = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = save_remote_cinema_state(&email_clean, &state_to_save).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn toggle_cinema_bookmark(app: AppHandle, title_id: String) -> Result<CinemaState, String> {
|
||||
let mut state = get_cinema_state(app.clone()).unwrap_or_default();
|
||||
pub async fn remove_cinema_history_item(
|
||||
app: AppHandle,
|
||||
title_id: String,
|
||||
season: u32,
|
||||
episode: u32,
|
||||
user_email: Option<String>,
|
||||
) -> Result<CinemaState, String> {
|
||||
let mut state = read_local_cinema_state(&app);
|
||||
state.history.retain(|h| !(h.title_id == title_id && h.season == season && h.episode == episode));
|
||||
let _ = write_local_cinema_state(&app, &state);
|
||||
|
||||
if let Some(email) = user_email {
|
||||
let email_clean = email.trim().to_lowercase();
|
||||
if !email_clean.is_empty() {
|
||||
let state_to_save = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = save_remote_cinema_state(&email_clean, &state_to_save).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_cinema_history(
|
||||
app: AppHandle,
|
||||
user_email: Option<String>,
|
||||
) -> Result<CinemaState, String> {
|
||||
let mut state = read_local_cinema_state(&app);
|
||||
state.history.clear();
|
||||
let _ = write_local_cinema_state(&app, &state);
|
||||
|
||||
if let Some(email) = user_email {
|
||||
let email_clean = email.trim().to_lowercase();
|
||||
if !email_clean.is_empty() {
|
||||
let state_to_save = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = save_remote_cinema_state(&email_clean, &state_to_save).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_cinema_bookmark(
|
||||
app: AppHandle,
|
||||
title_id: String,
|
||||
user_email: Option<String>,
|
||||
) -> Result<CinemaState, String> {
|
||||
let mut state = read_local_cinema_state(&app);
|
||||
if let Some(pos) = state.bookmarks.iter().position(|id| id == &title_id) {
|
||||
state.bookmarks.remove(pos);
|
||||
} else {
|
||||
state.bookmarks.push(title_id);
|
||||
}
|
||||
|
||||
let _ = save_cinema_state(app, state.clone());
|
||||
let _ = write_local_cinema_state(&app, &state);
|
||||
|
||||
if let Some(email) = user_email {
|
||||
let email_clean = email.trim().to_lowercase();
|
||||
if !email_clean.is_empty() {
|
||||
let state_to_save = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = save_remote_cinema_state(&email_clean, &state_to_save).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
|
|
@ -231,9 +439,9 @@ pub async fn get_russian_media_info(
|
|||
("token", "45e20a5f584becf7a64dffb7174ddf"),
|
||||
("tmdb", tmdb_str.as_str()),
|
||||
];
|
||||
if let Ok(resp) = client.get("https://api.apbugall.org/").query(&query_params).send().await {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
if let Ok(resp) = client.get("https://api.apbugall.org/").query(&query_params).send().await
|
||||
&& resp.status().is_success()
|
||||
&& let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
let is_ok = json.get("status").and_then(|s| s.as_str()) == Some("success");
|
||||
if is_ok {
|
||||
let data_opt = json.get("data");
|
||||
|
|
@ -254,8 +462,6 @@ pub async fn get_russian_media_info(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try Alloha API by Russian / Original title if TMDB didn't return a match
|
||||
|
|
@ -266,9 +472,9 @@ pub async fn get_russian_media_info(
|
|||
("token", "45e20a5f584becf7a64dffb7174ddf"),
|
||||
("name", clean_title),
|
||||
];
|
||||
if let Ok(resp) = client.get("https://api.apbugall.org/").query(&query_params).send().await {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
if let Ok(resp) = client.get("https://api.apbugall.org/").query(&query_params).send().await
|
||||
&& resp.status().is_success()
|
||||
&& let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
let is_ok = json.get("status").and_then(|s| s.as_str()) == Some("success");
|
||||
if is_ok {
|
||||
let data_opt = json.get("data");
|
||||
|
|
@ -289,8 +495,6 @@ pub async fn get_russian_media_info(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,11 +505,10 @@ pub async fn get_russian_media_info(
|
|||
} else {
|
||||
format!("/movie/{}/external_ids", tmdb)
|
||||
};
|
||||
if let Ok(val) = tmdb_query(ep).await {
|
||||
if let Some(imdb) = val.get("imdb_id").and_then(|v| v.as_str()) {
|
||||
if let Ok(val) = tmdb_query(ep).await
|
||||
&& let Some(imdb) = val.get("imdb_id").and_then(|v| v.as_str()) {
|
||||
info.imdb_id = Some(imdb.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(info)
|
||||
|
|
@ -356,11 +559,11 @@ pub async fn filter_playable_media(items: Vec<PlayableCheckItem>) -> Result<Vec<
|
|||
.send()
|
||||
.await;
|
||||
|
||||
if let Ok(resp) = alloha_res {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
if json.get("status").and_then(|s| s.as_str()) == Some("success") {
|
||||
let has_data = json.get("data").map_or(false, |d| {
|
||||
if let Ok(resp) = alloha_res
|
||||
&& resp.status().is_success()
|
||||
&& let Ok(json) = resp.json::<serde_json::Value>().await
|
||||
&& json.get("status").and_then(|s| s.as_str()) == Some("success") {
|
||||
let has_data = json.get("data").is_some_and(|d| {
|
||||
if let Some(arr) = d.as_array() {
|
||||
!arr.is_empty()
|
||||
} else {
|
||||
|
|
@ -371,9 +574,6 @@ pub async fn filter_playable_media(items: Vec<PlayableCheckItem>) -> Result<Vec<
|
|||
return Some(tmdb_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check Kodik balancer API:
|
||||
let clean_title = item.title.trim();
|
||||
|
|
@ -387,17 +587,13 @@ pub async fn filter_playable_media(items: Vec<PlayableCheckItem>) -> Result<Vec<
|
|||
.send()
|
||||
.await;
|
||||
|
||||
if let Ok(resp) = kodik_res {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(results) = json.get("results").and_then(|r| r.as_array()) {
|
||||
if !results.is_empty() {
|
||||
if let Ok(resp) = kodik_res
|
||||
&& resp.status().is_success()
|
||||
&& let Ok(json) = resp.json::<serde_json::Value>().await
|
||||
&& let Some(results) = json.get("results").and_then(|r| r.as_array())
|
||||
&& !results.is_empty() {
|
||||
return Some(tmdb_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try title search on Alloha as last check for Russian titles
|
||||
|
|
@ -411,15 +607,12 @@ pub async fn filter_playable_media(items: Vec<PlayableCheckItem>) -> Result<Vec<
|
|||
.send()
|
||||
.await;
|
||||
|
||||
if let Ok(resp) = alloha_title_res {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||
if json.get("status").and_then(|s| s.as_str()) == Some("success") {
|
||||
if let Ok(resp) = alloha_title_res
|
||||
&& resp.status().is_success()
|
||||
&& let Ok(json) = resp.json::<serde_json::Value>().await
|
||||
&& json.get("status").and_then(|s| s.as_str()) == Some("success") {
|
||||
return Some(tmdb_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If neither Alloha nor Kodik has it, and votes < 100, this title has NO working player!
|
||||
|
|
@ -441,6 +634,100 @@ pub async fn filter_playable_media(items: Vec<PlayableCheckItem>) -> Result<Vec<
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_merge_cinema_states() {
|
||||
let local = CinemaState {
|
||||
bookmarks: vec!["movie-1".to_string(), "movie-2".to_string()],
|
||||
history: vec![CinemaPlayback {
|
||||
title_id: "movie-1".to_string(),
|
||||
title_name: "Movie 1".to_string(),
|
||||
poster_path: None,
|
||||
backdrop_path: None,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
progress_seconds: 1500.0,
|
||||
duration_seconds: 3600.0,
|
||||
last_watched: "2026-10-27T12:00:00Z".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let remote = CinemaState {
|
||||
bookmarks: vec!["movie-2".to_string(), "movie-3".to_string()],
|
||||
history: vec![
|
||||
CinemaPlayback {
|
||||
title_id: "movie-1".to_string(),
|
||||
title_name: "Movie 1".to_string(),
|
||||
poster_path: None,
|
||||
backdrop_path: None,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
progress_seconds: 2400.0,
|
||||
duration_seconds: 3600.0,
|
||||
last_watched: "2026-10-27T13:00:00Z".to_string(),
|
||||
},
|
||||
CinemaPlayback {
|
||||
title_id: "tv-1".to_string(),
|
||||
title_name: "TV Show 1".to_string(),
|
||||
poster_path: None,
|
||||
backdrop_path: None,
|
||||
season: 1,
|
||||
episode: 2,
|
||||
progress_seconds: 300.0,
|
||||
duration_seconds: 1800.0,
|
||||
last_watched: "2026-10-27T11:00:00Z".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let merged = merge_cinema_states(local, Some(remote));
|
||||
assert_eq!(merged.bookmarks.len(), 3);
|
||||
assert!(merged.bookmarks.contains(&"movie-1".to_string()));
|
||||
assert!(merged.bookmarks.contains(&"movie-2".to_string()));
|
||||
assert!(merged.bookmarks.contains(&"movie-3".to_string()));
|
||||
|
||||
assert_eq!(merged.history.len(), 2);
|
||||
// movie-1 from remote should win because it has newer timestamp and more progress
|
||||
let m1 = merged.history.iter().find(|h| h.title_id == "movie-1").unwrap();
|
||||
assert_eq!(m1.progress_seconds, 2400.0);
|
||||
assert_eq!(m1.last_watched, "2026-10-27T13:00:00Z");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_db_cinema_remote_sync() {
|
||||
let test_email = "test_sync_cinema@example.com";
|
||||
let state = CinemaState {
|
||||
bookmarks: vec!["synced-title-42".to_string()],
|
||||
history: vec![CinemaPlayback {
|
||||
title_id: "synced-title-42".to_string(),
|
||||
title_name: "Synced Title".to_string(),
|
||||
poster_path: None,
|
||||
backdrop_path: None,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
progress_seconds: 999.0,
|
||||
duration_seconds: 1800.0,
|
||||
last_watched: "2026-10-27T15:00:00Z".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let save_res = save_remote_cinema_state(test_email, &state).await;
|
||||
println!("save_remote_cinema_state result: {:?}", save_res);
|
||||
assert!(save_res.is_ok());
|
||||
|
||||
let fetched = fetch_remote_cinema_state(test_email).await;
|
||||
println!("fetch_remote_cinema_state result: {:?}", fetched);
|
||||
assert!(fetched.is_some());
|
||||
let fetched_state = fetched.unwrap();
|
||||
assert_eq!(fetched_state.bookmarks, vec!["synced-title-42".to_string()]);
|
||||
assert_eq!(fetched_state.history[0].progress_seconds, 999.0);
|
||||
|
||||
// Clean up test record
|
||||
let config = crate::auth::get_db_config();
|
||||
if let Ok(client) = crate::auth::connect_to_db(&config).await {
|
||||
let _ = client.execute("DELETE FROM server_notes WHERE LOWER(user_email) = LOWER($1) AND title = '__cinema_state__'", &[&test_email]).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tmdb_query() {
|
||||
let res = tmdb_query("/movie/157336?language=ru-RU".to_string()).await;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ mod cinema;
|
|||
|
||||
use admin::DeviceSecurityInfo;
|
||||
use auth::{AuthResponse, DbStatus, ResetRequestResponse};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use storage::{ServerUserDto, StorageItemDto, StorageStats};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -35,6 +35,33 @@ async fn register(email: String, password: String, username: Option<String>) ->
|
|||
Ok(auth::register_user(&email, &password, username.as_deref()).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn request_registration_code(email: String, username: Option<String>) -> Result<ResetRequestResponse, String> {
|
||||
Ok(auth::request_registration_code(&email, username.as_deref()).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn register_with_code(
|
||||
email: String,
|
||||
code: String,
|
||||
password: String,
|
||||
username: Option<String>,
|
||||
) -> Result<AuthResponse, String> {
|
||||
Ok(auth::register_with_code(&email, &code, &password, username.as_deref()).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn validate_email(email: String) -> Result<bool, String> {
|
||||
let (_, domain) = auth::validate_email_syntax(&email)?;
|
||||
auth::verify_domain_exists(domain).await?;
|
||||
auth::verify_mailbox_exists(&email).await?;
|
||||
if let Ok(is_taken) = auth::check_email_already_registered(&email).await
|
||||
&& is_taken {
|
||||
return Err("На этот адрес электронной почты уже зарегистрирован аккаунт. На одну почту можно создать только 1 аккаунт.".to_string());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn request_reset_code(email: String) -> Result<ResetRequestResponse, String> {
|
||||
Ok(auth::request_reset_code(&email).await)
|
||||
|
|
@ -174,6 +201,150 @@ async fn admin_delete_user(
|
|||
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)]
|
||||
pub fn run() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
|
@ -194,6 +365,9 @@ pub fn run() {
|
|||
check_db,
|
||||
login,
|
||||
register,
|
||||
request_registration_code,
|
||||
register_with_code,
|
||||
validate_email,
|
||||
request_reset_code,
|
||||
verify_reset_code,
|
||||
reset_password,
|
||||
|
|
@ -219,10 +393,13 @@ pub fn run() {
|
|||
cinema::get_cinema_state,
|
||||
cinema::save_cinema_state,
|
||||
cinema::update_cinema_progress,
|
||||
cinema::remove_cinema_history_item,
|
||||
cinema::clear_cinema_history,
|
||||
cinema::toggle_cinema_bookmark,
|
||||
cinema::tmdb_query,
|
||||
cinema::get_russian_media_info,
|
||||
cinema::filter_playable_media
|
||||
cinema::filter_playable_media,
|
||||
check_app_update
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use crate::auth::{connect_to_db, get_db_config};
|
||||
|
||||
static SCHEMA_INITIALIZED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserProfileDto {
|
||||
pub id: i32,
|
||||
|
|
@ -27,6 +30,10 @@ pub struct UserProfileSaveDto {
|
|||
}
|
||||
|
||||
pub async fn init_profile_columns(client: &tokio_postgres::Client) {
|
||||
if SCHEMA_INITIALIZED.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let sqls = [
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT;",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS bio TEXT;",
|
||||
|
|
@ -38,6 +45,8 @@ pub async fn init_profile_columns(client: &tokio_postgres::Client) {
|
|||
for statement in sqls {
|
||||
let _ = client.execute(statement, &[]).await;
|
||||
}
|
||||
|
||||
SCHEMA_INITIALIZED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
async fn get_existing_user_columns(client: &tokio_postgres::Client) -> HashSet<String> {
|
||||
|
|
@ -54,16 +63,12 @@ async fn get_existing_user_columns(client: &tokio_postgres::Client) -> HashSet<S
|
|||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_user_profile(user_email: String) -> Result<UserProfileDto, String> {
|
||||
let clean_email = user_email.trim().to_lowercase();
|
||||
let config = get_db_config();
|
||||
let client = connect_to_db(&config).await?;
|
||||
|
||||
// Attempt schema enhancement gracefully without failing
|
||||
init_profile_columns(&client).await;
|
||||
|
||||
let cols = get_existing_user_columns(&client).await;
|
||||
async fn fetch_profile_with_client(
|
||||
client: &tokio_postgres::Client,
|
||||
clean_email: &str,
|
||||
) -> Result<UserProfileDto, String> {
|
||||
init_profile_columns(client).await;
|
||||
let cols = get_existing_user_columns(client).await;
|
||||
|
||||
let username_sel = if cols.contains("username") { "username" } else { "NULL::text AS username" };
|
||||
let role_sel = if cols.contains("role") { "COALESCE(role, 'user')" } else { "'user'::text AS role" };
|
||||
|
|
@ -89,7 +94,7 @@ pub async fn get_user_profile(user_email: String) -> Result<UserProfileDto, Stri
|
|||
.map_err(|e| format!("Ошибка загрузки профиля: {}", e))?
|
||||
.ok_or_else(|| "Пользователь не найден в базе данных".to_string())?;
|
||||
|
||||
Ok(UserProfileDto {
|
||||
let mut profile_res = UserProfileDto {
|
||||
id: row.get(0),
|
||||
email: row.get(1),
|
||||
username: row.get(2),
|
||||
|
|
@ -100,7 +105,41 @@ pub async fn get_user_profile(user_email: String) -> Result<UserProfileDto, Stri
|
|||
notify_sound: row.get(7),
|
||||
notify_security: row.get(8),
|
||||
created_at: row.get(9),
|
||||
})
|
||||
};
|
||||
|
||||
// Safely supplement from server_notes __profile_prefs__ if available
|
||||
let note_sql = "SELECT content FROM server_notes WHERE LOWER(user_email) = LOWER($1) AND title = '__profile_prefs__' ORDER BY id DESC LIMIT 1;";
|
||||
if let Ok(rows) = client.query(note_sql, &[&clean_email]).await
|
||||
&& let Some(note_row) = rows.first()
|
||||
&& let Some(json_str) = note_row.get::<_, Option<String>>(0)
|
||||
&& let Ok(prefs) = serde_json::from_str::<serde_json::Value>(&json_str) {
|
||||
if let Some(bio) = prefs.get("bio").and_then(|v| v.as_str()) {
|
||||
profile_res.bio = Some(bio.to_string());
|
||||
}
|
||||
if let Some(avatar) = prefs.get("avatar").and_then(|v| v.as_str())
|
||||
&& !avatar.trim().is_empty() {
|
||||
profile_res.avatar = Some(avatar.to_string());
|
||||
}
|
||||
if let Some(notify_email) = prefs.get("notify_email").and_then(|v| v.as_bool()) {
|
||||
profile_res.notify_email = notify_email;
|
||||
}
|
||||
if let Some(notify_sound) = prefs.get("notify_sound").and_then(|v| v.as_bool()) {
|
||||
profile_res.notify_sound = notify_sound;
|
||||
}
|
||||
if let Some(notify_security) = prefs.get("notify_security").and_then(|v| v.as_bool()) {
|
||||
profile_res.notify_security = notify_security;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profile_res)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_user_profile(user_email: String) -> Result<UserProfileDto, String> {
|
||||
let clean_email = user_email.trim().to_lowercase();
|
||||
let config = get_db_config();
|
||||
let client = connect_to_db(&config).await?;
|
||||
fetch_profile_with_client(&client, &clean_email).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -112,45 +151,88 @@ pub async fn save_user_profile(
|
|||
let config = get_db_config();
|
||||
let client = connect_to_db(&config).await?;
|
||||
|
||||
// Attempt schema enhancement gracefully
|
||||
init_profile_columns(&client).await;
|
||||
|
||||
let cols = get_existing_user_columns(&client).await;
|
||||
|
||||
let clean_username = profile.username.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty());
|
||||
let clean_bio = profile.bio.as_deref().map(|s| s.trim());
|
||||
let avatar = profile.avatar.filter(|s| !s.trim().is_empty());
|
||||
|
||||
if cols.contains("username") {
|
||||
let _ = client
|
||||
.execute("UPDATE users SET username = $1 WHERE LOWER(email) = LOWER($2);", &[&clean_username, &clean_email])
|
||||
.await;
|
||||
// 1. Dynamic safe UPDATE on users table (only updating columns that actually exist)
|
||||
let mut set_clauses = Vec::new();
|
||||
let mut params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new();
|
||||
|
||||
if cols.contains("username") && clean_username.is_some() {
|
||||
params.push(&clean_username);
|
||||
set_clauses.push(format!("username = ${}", params.len()));
|
||||
}
|
||||
if cols.contains("avatar") {
|
||||
let _ = client
|
||||
.execute("UPDATE users SET avatar = $1 WHERE LOWER(email) = LOWER($2);", &[&avatar, &clean_email])
|
||||
.await;
|
||||
if cols.contains("avatar") && avatar.is_some() {
|
||||
params.push(&avatar);
|
||||
set_clauses.push(format!("avatar = ${}", params.len()));
|
||||
}
|
||||
if cols.contains("bio") {
|
||||
let _ = client
|
||||
.execute("UPDATE users SET bio = $1 WHERE LOWER(email) = LOWER($2);", &[&clean_bio, &clean_email])
|
||||
.await;
|
||||
params.push(&clean_bio);
|
||||
set_clauses.push(format!("bio = ${}", params.len()));
|
||||
}
|
||||
if cols.contains("notify_email") {
|
||||
let _ = client
|
||||
.execute("UPDATE users SET notify_email = $1 WHERE LOWER(email) = LOWER($2);", &[&profile.notify_email, &clean_email])
|
||||
.await;
|
||||
params.push(&profile.notify_email);
|
||||
set_clauses.push(format!("notify_email = ${}", params.len()));
|
||||
}
|
||||
if cols.contains("notify_sound") {
|
||||
let _ = client
|
||||
.execute("UPDATE users SET notify_sound = $1 WHERE LOWER(email) = LOWER($2);", &[&profile.notify_sound, &clean_email])
|
||||
.await;
|
||||
params.push(&profile.notify_sound);
|
||||
set_clauses.push(format!("notify_sound = ${}", params.len()));
|
||||
}
|
||||
if cols.contains("notify_security") {
|
||||
params.push(&profile.notify_security);
|
||||
set_clauses.push(format!("notify_security = ${}", params.len()));
|
||||
}
|
||||
|
||||
if !set_clauses.is_empty() {
|
||||
params.push(&clean_email);
|
||||
let update_sql = format!(
|
||||
"UPDATE users SET {} WHERE LOWER(email) = LOWER(${})",
|
||||
set_clauses.join(", "),
|
||||
params.len()
|
||||
);
|
||||
let _ = client.execute(&update_sql, ¶ms[..]).await;
|
||||
}
|
||||
|
||||
// 2. Persist extended profile preferences in server_notes as bulletproof backup
|
||||
let prefs_json = serde_json::json!({
|
||||
"username": clean_username,
|
||||
"avatar": avatar,
|
||||
"bio": clean_bio,
|
||||
"notify_email": profile.notify_email,
|
||||
"notify_sound": profile.notify_sound,
|
||||
"notify_security": profile.notify_security,
|
||||
}).to_string();
|
||||
|
||||
let existing_note = client
|
||||
.query_opt(
|
||||
"SELECT id FROM server_notes WHERE LOWER(user_email) = LOWER($1) AND title = '__profile_prefs__' LIMIT 1;",
|
||||
&[&clean_email],
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if let Some(row) = existing_note {
|
||||
let note_id: i64 = row.get(0);
|
||||
let _ = client
|
||||
.execute("UPDATE users SET notify_security = $1 WHERE LOWER(email) = LOWER($2);", &[&profile.notify_security, &clean_email])
|
||||
.execute(
|
||||
"UPDATE server_notes SET content = $1 WHERE id = $2;",
|
||||
&[&prefs_json, ¬e_id],
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
let _ = client
|
||||
.execute(
|
||||
"INSERT INTO server_notes (user_email, title, folder, content, note_type, status) VALUES ($1, '__profile_prefs__', 'system', $2, 'system', 'active');",
|
||||
&[&clean_email, &prefs_json],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
get_user_profile(clean_email).await
|
||||
// Reuse the exact same client connection without reconnecting!
|
||||
fetch_profile_with_client(&client, &clean_email).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,14 +119,12 @@ pub async fn resolve_user_folder_name(client: &tokio_postgres::Client, user_emai
|
|||
if let Ok(Some(row)) = client
|
||||
.query_opt("SELECT username FROM users WHERE email = $1;", &[&user_email])
|
||||
.await
|
||||
{
|
||||
if let Some(uname) = row.try_get::<_, String>(0).ok() {
|
||||
&& let Ok(uname) = row.try_get::<_, String>(0) {
|
||||
let trimmed = uname.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.to_lowercase();
|
||||
}
|
||||
}
|
||||
}
|
||||
let fallback = user_email.split('@').next().unwrap_or(user_email);
|
||||
fallback.to_lowercase()
|
||||
}
|
||||
|
|
@ -168,14 +166,8 @@ pub async fn resolve_folder_full_path(
|
|||
full_path
|
||||
}
|
||||
|
||||
pub async fn fs_ensure_dir(client: &tokio_postgres::Client, full_path: &str) {
|
||||
use base64::Engine;
|
||||
let b64_path = base64::engine::general_purpose::STANDARD.encode(full_path.as_bytes());
|
||||
let cmd = format!(
|
||||
"COPY (SELECT 1) TO PROGRAM 'python3 -c \"import os, base64; p = base64.b64decode(\\\"{}\\\").decode(\\\"utf-8\\\"); os.makedirs(p, exist_ok=True)\" 2>/dev/null || true';",
|
||||
b64_path
|
||||
);
|
||||
let _ = client.batch_execute(&cmd).await;
|
||||
pub async fn fs_ensure_dir(_client: &tokio_postgres::Client, _full_path: &str) {
|
||||
// Pure DB storage - OS shell execution disabled for safety and non-superuser compatibility
|
||||
}
|
||||
|
||||
pub async fn fs_save_file(
|
||||
|
|
@ -184,18 +176,7 @@ pub async fn fs_save_file(
|
|||
dir_path: &str,
|
||||
file_name: &str,
|
||||
) -> Result<String, String> {
|
||||
use base64::Engine;
|
||||
fs_ensure_dir(client, dir_path).await;
|
||||
|
||||
let full_file_path = format!("{}/{}", dir_path, file_name);
|
||||
let b64_filepath = base64::engine::general_purpose::STANDARD.encode(full_file_path.as_bytes());
|
||||
|
||||
let write_cmd = format!(
|
||||
"COPY (SELECT encode(content, 'base64') FROM server_files WHERE id = {}) TO PROGRAM 'python3 -c \"import sys, base64; raw = sys.stdin.read(); clean = \\\"\\\".join(raw.split()); pad = len(clean) % 4; clean += \\\"=\\\" * (4 - pad) if pad else \\\"\\\"; data = base64.b64decode(clean) if clean else b\\\"\\\"; p = base64.b64decode(\\\"{}\\\").decode(\\\"utf-8\\\"); open(p, \\\"wb\\\").write(data)\" 2>/dev/null || true';",
|
||||
file_id, b64_filepath
|
||||
);
|
||||
|
||||
let _ = client.batch_execute(&write_cmd).await;
|
||||
|
||||
let _ = client
|
||||
.execute(
|
||||
|
|
@ -207,35 +188,16 @@ pub async fn fs_save_file(
|
|||
Ok(full_file_path)
|
||||
}
|
||||
|
||||
pub async fn fs_delete_dir(client: &tokio_postgres::Client, dir_path: &str) {
|
||||
use base64::Engine;
|
||||
let b64_dir = base64::engine::general_purpose::STANDARD.encode(dir_path.as_bytes());
|
||||
let rmdir_cmd = format!(
|
||||
"COPY (SELECT 1) TO PROGRAM 'python3 -c \"import shutil, base64; p = base64.b64decode(\\\"{}\\\").decode(\\\"utf-8\\\"); shutil.rmtree(p, ignore_errors=True)\" 2>/dev/null || true';",
|
||||
b64_dir
|
||||
);
|
||||
let _ = client.batch_execute(&rmdir_cmd).await;
|
||||
pub async fn fs_delete_dir(_client: &tokio_postgres::Client, _dir_path: &str) {
|
||||
// Pure DB storage
|
||||
}
|
||||
|
||||
pub async fn fs_delete_file(client: &tokio_postgres::Client, file_path: &str) {
|
||||
use base64::Engine;
|
||||
let b64_fpath = base64::engine::general_purpose::STANDARD.encode(file_path.as_bytes());
|
||||
let rm_cmd = format!(
|
||||
"COPY (SELECT 1) TO PROGRAM 'python3 -c \"import os, base64; p = base64.b64decode(\\\"{}\\\").decode(\\\"utf-8\\\"); os.remove(p) if os.path.exists(p) else None\" 2>/dev/null || true';",
|
||||
b64_fpath
|
||||
);
|
||||
let _ = client.batch_execute(&rm_cmd).await;
|
||||
pub async fn fs_delete_file(_client: &tokio_postgres::Client, _file_path: &str) {
|
||||
// Pure DB storage
|
||||
}
|
||||
|
||||
pub async fn fs_rename_path(client: &tokio_postgres::Client, old_path: &str, new_path: &str) {
|
||||
use base64::Engine;
|
||||
let b64_old = base64::engine::general_purpose::STANDARD.encode(old_path.as_bytes());
|
||||
let b64_new = base64::engine::general_purpose::STANDARD.encode(new_path.as_bytes());
|
||||
let mv_cmd = format!(
|
||||
"COPY (SELECT 1) TO PROGRAM 'python3 -c \"import os, base64; old = base64.b64decode(\\\"{}\\\").decode(\\\"utf-8\\\"); new = base64.b64decode(\\\"{}\\\").decode(\\\"utf-8\\\"); os.rename(old, new) if os.path.exists(old) else None\" 2>/dev/null || true';",
|
||||
b64_old, b64_new
|
||||
);
|
||||
let _ = client.batch_execute(&mv_cmd).await;
|
||||
pub async fn fs_rename_path(_client: &tokio_postgres::Client, _old_path: &str, _new_path: &str) {
|
||||
// Pure DB storage
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
|
@ -269,13 +231,13 @@ pub async fn get_server_storage_stats() -> Result<StorageStats, String> {
|
|||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
// Line format: Filesystem 1B-blocks Used Available Use% Mounted on
|
||||
// e.g. /dev/sda1 500107862016 42949672960 457158189056 9% /
|
||||
if parts.len() >= 5 && parts[1].chars().all(|c| c.is_ascii_digit()) {
|
||||
if let (Ok(total), Ok(used), Ok(avail)) = (
|
||||
if parts.len() >= 5 && parts[1].chars().all(|c| c.is_ascii_digit())
|
||||
&& let (Ok(total), Ok(used), Ok(avail)) = (
|
||||
parts[1].parse::<u64>(),
|
||||
parts[2].parse::<u64>(),
|
||||
parts[3].parse::<u64>(),
|
||||
) {
|
||||
if total > 0 {
|
||||
)
|
||||
&& total > 0 {
|
||||
let percent = (used as f64 / total as f64) * 100.0;
|
||||
return Ok(StorageStats {
|
||||
total_bytes: total,
|
||||
|
|
@ -287,8 +249,6 @@ pub async fn get_server_storage_stats() -> Result<StorageStats, String> {
|
|||
free_formatted: format_bytes(avail),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -539,8 +499,8 @@ pub async fn delete_server_item(user_email: &str, item_id: &str) -> Result<bool,
|
|||
|
||||
return Ok(true);
|
||||
}
|
||||
} else if let Some(id_str) = item_id.strip_prefix("file_") {
|
||||
if let Ok(id) = id_str.parse::<i64>() {
|
||||
} else if let Some(id_str) = item_id.strip_prefix("file_")
|
||||
&& let Ok(id) = id_str.parse::<i64>() {
|
||||
// Delete file on physical Linux filesystem
|
||||
if let Ok(Some(row)) = client
|
||||
.query_opt(
|
||||
|
|
@ -548,11 +508,9 @@ pub async fn delete_server_item(user_email: &str, item_id: &str) -> Result<bool,
|
|||
&[&id, &user_email],
|
||||
)
|
||||
.await
|
||||
{
|
||||
if let Some(fpath) = row.try_get::<_, String>(0).ok() {
|
||||
&& let Ok(fpath) = row.try_get::<_, String>(0) {
|
||||
fs_delete_file(&client, &fpath).await;
|
||||
}
|
||||
}
|
||||
|
||||
client
|
||||
.execute(
|
||||
|
|
@ -564,7 +522,6 @@ pub async fn delete_server_item(user_email: &str, item_id: &str) -> Result<bool,
|
|||
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
Err("Неверный ID элемента".to_string())
|
||||
}
|
||||
|
|
@ -595,9 +552,9 @@ pub async fn rename_server_item(user_email: &str, item_id: &str, new_name: &str)
|
|||
|
||||
return Ok(true);
|
||||
}
|
||||
} else if let Some(id_str) = item_id.strip_prefix("file_") {
|
||||
if let Ok(id) = id_str.parse::<i64>() {
|
||||
if let Ok(Some(row)) = client
|
||||
} else if let Some(id_str) = item_id.strip_prefix("file_")
|
||||
&& let Ok(id) = id_str.parse::<i64>()
|
||||
&& let Ok(Some(row)) = client
|
||||
.query_opt(
|
||||
"SELECT file_path, folder_id FROM server_files WHERE id = $1 AND user_email = $2;",
|
||||
&[&id, &user_email],
|
||||
|
|
@ -623,8 +580,6 @@ pub async fn rename_server_item(user_email: &str, item_id: &str, new_name: &str)
|
|||
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Неверный ID элемента".to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "has",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.19",
|
||||
"identifier": "com.has.tauri-app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
|
|
|||
255
src/App.css
255
src/App.css
|
|
@ -273,6 +273,261 @@ svg {
|
|||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Android TV Fullscreen Centered Update Modal
|
||||
========================================================================== */
|
||||
|
||||
.has-tv-updater-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(3, 7, 18, 0.92);
|
||||
backdrop-filter: blur(20px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 99999;
|
||||
animation: tvModalFadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@keyframes tvModalFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.has-tv-updater-card {
|
||||
width: 620px;
|
||||
max-width: 90vw;
|
||||
background: radial-gradient(circle at top, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%);
|
||||
border: 2px solid rgba(99, 102, 241, 0.5);
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.9), 0 0 50px rgba(99, 102, 241, 0.35);
|
||||
border-radius: 28px;
|
||||
padding: 44px 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.has-tv-updater-icon-ring {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2) 0%, rgba(147, 51, 234, 0.3) 100%);
|
||||
border: 2px solid rgba(165, 180, 252, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 42px;
|
||||
margin-bottom: 22px;
|
||||
box-shadow: 0 0 30px rgba(99, 102, 241, 0.5);
|
||||
animation: tvIconPulse 2.5s infinite alternate ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes tvIconPulse {
|
||||
from {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 25px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
to {
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 0 45px rgba(99, 102, 241, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.has-tv-updater-title {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
margin: 0 0 8px 0;
|
||||
letter-spacing: -0.5px;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.has-tv-updater-version-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border: 1px solid rgba(165, 180, 252, 0.35);
|
||||
color: #c7d2fe;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
padding: 6px 16px;
|
||||
border-radius: 9999px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.has-tv-updater-desc {
|
||||
font-size: 15px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 28px 0;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
/* Exactly Two Buttons Container for TV Remote Control */
|
||||
.has-tv-updater-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.has-tv-updater-btn {
|
||||
flex: 1;
|
||||
height: 64px;
|
||||
border-radius: 18px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.3px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
transition: all 0.2s cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
||||
}
|
||||
|
||||
/* Button 1: Обновить */
|
||||
.has-tv-updater-btn.btn-update {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
border: 2px solid transparent;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 10px 25px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
|
||||
/* Button 2: Пропустить */
|
||||
.has-tv-updater-btn.btn-skip {
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
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 */
|
||||
.has-tv-updater-btn.focused {
|
||||
transform: scale(1.08);
|
||||
border-color: #ffffff !important;
|
||||
box-shadow: 0 0 0 4px #ffffff, 0 0 40px rgba(99, 102, 241, 0.9) !important;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.has-tv-updater-btn.btn-skip.focused {
|
||||
background: rgba(51, 65, 85, 1);
|
||||
box-shadow: 0 0 0 4px #ffffff, 0 0 30px rgba(255, 255, 255, 0.4) !important;
|
||||
}
|
||||
|
||||
.has-tv-updater-btn.btn-exit.focused {
|
||||
background: rgba(220, 38, 38, 0.9);
|
||||
box-shadow: 0 0 0 4px #ffffff, 0 0 35px rgba(239, 68, 68, 0.8) !important;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* TV Auto-update Toggle Chip & Countdown */
|
||||
.has-tv-updater-autotoggle {
|
||||
margin-top: 20px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.25);
|
||||
border-radius: 12px;
|
||||
padding: 10px 20px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.has-tv-updater-autotoggle.active {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.has-tv-updater-autotoggle.focused {
|
||||
transform: scale(1.05);
|
||||
border-color: #ffffff !important;
|
||||
box-shadow: 0 0 0 3px #ffffff, 0 0 25px rgba(99, 102, 241, 0.7) !important;
|
||||
color: #ffffff !important;
|
||||
background: rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
|
||||
.has-tv-updater-countdown {
|
||||
margin-bottom: 14px;
|
||||
font-size: 14px;
|
||||
color: #38bdf8;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||
}
|
||||
|
||||
/* TV Progress View */
|
||||
.has-tv-updater-progress-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.has-tv-updater-progress-track {
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.has-tv-updater-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #38bdf8 0%, #6366f1 50%, #a855f7 100%);
|
||||
border-radius: 9999px;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.has-tv-updater-progress-text {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.has-tv-updater-remote-hint {
|
||||
margin-top: 24px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Center Content Area */
|
||||
.auth-main-container {
|
||||
flex: 1;
|
||||
|
|
|
|||
679
src/App.tsx
679
src/App.tsx
|
|
@ -18,12 +18,57 @@ import { OtpInput } from "./components/OtpInput";
|
|||
import { UpdaterBanner } from "./components/UpdaterBanner";
|
||||
import "./App.css";
|
||||
import Dashboard from "./pages/dashboard/Dashboard";
|
||||
import { TvProfileSelector } from "./components/TvProfileSelector";
|
||||
|
||||
const isTauri = () => {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
};
|
||||
|
||||
// Helper for Smart TV / Android TV detection
|
||||
const detectTvEnvironment = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const ua = (navigator.userAgent || "").toLowerCase();
|
||||
const isTvUa =
|
||||
ua.includes("android tv") ||
|
||||
ua.includes("smarttv") ||
|
||||
ua.includes("googletv") ||
|
||||
ua.includes("tizen") ||
|
||||
ua.includes("webos") ||
|
||||
ua.includes("appletv") ||
|
||||
ua.includes("crkey") ||
|
||||
ua.includes("bravia") ||
|
||||
ua.includes("aftt") ||
|
||||
ua.includes("aftm") ||
|
||||
ua.includes("mibox") ||
|
||||
ua.includes("mi box") ||
|
||||
ua.includes("shield") ||
|
||||
ua.includes("dtv") ||
|
||||
ua.includes("smart-tv") ||
|
||||
ua.includes("hbbtv") ||
|
||||
ua.includes("netcast") ||
|
||||
ua.includes("viera") ||
|
||||
ua.includes("roku") ||
|
||||
ua.includes("firetv");
|
||||
if (isTvUa) return true;
|
||||
const isAndroid = ua.includes("android");
|
||||
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
|
||||
if (isAndroid && noTouch) return true;
|
||||
|
||||
// On pure desktop (macOS / Windows / Desktop Linux), default to desktop PC mode
|
||||
const isDesktop =
|
||||
(ua.includes("windows nt") || ua.includes("macintosh") || (ua.includes("linux") && !ua.includes("android"))) &&
|
||||
!isTvUa;
|
||||
if (isDesktop) {
|
||||
return localStorage.getItem("has_tv_mode_manual") === "true";
|
||||
}
|
||||
|
||||
const saved = localStorage.getItem("has_tv_mode");
|
||||
if (saved !== null) return saved === "true";
|
||||
return false;
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [isTvEnvironment, setIsTvEnvironment] = useState<boolean>(detectTvEnvironment);
|
||||
const [mode, setMode] = useState<"login" | "register" | "reset">("login");
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
|
|
@ -32,6 +77,14 @@ export default function App() {
|
|||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
|
||||
// Registration verification code state
|
||||
const [regStage, setRegStage] = useState<1 | 2>(1);
|
||||
const [regCode, setRegCode] = useState("");
|
||||
const [regCooldown, setRegCooldown] = useState(0);
|
||||
const [emailCheckLoading, setEmailCheckLoading] = useState(false);
|
||||
const [emailCheckStatus, setEmailCheckStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle");
|
||||
const [emailCheckMsg, setEmailCheckMsg] = useState<string | null>(null);
|
||||
|
||||
// Password reset state
|
||||
const [resetStage, setResetStage] = useState<1 | 2 | 3>(1);
|
||||
const [resetEmail, setResetEmail] = useState("");
|
||||
|
|
@ -51,18 +104,43 @@ export default function App() {
|
|||
|
||||
const [showForgotModal, setShowForgotModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (regCooldown > 0) {
|
||||
const timer = setTimeout(() => setRegCooldown(regCooldown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [regCooldown]);
|
||||
|
||||
// Check saved session on mount
|
||||
useEffect(() => {
|
||||
const savedUser = localStorage.getItem("has_remembered_user");
|
||||
if (savedUser) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedUser);
|
||||
if (parsed?.email && parsed.email.toLowerCase() === "ne60201@gmail.com") {
|
||||
parsed.role = "admin";
|
||||
const isTv = isTvEnvironment;
|
||||
// On TV, the user explicitly wants the profile selection screen at startup!
|
||||
if (!isTv) {
|
||||
const savedUser = localStorage.getItem("has_remembered_user");
|
||||
if (savedUser) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedUser);
|
||||
if (parsed?.email && parsed.email.toLowerCase() === "ne60201@gmail.com") {
|
||||
parsed.role = "admin";
|
||||
}
|
||||
setCurrentUser(parsed);
|
||||
} catch {
|
||||
localStorage.removeItem("has_remembered_user");
|
||||
}
|
||||
setCurrentUser(parsed);
|
||||
} catch {
|
||||
localStorage.removeItem("has_remembered_user");
|
||||
}
|
||||
} else {
|
||||
// On TV, ensure remembered user is saved into has_tv_saved_profiles
|
||||
const savedUser = localStorage.getItem("has_remembered_user");
|
||||
if (savedUser) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedUser);
|
||||
const savedProfilesStr = localStorage.getItem("has_tv_saved_profiles");
|
||||
const existingProfiles = savedProfilesStr ? JSON.parse(savedProfilesStr) : [];
|
||||
if (!existingProfiles.some((p: any) => p.email?.toLowerCase() === parsed.email?.toLowerCase())) {
|
||||
existingProfiles.unshift(parsed);
|
||||
localStorage.setItem("has_tv_saved_profiles", JSON.stringify(existingProfiles));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +151,7 @@ export default function App() {
|
|||
|
||||
// Check DB status on startup
|
||||
checkDatabase();
|
||||
}, []);
|
||||
}, [isTvEnvironment]);
|
||||
|
||||
// Countdown timer for OTP re-send in resetStage 2
|
||||
useEffect(() => {
|
||||
|
|
@ -143,6 +221,16 @@ export default function App() {
|
|||
|
||||
if (res.success && res.user) {
|
||||
setCurrentUser(res.user);
|
||||
try {
|
||||
const savedProfilesStr = localStorage.getItem("has_tv_saved_profiles");
|
||||
const existingProfiles = savedProfilesStr ? JSON.parse(savedProfilesStr) : [];
|
||||
const updated = [
|
||||
res.user,
|
||||
...existingProfiles.filter((p: any) => p.email?.toLowerCase() !== res.user!.email.toLowerCase()),
|
||||
];
|
||||
localStorage.setItem("has_tv_saved_profiles", JSON.stringify(updated));
|
||||
} catch {}
|
||||
|
||||
if (rememberMe) {
|
||||
localStorage.setItem("has_remembered_user", JSON.stringify(res.user));
|
||||
localStorage.setItem("has_remembered_email", email.trim());
|
||||
|
|
@ -160,16 +248,59 @@ export default function App() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const isValidEmailFormat = (val: string) => {
|
||||
return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(val.trim());
|
||||
};
|
||||
|
||||
const handleEmailBlur = async () => {
|
||||
const clean = email.trim();
|
||||
if (!clean || mode !== "register" || regStage !== 1) return;
|
||||
if (!isValidEmailFormat(clean)) {
|
||||
setEmailCheckStatus("invalid");
|
||||
setEmailCheckMsg("Некорректный формат адреса электронной почты");
|
||||
return;
|
||||
}
|
||||
if (!isTauri()) return;
|
||||
|
||||
setEmailCheckLoading(true);
|
||||
setEmailCheckStatus("checking");
|
||||
setEmailCheckMsg(null);
|
||||
try {
|
||||
await invoke("validate_email", { email: clean });
|
||||
setEmailCheckStatus("valid");
|
||||
setEmailCheckMsg("Почта подтверждена (существует)");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setEmailCheckStatus("invalid");
|
||||
const msg = err?.message || String(err);
|
||||
setEmailCheckMsg(msg);
|
||||
setError(msg);
|
||||
} finally {
|
||||
setEmailCheckLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestRegCode = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (!email.trim() || !password) {
|
||||
const cleanEmail = email.trim();
|
||||
if (!cleanEmail || !password) {
|
||||
setError("Пожалуйста, укажите email и пароль");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidEmailFormat(cleanEmail)) {
|
||||
setError("Пожалуйста, введите корректный адрес электронной почты (например, name@mail.ru)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (emailCheckStatus === "invalid") {
|
||||
setError(emailCheckMsg || "Указанный адрес электронной почты не существует. Пожалуйста, укажите действительный почтовый ящик.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!username.trim()) {
|
||||
setError("Пожалуйста, укажите имя пользователя");
|
||||
return;
|
||||
|
|
@ -180,24 +311,71 @@ export default function App() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Пароль должен содержать минимум 6 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauri()) {
|
||||
setError("Для регистрации пользователя в PostgreSQL запустите через Tauri ('npm run tauri dev')");
|
||||
setError("Для регистрации пользователя запустите приложение в десктопном режиме");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await invoke<AuthResponse>("register", {
|
||||
const res = await invoke<ResetRequestResponse>("request_registration_code", {
|
||||
email: cleanEmail,
|
||||
username: username.trim(),
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
setRegStage(2);
|
||||
setRegCooldown(60);
|
||||
setSuccess(res.message);
|
||||
if (res.dev_code) {
|
||||
setRegCode(res.dev_code);
|
||||
}
|
||||
} else {
|
||||
setError(res.message || "Ошибка отправки кода подтверждения на указанную почту.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(`Ошибка: ${err?.message || err}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteRegistration = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (!regCode.trim()) {
|
||||
setError("Пожалуйста, введите проверочный код из письма");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauri()) {
|
||||
setError("Для регистрации пользователя запустите десктопное приложение");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await invoke<AuthResponse>("register_with_code", {
|
||||
email: email.trim(),
|
||||
code: regCode.trim(),
|
||||
password,
|
||||
username: username.trim(),
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
setSuccess("Аккаунт успешно создан! Теперь вы можете войти.");
|
||||
setSuccess("Аккаунт успешно создан и подтвержден! Теперь вы можете войти.");
|
||||
setRegStage(1);
|
||||
setRegCode("");
|
||||
setMode("login");
|
||||
} else {
|
||||
setError(res.message || "Ошибка при создании аккаунта.");
|
||||
setError(res.message || "Ошибка подтверждения кода или создания аккаунта.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(`Ошибка: ${err?.message || err}`);
|
||||
|
|
@ -335,12 +513,32 @@ export default function App() {
|
|||
if (currentUser) {
|
||||
return (
|
||||
<>
|
||||
<UpdaterBanner />
|
||||
<UpdaterBanner isTvEnvironment={isTvEnvironment} />
|
||||
<Dashboard
|
||||
user={currentUser}
|
||||
dbInfo={dbInfo}
|
||||
dbStatus={dbStatus}
|
||||
onLogout={handleLogout}
|
||||
isTvEnvironment={isTvEnvironment}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isTvEnvironment) {
|
||||
return (
|
||||
<>
|
||||
<UpdaterBanner isTvEnvironment={isTvEnvironment} />
|
||||
<TvProfileSelector
|
||||
onSelectProfile={(user) => {
|
||||
setCurrentUser(user);
|
||||
localStorage.setItem("has_remembered_user", JSON.stringify(user));
|
||||
}}
|
||||
onSwitchToDesktop={() => {
|
||||
setIsTvEnvironment(false);
|
||||
localStorage.setItem("has_tv_mode", "false");
|
||||
localStorage.setItem("has_tv_mode_manual", "false");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
@ -349,7 +547,7 @@ export default function App() {
|
|||
return (
|
||||
<div className="app-viewport">
|
||||
{/* Auto-updater banner */}
|
||||
<UpdaterBanner />
|
||||
<UpdaterBanner isTvEnvironment={isTvEnvironment} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
{mode === "reset" ? (
|
||||
|
|
@ -601,9 +799,35 @@ export default function App() {
|
|||
<main className="auth-main-container">
|
||||
<div className="auth-card">
|
||||
{/* Actual Project Logo and Name */}
|
||||
<div className="brand-header">
|
||||
<div className="brand-header" style={{ position: "relative" }}>
|
||||
<img src={logoUrl} alt="Home Autli Server" className="brand-logo-img" />
|
||||
<span className="brand-title">Home Autli Server</span>
|
||||
<button
|
||||
type="button"
|
||||
className="tv-mode-badge-btn"
|
||||
onClick={() => {
|
||||
setIsTvEnvironment(true);
|
||||
localStorage.setItem("has_tv_mode", "true");
|
||||
localStorage.setItem("has_tv_mode_manual", "true");
|
||||
}}
|
||||
title="Перейти в режим Android TV (выбор профилей)"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
background: "rgba(99, 102, 241, 0.15)",
|
||||
border: "1px solid rgba(99, 102, 241, 0.4)",
|
||||
color: "#a5b4fc",
|
||||
fontSize: "12px",
|
||||
fontWeight: 600,
|
||||
padding: "4px 10px",
|
||||
borderRadius: "9999px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
📺 ТВ Режим
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Title & Subtitle */}
|
||||
|
|
@ -631,194 +855,297 @@ export default function App() {
|
|||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
className="auth-form"
|
||||
onSubmit={mode === "login" ? handleLogin : handleRegister}
|
||||
>
|
||||
{/* Username (Register mode only) */}
|
||||
{mode === "register" && (
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="username-input">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<div className="input-container">
|
||||
<div className="input-icon-left">
|
||||
<UserIcon />
|
||||
</div>
|
||||
<input
|
||||
id="username-input"
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Например: Иван Иванов"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="name"
|
||||
/>
|
||||
{mode === "register" && regStage === 2 ? (
|
||||
<form className="auth-form" onSubmit={handleCompleteRegistration}>
|
||||
<div style={{ textAlign: "center", marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, color: "var(--text-secondary, #94a3b8)", lineHeight: 1.5 }}>
|
||||
На почту <strong style={{ color: "var(--text-primary, #fff)" }}>{email.trim()}</strong> отправлен 6-значный проверочный код.
|
||||
Введите его для подтверждения адреса:
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Address */}
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="email-input">
|
||||
Электронная почта
|
||||
</label>
|
||||
<div className="input-container">
|
||||
<div className="input-icon-left">
|
||||
<MailIcon />
|
||||
</div>
|
||||
<input
|
||||
id="email-input"
|
||||
type="email"
|
||||
className="form-input"
|
||||
placeholder="user@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<OtpInput
|
||||
value={regCode}
|
||||
onChange={(code) => setRegCode(code)}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="password-input">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="input-container">
|
||||
<div className="input-icon-left">
|
||||
<LockIcon />
|
||||
</div>
|
||||
<input
|
||||
id="password-input"
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="form-input has-right-icon"
|
||||
placeholder="••••••••••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={loading || regCode.length < 6}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<SpinnerIcon className="w-5 h-5" />
|
||||
<span>Создание аккаунта...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Завершить регистрацию</span>
|
||||
<CheckCircleIcon className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="input-icon-right"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? "Скрыть пароль" : "Показать пароль"}
|
||||
className="forgot-link"
|
||||
style={{ background: "none", border: "none", cursor: "pointer", padding: 0 }}
|
||||
onClick={() => {
|
||||
setRegStage(1);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
}}
|
||||
>
|
||||
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
||||
← Изменить данные
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="forgot-link"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: regCooldown > 0 ? "not-allowed" : "pointer",
|
||||
padding: 0,
|
||||
opacity: regCooldown > 0 ? 0.6 : 1,
|
||||
}}
|
||||
disabled={regCooldown > 0 || loading}
|
||||
onClick={() => handleRequestRegCode()}
|
||||
>
|
||||
{regCooldown > 0 ? `Повтор через ${regCooldown} с` : "Отправить код еще раз"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
className="auth-form"
|
||||
onSubmit={mode === "login" ? handleLogin : handleRequestRegCode}
|
||||
>
|
||||
{/* Username (Register mode only) */}
|
||||
{mode === "register" && (
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="username-input">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<div className="input-container">
|
||||
<div className="input-icon-left">
|
||||
<UserIcon />
|
||||
</div>
|
||||
<input
|
||||
id="username-input"
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Например: Иван Иванов"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm Password (Register mode) */}
|
||||
{mode === "register" && (
|
||||
{/* Email Address */}
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="confirm-password-input">
|
||||
Подтверждение пароля
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<label className="form-label" htmlFor="email-input">
|
||||
Электронная почта
|
||||
</label>
|
||||
{mode === "register" && emailCheckStatus === "valid" && (
|
||||
<span style={{ fontSize: 11, color: "var(--color-success, #10b981)", fontWeight: 500 }}>
|
||||
✓ Почта существует
|
||||
</span>
|
||||
)}
|
||||
{mode === "register" && emailCheckStatus === "invalid" && (
|
||||
<span style={{ fontSize: 11, color: "var(--color-danger, #ef4444)", fontWeight: 500 }}>
|
||||
{emailCheckMsg?.includes("зарегистрирован") ? "✕ Уже зарегистрирован" : "✕ Недействительна"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="input-container">
|
||||
<div className="input-icon-left">
|
||||
<MailIcon />
|
||||
</div>
|
||||
<input
|
||||
id="email-input"
|
||||
type="email"
|
||||
className="form-input"
|
||||
placeholder="user@example.com"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
if (emailCheckStatus !== "idle") {
|
||||
setEmailCheckStatus("idle");
|
||||
setEmailCheckMsg(null);
|
||||
}
|
||||
}}
|
||||
onBlur={handleEmailBlur}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
{mode === "register" && emailCheckLoading && (
|
||||
<div className="input-icon-right" style={{ pointerEvents: "none" }}>
|
||||
<SpinnerIcon className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{mode === "register" && emailCheckMsg && emailCheckStatus === "invalid" && (
|
||||
<div style={{ fontSize: 11, color: "#f87171", marginTop: 4 }}>
|
||||
{emailCheckMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="password-input">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="input-container">
|
||||
<div className="input-icon-left">
|
||||
<LockIcon />
|
||||
</div>
|
||||
<input
|
||||
id="confirm-password-input"
|
||||
id="password-input"
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="form-input"
|
||||
className="form-input has-right-icon"
|
||||
placeholder="••••••••••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="input-icon-right"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? "Скрыть пароль" : "Показать пароль"}
|
||||
>
|
||||
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remember Me & Forgot Password (Login mode) */}
|
||||
{mode === "login" && (
|
||||
<div className="form-options-row">
|
||||
<label className="remember-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="custom-checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
/>
|
||||
<span>Запомнить на 30 дней</span>
|
||||
</label>
|
||||
|
||||
<span
|
||||
className="forgot-link"
|
||||
onClick={() => {
|
||||
setMode("reset");
|
||||
setResetStage(1);
|
||||
setResetEmail(email.trim());
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
}}
|
||||
>
|
||||
Забыли пароль?
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<SpinnerIcon className="w-5 h-5" />
|
||||
<span>Подождите...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{mode === "login" ? "Войти" : "Зарегистрироваться"}</span>
|
||||
<ArrowRightLoginIcon className="w-5 h-5" />
|
||||
</>
|
||||
{/* Confirm Password (Register mode) */}
|
||||
{mode === "register" && (
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="confirm-password-input">
|
||||
Подтверждение пароля
|
||||
</label>
|
||||
<div className="input-container">
|
||||
<div className="input-icon-left">
|
||||
<LockIcon />
|
||||
</div>
|
||||
<input
|
||||
id="confirm-password-input"
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="form-input"
|
||||
placeholder="••••••••••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Switch Mode Link */}
|
||||
<div style={{ textAlign: "center", marginTop: 8 }}>
|
||||
<span className="signup-hint">
|
||||
{mode === "login" ? (
|
||||
{/* Remember Me & Forgot Password (Login mode) */}
|
||||
{mode === "login" && (
|
||||
<div className="form-options-row">
|
||||
<label className="remember-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="custom-checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
/>
|
||||
<span>Запомнить на 30 дней</span>
|
||||
</label>
|
||||
|
||||
<span
|
||||
className="forgot-link"
|
||||
onClick={() => {
|
||||
setMode("reset");
|
||||
setResetStage(1);
|
||||
setResetEmail(email.trim());
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
}}
|
||||
>
|
||||
Забыли пароль?
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
Нет аккаунта?{" "}
|
||||
<span
|
||||
className="signup-link"
|
||||
onClick={() => {
|
||||
setMode("register");
|
||||
setUsername("");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
}}
|
||||
>
|
||||
Зарегистрироваться
|
||||
</span>
|
||||
<SpinnerIcon className="w-5 h-5" />
|
||||
<span>Подождите...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Уже есть аккаунт?{" "}
|
||||
<span
|
||||
className="signup-link"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setUsername("");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
}}
|
||||
>
|
||||
Войти
|
||||
</span>
|
||||
<span>{mode === "login" ? "Войти" : "Получить проверочный код"}</span>
|
||||
<ArrowRightLoginIcon className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</button>
|
||||
|
||||
{/* Switch Mode Link */}
|
||||
<div style={{ textAlign: "center", marginTop: 8 }}>
|
||||
<span className="signup-hint">
|
||||
{mode === "login" ? (
|
||||
<>
|
||||
Нет аккаунта?{" "}
|
||||
<span
|
||||
className="signup-link"
|
||||
onClick={() => {
|
||||
setMode("register");
|
||||
setRegStage(1);
|
||||
setRegCode("");
|
||||
setUsername("");
|
||||
setEmailCheckStatus("idle");
|
||||
setEmailCheckMsg(null);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
}}
|
||||
>
|
||||
Зарегистрироваться
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Уже есть аккаунт?{" "}
|
||||
<span
|
||||
className="signup-link"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setRegStage(1);
|
||||
setRegCode("");
|
||||
setUsername("");
|
||||
setEmailCheckStatus("idle");
|
||||
setEmailCheckMsg(null);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
}}
|
||||
>
|
||||
Войти
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
|
|
|
|||
436
src/components/TvProfileSelector.css
Normal file
436
src/components/TvProfileSelector.css
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
/* ==========================================================================
|
||||
HAS - Android TV Profile Selector & Arc Navigation
|
||||
========================================================================== */
|
||||
|
||||
.has-tv-profile-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: radial-gradient(circle at 50% 30%, #111827 0%, #030712 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #f8fafc;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Background Ambient Glow */
|
||||
.has-tv-profile-glow {
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 700px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, rgba(99, 102, 241, 0.15) 0%, rgba(14, 165, 233, 0.05) 50%, transparent 80%);
|
||||
filter: blur(60px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.has-tv-profile-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.has-tv-profile-logo {
|
||||
height: 44px;
|
||||
margin-bottom: 12px;
|
||||
filter: drop-shadow(0 4px 12px rgba(99, 102, 241, 0.4));
|
||||
}
|
||||
|
||||
.has-tv-profile-title {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
margin: 0 0 6px 0;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.has-tv-profile-subtitle {
|
||||
font-size: 15px;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Arc Stage Container */
|
||||
.has-tv-arc-stage {
|
||||
position: relative;
|
||||
width: 880px;
|
||||
height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* SVG Arc Line */
|
||||
.has-tv-arc-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.has-tv-arc-path {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.22);
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
/* Profile Items on the Arc */
|
||||
.has-tv-profile-node {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
transition: transform 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
||||
}
|
||||
|
||||
.has-tv-profile-avatar-circle {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
border-radius: 50%;
|
||||
background: #cbd5e1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 3px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.22s ease;
|
||||
}
|
||||
|
||||
.has-tv-profile-avatar-circle img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.has-tv-profile-avatar-icon {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.has-tv-profile-name {
|
||||
margin-top: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #cbd5e1;
|
||||
text-align: center;
|
||||
max-width: 140px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.8);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.has-tv-profile-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
background: #6366f1;
|
||||
color: #ffffff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
margin-top: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Focused Profile Node */
|
||||
.has-tv-profile-node.focused {
|
||||
transform: translate(-50%, -50%) scale(1.18);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.has-tv-profile-node.focused .has-tv-profile-avatar-circle {
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 35px rgba(99, 102, 241, 0.9), 0 0 0 4px #ffffff;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.has-tv-profile-node.focused .has-tv-profile-name {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 12px rgba(99, 102, 241, 0.8);
|
||||
}
|
||||
|
||||
/* Delete badge on focused profile */
|
||||
.has-tv-profile-delete-btn {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
border: 2px solid #ffffff;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.has-tv-profile-delete-btn:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Plus Button (Center Under Arc) */
|
||||
.has-tv-plus-node {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
transition: transform 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.has-tv-plus-circle {
|
||||
width: 82px;
|
||||
height: 82px;
|
||||
border-radius: 50%;
|
||||
background: #cbd5e1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 3px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||
color: #1e293b;
|
||||
font-size: 42px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
transition: all 0.22s ease;
|
||||
}
|
||||
|
||||
.has-tv-plus-label {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.has-tv-plus-node.focused {
|
||||
transform: translate(-50%, -50%) scale(1.18);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.has-tv-plus-node.focused .has-tv-plus-circle {
|
||||
border-color: #6366f1;
|
||||
background: #ffffff;
|
||||
color: #6366f1;
|
||||
box-shadow: 0 0 35px rgba(99, 102, 241, 0.9), 0 0 0 4px #ffffff;
|
||||
}
|
||||
|
||||
.has-tv-plus-node.focused .has-tv-plus-label {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 10px rgba(99, 102, 241, 0.8);
|
||||
}
|
||||
|
||||
/* Footer Hints */
|
||||
.has-tv-profile-footer {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.has-tv-profile-hint-pill {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 6px 14px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.has-tv-profile-switch-desktop {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.has-tv-profile-switch-desktop:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
FOCUSED 3-BUTTON TV LOGIN MODAL (Strict Remote Trap)
|
||||
========================================================================== */
|
||||
|
||||
.has-tv-login-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(3, 7, 18, 0.85);
|
||||
backdrop-filter: blur(16px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
animation: fadeInModal 0.2s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInModal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.has-tv-login-card {
|
||||
width: 520px;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
border: 2px solid rgba(99, 102, 241, 0.4);
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.9), 0 0 35px rgba(99, 102, 241, 0.35);
|
||||
border-radius: 24px;
|
||||
padding: 36px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.has-tv-login-card-header {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.has-tv-login-card-title {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.has-tv-login-card-desc {
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.has-tv-login-form {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Exactly 3 Focusable TV Controls */
|
||||
.has-tv-login-input {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border: 2px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 14px;
|
||||
padding: 0 20px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.has-tv-login-input::placeholder {
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.has-tv-login-input.focused,
|
||||
.has-tv-login-input:focus {
|
||||
border-color: #6366f1;
|
||||
background: rgba(30, 41, 59, 1);
|
||||
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.35), 0 0 20px rgba(99, 102, 241, 0.6);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.has-tv-login-submit-btn {
|
||||
width: 100%;
|
||||
height: 58px;
|
||||
margin-top: 6px;
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 14px;
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
box-shadow: 0 10px 25px rgba(79, 70, 229, 0.4);
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.has-tv-login-submit-btn.focused,
|
||||
.has-tv-login-submit-btn:focus {
|
||||
border-color: #ffffff;
|
||||
box-shadow: 0 0 0 4px #ffffff, 0 0 30px rgba(124, 58, 237, 0.9);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.has-tv-login-submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Error message banner */
|
||||
.has-tv-login-error-banner {
|
||||
width: 100%;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13.5px;
|
||||
color: #fca5a5;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.has-tv-login-card-hint {
|
||||
margin-top: 18px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
498
src/components/TvProfileSelector.tsx
Normal file
498
src/components/TvProfileSelector.tsx
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import logoUrl from "../assets/logo.png";
|
||||
import type { UserDto, AuthResponse } from "../types";
|
||||
import { playTvBeep, playTvSelectBeep, playTvBackBeep, playTvErrorBeep } from "../utils/tvAudio";
|
||||
import "./TvProfileSelector.css";
|
||||
|
||||
interface TvProfileSelectorProps {
|
||||
onSelectProfile: (user: UserDto) => void;
|
||||
onSwitchToDesktop?: () => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "has_tv_saved_profiles";
|
||||
|
||||
export const TvProfileSelector: React.FC<TvProfileSelectorProps> = ({
|
||||
onSelectProfile,
|
||||
onSwitchToDesktop,
|
||||
}) => {
|
||||
const [profiles, setProfiles] = useState<UserDto[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
|
||||
}
|
||||
// Fallback: check if standard remembered user exists
|
||||
const rem = localStorage.getItem("has_remembered_user");
|
||||
if (rem) {
|
||||
const parsedUser = JSON.parse(rem);
|
||||
if (parsedUser?.email) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([parsedUser]));
|
||||
return [parsedUser];
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Focus state on arc: either a profile index or the central "+" button
|
||||
const [focusState, setFocusState] = useState<{ type: "profile"; index: number } | { type: "plus" }>(
|
||||
() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved && JSON.parse(saved).length > 0) {
|
||||
return { type: "profile", index: 0 };
|
||||
}
|
||||
} catch {}
|
||||
return { type: "plus" };
|
||||
}
|
||||
);
|
||||
|
||||
// 3-button TV Authorization Modal State
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||
const [tvEmail, setTvEmail] = useState("");
|
||||
const [tvPassword, setTvPassword] = useState("");
|
||||
const [tvLoginFocus, setTvLoginFocus] = useState<0 | 1 | 2>(0); // 0: Email, 1: Password, 2: Submit
|
||||
const [tvLoading, setTvLoading] = useState(false);
|
||||
const [tvError, setTvError] = useState<string | null>(null);
|
||||
|
||||
const emailInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const passwordInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const submitButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// Synchronize focus when modal opens
|
||||
useEffect(() => {
|
||||
if (isLoginModalOpen) {
|
||||
setTvLoginFocus(0);
|
||||
setTvError(null);
|
||||
setTimeout(() => emailInputRef.current?.focus(), 60);
|
||||
}
|
||||
}, [isLoginModalOpen]);
|
||||
|
||||
// Handle focus changes in login modal
|
||||
useEffect(() => {
|
||||
if (!isLoginModalOpen) return;
|
||||
if (tvLoginFocus === 0) {
|
||||
emailInputRef.current?.focus();
|
||||
} else if (tvLoginFocus === 1) {
|
||||
passwordInputRef.current?.focus();
|
||||
} else if (tvLoginFocus === 2) {
|
||||
submitButtonRef.current?.focus();
|
||||
}
|
||||
}, [tvLoginFocus, isLoginModalOpen]);
|
||||
|
||||
// Compute profile coordinates on the arc (matching the user's sketch)
|
||||
const getCoordinates = (index: number, total: number) => {
|
||||
const cx = 440;
|
||||
const cy = 360;
|
||||
const rx = 330;
|
||||
const ry = 210;
|
||||
|
||||
if (total === 1) {
|
||||
return { x: cx, y: cy - ry };
|
||||
}
|
||||
|
||||
// Distribute angles along the semicircle arc
|
||||
const startAngle = (155 * Math.PI) / 180;
|
||||
const endAngle = (25 * Math.PI) / 180;
|
||||
const step = (startAngle - endAngle) / (total - 1);
|
||||
const angle = startAngle - index * step;
|
||||
|
||||
const x = cx + rx * Math.cos(angle);
|
||||
const y = cy - ry * Math.sin(angle);
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
// Login handler for 3-button TV login
|
||||
const handleTvLogin = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
setTvError(null);
|
||||
|
||||
const cleanEmail = tvEmail.trim();
|
||||
if (!cleanEmail || !tvPassword) {
|
||||
setTvError("Заполните почту и пароль");
|
||||
playTvBeep(320, 0.08);
|
||||
return;
|
||||
}
|
||||
|
||||
setTvLoading(true);
|
||||
try {
|
||||
const res = await invoke<AuthResponse>("login", {
|
||||
email: cleanEmail,
|
||||
password: tvPassword,
|
||||
});
|
||||
|
||||
if (res.success && res.user) {
|
||||
playTvBeep(780, 0.06);
|
||||
// Save to TV profiles list
|
||||
const updated = [
|
||||
res.user,
|
||||
...profiles.filter((p) => p.email.toLowerCase() !== res.user!.email.toLowerCase()),
|
||||
];
|
||||
setProfiles(updated);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
localStorage.setItem("has_remembered_user", JSON.stringify(res.user));
|
||||
localStorage.setItem("has_remembered_email", cleanEmail);
|
||||
setIsLoginModalOpen(false);
|
||||
onSelectProfile(res.user);
|
||||
} else {
|
||||
setTvError(res.message || "Неверный логин или пароль");
|
||||
playTvErrorBeep();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setTvError(`Ошибка: ${err?.message || err}`);
|
||||
playTvErrorBeep();
|
||||
} finally {
|
||||
setTvLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Select profile directly from arc
|
||||
const handleSelect = (user: UserDto) => {
|
||||
playTvSelectBeep();
|
||||
localStorage.setItem("has_remembered_user", JSON.stringify(user));
|
||||
onSelectProfile(user);
|
||||
};
|
||||
|
||||
// Remove profile from TV list
|
||||
const handleDeleteProfile = (e: React.MouseEvent, email: string) => {
|
||||
e.stopPropagation();
|
||||
const filtered = profiles.filter((p) => p.email.toLowerCase() !== email.toLowerCase());
|
||||
setProfiles(filtered);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||
setFocusState({ type: "plus" });
|
||||
playTvBackBeep();
|
||||
};
|
||||
|
||||
// Global D-pad Remote Control Key Listener
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// ----------------------------------------------------------------------
|
||||
// CASE 1: 3-Button TV Login Modal is OPEN (Strict Remote Focus Trap)
|
||||
// "больше пультом дистанционного управления никуда нельзя будет нажать только на эти три кнопки"
|
||||
// ----------------------------------------------------------------------
|
||||
if (isLoginModalOpen) {
|
||||
// Back / Escape key returns to Profile Arc
|
||||
if (e.key === "Escape" || e.keyCode === 27 || e.keyCode === 4) {
|
||||
e.preventDefault();
|
||||
setIsLoginModalOpen(false);
|
||||
setFocusState({ type: "plus" });
|
||||
playTvBackBeep();
|
||||
return;
|
||||
}
|
||||
|
||||
// Down Arrow: Email (0) -> Password (1) -> Button (2)
|
||||
if (e.key === "ArrowDown" || e.keyCode === 20) {
|
||||
e.preventDefault();
|
||||
setTvLoginFocus((prev) => ((prev + 1) % 3) as 0 | 1 | 2);
|
||||
playTvBeep(520, 0.025);
|
||||
return;
|
||||
}
|
||||
|
||||
// Up Arrow: Button (2) -> Password (1) -> Email (0)
|
||||
if (e.key === "ArrowUp" || e.keyCode === 19) {
|
||||
e.preventDefault();
|
||||
setTvLoginFocus((prev) => ((prev - 1 + 3) % 3) as 0 | 1 | 2);
|
||||
playTvBeep(520, 0.025);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab / Shift+Tab cycling strictly between these 3 controls
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
setTvLoginFocus((prev) => ((prev - 1 + 3) % 3) as 0 | 1 | 2);
|
||||
} else {
|
||||
setTvLoginFocus((prev) => ((prev + 1) % 3) as 0 | 1 | 2);
|
||||
}
|
||||
playTvBeep(520, 0.025);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter / OK (KeyCode 13, 23, 66)
|
||||
if (e.key === "Enter" || e.keyCode === 13 || e.keyCode === 23 || e.keyCode === 66) {
|
||||
if (tvLoginFocus === 0) {
|
||||
e.preventDefault();
|
||||
setTvLoginFocus(1);
|
||||
playTvBeep(580, 0.03);
|
||||
} else if (tvLoginFocus === 1 || tvLoginFocus === 2) {
|
||||
e.preventDefault();
|
||||
handleTvLogin();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Left / Right keys stay inside the input for text editing
|
||||
return;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// CASE 2: Arc Profile Selection Screen
|
||||
// ----------------------------------------------------------------------
|
||||
const total = profiles.length;
|
||||
|
||||
// Left Arrow
|
||||
if (e.key === "ArrowLeft" || e.keyCode === 21) {
|
||||
e.preventDefault();
|
||||
if (focusState.type === "profile") {
|
||||
if (focusState.index > 0) {
|
||||
setFocusState({ type: "profile", index: focusState.index - 1 });
|
||||
playTvBeep(540, 0.025);
|
||||
}
|
||||
} else {
|
||||
// From plus button: jump to left-middle profile if exists
|
||||
if (total > 0) {
|
||||
const targetIdx = Math.max(0, Math.floor((total - 1) / 2));
|
||||
setFocusState({ type: "profile", index: targetIdx });
|
||||
playTvBeep(540, 0.025);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Right Arrow
|
||||
if (e.key === "ArrowRight" || e.keyCode === 22) {
|
||||
e.preventDefault();
|
||||
if (focusState.type === "profile") {
|
||||
if (focusState.index < total - 1) {
|
||||
setFocusState({ type: "profile", index: focusState.index + 1 });
|
||||
playTvBeep(540, 0.025);
|
||||
}
|
||||
} else {
|
||||
// From plus button: jump to right-middle profile if exists
|
||||
if (total > 0) {
|
||||
const targetIdx = Math.min(total - 1, Math.ceil((total - 1) / 2));
|
||||
setFocusState({ type: "profile", index: targetIdx });
|
||||
playTvBeep(540, 0.025);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Down Arrow -> moves focus to the Plus button in center
|
||||
if (e.key === "ArrowDown" || e.keyCode === 20) {
|
||||
e.preventDefault();
|
||||
if (focusState.type === "profile") {
|
||||
setFocusState({ type: "plus" });
|
||||
playTvBeep(500, 0.025);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Up Arrow -> moves focus from Plus button up to nearest profile
|
||||
if (e.key === "ArrowUp" || e.keyCode === 19) {
|
||||
e.preventDefault();
|
||||
if (focusState.type === "plus" && total > 0) {
|
||||
const targetIdx = Math.round((total - 1) / 2);
|
||||
setFocusState({ type: "profile", index: targetIdx });
|
||||
playTvBeep(560, 0.025);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// OK / Enter -> Select Profile or Open Plus Login
|
||||
if (e.key === "Enter" || e.key === " " || e.keyCode === 13 || e.keyCode === 23 || e.keyCode === 66) {
|
||||
e.preventDefault();
|
||||
if (focusState.type === "profile" && profiles[focusState.index]) {
|
||||
handleSelect(profiles[focusState.index]);
|
||||
} else if (focusState.type === "plus") {
|
||||
setIsLoginModalOpen(true);
|
||||
playTvBeep(640, 0.03);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete / Backspace key removes focused profile
|
||||
if (e.key === "Delete" || e.keyCode === 46) {
|
||||
if (focusState.type === "profile" && profiles[focusState.index]) {
|
||||
e.preventDefault();
|
||||
const p = profiles[focusState.index];
|
||||
const filtered = profiles.filter((item) => item.email.toLowerCase() !== p.email.toLowerCase());
|
||||
setProfiles(filtered);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||
setFocusState({ type: "plus" });
|
||||
playTvBeep(360, 0.04);
|
||||
}
|
||||
}
|
||||
},
|
||||
[isLoginModalOpen, tvLoginFocus, focusState, profiles, tvEmail, tvPassword]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
return (
|
||||
<div className="has-tv-profile-screen">
|
||||
<div className="has-tv-profile-glow" />
|
||||
|
||||
{/* Header */}
|
||||
<header className="has-tv-profile-header">
|
||||
<img src={logoUrl} alt="HAS Logo" className="has-tv-profile-logo" />
|
||||
<h1 className="has-tv-profile-title">Кто сейчас смотрит?</h1>
|
||||
<p className="has-tv-profile-subtitle">Выберите профиль для автоматического входа</p>
|
||||
</header>
|
||||
|
||||
{/* Arc Stage with Semicircle & Profile Circles */}
|
||||
<div className="has-tv-arc-stage">
|
||||
{/* SVG Semicircle Arc (matching user's drawing) */}
|
||||
<svg className="has-tv-arc-svg" viewBox="0 0 880 400" preserveAspectRatio="none">
|
||||
<path d="M 110 360 A 330 210 0 0 1 770 360" className="has-tv-arc-path" />
|
||||
</svg>
|
||||
|
||||
{/* Profile Avatars along the Arc */}
|
||||
{profiles.map((user, idx) => {
|
||||
const coords = getCoordinates(idx, profiles.length);
|
||||
const isFocused = focusState.type === "profile" && focusState.index === idx;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${user.email}-${idx}`}
|
||||
type="button"
|
||||
className={`has-tv-profile-node ${isFocused ? "focused" : ""}`}
|
||||
style={{ left: `${coords.x}px`, top: `${coords.y}px` }}
|
||||
onClick={() => handleSelect(user)}
|
||||
onMouseEnter={() => setFocusState({ type: "profile", index: idx })}
|
||||
title={`Войти как ${user.username || user.email}`}
|
||||
>
|
||||
<div className="has-tv-profile-avatar-circle">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt={user.username || user.email} />
|
||||
) : (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="has-tv-profile-avatar-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Delete badge visible on hover/focus */}
|
||||
{isFocused && (
|
||||
<span
|
||||
className="has-tv-profile-delete-btn"
|
||||
onClick={(e) => handleDeleteProfile(e, user.email)}
|
||||
title="Удалить профиль с ТВ"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="has-tv-profile-name">
|
||||
{user.username || user.email.split("@")[0]}
|
||||
</span>
|
||||
{user.role === "admin" && <span className="has-tv-profile-badge">ADMIN</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Plus Button: Positioned in Center under the Arc */}
|
||||
<button
|
||||
type="button"
|
||||
className={`has-tv-plus-node ${focusState.type === "plus" ? "focused" : ""}`}
|
||||
style={{ left: "440px", top: "340px" }}
|
||||
onClick={() => {
|
||||
setIsLoginModalOpen(true);
|
||||
playTvBeep(640, 0.03);
|
||||
}}
|
||||
onMouseEnter={() => setFocusState({ type: "plus" })}
|
||||
title="Добавить новый профиль"
|
||||
>
|
||||
<div className="has-tv-plus-circle">+</div>
|
||||
<span className="has-tv-plus-label">Добавить</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer Navigation Hints */}
|
||||
<footer className="has-tv-profile-footer">
|
||||
<span className="has-tv-profile-hint-pill">
|
||||
◄ ► Влево / Вправо • ▼ Кнопка + • OK Вход в аккаунт
|
||||
</span>
|
||||
{onSwitchToDesktop && (
|
||||
<button
|
||||
type="button"
|
||||
className="has-tv-profile-switch-desktop"
|
||||
onClick={onSwitchToDesktop}
|
||||
>
|
||||
💻 Обычный режим ПК
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{/* ==================================================================
|
||||
STRICT 3-BUTTON TV LOGIN MODAL
|
||||
"два поля почта и пароль с кнопкой входа больше пультом дистанционного
|
||||
управления никуда нельзя будет нажать только на эти три кнопки"
|
||||
================================================================== */}
|
||||
{isLoginModalOpen && (
|
||||
<div
|
||||
className="has-tv-login-overlay"
|
||||
onClick={() => {
|
||||
setIsLoginModalOpen(false);
|
||||
setFocusState({ type: "plus" });
|
||||
}}
|
||||
>
|
||||
<div className="has-tv-login-card" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="has-tv-login-card-header">
|
||||
<h2 className="has-tv-login-card-title">Добавить профиль</h2>
|
||||
<p className="has-tv-login-card-desc">
|
||||
Введите данные для авторизации нового аккаунта на ТВ
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="has-tv-login-form" onSubmit={handleTvLogin}>
|
||||
{/* Field 1: Email */}
|
||||
<input
|
||||
ref={emailInputRef}
|
||||
type="email"
|
||||
value={tvEmail}
|
||||
onChange={(e) => setTvEmail(e.target.value)}
|
||||
placeholder="Почта"
|
||||
className={`has-tv-login-input ${tvLoginFocus === 0 ? "focused" : ""}`}
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Field 2: Password */}
|
||||
<input
|
||||
ref={passwordInputRef}
|
||||
type="password"
|
||||
value={tvPassword}
|
||||
onChange={(e) => setTvPassword(e.target.value)}
|
||||
placeholder="Пароль"
|
||||
className={`has-tv-login-input ${tvLoginFocus === 1 ? "focused" : ""}`}
|
||||
tabIndex={0}
|
||||
/>
|
||||
|
||||
{tvError && <div className="has-tv-login-error-banner">{tvError}</div>}
|
||||
|
||||
{/* Button 3: Submit Button */}
|
||||
<button
|
||||
ref={submitButtonRef}
|
||||
type="submit"
|
||||
disabled={tvLoading}
|
||||
className={`has-tv-login-submit-btn ${tvLoginFocus === 2 ? "focused" : ""}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
{tvLoading ? "Подключение..." : "Войти"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="has-tv-login-card-hint">
|
||||
▲ ▼ Пульт: перемещение • OK: выбор • Назад (Back / Esc): отмена
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,112 +1,775 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { playTvBeep } from "../utils/tvAudio";
|
||||
|
||||
export type DetectedPlatform = "windows" | "android_tv" | "android_mobile" | "linux" | "macos" | "web";
|
||||
|
||||
interface UpdateInfo {
|
||||
version: string;
|
||||
body: string | null;
|
||||
downloadUrl?: string;
|
||||
platform?: string;
|
||||
windowsUrl?: string | null;
|
||||
tvApkUrl?: string | null;
|
||||
mobileApkUrl?: string | null;
|
||||
linuxUrl?: string | null;
|
||||
macosUrl?: string | null;
|
||||
}
|
||||
|
||||
export function UpdaterBanner() {
|
||||
interface UpdaterBannerProps {
|
||||
isTvEnvironment?: boolean;
|
||||
}
|
||||
|
||||
// Helper for Smart TV / Android TV detection
|
||||
const detectTvEnvironment = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const ua = (navigator.userAgent || "").toLowerCase();
|
||||
const isTvUa =
|
||||
ua.includes("android tv") ||
|
||||
ua.includes("smarttv") ||
|
||||
ua.includes("googletv") ||
|
||||
ua.includes("tizen") ||
|
||||
ua.includes("webos") ||
|
||||
ua.includes("appletv") ||
|
||||
ua.includes("crkey") ||
|
||||
ua.includes("bravia") ||
|
||||
ua.includes("aftt") ||
|
||||
ua.includes("aftm") ||
|
||||
ua.includes("mibox") ||
|
||||
ua.includes("mi box") ||
|
||||
ua.includes("shield") ||
|
||||
ua.includes("dtv") ||
|
||||
ua.includes("smart-tv") ||
|
||||
ua.includes("hbbtv") ||
|
||||
ua.includes("netcast") ||
|
||||
ua.includes("viera") ||
|
||||
ua.includes("roku") ||
|
||||
ua.includes("firetv");
|
||||
if (isTvUa) return true;
|
||||
const isAndroid = ua.includes("android");
|
||||
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
|
||||
if (isAndroid && noTouch) return true;
|
||||
|
||||
// On pure desktop (macOS / Windows / Desktop Linux), default to desktop PC mode
|
||||
const isDesktop =
|
||||
(ua.includes("windows nt") || ua.includes("macintosh") || (ua.includes("linux") && !ua.includes("android"))) &&
|
||||
!isTvUa;
|
||||
if (isDesktop) {
|
||||
return localStorage.getItem("has_tv_mode_manual") === "true";
|
||||
}
|
||||
|
||||
const saved = localStorage.getItem("has_tv_mode");
|
||||
if (saved !== null) return saved === "true";
|
||||
return false;
|
||||
};
|
||||
|
||||
// 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 = {}) {
|
||||
const [isTv, setIsTv] = useState<boolean>(() => isTvEnvironment ?? detectTvEnvironment());
|
||||
const [currentAppVersion, setCurrentAppVersion] = useState<string>("0.0.19");
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [statusText, setStatusText] = useState<string>("Загрузка обновления...");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) {
|
||||
return;
|
||||
}
|
||||
// Auto-update preferences for Android TV
|
||||
const [autoUpdateTv, setAutoUpdateTv] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return localStorage.getItem("has_auto_update_tv") === "true";
|
||||
});
|
||||
const [autoCountdown, setAutoCountdown] = useState<number | null>(null);
|
||||
|
||||
// TV remote navigation: "update" | "skip" | "exit" | "autotoggle"
|
||||
const [tvFocusedBtn, setTvFocusedBtn] = useState<"update" | "skip" | "exit" | "autotoggle">("update");
|
||||
const updateBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof isTvEnvironment === "boolean") {
|
||||
setIsTv(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
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
const { check } = await import("@tauri-apps/plugin-updater");
|
||||
const update = await check();
|
||||
if (!cancelled && update?.available) {
|
||||
setUpdateAvailable(true);
|
||||
setUpdateInfo({
|
||||
version: update.version,
|
||||
body: update.body ?? null,
|
||||
// 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,
|
||||
});
|
||||
setDismissed(false);
|
||||
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) {
|
||||
try {
|
||||
const { check } = await import("@tauri-apps/plugin-updater");
|
||||
const update = await check();
|
||||
if (!cancelled && update?.available) {
|
||||
setUpdateAvailable(true);
|
||||
setUpdateInfo({
|
||||
version: update.version,
|
||||
body: update.body ?? null,
|
||||
});
|
||||
setDismissed(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Expected on mobile / TV webview
|
||||
}
|
||||
}
|
||||
|
||||
// 3. GitHub API check fallback
|
||||
try {
|
||||
const resp = await fetch("https://api.github.com/repos/autli5/has/releases/latest", {
|
||||
headers: { Accept: "application/vnd.github.v3+json" },
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
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);
|
||||
setUpdateInfo({
|
||||
version: remoteTag,
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Updater] Check failed:", err);
|
||||
console.warn("[Updater] Fallback fetch error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Initial check after startup
|
||||
const timer = setTimeout(checkForUpdates, 800);
|
||||
|
||||
// 2. Periodic check every 60 seconds while app is running
|
||||
const interval = setInterval(checkForUpdates, 60 * 1000);
|
||||
|
||||
// 3. Check whenever window gains focus
|
||||
const timer = setTimeout(checkForUpdates, 1200);
|
||||
const interval = setInterval(checkForUpdates, 90 * 1000);
|
||||
const handleFocus = () => checkForUpdates();
|
||||
window.addEventListener("focus", handleFocus);
|
||||
|
||||
// 4. Custom event for manual checks from UI
|
||||
window.addEventListener("has-check-update", handleFocus);
|
||||
|
||||
const handleSimulateUpdate = (e: any) => {
|
||||
setUpdateAvailable(true);
|
||||
setUpdateInfo({
|
||||
version: e.detail?.version || "1.0.0",
|
||||
body: e.detail?.body || "Вышло масштабное обновление программы.",
|
||||
downloadUrl: "https://github.com/autli5/has/releases/latest",
|
||||
});
|
||||
setDismissed(false);
|
||||
};
|
||||
window.addEventListener("has-simulate-update", handleSimulateUpdate);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
clearInterval(interval);
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
window.removeEventListener("has-check-update", handleFocus);
|
||||
window.removeEventListener("has-simulate-update", handleSimulateUpdate);
|
||||
};
|
||||
}, []);
|
||||
}, [currentAppVersion]);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) return;
|
||||
// Determine current active platform
|
||||
const clientPlatform = detectClientPlatform(isTv, updateInfo?.platform);
|
||||
|
||||
setDownloading(true);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
const { check } = await import("@tauri-apps/plugin-updater");
|
||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||
|
||||
const update = await check();
|
||||
if (!update?.available) return;
|
||||
|
||||
let downloaded = 0;
|
||||
let total = 0;
|
||||
|
||||
await update.downloadAndInstall((event: any) => {
|
||||
if (event.event === "Started") {
|
||||
total = event.data?.contentLength ?? 0;
|
||||
} else if (event.event === "Progress") {
|
||||
downloaded += event.data?.chunkLength ?? 0;
|
||||
if (total > 0) {
|
||||
setProgress(Math.round((downloaded / total) * 100));
|
||||
}
|
||||
} else if (event.event === "Finished") {
|
||||
setProgress(100);
|
||||
}
|
||||
});
|
||||
|
||||
await relaunch();
|
||||
} catch (e: unknown) {
|
||||
setError(`Ошибка обновления: ${e instanceof Error ? e.message : String(e)}`);
|
||||
setDownloading(false);
|
||||
// Platform-tailored texts and assets
|
||||
const getPlatformDetails = () => {
|
||||
const version = updateInfo?.version || "1.0.0";
|
||||
switch (clientPlatform) {
|
||||
case "windows":
|
||||
return {
|
||||
badge: "🪟 Windows",
|
||||
title: `Доступно обновление для Windows (v${version})`,
|
||||
desc: "Вышла новая версия приложения для Windows. Нажмите «Обновить», чтобы установить последнюю версию программы.",
|
||||
btnLabel: "🚀 Обновить для Windows (.exe)",
|
||||
icon: "🪟",
|
||||
};
|
||||
case "android_tv":
|
||||
return {
|
||||
badge: "📺 Android TV",
|
||||
title: "Хотите ли вы обновить приложение HAS?",
|
||||
desc: "Доступна новая оптимизированная версия для телевизоров и ТВ-приставок с поддержкой пульта ДУ, балансеров видео и повышенной стабильностью.",
|
||||
btnLabel: "🚀 Да, обновить сейчас",
|
||||
icon: "📺",
|
||||
};
|
||||
case "android_mobile":
|
||||
return {
|
||||
badge: "📱 Android",
|
||||
title: "Хотите ли вы обновить приложение HAS?",
|
||||
desc: "Новая версия приложения для мобильных устройств на Android.",
|
||||
btnLabel: "🚀 Скачать APK для Android",
|
||||
icon: "📱",
|
||||
};
|
||||
case "linux":
|
||||
return {
|
||||
badge: "🐧 Linux",
|
||||
title: `Доступно обновление для Linux (v${version})`,
|
||||
desc: "Пакет обновления для дистрибутивов Linux (.deb / .AppImage / .rpm).",
|
||||
btnLabel: "🚀 Скачать для Linux (.deb)",
|
||||
icon: "🐧",
|
||||
};
|
||||
case "macos":
|
||||
return {
|
||||
badge: "🍏 macOS",
|
||||
title: `Доступно обновление для macOS (v${version})`,
|
||||
desc: "Новая версия приложения для macOS (.dmg).",
|
||||
btnLabel: "🚀 Скачать образ (.dmg)",
|
||||
icon: "🍏",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
badge: "🚀 Обновление",
|
||||
title: `Доступно обновление v${version}`,
|
||||
desc: "Вышла новая версия программы с улучшенной стабильностью.",
|
||||
btnLabel: "🚀 Обновить сейчас",
|
||||
icon: "🚀",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const platformInfo = getPlatformDetails();
|
||||
|
||||
// Smart download target selector
|
||||
const resolveTargetUrl = useCallback((): string => {
|
||||
if (!updateInfo) return "https://github.com/autli5/has/releases/latest";
|
||||
switch (clientPlatform) {
|
||||
case "windows":
|
||||
return updateInfo.windowsUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
|
||||
case "android_tv":
|
||||
return updateInfo.tvApkUrl || updateInfo.mobileApkUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
|
||||
case "android_mobile":
|
||||
return updateInfo.mobileApkUrl || updateInfo.tvApkUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
|
||||
case "linux":
|
||||
return updateInfo.linuxUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
|
||||
case "macos":
|
||||
return updateInfo.macosUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
|
||||
default:
|
||||
return updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
|
||||
}
|
||||
}, [clientPlatform, updateInfo]);
|
||||
|
||||
const cancelAutoCountdown = useCallback(() => {
|
||||
setAutoCountdown(null);
|
||||
}, []);
|
||||
|
||||
const toggleAutoUpdate = useCallback(() => {
|
||||
setAutoUpdateTv((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem("has_auto_update_tv", next ? "true" : "false");
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
cancelAutoCountdown();
|
||||
}, [cancelAutoCountdown]);
|
||||
|
||||
const handleInstall = useCallback(async () => {
|
||||
cancelAutoCountdown();
|
||||
setDownloading(true);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setStatusText("Подготовка к установке обновления...");
|
||||
|
||||
// Desktop Windows / macOS / Linux Tauri updater plugin
|
||||
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window && !isTv) {
|
||||
try {
|
||||
const { check } = await import("@tauri-apps/plugin-updater");
|
||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||
|
||||
const update = await check();
|
||||
if (update?.available) {
|
||||
let downloaded = 0;
|
||||
let total = 0;
|
||||
setStatusText("Загрузка обновления...");
|
||||
|
||||
await update.downloadAndInstall((event: any) => {
|
||||
if (event.event === "Started") {
|
||||
total = event.data?.contentLength ?? 0;
|
||||
} else if (event.event === "Progress") {
|
||||
downloaded += event.data?.chunkLength ?? 0;
|
||||
} else if (event.event === "Finished") {
|
||||
setProgress(100);
|
||||
setStatusText("Перезапуск программы...");
|
||||
}
|
||||
if (total > 0) {
|
||||
const p = Math.round((downloaded / total) * 100);
|
||||
setProgress(p);
|
||||
setStatusText(`Загрузка: ${p}%`);
|
||||
}
|
||||
});
|
||||
|
||||
await relaunch();
|
||||
return;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn("[Updater] Desktop plugin install skipped/fallback:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Android TV / Mobile / Direct APK Download & Installation
|
||||
const targetUrl = resolveTargetUrl();
|
||||
setStatusText("Подключение к серверу загрузки APK...");
|
||||
|
||||
let streamSucceeded = false;
|
||||
try {
|
||||
const response = await fetch(targetUrl, { mode: "cors" });
|
||||
if (response.ok && response.body) {
|
||||
const contentLength = +(response.headers.get("Content-Length") || 0);
|
||||
const reader = response.body.getReader();
|
||||
let received = 0;
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
if (contentLength > 0) {
|
||||
const pct = Math.min(99, Math.round((received / contentLength) * 100));
|
||||
setProgress(pct);
|
||||
setStatusText(`Скачивание APK: ${pct}% (${(received / 1024 / 1024).toFixed(1)} МБ)`);
|
||||
} else {
|
||||
setStatusText(`Скачивание APK: ${(received / 1024 / 1024).toFixed(1)} МБ`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setProgress(100);
|
||||
setStatusText("APK успешно скачан! Запуск установки...");
|
||||
streamSucceeded = true;
|
||||
|
||||
const blob = new Blob(chunks as BlobPart[], { type: "application/vnd.android.package-archive" });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = blobUrl;
|
||||
a.download = `has-tv-v${updateInfo?.version || "update"}.apk`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} catch {
|
||||
// Fetch streaming can be restricted by CORS on certain hosts; fallback below
|
||||
}
|
||||
|
||||
if (!streamSucceeded) {
|
||||
let p = 0;
|
||||
await new Promise<void>((resolve) => {
|
||||
const progressTimer = setInterval(() => {
|
||||
p += 20;
|
||||
if (p >= 100) {
|
||||
clearInterval(progressTimer);
|
||||
setProgress(100);
|
||||
setStatusText("Запуск установщика приложения...");
|
||||
resolve();
|
||||
} else {
|
||||
setProgress(p);
|
||||
setStatusText(`Загрузка: ${p}%`);
|
||||
}
|
||||
}, 120);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { openUrl } = await import("@tauri-apps/plugin-opener");
|
||||
await openUrl(targetUrl);
|
||||
} catch {
|
||||
window.open(targetUrl, "_blank");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setDismissed(true);
|
||||
setDownloading(false);
|
||||
}, 1800);
|
||||
}, [cancelAutoCountdown, isTv, updateInfo, resolveTargetUrl]);
|
||||
|
||||
// Clean Exit from TV application
|
||||
const handleExitApp = useCallback(async () => {
|
||||
try {
|
||||
const { exit } = await import("@tauri-apps/plugin-process");
|
||||
await exit(0);
|
||||
} catch {
|
||||
window.close();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-update countdown for Android TV
|
||||
useEffect(() => {
|
||||
if (!updateAvailable || dismissed || !isTv || downloading) return;
|
||||
if (!autoUpdateTv) {
|
||||
setAutoCountdown(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoCountdown(5);
|
||||
const interval = setInterval(() => {
|
||||
setAutoCountdown((prev) => {
|
||||
if (prev === null) return null;
|
||||
if (prev <= 1) {
|
||||
clearInterval(interval);
|
||||
handleInstall();
|
||||
return null;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [updateAvailable, dismissed, isTv, downloading, autoUpdateTv, handleInstall]);
|
||||
|
||||
// Strict Remote Control D-Pad Navigation for TV Update Modal
|
||||
useEffect(() => {
|
||||
if (!updateAvailable || dismissed || !isTv || downloading) return;
|
||||
|
||||
// Auto-focus the "Update" button when TV modal appears
|
||||
setTimeout(() => {
|
||||
if (updateBtnRef.current) {
|
||||
updateBtnRef.current.focus();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
const handleTvKeyDown = (e: KeyboardEvent) => {
|
||||
cancelAutoCountdown();
|
||||
|
||||
// Down: switch to auto-update toggle
|
||||
if (e.key === "ArrowDown" || e.keyCode === 20) {
|
||||
e.preventDefault();
|
||||
playTvBeep(520, 0.025);
|
||||
if (tvFocusedBtn !== "autotoggle") {
|
||||
setTvFocusedBtn("autotoggle");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Up: switch back from auto-update toggle to update button
|
||||
if (e.key === "ArrowUp" || e.keyCode === 19) {
|
||||
e.preventDefault();
|
||||
playTvBeep(520, 0.025);
|
||||
if (tvFocusedBtn === "autotoggle") {
|
||||
setTvFocusedBtn("update");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Left: cycle within top button row
|
||||
if (e.key === "ArrowLeft" || e.keyCode === 21) {
|
||||
e.preventDefault();
|
||||
playTvBeep(520, 0.025);
|
||||
setTvFocusedBtn((prev) => {
|
||||
if (prev === "exit") return "skip";
|
||||
if (prev === "skip") return "update";
|
||||
return "exit";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Right / Tab: cycle within top button row
|
||||
if (e.key === "ArrowRight" || e.keyCode === 22 || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
playTvBeep(520, 0.025);
|
||||
setTvFocusedBtn((prev) => {
|
||||
if (prev === "update") return "skip";
|
||||
if (prev === "skip") return "exit";
|
||||
return "update";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter / OK (KeyCode 13, 23, 66)
|
||||
if (e.key === "Enter" || e.key === " " || e.keyCode === 13 || e.keyCode === 23 || e.keyCode === 66) {
|
||||
e.preventDefault();
|
||||
if (tvFocusedBtn === "update") {
|
||||
playTvBeep(680, 0.05);
|
||||
handleInstall();
|
||||
} else if (tvFocusedBtn === "exit") {
|
||||
playTvBeep(320, 0.06);
|
||||
handleExitApp();
|
||||
} else if (tvFocusedBtn === "autotoggle") {
|
||||
playTvBeep(560, 0.04);
|
||||
toggleAutoUpdate();
|
||||
} else {
|
||||
playTvBeep(420, 0.04);
|
||||
setDismissed(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Back / Escape key triggers "skip"
|
||||
if (e.key === "Escape" || e.keyCode === 27 || e.keyCode === 4) {
|
||||
e.preventDefault();
|
||||
playTvBeep(420, 0.04);
|
||||
setDismissed(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleTvKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleTvKeyDown);
|
||||
}, [updateAvailable, dismissed, isTv, downloading, tvFocusedBtn, handleInstall, handleExitApp, cancelAutoCountdown, toggleAutoUpdate]);
|
||||
|
||||
if (!updateAvailable || dismissed) return null;
|
||||
|
||||
// =========================================================================
|
||||
// 1. ANDROID TV: Full-screen Centered Modal with D-Pad TV Navigation & Exit
|
||||
// =========================================================================
|
||||
if (isTv) {
|
||||
return (
|
||||
<div className="has-tv-updater-overlay" role="dialog" aria-modal="true">
|
||||
<div className="has-tv-updater-card">
|
||||
<div className="has-tv-updater-icon-ring">{platformInfo.icon}</div>
|
||||
<h2 className="has-tv-updater-title">Хотите ли вы обновить приложение HAS?</h2>
|
||||
<div className="has-tv-updater-version-tag">
|
||||
<span>📺 Android TV • v{currentAppVersion} ➔ v{updateInfo?.version || "новейшая"}</span>
|
||||
</div>
|
||||
|
||||
<p className="has-tv-updater-desc">
|
||||
{updateInfo?.body || platformInfo.desc}
|
||||
</p>
|
||||
|
||||
{autoCountdown !== null && !downloading && (
|
||||
<div className="has-tv-updater-countdown">
|
||||
⚡ Автообновление активно: начнется через {autoCountdown} сек... (нажмите любую кнопку для отмены)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{downloading ? (
|
||||
<div className="has-tv-updater-progress-container">
|
||||
<div className="has-tv-updater-progress-track">
|
||||
<div
|
||||
className="has-tv-updater-progress-bar"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="has-tv-updater-progress-text">
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="has-tv-updater-buttons">
|
||||
{/* Button 1: Да, обновить сейчас */}
|
||||
<button
|
||||
ref={updateBtnRef}
|
||||
type="button"
|
||||
className={`has-tv-updater-btn btn-update ${
|
||||
tvFocusedBtn === "update" ? "focused" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
playTvBeep(680, 0.05);
|
||||
cancelAutoCountdown();
|
||||
handleInstall();
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
cancelAutoCountdown();
|
||||
setTvFocusedBtn("update");
|
||||
}}
|
||||
tabIndex={0}
|
||||
data-tv-focusable="true"
|
||||
>
|
||||
<span>{platformInfo.btnLabel}</span>
|
||||
</button>
|
||||
|
||||
{/* Button 2: Напомнить позже */}
|
||||
<button
|
||||
type="button"
|
||||
className={`has-tv-updater-btn btn-skip ${
|
||||
tvFocusedBtn === "skip" ? "focused" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
playTvBeep(420, 0.04);
|
||||
cancelAutoCountdown();
|
||||
setDismissed(true);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
cancelAutoCountdown();
|
||||
setTvFocusedBtn("skip");
|
||||
}}
|
||||
tabIndex={0}
|
||||
data-tv-focusable="true"
|
||||
>
|
||||
<span>Напомнить позже</span>
|
||||
</button>
|
||||
|
||||
{/* Button 3: Выход из приложения (Exit) */}
|
||||
<button
|
||||
type="button"
|
||||
className={`has-tv-updater-btn btn-exit ${
|
||||
tvFocusedBtn === "exit" ? "focused" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
playTvBeep(320, 0.06);
|
||||
cancelAutoCountdown();
|
||||
handleExitApp();
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
cancelAutoCountdown();
|
||||
setTvFocusedBtn("exit");
|
||||
}}
|
||||
tabIndex={0}
|
||||
data-tv-focusable="true"
|
||||
title="Выйти из приложения"
|
||||
>
|
||||
<span>✕ Выход</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto-update Toggle Chip for Android TV */}
|
||||
<button
|
||||
type="button"
|
||||
className={`has-tv-updater-autotoggle ${autoUpdateTv ? "active" : ""} ${
|
||||
tvFocusedBtn === "autotoggle" ? "focused" : ""
|
||||
}`}
|
||||
onClick={toggleAutoUpdate}
|
||||
onMouseEnter={() => {
|
||||
cancelAutoCountdown();
|
||||
setTvFocusedBtn("autotoggle");
|
||||
}}
|
||||
tabIndex={0}
|
||||
data-tv-focusable="true"
|
||||
title="Переключить режим автоматического скачивания обновлений для ТВ"
|
||||
>
|
||||
{autoUpdateTv
|
||||
? "🟢 Автообновление для TV: ВКЛЮЧЕНО (скачивать без запроса)"
|
||||
: "⚪ Автообновление для TV: ВЫКЛЮЧЕНО (нажмите OK чтобы включить)"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div className="updater-error" style={{ marginTop: 16 }}>{error}</div>}
|
||||
|
||||
<div className="has-tv-updater-remote-hint">
|
||||
◄ ► Стрелки на пульте: выбор • ▲ ▼ Вверх/Вниз: переключение опций • OK: подтвердить • Back: позже
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 2. DESKTOP (WINDOWS / MAC / LINUX) & MOBILE: Smart Platform Banner
|
||||
// =========================================================================
|
||||
return (
|
||||
<div className="updater-banner">
|
||||
<div className="updater-banner-content">
|
||||
<div className="updater-icon">🚀</div>
|
||||
<div className="updater-icon">{platformInfo.icon}</div>
|
||||
<div className="updater-text">
|
||||
<strong>Доступно обновление v{updateInfo?.version}</strong>
|
||||
{updateInfo?.body && (
|
||||
<span className="updater-notes">{updateInfo.body}</span>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
|
||||
<strong>{platformInfo.title}</strong>
|
||||
<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 className="updater-actions">
|
||||
{downloading ? (
|
||||
|
|
@ -126,7 +789,7 @@ export function UpdaterBanner() {
|
|||
className="updater-btn-install"
|
||||
onClick={handleInstall}
|
||||
>
|
||||
Обновить сейчас
|
||||
{platformInfo.btnLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -769,6 +769,296 @@
|
|||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* AdBlock Guard pill */
|
||||
.has-cinema-player-hud-btn.adblock-pill {
|
||||
font-size: 12px;
|
||||
padding: 7px 13px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
border: 1px solid rgba(16, 185, 129, 0.35);
|
||||
color: #a7f3d0;
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.has-cinema-player-hud-btn.adblock-pill:hover {
|
||||
background: rgba(16, 185, 129, 0.25);
|
||||
border-color: #34d399;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.has-cinema-player-hud-btn.adblock-pill.active {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.35), rgba(5, 150, 105, 0.5));
|
||||
border-color: #10b981;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 0 16px rgba(16, 185, 129, 0.55);
|
||||
}
|
||||
|
||||
.has-cinema-player-hud-btn.adblock-pill:focus-visible,
|
||||
.has-cinema-player-hud-btn.adblock-pill[data-tv-focused="true"] {
|
||||
outline: 3px solid #10b981 !important;
|
||||
box-shadow: 0 0 25px rgba(16, 185, 129, 0.85) !important;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Quick Ad / Intro Skip Button */
|
||||
.has-cinema-player-hud-btn.ad-skip-pill {
|
||||
font-size: 12px;
|
||||
padding: 7px 13px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
color: #fde68a;
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.has-cinema-player-hud-btn.ad-skip-pill:hover,
|
||||
.has-cinema-player-hud-btn.ad-skip-pill:focus-visible,
|
||||
.has-cinema-player-hud-btn.ad-skip-pill[data-tv-focused="true"] {
|
||||
background: rgba(245, 158, 11, 0.35) !important;
|
||||
border-color: #f59e0b !important;
|
||||
color: #ffffff !important;
|
||||
outline: 3px solid #f59e0b !important;
|
||||
box-shadow: 0 0 20px rgba(245, 158, 11, 0.8) !important;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Server selector pills in top HUD */
|
||||
.has-cinema-player-hud-servers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
padding: 3px 6px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.has-cinema-player-server-pill {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: #94a3b8;
|
||||
padding: 5px 11px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.has-cinema-player-server-pill:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.has-cinema-player-server-pill.active {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
color: #ffffff;
|
||||
border-color: #818cf8;
|
||||
box-shadow: 0 0 14px rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
.has-cinema-player-server-pill:focus-visible,
|
||||
.has-cinema-player-server-pill[data-tv-focused="true"] {
|
||||
outline: none;
|
||||
background: #6366f1;
|
||||
color: #ffffff;
|
||||
border-color: #ffffff;
|
||||
box-shadow: 0 0 0 3px #818cf8;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
/* Leanback Bottom Playback Controls Bar */
|
||||
.has-cinema-player-bottom-hud {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 30px calc(env(safe-area-inset-bottom, 0px) + 24px);
|
||||
background: linear-gradient(0deg, rgba(6, 9, 18, 0.96) 0%, rgba(6, 9, 18, 0.75) 60%, transparent 100%);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.has-cinema-player-bottom-hud.hud-hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.has-cinema-player-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.has-cinema-player-ctrl-btn {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
color: #ffffff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
backdrop-filter: blur(14px);
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.has-cinema-player-ctrl-btn:hover,
|
||||
.has-cinema-player-ctrl-btn:focus-visible,
|
||||
.has-cinema-player-ctrl-btn:focus {
|
||||
background: rgba(255, 255, 255, 0.25) !important;
|
||||
border-color: #818cf8 !important;
|
||||
outline: 3px solid #818cf8 !important;
|
||||
box-shadow: 0 0 20px rgba(129, 140, 248, 0.8) !important;
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 76px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
border: 1px solid rgba(59, 130, 246, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 8px 18px;
|
||||
border-radius: 9999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 15px rgba(59, 130, 246, 0.3);
|
||||
z-index: 45;
|
||||
pointer-events: none;
|
||||
animation: fadeInBanner 0.22s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInBanner {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.has-cinema-loading-spinner.small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 2.5px;
|
||||
}
|
||||
|
||||
/* Unreleased movie player card */
|
||||
.has-cinema-unreleased-player-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: radial-gradient(circle at center, rgba(30, 41, 59, 0.6) 0%, #030712 100%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.has-cinema-unreleased-card {
|
||||
max-width: 680px;
|
||||
width: 100%;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 20px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.has-cinema-unreleased-card .unreleased-icon {
|
||||
font-size: 42px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.has-cinema-unreleased-card h3 {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.has-cinema-unreleased-card p {
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.unreleased-trailer-box {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.7);
|
||||
margin-bottom: 20px;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.unreleased-trailer-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 100% Immersive Video Frame */
|
||||
.has-cinema-video-frame {
|
||||
width: 100vw;
|
||||
|
|
@ -1067,6 +1357,21 @@
|
|||
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) */
|
||||
.has-cinema-drawer-episodes-grid {
|
||||
display: grid;
|
||||
|
|
@ -1851,21 +2156,22 @@
|
|||
}
|
||||
|
||||
/* Android TV Leanback Navigation Drawer (Slide-in Overlay) */
|
||||
/* Android TV Leanback Navigation Drawer (Zona / Android TV 10-Foot UI) */
|
||||
.has-cinema-tv-nav-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 270px;
|
||||
background: rgba(8, 12, 24, 0.97);
|
||||
width: 280px;
|
||||
background: rgba(8, 12, 24, 0.98);
|
||||
backdrop-filter: blur(28px);
|
||||
border-right: 1px solid rgba(129, 140, 248, 0.35);
|
||||
box-shadow: 15px 0 50px rgba(0, 0, 0, 0.9);
|
||||
box-shadow: 20px 0 60px rgba(0, 0, 0, 0.95);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 2000;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.26s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
|
@ -1879,63 +2185,40 @@
|
|||
.has-cinema-tv-nav-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 16px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
justify-content: flex-start;
|
||||
padding: 24px 20px 18px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.has-cinema-tv-nav-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.has-cinema-tv-nav-logo-badge {
|
||||
background: linear-gradient(135deg, #6366f1, #a855f7);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
font-weight: 900;
|
||||
font-size: 14px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
letter-spacing: 1px;
|
||||
letter-spacing: 1.5px;
|
||||
box-shadow: 0 0 16px rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
.has-cinema-tv-nav-logo-text {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #f8fafc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.has-cinema-tv-nav-toggle-btn {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
color: #cbd5e1;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.has-cinema-tv-nav-toggle-btn:focus,
|
||||
.has-cinema-tv-nav-toggle-btn:hover {
|
||||
background: #6366f1;
|
||||
color: #ffffff;
|
||||
outline: 3px solid #818cf8 !important;
|
||||
box-shadow: 0 0 16px rgba(129, 140, 248, 0.7) !important;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.has-cinema-tv-nav-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 10px;
|
||||
padding: 16px 12px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
|
@ -1945,12 +2228,12 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
color: #94a3b8;
|
||||
font-size: 15px;
|
||||
font-size: 15.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
|
@ -1959,18 +2242,19 @@
|
|||
|
||||
.has-cinema-tv-nav-item:hover,
|
||||
.has-cinema-tv-nav-item:focus {
|
||||
background: rgba(99, 102, 241, 0.25) !important;
|
||||
background: rgba(99, 102, 241, 0.3) !important;
|
||||
border-color: #818cf8 !important;
|
||||
color: #ffffff !important;
|
||||
outline: 3px solid #818cf8 !important;
|
||||
box-shadow: 0 0 24px rgba(129, 140, 248, 0.8) !important;
|
||||
transform: scale(1.04) !important;
|
||||
box-shadow: 0 0 24px rgba(129, 140, 248, 0.85) !important;
|
||||
transform: scale(1.03) !important;
|
||||
}
|
||||
|
||||
.has-cinema-tv-nav-item.active {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.35), rgba(139, 92, 246, 0.35));
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.4), rgba(139, 92, 246, 0.4));
|
||||
border-color: rgba(129, 140, 248, 0.6);
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.has-cinema-tv-nav-item.logout {
|
||||
|
|
@ -2002,36 +2286,185 @@
|
|||
}
|
||||
|
||||
.has-cinema-tv-nav-footer {
|
||||
padding: 12px;
|
||||
font-size: 11.5px;
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* TV Mode Root Overrides (Safe Overscan Area & Compact Proportions) */
|
||||
/* TV Mode Root Overrides (Zona TV & Android TV Experience) */
|
||||
.has-tv-mode {
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
/* Navigation is completely invisible on cinema screen (summoned by pressing Left from edge) */
|
||||
.has-tv-mode .has-cinema-header {
|
||||
padding: 16px 54px !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.has-tv-mode .has-cinema-nav-links {
|
||||
gap: 20px;
|
||||
/* 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-tv-mode .has-cinema-nav-item {
|
||||
font-size: 15px;
|
||||
padding: 6px 10px;
|
||||
.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 {
|
||||
padding: 85px 54px 30px !important;
|
||||
padding: 40px 54px 30px !important;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
/* Dedicated TV Search Section when activeTab === "search" */
|
||||
.has-cinema-tv-search-page {
|
||||
padding: 40px 54px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.has-cinema-tv-search-box-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 16px;
|
||||
padding: 14px 22px;
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.has-cinema-tv-search-box-wrap:focus-within {
|
||||
border-color: #818cf8;
|
||||
box-shadow: 0 0 25px rgba(129, 140, 248, 0.6);
|
||||
}
|
||||
|
||||
.has-cinema-tv-search-lens {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.has-cinema-tv-search-input-field {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.has-cinema-tv-search-input-field::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.has-cinema-tv-search-clear-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.has-cinema-tv-search-clear-btn:hover,
|
||||
.has-cinema-tv-search-clear-btn:focus {
|
||||
background: #ef4444;
|
||||
color: #ffffff;
|
||||
outline: 2px solid #ffffff;
|
||||
}
|
||||
|
||||
.has-cinema-tv-search-hint {
|
||||
font-size: 13.5px;
|
||||
color: #94a3b8;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.has-tv-mode .has-cinema-hero-title {
|
||||
font-size: 30px;
|
||||
margin-bottom: 8px;
|
||||
|
|
@ -2807,6 +3240,10 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.has-cinema-player-hud-servers {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.has-cinema-player-hud-btn {
|
||||
padding: 6px 10px !important;
|
||||
font-size: 12px !important;
|
||||
|
|
@ -2836,6 +3273,177 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* ========================================================
|
||||
WATCH HISTORY (ИСТОРИЯ ПРОСМОТРОВ) STYLES
|
||||
======================================================== */
|
||||
.has-cinema-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.has-cinema-history-subtitle {
|
||||
color: #94a3b8;
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.has-cinema-clear-history-btn {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.28);
|
||||
color: #fca5a5;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.has-cinema-clear-history-btn:hover,
|
||||
.has-cinema-clear-history-btn:focus-visible,
|
||||
.has-tv-mode .has-cinema-clear-history-btn:focus {
|
||||
background: rgba(239, 68, 68, 0.25) !important;
|
||||
border-color: #ef4444 !important;
|
||||
color: #fff !important;
|
||||
outline: 2px solid #ef4444 !important;
|
||||
outline-offset: 2px !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.has-cinema-row-more-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
color: #cbd5e1;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.has-cinema-row-more-btn:hover,
|
||||
.has-cinema-row-more-btn:focus-visible,
|
||||
.has-tv-mode .has-cinema-row-more-btn:focus {
|
||||
background: rgba(99, 102, 241, 0.25) !important;
|
||||
border-color: #818cf8 !important;
|
||||
color: #fff !important;
|
||||
outline: 2px solid #818cf8 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
/* History Card Enhancements */
|
||||
.has-cinema-history-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.has-cinema-history-item-del-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
backdrop-filter: blur(6px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
color: #cbd5e1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 25;
|
||||
}
|
||||
|
||||
.has-cinema-media-card:hover .has-cinema-history-item-del-btn,
|
||||
.has-cinema-grid-card:hover .has-cinema-history-item-del-btn,
|
||||
.has-cinema-media-card:focus-within .has-cinema-history-item-del-btn,
|
||||
.has-cinema-grid-card:focus-within .has-cinema-history-item-del-btn,
|
||||
.has-tv-mode .has-cinema-media-card:focus .has-cinema-history-item-del-btn,
|
||||
.has-tv-mode .has-cinema-grid-card:focus .has-cinema-history-item-del-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.has-cinema-history-item-del-btn:hover,
|
||||
.has-cinema-history-item-del-btn:focus-visible {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border-color: #ef4444;
|
||||
transform: scale(1.15);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.has-cinema-history-status-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
backdrop-filter: blur(6px);
|
||||
border: 1px solid rgba(129, 140, 248, 0.35);
|
||||
color: #c7d2fe;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.has-cinema-history-status-badge.completed {
|
||||
background: rgba(6, 78, 59, 0.85);
|
||||
border-color: rgba(52, 211, 153, 0.4);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.has-cinema-history-percent-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
color: #e2e8f0;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.has-cinema-history-card-sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.has-cinema-history-ep-info {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.has-cinema-history-date {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,9 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, lazy, Suspense } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { UserDto, DbStatus, SafeDbInfo, ServerUserDto, UserProfileDto } from "../../types";
|
||||
import logoUrl from "../../assets/logo.png";
|
||||
import {
|
||||
HomeNavIcon,
|
||||
StorageNavIcon,
|
||||
UsersNavIcon,
|
||||
SettingsNavIcon,
|
||||
HelpNavIcon,
|
||||
|
|
@ -15,28 +14,55 @@ import {
|
|||
CloseIcon,
|
||||
} from "../../components/Icons";
|
||||
import Home from "../home/Home";
|
||||
import Storage from "../storage/Storage";
|
||||
import Users from "../users/Users";
|
||||
import Settings from "../settings/Settings";
|
||||
import Help from "../help/Help";
|
||||
import Profile from "../profile/Profile";
|
||||
import Cinema from "../cinema/Cinema";
|
||||
import "./Dashboard.css";
|
||||
|
||||
const Cinema = lazy(() => import("../cinema/Cinema"));
|
||||
|
||||
function CinemaLoadingFallback() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "70vh",
|
||||
color: "#94a3b8",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
border: "3px solid rgba(129, 140, 248, 0.2)",
|
||||
borderTopColor: "#818cf8",
|
||||
borderRadius: "50%",
|
||||
animation: "has-spin 0.8s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 16, fontWeight: 500 }}>Загрузка Кинотеатра...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DashboardProps {
|
||||
user: UserDto;
|
||||
dbInfo: SafeDbInfo | null;
|
||||
dbStatus: DbStatus | null;
|
||||
onLogout: () => void;
|
||||
isTvEnvironment?: boolean;
|
||||
}
|
||||
|
||||
type NavTab = "home" | "storage" | "cinema" | "users" | "settings" | "profile" | "help";
|
||||
type NavTab = "cinema" | "home" | "users" | "settings" | "profile" | "help";
|
||||
|
||||
// Helper for Smart TV / Android TV detection
|
||||
const detectTvEnvironment = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const saved = localStorage.getItem("has_tv_mode");
|
||||
if (saved !== null) return saved === "true";
|
||||
const ua = (navigator.userAgent || "").toLowerCase();
|
||||
const isTvUa =
|
||||
ua.includes("android tv") ||
|
||||
|
|
@ -63,6 +89,17 @@ const detectTvEnvironment = (): boolean => {
|
|||
const isAndroid = ua.includes("android");
|
||||
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
|
||||
if (isAndroid && noTouch) return true;
|
||||
|
||||
// On pure desktop (macOS / Windows / Desktop Linux), default to desktop PC mode
|
||||
const isDesktop =
|
||||
(ua.includes("windows nt") || ua.includes("macintosh") || (ua.includes("linux") && !ua.includes("android"))) &&
|
||||
!isTvUa;
|
||||
if (isDesktop) {
|
||||
return localStorage.getItem("has_tv_mode_manual") === "true";
|
||||
}
|
||||
|
||||
const saved = localStorage.getItem("has_tv_mode");
|
||||
if (saved !== null) return saved === "true";
|
||||
return false;
|
||||
};
|
||||
|
||||
|
|
@ -71,16 +108,24 @@ export default function Dashboard({
|
|||
dbInfo,
|
||||
dbStatus,
|
||||
onLogout,
|
||||
isTvEnvironment: propIsTv,
|
||||
}: DashboardProps) {
|
||||
const [currentUser, setCurrentUser] = useState<UserDto>(user);
|
||||
useEffect(() => {
|
||||
setCurrentUser(user);
|
||||
}, [user]);
|
||||
|
||||
const [isTvEnvironment, setIsTvEnvironment] = useState<boolean>(detectTvEnvironment);
|
||||
const [activeTab, setActiveTab] = useState<NavTab>(() => {
|
||||
return detectTvEnvironment() ? "cinema" : "home";
|
||||
const [isTvEnvironment, setIsTvEnvironment] = useState<boolean>(() => {
|
||||
return propIsTv !== undefined ? propIsTv : detectTvEnvironment();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (propIsTv !== undefined && propIsTv !== isTvEnvironment) {
|
||||
setIsTvEnvironment(propIsTv);
|
||||
}
|
||||
}, [propIsTv, isTvEnvironment]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<NavTab>("cinema");
|
||||
const [serverUsers, setServerUsers] = useState<ServerUserDto[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||
|
|
@ -88,21 +133,6 @@ export default function Dashboard({
|
|||
});
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
// Dynamic D-Pad Remote Control Key Listener (Auto-detects TV when user presses remote buttons)
|
||||
useEffect(() => {
|
||||
const handleRemoteDpadKey = (e: KeyboardEvent) => {
|
||||
const isRemoteKey =
|
||||
[19, 20, 21, 22, 23, 4, 82, 85, 179].includes(e.keyCode) ||
|
||||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key);
|
||||
if (isRemoteKey && !isTvEnvironment) {
|
||||
setIsTvEnvironment(true);
|
||||
localStorage.setItem("has_tv_mode", "true");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleRemoteDpadKey);
|
||||
return () => window.removeEventListener("keydown", handleRemoteDpadKey);
|
||||
}, [isTvEnvironment]);
|
||||
|
||||
const handleNavClick = (tab: NavTab) => {
|
||||
setActiveTab(tab);
|
||||
setMobileMenuOpen(false);
|
||||
|
|
@ -168,14 +198,14 @@ export default function Dashboard({
|
|||
(u.role?.toLowerCase() === "admin" || u.role?.toLowerCase() === "администратор" || u.id === 1)
|
||||
);
|
||||
|
||||
// Non-admin redirection: users and separate cinema tabs are reserved for admin
|
||||
// Non-admin redirection: users and home (server stats) tabs are reserved for admin
|
||||
useEffect(() => {
|
||||
if (!isUserAdmin && (activeTab === "users" || activeTab === "cinema")) {
|
||||
setActiveTab("home");
|
||||
if (!isUserAdmin && (activeTab === "users" || activeTab === "home")) {
|
||||
setActiveTab("cinema");
|
||||
}
|
||||
}, [isUserAdmin, activeTab]);
|
||||
|
||||
const isCinemaView = activeTab === "cinema" || (!isUserAdmin && activeTab === "home");
|
||||
const isCinemaView = activeTab === "cinema";
|
||||
|
||||
const loadServerUsers = useCallback(async () => {
|
||||
if (!isTauri) return;
|
||||
|
|
@ -283,42 +313,26 @@ export default function Dashboard({
|
|||
<nav className="has-nav-menu">
|
||||
<button
|
||||
type="button"
|
||||
className={`has-nav-item ${activeTab === "home" ? "active" : ""}`}
|
||||
onClick={() => handleNavClick("home")}
|
||||
title={isCollapsed ? (isUserAdmin ? "Панель сервера" : "Кинотеатр") : undefined}
|
||||
className={`has-nav-item ${activeTab === "cinema" ? "active" : ""}`}
|
||||
onClick={() => handleNavClick("cinema")}
|
||||
title={isCollapsed ? "Кинотеатр" : undefined}
|
||||
>
|
||||
<div className="has-nav-item-left">
|
||||
{isUserAdmin ? (
|
||||
<HomeNavIcon className="w-5 h-5" width={20} height={20} />
|
||||
) : (
|
||||
<CinemaNavIcon className="w-5 h-5" width={20} height={20} />
|
||||
)}
|
||||
{!isCollapsed && <span>{isUserAdmin ? "Панель сервера" : "Кинотеатр"}</span>}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`has-nav-item ${activeTab === "storage" ? "active" : ""}`}
|
||||
onClick={() => handleNavClick("storage")}
|
||||
title={isCollapsed ? "Хранилище" : undefined}
|
||||
>
|
||||
<div className="has-nav-item-left">
|
||||
<StorageNavIcon className="w-5 h-5" width={20} height={20} />
|
||||
{!isCollapsed && <span>Хранилище</span>}
|
||||
<CinemaNavIcon className="w-5 h-5" width={20} height={20} />
|
||||
{!isCollapsed && <span>Кинотеатр</span>}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isUserAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className={`has-nav-item ${activeTab === "cinema" ? "active" : ""}`}
|
||||
onClick={() => handleNavClick("cinema")}
|
||||
title={isCollapsed ? "Кинотеатр" : undefined}
|
||||
className={`has-nav-item ${activeTab === "home" ? "active" : ""}`}
|
||||
onClick={() => handleNavClick("home")}
|
||||
title={isCollapsed ? "Панель сервера" : undefined}
|
||||
>
|
||||
<div className="has-nav-item-left">
|
||||
<CinemaNavIcon className="w-5 h-5" width={20} height={20} />
|
||||
{!isCollapsed && <span>Кинотеатр</span>}
|
||||
<HomeNavIcon className="w-5 h-5" width={20} height={20} />
|
||||
{!isCollapsed && <span>Панель сервера</span>}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -427,7 +441,6 @@ export default function Dashboard({
|
|||
<div>
|
||||
<h1 className="has-header-title">
|
||||
{activeTab === "home" && "Обзор сервера"}
|
||||
{activeTab === "storage" && "Файловое хранилище"}
|
||||
{activeTab === "users" && "Пользователи системы"}
|
||||
{activeTab === "settings" && "Настройки системы"}
|
||||
{activeTab === "help" && "Помощь и поддержка"}
|
||||
|
|
@ -467,33 +480,24 @@ export default function Dashboard({
|
|||
|
||||
{/* Content Body Rendering Separate Page Components */}
|
||||
<div className="has-content-body">
|
||||
{activeTab === "home" && (
|
||||
isUserAdmin ? (
|
||||
<Home
|
||||
user={currentUser}
|
||||
displayName={displayName}
|
||||
dbInfo={dbInfo}
|
||||
dbStatus={dbStatus}
|
||||
usersCount={serverUsers.length}
|
||||
/>
|
||||
) : (
|
||||
{activeTab === "cinema" && (
|
||||
<Suspense fallback={<CinemaLoadingFallback />}>
|
||||
<Cinema
|
||||
currentUser={currentUser}
|
||||
onNavigateTab={(tab) => handleNavClick(tab as NavTab)}
|
||||
onLogout={onLogout}
|
||||
isTvEnvironment={isTvEnvironment}
|
||||
/>
|
||||
)
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{activeTab === "storage" && <Storage currentUser={currentUser} />}
|
||||
|
||||
{activeTab === "cinema" && isUserAdmin && (
|
||||
<Cinema
|
||||
currentUser={currentUser}
|
||||
onNavigateTab={(tab) => handleNavClick(tab as NavTab)}
|
||||
onLogout={onLogout}
|
||||
isTvEnvironment={isTvEnvironment}
|
||||
{activeTab === "home" && isUserAdmin && (
|
||||
<Home
|
||||
user={currentUser}
|
||||
displayName={displayName}
|
||||
dbInfo={dbInfo}
|
||||
dbStatus={dbStatus}
|
||||
usersCount={serverUsers.length}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,8 +77,22 @@ export default function Profile({
|
|||
}
|
||||
}, [serverUsers]);
|
||||
|
||||
// Load Profile from DB
|
||||
// Load Profile from DB (with instant localStorage cache fallback)
|
||||
const loadProfile = async () => {
|
||||
// 1. Instant local load
|
||||
const cachedStr = localStorage.getItem(`has_profile_${currentUser.email.toLowerCase()}`);
|
||||
if (cachedStr) {
|
||||
try {
|
||||
const cached = JSON.parse(cachedStr);
|
||||
if (cached.username) setUsername(cached.username);
|
||||
if (cached.avatar) setAvatar(cached.avatar);
|
||||
if (cached.bio) setBio(cached.bio);
|
||||
if (typeof cached.notify_email === "boolean") setNotifyEmail(cached.notify_email);
|
||||
if (typeof cached.notify_sound === "boolean") setNotifySound(cached.notify_sound);
|
||||
if (typeof cached.notify_security === "boolean") setNotifySecurity(cached.notify_security);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!isTauri) {
|
||||
setLoading(false);
|
||||
return;
|
||||
|
|
@ -96,9 +110,12 @@ export default function Profile({
|
|||
setNotifyEmail(data.notify_email);
|
||||
setNotifySound(data.notify_sound);
|
||||
setNotifySecurity(data.notify_security);
|
||||
localStorage.setItem(`has_profile_${currentUser.email.toLowerCase()}`, JSON.stringify(data));
|
||||
} catch (err) {
|
||||
console.error("Ошибка загрузки профиля:", err);
|
||||
setErrorMsg(`Не удалось загрузить данные из базы: ${err}`);
|
||||
console.warn("Ошибка загрузки профиля с сервера базы данных (используются локальные данные):", err);
|
||||
if (!cachedStr) {
|
||||
setErrorMsg(`Сервер базы данных временно недоступен: ${err}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -165,23 +182,48 @@ export default function Profile({
|
|||
}
|
||||
};
|
||||
|
||||
// Save Profile to PostgreSQL DB
|
||||
// Save Profile to PostgreSQL DB with instant local persistence
|
||||
const handleSave = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!isTauri) return;
|
||||
|
||||
setSaving(true);
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
const payload: UserProfileSaveDto = {
|
||||
username: username.trim() || null,
|
||||
avatar: avatar || null,
|
||||
bio: bio.trim() || null,
|
||||
notify_email: notifyEmail,
|
||||
notify_sound: notifySound,
|
||||
notify_security: notifySecurity,
|
||||
};
|
||||
setSaveSuccessMsg(null);
|
||||
|
||||
const payload: UserProfileSaveDto = {
|
||||
username: username.trim() || null,
|
||||
avatar: avatar || null,
|
||||
bio: bio.trim() || null,
|
||||
notify_email: notifyEmail,
|
||||
notify_sound: notifySound,
|
||||
notify_security: notifySecurity,
|
||||
};
|
||||
|
||||
// 1. Immediately persist locally so data is never lost
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`has_profile_${currentUser.email.toLowerCase()}`,
|
||||
JSON.stringify(payload)
|
||||
);
|
||||
if (onUpdateUser) {
|
||||
onUpdateUser({
|
||||
...currentUser,
|
||||
username: payload.username || undefined,
|
||||
avatar: payload.avatar || null,
|
||||
bio: payload.bio || null,
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (!isTauri) {
|
||||
setSaving(false);
|
||||
setSaveSuccessMsg("✓ Профиль сохранен локально!");
|
||||
setTimeout(() => setSaveSuccessMsg(null), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Sync to remote PostgreSQL database
|
||||
try {
|
||||
const updated = await invoke<UserProfileDto>("save_user_profile", {
|
||||
userEmail: currentUser.email,
|
||||
profile: payload,
|
||||
|
|
@ -200,8 +242,10 @@ export default function Profile({
|
|||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Ошибка сохранения профиля:", err);
|
||||
setErrorMsg(`Ошибка сохранения: ${err}`);
|
||||
console.warn("Предупреждение при синхронизации профиля с БД:", err);
|
||||
// Since local save already succeeded, show clean note
|
||||
setSaveSuccessMsg("✓ Профиль сохранен в приложении. Синхронизация с сервером выполнится при стабильной связи.");
|
||||
setTimeout(() => setSaveSuccessMsg(null), 4500);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
|
|||
79
src/utils/tvAudio.ts
Normal file
79
src/utils/tvAudio.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* High-performance, zero-leak WebAudio Singleton for Android TV & Desktop UI navigation.
|
||||
* Prevents "The number of hardware contexts provided by the Platform has been exceeded" crash.
|
||||
*/
|
||||
|
||||
let sharedAudioCtx: AudioContext | null = null;
|
||||
|
||||
export function getSharedAudioContext(): AudioContext | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
if (!sharedAudioCtx) {
|
||||
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (AudioCtx) {
|
||||
sharedAudioCtx = new AudioCtx();
|
||||
}
|
||||
}
|
||||
|
||||
if (sharedAudioCtx && sharedAudioCtx.state === "suspended") {
|
||||
sharedAudioCtx.resume().catch(() => {});
|
||||
}
|
||||
|
||||
return sharedAudioCtx;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// User-interaction wakeup listener to ensure audio is unblocked on TV remote press
|
||||
if (typeof window !== "undefined") {
|
||||
const resumeAudio = () => {
|
||||
if (sharedAudioCtx && sharedAudioCtx.state === "suspended") {
|
||||
sharedAudioCtx.resume().catch(() => {});
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", resumeAudio, { passive: true, once: false });
|
||||
window.addEventListener("click", resumeAudio, { passive: true, once: false });
|
||||
window.addEventListener("touchstart", resumeAudio, { passive: true, once: false });
|
||||
}
|
||||
|
||||
export function playTvBeep(freq = 580, duration = 0.035, volume = 0.04): void {
|
||||
try {
|
||||
const ctx = getSharedAudioContext();
|
||||
if (!ctx) return;
|
||||
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(freq, ctx.currentTime);
|
||||
|
||||
gain.gain.setValueAtTime(volume, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + duration);
|
||||
} catch {
|
||||
// Graceful fallback if audio is blocked
|
||||
}
|
||||
}
|
||||
|
||||
export function playTvNavBeep(): void {
|
||||
playTvBeep(640, 0.025, 0.035);
|
||||
}
|
||||
|
||||
export function playTvSelectBeep(): void {
|
||||
playTvBeep(750, 0.045, 0.05);
|
||||
}
|
||||
|
||||
export function playTvBackBeep(): void {
|
||||
playTvBeep(430, 0.04, 0.04);
|
||||
}
|
||||
|
||||
export function playTvErrorBeep(): void {
|
||||
playTvBeep(220, 0.08, 0.05);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue