diff --git a/package.json b/package.json index 13fdec6..994a7d2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "has", "private": true, - "version": "0.0.9", + "version": "0.0.10", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 140bd01..849b54c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1581,7 +1581,7 @@ dependencies = [ [[package]] name = "has" -version = "0.0.9" +version = "0.0.10" dependencies = [ "base64 0.22.1", "bcrypt", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ceadbae..b97233e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "has" -version = "0.0.9" +version = "0.0.10" description = "has" authors = ["autli"] edition = "2024" diff --git a/src-tauri/src/admin.rs b/src-tauri/src/admin.rs index 9f9b836..d4f29a0 100644 --- a/src-tauri/src/admin.rs +++ b/src-tauri/src/admin.rs @@ -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() } @@ -140,7 +135,7 @@ pub async fn get_admin_security_info(admin_email: &str) -> Result t.trim().eq_ignore_ascii_case(¤t_device.trim()), + Some(t) => t.trim().eq_ignore_ascii_case(current_device.trim()), None => true, } }; @@ -149,7 +144,7 @@ pub async fn get_admin_security_info(admin_email: &str) -> Result t.trim().eq_ignore_ascii_case(¤t_ip.trim()), + Some(t) => t.trim().eq_ignore_ascii_case(current_ip.trim()), None => true, } }; diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs index 2640ee9..54dd245 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -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 { @@ -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(" ") } @@ -271,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()) { @@ -578,6 +574,16 @@ pub fn validate_email_syntax(email: &str) -> Result<(&str, &str), 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", @@ -587,7 +593,7 @@ pub fn validate_email_syntax(email: &str) -> Result<(&str, &str), String> { ]; 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()); + return Err("Временные и одноразовые почтовые ящики не поддерживаются. Пожалуйста, используйте действительный личный адрес.".to_string()); } Ok((local, domain)) @@ -612,6 +618,189 @@ pub async fn verify_domain_exists(domain: &str) -> Result<(), String> { Ok(()) } +pub async fn check_mailru_api(email: &str) -> Result { + 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 { + 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(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::().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:\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 { + 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()); @@ -642,6 +831,14 @@ pub async fn register_user(email: &str, password: &str, username: Option<&str>) }; } + 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, @@ -654,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 = " @@ -724,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 { @@ -754,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, @@ -1113,6 +1324,14 @@ pub async fn request_registration_code(email: &str, _username: Option<&str>) -> }; } + 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, @@ -1133,7 +1352,7 @@ pub async fn request_registration_code(email: &str, _username: Option<&str>) -> match client.query_opt(&check_query, &[&clean_email]).await { Ok(Some(_)) => ResetRequestResponse { success: false, - message: "Пользователь с таким адресом электронной почты уже зарегистрирован".to_string(), + message: "На этот адрес электронной почты уже зарегистрирован аккаунт. На одну почту можно создать только 1 аккаунт.".to_string(), dev_code: None, }, Ok(None) => { @@ -1271,6 +1490,25 @@ mod tests { 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(); diff --git a/src-tauri/src/cinema.rs b/src-tauri/src/cinema.rs index 7b4a76d..e46fd8a 100644 --- a/src-tauri/src/cinema.rs +++ b/src-tauri/src/cinema.rs @@ -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 { - 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::(&content) { - Ok(state) => Ok(state), - Err(e) => { - eprintln!("Error parsing cinema_state.json: {}", e); - Ok(CinemaState::default()) + Ok(content) => serde_json::from_str::(&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 { + 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::(&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 { + 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 = 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 { - 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) -> Result { + 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, +) -> Result { + 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, ) -> Result { - 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,46 @@ 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 { - let mut state = get_cinema_state(app.clone()).unwrap_or_default(); +pub async fn toggle_cinema_bookmark( + app: AppHandle, + title_id: String, + user_email: Option, +) -> Result { + 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 +392,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::().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::().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 +415,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 +425,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::().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::().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 +448,6 @@ pub async fn get_russian_media_info( } } } - } - } } } @@ -301,11 +458,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 +512,11 @@ pub async fn filter_playable_media(items: Vec) -> Result().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::().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 +527,6 @@ pub async fn filter_playable_media(items: Vec) -> Result) -> Result().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::().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 +560,12 @@ pub async fn filter_playable_media(items: Vec) -> Result().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::().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 +587,100 @@ pub async fn filter_playable_media(items: Vec) -> Result Result { 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) } diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs index 05aa5e9..0b26ee9 100644 --- a/src-tauri/src/profile.rs +++ b/src-tauri/src/profile.rs @@ -94,7 +94,7 @@ async fn fetch_profile_with_client( .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), @@ -105,7 +105,33 @@ async fn fetch_profile_with_client( 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>(0) + && let Ok(prefs) = serde_json::from_str::(&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] @@ -132,29 +158,77 @@ pub async fn save_user_profile( let clean_bio = profile.bio.as_deref().map(|s| s.trim()); let avatar = profile.avatar.filter(|s| !s.trim().is_empty()); - // Single unified UPDATE if columns exist - if cols.contains("username") || cols.contains("avatar") || cols.contains("bio") { - let update_sql = "UPDATE users SET \ - username = CASE WHEN $1::text IS NOT NULL THEN $1 ELSE username END, \ - avatar = CASE WHEN $2::text IS NOT NULL THEN $2 ELSE avatar END, \ - bio = $3, \ - notify_email = $4, \ - notify_sound = $5, \ - notify_security = $6 \ - WHERE LOWER(email) = LOWER($7);"; - + // 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") && avatar.is_some() { + params.push(&avatar); + set_clauses.push(format!("avatar = ${}", params.len())); + } + if cols.contains("bio") { + params.push(&clean_bio); + set_clauses.push(format!("bio = ${}", params.len())); + } + if cols.contains("notify_email") { + params.push(&profile.notify_email); + set_clauses.push(format!("notify_email = ${}", params.len())); + } + if cols.contains("notify_sound") { + 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_sql, - &[ - &clean_username, - &avatar, - &clean_bio, - &profile.notify_email, - &profile.notify_sound, - &profile.notify_security, - &clean_email, - ], + "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; } diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index e72e5df..9d79e3c 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -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 { - 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 { 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::(), parts[2].parse::(), parts[3].parse::(), - ) { - 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 { free_formatted: format_bytes(avail), }); } - } - } } } @@ -539,8 +499,8 @@ pub async fn delete_server_item(user_email: &str, item_id: &str) -> Result() { + } else if let Some(id_str) = item_id.strip_prefix("file_") + && let Ok(id) = id_str.parse::() { // 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(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() { - if let Ok(Some(row)) = client + } else if let Some(id_str) = item_id.strip_prefix("file_") + && let Ok(id) = id_str.parse::() + && 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()) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4539933..f3ea30a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "has", - "version": "0.0.9", + "version": "0.0.10", "identifier": "com.has.tauri-app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.css b/src/App.css index 81c4eb0..50c5cf1 100644 --- a/src/App.css +++ b/src/App.css @@ -273,6 +273,202 @@ 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; +} + +/* 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; +} + +/* 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; diff --git a/src/App.tsx b/src/App.tsx index 81a70c1..37f948e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,12 +18,48 @@ 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 saved = localStorage.getItem("has_tv_mode"); + if (saved !== null) return saved === "true"; + 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; + return false; +}; + export default function App() { + const [isTvEnvironment, setIsTvEnvironment] = useState(detectTvEnvironment); const [mode, setMode] = useState<"login" | "register" | "reset">("login"); const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); @@ -36,6 +72,9 @@ export default function App() { 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(null); // Password reset state const [resetStage, setResetStage] = useState<1 | 2 | 3>(1); @@ -65,16 +104,34 @@ export default function App() { // 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 {} } } @@ -85,7 +142,22 @@ export default function App() { // Check DB status on startup checkDatabase(); - }, []); + }, [isTvEnvironment]); + + // Activate TV mode immediately upon any remote control key press + useEffect(() => { + const handleRemoteKey = (e: KeyboardEvent) => { + const isRemoteKey = + [19, 20, 21, 22, 23, 4, 82, 85, 179].includes(e.keyCode) || + ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key); + if (isRemoteKey && !isTvEnvironment) { + setIsTvEnvironment(true); + localStorage.setItem("has_tv_mode", "true"); + } + }; + window.addEventListener("keydown", handleRemoteKey); + return () => window.removeEventListener("keydown", handleRemoteKey); + }, [isTvEnvironment]); // Countdown timer for OTP re-send in resetStage 2 useEffect(() => { @@ -155,6 +227,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()); @@ -176,6 +258,34 @@ export default function App() { 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); @@ -192,6 +302,11 @@ export default function App() { return; } + if (emailCheckStatus === "invalid") { + setError(emailCheckMsg || "Указанный адрес электронной почты не существует. Пожалуйста, укажите действительный почтовый ящик."); + return; + } + if (!username.trim()) { setError("Пожалуйста, укажите имя пользователя"); return; @@ -404,7 +519,7 @@ export default function App() { if (currentUser) { return ( <> - + + + { + setCurrentUser(user); + localStorage.setItem("has_remembered_user", JSON.stringify(user)); + }} + onSwitchToDesktop={() => { + setIsTvEnvironment(false); + localStorage.setItem("has_tv_mode", "false"); + }} + /> + + ); + } + return (
{/* Auto-updater banner */} - + {/* Main Content Area */} {mode === "reset" ? ( @@ -670,9 +803,34 @@ export default function App() {
{/* Actual Project Logo and Name */} -
+
Home Autli Server Home Autli Server +
{/* Title & Subtitle */} @@ -795,9 +953,21 @@ export default function App() { {/* Email Address */}
- +
+ + {mode === "register" && emailCheckStatus === "valid" && ( + + ✓ Почта существует + + )} + {mode === "register" && emailCheckStatus === "invalid" && ( + + {emailCheckMsg?.includes("зарегистрирован") ? "✕ Уже зарегистрирован" : "✕ Недействительна"} + + )} +
@@ -808,11 +978,28 @@ export default function App() { className="form-input" placeholder="user@example.com" value={email} - onChange={(e) => setEmail(e.target.value)} + onChange={(e) => { + setEmail(e.target.value); + if (emailCheckStatus !== "idle") { + setEmailCheckStatus("idle"); + setEmailCheckMsg(null); + } + }} + onBlur={handleEmailBlur} required autoComplete="email" /> + {mode === "register" && emailCheckLoading && ( +
+ +
+ )}
+ {mode === "register" && emailCheckMsg && emailCheckStatus === "invalid" && ( +
+ {emailCheckMsg} +
+ )}
{/* Password */} @@ -929,6 +1116,8 @@ export default function App() { setRegStage(1); setRegCode(""); setUsername(""); + setEmailCheckStatus("idle"); + setEmailCheckMsg(null); setError(null); setSuccess(null); }} @@ -946,6 +1135,8 @@ export default function App() { setRegStage(1); setRegCode(""); setUsername(""); + setEmailCheckStatus("idle"); + setEmailCheckMsg(null); setError(null); setSuccess(null); }} diff --git a/src/components/TvProfileSelector.css b/src/components/TvProfileSelector.css new file mode 100644 index 0000000..7bd0e21 --- /dev/null +++ b/src/components/TvProfileSelector.css @@ -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; +} diff --git a/src/components/TvProfileSelector.tsx b/src/components/TvProfileSelector.tsx new file mode 100644 index 0000000..2cde364 --- /dev/null +++ b/src/components/TvProfileSelector.tsx @@ -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 = ({ + onSelectProfile, + onSwitchToDesktop, +}) => { + const [profiles, setProfiles] = useState(() => { + 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(null); + + const emailInputRef = useRef(null); + const passwordInputRef = useRef(null); + const submitButtonRef = useRef(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("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 ( +
+
+ + {/* Header */} +
+ HAS Logo +

Кто сейчас смотрит?

+

Выберите профиль для автоматического входа

+
+ + {/* Arc Stage with Semicircle & Profile Circles */} +
+ {/* SVG Semicircle Arc (matching user's drawing) */} + + + + + {/* Profile Avatars along the Arc */} + {profiles.map((user, idx) => { + const coords = getCoordinates(idx, profiles.length); + const isFocused = focusState.type === "profile" && focusState.index === idx; + + return ( + + ); + })} + + {/* Plus Button: Positioned in Center under the Arc */} + +
+ + {/* Footer Navigation Hints */} +
+ + ◄ ► Влево / Вправо • ▼ Кнопка + • OK Вход в аккаунт + + {onSwitchToDesktop && ( + + )} +
+ + {/* ================================================================== + STRICT 3-BUTTON TV LOGIN MODAL + "два поля почта и пароль с кнопкой входа больше пультом дистанционного + управления никуда нельзя будет нажать только на эти три кнопки" + ================================================================== */} + {isLoginModalOpen && ( +
{ + setIsLoginModalOpen(false); + setFocusState({ type: "plus" }); + }} + > +
e.stopPropagation()}> +
+

Добавить профиль

+

+ Введите данные для авторизации нового аккаунта на ТВ +

+
+ +
+ {/* Field 1: Email */} + setTvEmail(e.target.value)} + placeholder="Почта" + className={`has-tv-login-input ${tvLoginFocus === 0 ? "focused" : ""}`} + tabIndex={0} + autoFocus + /> + + {/* Field 2: Password */} + setTvPassword(e.target.value)} + placeholder="Пароль" + className={`has-tv-login-input ${tvLoginFocus === 1 ? "focused" : ""}`} + tabIndex={0} + /> + + {tvError &&
{tvError}
} + + {/* Button 3: Submit Button */} + +
+ +
+ ▲ ▼ Пульт: перемещение • OK: выбор • Назад (Back / Esc): отмена +
+
+
+ )} +
+ ); +}; diff --git a/src/components/UpdaterBanner.tsx b/src/components/UpdaterBanner.tsx index f964edb..097cbdd 100644 --- a/src/components/UpdaterBanner.tsx +++ b/src/components/UpdaterBanner.tsx @@ -1,11 +1,51 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; +import { playTvBeep } from "../utils/tvAudio"; interface UpdateInfo { version: string; body: 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 saved = localStorage.getItem("has_tv_mode"); + if (saved !== null) return saved === "true"; + 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; + return false; +}; + +export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) { + const [isTv, setIsTv] = useState(() => isTvEnvironment ?? detectTvEnvironment()); const [updateAvailable, setUpdateAvailable] = useState(false); const [updateInfo, setUpdateInfo] = useState(null); const [downloading, setDownloading] = useState(false); @@ -13,91 +53,280 @@ export function UpdaterBanner() { const [error, setError] = useState(null); const [dismissed, setDismissed] = useState(false); - useEffect(() => { - if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) { - return; - } + // Strictly two buttons for TV remote navigation: "update" | "skip" + const [tvFocusedBtn, setTvFocusedBtn] = useState<"update" | "skip">("update"); + useEffect(() => { + if (typeof isTvEnvironment === "boolean") { + setIsTv(isTvEnvironment); + } + }, [isTvEnvironment]); + + // 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, - }); - setDismissed(false); + // 1. Try official Tauri updater plugin first (Desktop) + 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 (err) { + console.warn("[Updater] Plugin check fallback:", err); } - } catch (err) { - console.warn("[Updater] Check failed:", err); } + + // 2. Fallback check for Android TV / browser via releases endpoint + try { + const resp = await fetch("https://github.com/autli5/has/releases/latest/download/latest.json"); + if (resp.ok) { + const data = await resp.json(); + if (!cancelled && data?.version && data.version !== "0.0.10") { + setUpdateAvailable(true); + setUpdateInfo({ + version: data.version, + body: data.notes || data.body || "Новое обновление с улучшенной стабильностью и функциями.", + }); + setDismissed(false); + } + } + } catch {} }; // 1. Initial check after startup - const timer = setTimeout(checkForUpdates, 800); + const timer = setTimeout(checkForUpdates, 1000); - // 2. Periodic check every 60 seconds while app is running + // 2. Periodic check every 60 seconds const interval = setInterval(checkForUpdates, 60 * 1000); // 3. Check whenever window gains focus const handleFocus = () => checkForUpdates(); window.addEventListener("focus", handleFocus); - // 4. Custom event for manual checks from UI + // 4. Custom event for manual checks from UI or simulation window.addEventListener("has-check-update", handleFocus); + const handleSimulateUpdate = (e: any) => { + setUpdateAvailable(true); + setUpdateInfo({ + version: e.detail?.version || "1.0.0", + body: e.detail?.body || "Вышло масштабное обновление программы: оптимизация для Android TV, автопереключение плееров и синхронизация.", + }); + 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); }; }, []); - const handleInstall = async () => { - if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) return; - + const handleInstall = useCallback(async () => { setDownloading(true); setError(null); setProgress(0); - try { - const { check } = await import("@tauri-apps/plugin-updater"); - const { relaunch } = await import("@tauri-apps/plugin-process"); + // Desktop Tauri plugin install + if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) { + 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; + const update = await check(); + if (update?.available) { + let downloaded = 0; + let total = 0; - 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 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(); + return; } - }); - - await relaunch(); - } catch (e: unknown) { - setError(`Ошибка обновления: ${e instanceof Error ? e.message : String(e)}`); - setDownloading(false); + } catch (e: unknown) { + console.warn("[Updater] Desktop updater failed, falling back to download:", e); + } } - }; + + // Fallback for Android TV / browser: animated progress and open download + let p = 0; + const progressTimer = setInterval(() => { + p += 15; + if (p >= 100) { + clearInterval(progressTimer); + setProgress(100); + setTimeout(async () => { + try { + const { openUrl } = await import("@tauri-apps/plugin-opener"); + await openUrl("https://github.com/autli5/has/releases/latest"); + } catch { + window.open("https://github.com/autli5/has/releases/latest", "_blank"); + } + setDismissed(true); + setDownloading(false); + }, 600); + } else { + setProgress(p); + } + }, 250); + }, []); + + // Strict Remote Control D-Pad Navigation for TV Update Modal + // "рабочие две кнопки чтобы можно было переключаться только между ними + // эти две кнопки написанные кнопка обновить и кнопка пропустить" + useEffect(() => { + if (!updateAvailable || dismissed || !isTv || downloading) return; + + const handleTvKeyDown = (e: KeyboardEvent) => { + // Toggle strictly between "update" and "skip" on Left / Right / Up / Down / Tab + if ( + e.key === "ArrowLeft" || + e.key === "ArrowRight" || + e.key === "ArrowUp" || + e.key === "ArrowDown" || + e.keyCode === 21 || + e.keyCode === 22 || + e.keyCode === 19 || + e.keyCode === 20 || + e.key === "Tab" + ) { + e.preventDefault(); + setTvFocusedBtn((prev) => (prev === "update" ? "skip" : "update")); + playTvBeep(520, 0.025); + 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 { + 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]); if (!updateAvailable || dismissed) return null; + // ========================================================================= + // 1. ANDROID TV: Full-screen Centered Modal with EXACTLY Two Buttons + // ========================================================================= + if (isTv) { + return ( +
+
+
🚀
+

Доступно обновление

+
+ Вышла новая версия v{updateInfo?.version || "1.0.0"} +
+ +

+ {updateInfo?.body || + "Вышло новое обновление программы с оптимизацией для Android TV, автопереключением плееров и синхронизацией истории."} +

+ + {downloading ? ( +
+
+
+
+ + Загрузка и установка обновления: {progress}% + +
+ ) : ( +
+ {/* Button 1: Обновить */} + + + {/* Button 2: Пропустить */} + +
+ )} + + {error &&
{error}
} + +
+ ◄ ► Стрелки на пульте: переключение • OK: подтвердить выбор • Back: пропустить +
+
+
+ ); + } + + // ========================================================================= + // 2. DESKTOP: Classic Bottom-Right Popup Banner + // ========================================================================= return (
diff --git a/src/pages/cinema/Cinema.css b/src/pages/cinema/Cinema.css index e2d0bd9..00182ab 100644 --- a/src/pages/cinema/Cinema.css +++ b/src/pages/cinema/Cinema.css @@ -904,6 +904,45 @@ transform: scale(1.08); } +.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%; diff --git a/src/pages/cinema/Cinema.tsx b/src/pages/cinema/Cinema.tsx index 444e019..e840088 100644 --- a/src/pages/cinema/Cinema.tsx +++ b/src/pages/cinema/Cinema.tsx @@ -3,6 +3,12 @@ import Hls from "hls.js"; import { invoke } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { openUrl } from "@tauri-apps/plugin-opener"; +import { + playTvBeep as playTvBeepAudio, + playTvNavBeep, + playTvSelectBeep, + playTvBackBeep, +} from "../../utils/tvAudio"; import type { CinemaState, CinemaConfig, @@ -540,7 +546,6 @@ export default function Cinema({ }, [isTvEnvironment, isTvMode]); const [tvToast, setTvToast] = useState<{ text: string; icon?: string } | null>(null); const tvToastTimerRef = useRef(null); - const audioCtxRef = useRef(null); const showTvToast = useCallback((text: string, icon = "📺", duration = 2000) => { if (tvToastTimerRef.current) clearTimeout(tvToastTimerRef.current); @@ -551,29 +556,7 @@ export default function Cinema({ }, []); const playTvBeep = useCallback((freq = 580, duration = 0.035) => { - try { - const AudioCtx = window.AudioContext || (window as any).webkitAudioContext; - if (!AudioCtx) return; - if (!audioCtxRef.current) { - audioCtxRef.current = new AudioCtx(); - } - const ctx = audioCtxRef.current; - if (ctx.state === "suspended") { - ctx.resume().catch(() => {}); - } - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = "sine"; - osc.frequency.setValueAtTime(freq, ctx.currentTime); - gain.gain.setValueAtTime(0.04, 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 { - // AudioContext might be blocked until user gesture, ignore safely - } + playTvBeepAudio(freq, duration); }, []); const toggleTvMode = useCallback(() => { @@ -631,6 +614,13 @@ export default function Cinema({ const hlsInstanceRef = useRef(null); const aniLibriaReleasesCache = useRef>(new Map()); + // Automatic Mirror Watchdog & Failover System (zero dropped playback) + const triedMirrorsRef = useRef>(new Set()); + const mirrorWatchdogTimerRef = useRef(null); + const [isMirrorConnecting, setIsMirrorConnecting] = useState(false); + const [allMirrorsFailed, setAllMirrorsFailed] = useState(false); + const currentPlaybackRef = useRef<{ currentTime: number; duration: number }>({ currentTime: 0, duration: 0 }); + // Detailed Movie / Series Information Modal State const [detailsMedia, setDetailsMedia] = useState(null); const [isLoadingDetails, setIsLoadingDetails] = useState(false); @@ -980,12 +970,12 @@ export default function Cinema({ }) .catch((err) => console.warn("Failed to get cinema config:", err)); - invoke("get_cinema_state") + invoke("get_cinema_state", { userEmail: currentUser?.email }) .then((state) => { if (state) setCinemaState(state); }) .catch((err) => console.warn("Failed to get local cinema state:", err)); - }, [isTauri, loadFullCatalog]); + }, [isTauri, loadFullCatalog, currentUser?.email]); // Search TMDB live across all movies, series, and anime (only released and verified playable titles) const executeSearch = useCallback( @@ -1085,12 +1075,13 @@ export default function Cinema({ const isBookmarked = cinemaState.bookmarks.includes(selectedMedia.id); - // Toggle Bookmark (Saved locally on user's computer in cinema_state.json) + // Toggle Bookmark (Saved locally on user's computer and synced to PostgreSQL) const handleToggleBookmark = async () => { if (isTauri) { try { const nextState = await invoke("toggle_cinema_bookmark", { titleId: selectedMedia.id, + userEmail: currentUser?.email, }); setCinemaState(nextState); } catch (err) { @@ -1243,6 +1234,81 @@ export default function Cinema({ }; }, [selectedMirror, anilibriaStreamUrl]); + // List of all available mirrors sorted with preferred options first + const getAvailableMirrorsList = useCallback((): BalancerMirror[] => { + const activeMedia = playingMedia || selectedMedia; + const isAnime = + activeMedia?.genre?.toLowerCase().includes("аниме") || + activeMedia?.genre?.toLowerCase().includes("anime") || + activeMedia?.title?.toLowerCase().includes("клинок") || + (activeMedia?.original_title || "").toLowerCase().includes("kimetsu"); + + const mirrors: BalancerMirror[] = []; + if (isAnime && anilibriaStreamUrl) mirrors.push("anilibria"); + if (currentAllohaUrl) mirrors.push("alloha_ru"); + if (kodikResults.length > 0 || isAnime) mirrors.push("kodik_ru"); + mirrors.push("vidlink_pro"); + if (!mirrors.includes("alloha_ru")) mirrors.push("alloha_ru"); + if (!mirrors.includes("kodik_ru")) mirrors.push("kodik_ru"); + mirrors.push("autoembed"); + mirrors.push("vidsrc_pm"); + mirrors.push("multiembed"); + mirrors.push("vidsrc_to"); + + return Array.from(new Set(mirrors)); + }, [playingMedia, selectedMedia, anilibriaStreamUrl, currentAllohaUrl, kodikResults]); + + // Automatic Failover Watchdog: seamlessly switches to the next mirror if a server stalls or throws error + const handleAutoSwitchNextMirror = useCallback((reason: string) => { + if (mirrorWatchdogTimerRef.current) { + clearTimeout(mirrorWatchdogTimerRef.current); + mirrorWatchdogTimerRef.current = null; + } + + const available = getAvailableMirrorsList(); + triedMirrorsRef.current.add(selectedMirror); + + const nextUntried = available.find((m) => !triedMirrorsRef.current.has(m)); + + if (nextUntried) { + console.log(`[Cinema Failover] ${reason}. Auto-switching to:`, nextUntried); + setSelectedMirror(nextUntried); + setIsMirrorConnecting(true); + setAllMirrorsFailed(false); + + const labels: Record = { + vidlink_pro: "Сервер 1: VidLink Pro (1080p)", + kodik_ru: "Сервер 2: Kodik (Студии озвучки)", + autoembed: "Сервер 3: AutoEmbed", + vidsrc_pm: "Сервер 4: VidSrc Pro", + multiembed: "Сервер 5: MultiStream VIP", + vidsrc_to: "Сервер 6: VidSrc To", + alloha_ru: "Сервер 7: Alloha", + anilibria: "Сервер 8: AniLibria HLS", + embess_ru: "Embess", + smotrim_ru: "Smotrim", + }; + + showTvToast(`Автопереключение: ${labels[nextUntried] || nextUntried}`, "🔄", 2500); + + mirrorWatchdogTimerRef.current = setTimeout(() => { + handleAutoSwitchNextMirror("Сервер не ответил за 8 секунд"); + }, 8000); + } else { + setIsMirrorConnecting(false); + setAllMirrorsFailed(true); + showTvToast("Все серверы временно недоступны. Нажмите «Попробовать снова».", "⚠️", 3500); + } + }, [selectedMirror, getAvailableMirrorsList, showTvToast]); + + const handleIframeLoaded = useCallback(() => { + if (mirrorWatchdogTimerRef.current) { + clearTimeout(mirrorWatchdogTimerRef.current); + mirrorWatchdogTimerRef.current = null; + } + setIsMirrorConnecting(false); + }, []); + // Start playback for a media item (movie or specific series episode) const startPlayback = (media: CinemaMediaItem, seasonNum: number = 1, episode?: CinemaEpisode) => { setSelectedMedia(media); @@ -1267,6 +1333,22 @@ export default function Cinema({ }; setPlayingEpisode(ep); + // Track playback starting point from synchronized history + const existingProg = cinemaState.history.find( + (h) => h.title_id === media.id && h.season === seasonNum && h.episode === ep.number + ); + if (existingProg && existingProg.progress_seconds > 0) { + currentPlaybackRef.current = { + currentTime: existingProg.progress_seconds, + duration: existingProg.duration_seconds || (ep.duration_minutes ? ep.duration_minutes * 60 : 7200), + }; + } else { + currentPlaybackRef.current = { + currentTime: 0, + duration: ep.duration_minutes ? ep.duration_minutes * 60 : (media.duration_minutes ? media.duration_minutes * 60 : 7200), + }; + } + const isAnime = media.genre?.toLowerCase().includes("аниме") || media.genre?.toLowerCase().includes("cartoon") || @@ -1283,6 +1365,18 @@ export default function Cinema({ setCurrentImdbId(null); setCurrentAllohaUrl(null); + // Reset mirror tracking and watchdog for fresh playback session + triedMirrorsRef.current.clear(); + setAllMirrorsFailed(false); + setIsMirrorConnecting(true); + + if (mirrorWatchdogTimerRef.current) { + clearTimeout(mirrorWatchdogTimerRef.current); + } + mirrorWatchdogTimerRef.current = setTimeout(() => { + handleAutoSwitchNextMirror("Сервер не ответил за 8 секунд (автопереключение)"); + }, 8000); + // Default to verified reliable high-speed 1080p stream: if (isAnime) { if (anilibriaStreamUrl) { @@ -1390,6 +1484,7 @@ export default function Cinema({ try { const nextState = await invoke("toggle_cinema_bookmark", { titleId: mediaId, + userEmail: currentUser?.email, }); setCinemaState(nextState); } catch (err) { @@ -1476,21 +1571,19 @@ export default function Cinema({ // Quick 1-click multi-source switcher with automatic fallback const handleCycleMirror = () => { - const mirrors: BalancerMirror[] = [ - "vidlink_pro", - "kodik_ru", - "autoembed", - "vidsrc_pm", - "multiembed", - "vidsrc_to", - ]; - if (currentAllohaUrl) mirrors.push("alloha_ru"); - if (anilibriaStreamUrl) mirrors.push("anilibria"); + if (mirrorWatchdogTimerRef.current) { + clearTimeout(mirrorWatchdogTimerRef.current); + mirrorWatchdogTimerRef.current = null; + } + const mirrors = getAvailableMirrorsList(); const currentIndex = mirrors.indexOf(selectedMirror); const nextIndex = (currentIndex + 1) % mirrors.length; const nextMirror = mirrors[nextIndex]; setSelectedMirror(nextMirror); + setAllMirrorsFailed(false); + setIsMirrorConnecting(true); + triedMirrorsRef.current.add(nextMirror); const labels: Record = { vidlink_pro: "Сервер 1: VidLink Pro (1080p)", @@ -1506,6 +1599,10 @@ export default function Cinema({ }; showTvToast(`Переключено на: ${labels[nextMirror] || nextMirror}`, "🔄", 2200); resetHudTimer(); + + mirrorWatchdogTimerRef.current = setTimeout(() => { + handleAutoSwitchNextMirror("Сервер не ответил за 8 секунд"); + }, 8000); }; // Save playback progress on time update @@ -1514,6 +1611,7 @@ export default function Cinema({ const currentTime = videoRef.current.currentTime; const duration = videoRef.current.duration; const activeMedia = playingMedia || selectedMedia; + currentPlaybackRef.current = { currentTime, duration }; if (duration > 0 && isTauri) { const payload: CinemaPlayback = { @@ -1523,13 +1621,13 @@ export default function Cinema({ backdrop_path: activeMedia.backdrop_url, season: playingSeasonNumber, episode: playingEpisode.number, - progress_seconds: currentTime, - duration_seconds: duration, + progress_seconds: Math.round(currentTime), + duration_seconds: Math.round(duration), last_watched: new Date().toISOString(), }; if (Math.floor(currentTime) % 5 === 0) { - invoke("update_cinema_progress", { item: payload }) + invoke("update_cinema_progress", { item: payload, userEmail: currentUser?.email }) .then((st) => setCinemaState(st)) .catch(() => {}); } @@ -1539,6 +1637,13 @@ export default function Cinema({ // Close player and save exact position const handleClosePlayer = useCallback(() => { const activeMedia = playingMedia || selectedMedia; + if (mirrorWatchdogTimerRef.current) { + clearTimeout(mirrorWatchdogTimerRef.current); + mirrorWatchdogTimerRef.current = null; + } + setIsMirrorConnecting(false); + triedMirrorsRef.current.clear(); + try { if (isTauri && isFullscreen) { try { @@ -1548,11 +1653,19 @@ export default function Cinema({ } } catch {} - if (videoRef.current && playingEpisode && isTauri) { + if (playingEpisode && isTauri && activeMedia) { try { - const currentTime = videoRef.current.currentTime; - const duration = videoRef.current.duration; - if (duration > 0 && activeMedia) { + let currentTime = currentPlaybackRef.current.currentTime; + let duration = currentPlaybackRef.current.duration; + if (videoRef.current && videoRef.current.duration > 0) { + currentTime = videoRef.current.currentTime; + duration = videoRef.current.duration; + } + if (duration <= 0) { + duration = (playingEpisode.duration_minutes || activeMedia.duration_minutes || (activeMedia.media_type === "movie" ? 120 : 45)) * 60; + } + + if (currentTime > 5) { const payload: CinemaPlayback = { title_id: activeMedia.id, title_name: activeMedia.title, @@ -1560,11 +1673,11 @@ export default function Cinema({ backdrop_path: activeMedia.backdrop_url, season: playingSeasonNumber, episode: playingEpisode.number, - progress_seconds: currentTime, - duration_seconds: duration, + progress_seconds: Math.round(currentTime), + duration_seconds: Math.round(duration), last_watched: new Date().toISOString(), }; - invoke("update_cinema_progress", { item: payload }) + invoke("update_cinema_progress", { item: payload, userEmail: currentUser?.email }) .then((st) => setCinemaState(st)) .catch(() => {}); } @@ -1598,7 +1711,99 @@ export default function Cinema({ setSelectedKodikLink(null); setRussianDubNotice(null); setAnilibriaStreamUrl(null); - }, [isTauri, isFullscreen, playingEpisode, playingMedia, selectedMedia, playingSeasonNumber]); + }, [isTauri, isFullscreen, playingEpisode, playingMedia, selectedMedia, playingSeasonNumber, currentUser?.email]); + + // Synchronize playback progress across devices even for embedded players (iframes) + useEffect(() => { + if (!playingEpisode) return; + + const interval = setInterval(() => { + const activeMedia = playingMedia || selectedMedia; + if (!activeMedia) return; + + if (videoRef.current && videoRef.current.duration > 0) { + currentPlaybackRef.current.currentTime = videoRef.current.currentTime; + currentPlaybackRef.current.duration = videoRef.current.duration; + } else if (playerMode === "balancer" && !allMirrorsFailed) { + currentPlaybackRef.current.currentTime += 5; + } + + const cur = currentPlaybackRef.current.currentTime; + const dur = currentPlaybackRef.current.duration || 7200; + + if (cur >= 5 && isTauri) { + const payload: CinemaPlayback = { + title_id: activeMedia.id, + title_name: activeMedia.title, + poster_path: activeMedia.poster_url, + backdrop_path: activeMedia.backdrop_url, + season: playingSeasonNumber, + episode: playingEpisode.number, + progress_seconds: Math.round(cur), + duration_seconds: Math.round(dur), + last_watched: new Date().toISOString(), + }; + + invoke("update_cinema_progress", { + item: payload, + userEmail: currentUser?.email, + }) + .then((st) => setCinemaState(st)) + .catch(() => {}); + } + }, 5000); + + return () => clearInterval(interval); + }, [playingEpisode, playerMode, allMirrorsFailed, playingMedia, selectedMedia, playingSeasonNumber, isTauri, currentUser?.email]); + + // Listen to postMessage from embedded players (VidLink, Kodik, Alloha) + useEffect(() => { + const handleWindowMessage = (e: MessageEvent) => { + try { + let data = e.data; + if (typeof data === "string" && (data.startsWith("{") || data.startsWith("["))) { + try { + data = JSON.parse(data); + } catch {} + } + if (!data || typeof data !== "object") return; + + // VidLink postMessage + if (data.type === "MEDIA_DATA" && data.data) { + if (typeof data.data.currentTime === "number" && data.data.currentTime > 0) { + currentPlaybackRef.current.currentTime = data.data.currentTime; + } + if (typeof data.data.duration === "number" && data.data.duration > 0) { + currentPlaybackRef.current.duration = data.data.duration; + } + handleIframeLoaded(); + } + + // Kodik postMessage + if (data.key === "kodik_player_time_update" && data.value) { + if (typeof data.value.time === "number" && data.value.time > 0) { + currentPlaybackRef.current.currentTime = data.value.time; + } + if (typeof data.value.duration === "number" && data.value.duration > 0) { + currentPlaybackRef.current.duration = data.value.duration; + } + handleIframeLoaded(); + } + + // Generic timeupdate + if ((data.event === "timeUpdate" || data.type === "timeupdate") && typeof data.time === "number") { + currentPlaybackRef.current.currentTime = data.time; + if (typeof data.duration === "number") { + currentPlaybackRef.current.duration = data.duration; + } + handleIframeLoaded(); + } + } catch {} + }; + + window.addEventListener("message", handleWindowMessage); + return () => window.removeEventListener("message", handleWindowMessage); + }, [handleIframeLoaded]); // D-Pad Spatial Navigation & TV Remote Key Handler const handleRemoteKeyDown = useCallback( @@ -1623,6 +1828,13 @@ export default function Cinema({ e.key === "BrowserBack" || e.keyCode === 4 || e.keyCode === 27; + const isOk = + e.key === "Enter" || + e.key === "Select" || + e.key === "Accept" || + e.keyCode === 13 || + e.keyCode === 23 || + e.keyCode === 66; const isPlayPause = e.key === "MediaPlayPause" || e.keyCode === 85 || e.keyCode === 179; const isFastForward = e.key === "MediaFastForward" || e.keyCode === 90; @@ -1640,7 +1852,7 @@ export default function Cinema({ document.querySelector(".has-cinema-tv-nav-item"); if (navBtn) { navBtn.focus(); - playTvBeep(640, 0.03); + playTvNavBeep(); } }, 30); } @@ -1672,7 +1884,7 @@ export default function Cinema({ } handleClosePlayer(); showTvToast("Плеер закрыт", "✕", 1500); - playTvBeep(400, 0.05); + playTvBackBeep(); return; } @@ -1690,22 +1902,83 @@ export default function Cinema({ const backBtn = document.querySelector(".has-cinema-player-btn-back"); if (backBtn) { backBtn.focus(); - playTvBeep(640, 0.03); + playTvNavBeep(); } }, 40); return; } - // Pressing OK / Enter when HUD is idle wakes up the full HUD - if ((e.key === "Enter" || e.keyCode === 13) && !isHudVisible && !videoRef.current) { - e.preventDefault(); - setIsHudVisible(true); - resetHudTimer(); - setTimeout(() => { - const backBtn = document.querySelector(".has-cinema-player-btn-back"); - if (backBtn) backBtn.focus(); - }, 40); - return; + // Pressing ArrowDown on TV remote reveals HUD and focuses server mirror pills / episodes + if ( + e.key === "ArrowDown" || + e.keyCode === 20 || + e.keyCode === 40 + ) { + const isAtBottom = document.activeElement?.closest(".has-cinema-player-server-pill, .has-cinema-player-hud-btn"); + if (!isAtBottom || !isHudVisible) { + e.preventDefault(); + setIsHudVisible(true); + resetHudTimer(); + setTimeout(() => { + const serverPill = + document.querySelector(".has-cinema-player-server-pill.active") || + document.querySelector(".has-cinema-player-server-pill") || + document.querySelector(".has-cinema-player-hud-btn"); + if (serverPill) { + serverPill.focus(); + playTvNavBeep(); + } + }, 40); + return; + } + } + + // Pressing OK / Enter / Center button in player + if (isOk) { + const active = document.activeElement as HTMLElement | null; + const isButtonFocused = + active && + (active.tagName === "BUTTON" || + active.getAttribute("role") === "button" || + active.hasAttribute("data-tv-focusable")); + + if (!isHudVisible) { + e.preventDefault(); + setIsHudVisible(true); + resetHudTimer(); + if (videoRef.current) { + if (videoRef.current.paused) { + videoRef.current.play().catch(() => {}); + showTvToast("Воспроизведение", "▶", 1200); + } else { + videoRef.current.pause(); + showTvToast("Пауза", "⏸", 1200); + } + playTvSelectBeep(); + } + return; + } + + if (isButtonFocused && active) { + // Let the button click handler do its work, give audio feedback + playTvSelectBeep(); + active.click(); + e.preventDefault(); + return; + } + + if (videoRef.current) { + e.preventDefault(); + if (videoRef.current.paused) { + videoRef.current.play().catch(() => {}); + showTvToast("Воспроизведение", "▶", 1200); + } else { + videoRef.current.pause(); + showTvToast("Пауза", "⏸", 1200); + } + playTvSelectBeep(); + return; + } } if (isPlayPause || (e.code === "Space" && !isInput)) { @@ -1718,7 +1991,7 @@ export default function Cinema({ videoRef.current.pause(); showTvToast("Пауза", "⏸", 1200); } - playTvBeep(560, 0.04); + playTvSelectBeep(); } return; } @@ -1727,7 +2000,7 @@ export default function Cinema({ isFastForward || (e.key === "ArrowRight" && !isInput && - !document.activeElement?.closest(".has-cinema-player-modal")) + !document.activeElement?.closest(".has-cinema-player-modal .has-cinema-player-hud-btn, .has-cinema-player-server-pill")) ) { if (videoRef.current) { e.preventDefault(); @@ -1736,7 +2009,7 @@ export default function Cinema({ videoRef.current.currentTime + 10 ); showTvToast("+10 сек", "⏩", 1000); - playTvBeep(520, 0.03); + playTvNavBeep(); return; } } @@ -1745,7 +2018,7 @@ export default function Cinema({ isRewind || (e.key === "ArrowLeft" && !isInput && - !document.activeElement?.closest(".has-cinema-player-modal")) + !document.activeElement?.closest(".has-cinema-player-modal .has-cinema-player-hud-btn, .has-cinema-player-server-pill")) ) { if (videoRef.current) { e.preventDefault(); @@ -1754,7 +2027,24 @@ export default function Cinema({ videoRef.current.currentTime - 10 ); showTvToast("-10 сек", "⏪", 1000); - playTvBeep(480, 0.03); + playTvNavBeep(); + return; + } + } + } + + // 2. OK / Select Button outside player (Immediate activation of focused card/button/tab/chip) + if (isOk && !isInput) { + const active = document.activeElement as HTMLElement | null; + if (active && active !== document.body) { + if ( + active.matches( + 'button, a, [data-tv-focusable="true"], .has-cinema-nav-item, .has-cinema-tv-nav-item, .has-cinema-btn-play, .has-cinema-btn-bookmark, .has-cinema-media-card, .has-cinema-grid-card, .has-cinema-episode-card, .has-cinema-drawer-option-btn, .has-cinema-drawer-chip, .has-cinema-drawer-ep-btn, .has-cinema-season-tab, .has-cinema-details-btn-play, .has-cinema-details-close-btn, .has-cinema-details-btn-bookmark, .has-cinema-details-btn-trailer, .has-cinema-actor-card, .has-cinema-btn-info-wide' + ) + ) { + e.preventDefault(); + playTvSelectBeep(); + active.click(); return; } } @@ -2128,7 +2418,20 @@ export default function Cinema({ // Switch mirror manually const handleSelectMirror = async (mirrorId: BalancerMirror) => { + if (mirrorWatchdogTimerRef.current) { + clearTimeout(mirrorWatchdogTimerRef.current); + mirrorWatchdogTimerRef.current = null; + } setSelectedMirror(mirrorId); + setAllMirrorsFailed(false); + setIsMirrorConnecting(true); + triedMirrorsRef.current.add(mirrorId); + + // Watchdog for manually chosen mirror (8 seconds) + mirrorWatchdogTimerRef.current = setTimeout(() => { + handleAutoSwitchNextMirror("Сервер не ответил за 8 секунд"); + }, 8000); + if (mirrorId === "anilibria") { const activeMedia = playingMedia || selectedMedia; const epNum = playingEpisode?.number || 1; @@ -2136,6 +2439,11 @@ export default function Cinema({ if (stream) { setAnilibriaStreamUrl(stream); setRussianDubNotice("🌸 Подключен поток AniLibria Full HD (Studio Band)."); + if (mirrorWatchdogTimerRef.current) { + clearTimeout(mirrorWatchdogTimerRef.current); + mirrorWatchdogTimerRef.current = null; + } + setIsMirrorConnecting(false); } } else { setAnilibriaStreamUrl(null); @@ -2905,6 +3213,38 @@ export default function Cinema({ ].find((item) => item.id === h.title_id); if (found) { handleMediaCardClick(found); + } else { + const tmdbMatch = h.title_id.match(/tmdb-(movie|tv)-(\d+)/); + const isTv = tmdbMatch ? tmdbMatch[1] === "tv" : h.season > 0 && h.episode > 0; + const tmdbId = tmdbMatch ? parseInt(tmdbMatch[2], 10) : undefined; + const fallbackItem: CinemaMediaItem = { + id: h.title_id, + tmdb_id: tmdbId, + media_type: isTv ? "tv" : "movie", + title: h.title_name, + poster_url: h.poster_path || INITIAL_LIBRARY[0].poster_url, + backdrop_url: h.backdrop_path || INITIAL_LIBRARY[0].backdrop_url, + genre: isTv ? "Сериал" : "Фильм", + synopsis: `Продолжение просмотра: ${h.title_name}`, + release_year: "2024", + rating: 8.5, + is_original: false, + seasons: [ + { + season_number: h.season || 1, + episodes: [ + { + id: `ep-${h.title_id}-${h.season}-${h.episode}`, + number: h.episode || 1, + title: `Серия ${h.episode || 1}`, + thumbnail: h.poster_path || INITIAL_LIBRARY[0].poster_url, + duration_minutes: Math.round((h.duration_seconds || 3600) / 60), + }, + ], + }, + ], + }; + handleMediaCardClick(fallbackItem); } }} data-tv-focusable="true" @@ -2921,6 +3261,38 @@ export default function Cinema({ ].find((item) => item.id === h.title_id); if (found) { handleMediaCardClick(found); + } else { + const tmdbMatch = h.title_id.match(/tmdb-(movie|tv)-(\d+)/); + const isTv = tmdbMatch ? tmdbMatch[1] === "tv" : h.season > 0 && h.episode > 0; + const tmdbId = tmdbMatch ? parseInt(tmdbMatch[2], 10) : undefined; + const fallbackItem: CinemaMediaItem = { + id: h.title_id, + tmdb_id: tmdbId, + media_type: isTv ? "tv" : "movie", + title: h.title_name, + poster_url: h.poster_path || INITIAL_LIBRARY[0].poster_url, + backdrop_url: h.backdrop_path || INITIAL_LIBRARY[0].backdrop_url, + genre: isTv ? "Сериал" : "Фильм", + synopsis: `Продолжение просмотра: ${h.title_name}`, + release_year: "2024", + rating: 8.5, + is_original: false, + seasons: [ + { + season_number: h.season || 1, + episodes: [ + { + id: `ep-${h.title_id}-${h.season}-${h.episode}`, + number: h.episode || 1, + title: `Серия ${h.episode || 1}`, + thumbnail: h.poster_path || INITIAL_LIBRARY[0].poster_url, + duration_minutes: Math.round((h.duration_seconds || 3600) / 60), + }, + ], + }, + ], + }; + handleMediaCardClick(fallbackItem); } } }} @@ -3812,6 +4184,13 @@ export default function Cinema({ {/* Clean 100% Video Canvas */}
+ {isMirrorConnecting && ( +
+
+ Защита от сбоев: подключение к серверу... +
+ )} + {(playingMedia || selectedMedia).is_unreleased ? (
@@ -3846,6 +4225,38 @@ export default function Cinema({
+ ) : allMirrorsFailed ? ( +
+
+ 🛡️ +

Все доступные серверы были проверены

+

+ Видеопоток временно не ответил ни на одном из зеркал (возможно блокировка провайдера). Вы можете запустить повторную проверку или вернуться в каталог. +

+
+ + +
+
+
) : selectedMirror === "anilibria" ? ( anilibriaStreamUrl ? (