feat(release): v0.0.10 - remove storage, pure cinema focus, tv remote dpad nav, zero-leak audio & bundle optimization
This commit is contained in:
parent
fd1fcffef7
commit
4d7fab0166
19 changed files with 2961 additions and 374 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "has",
|
"name": "has",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.9",
|
"version": "0.0.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
|
||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -1581,7 +1581,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "has"
|
name = "has"
|
||||||
version = "0.0.9"
|
version = "0.0.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "has"
|
name = "has"
|
||||||
version = "0.0.9"
|
version = "0.0.10"
|
||||||
description = "has"
|
description = "has"
|
||||||
authors = ["autli"]
|
authors = ["autli"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
|
||||||
|
|
@ -37,24 +37,21 @@ pub fn verify_admin_pin(input_pin: Option<&str>) -> Result<(), String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_current_device_name() -> String {
|
pub fn get_current_device_name() -> String {
|
||||||
if let Ok(name) = std::env::var("COMPUTERNAME") {
|
if let Ok(name) = std::env::var("COMPUTERNAME")
|
||||||
if !name.trim().is_empty() {
|
&& !name.trim().is_empty() {
|
||||||
return name.trim().to_string();
|
return name.trim().to_string();
|
||||||
}
|
}
|
||||||
}
|
if let Ok(name) = std::env::var("HOSTNAME")
|
||||||
if let Ok(name) = std::env::var("HOSTNAME") {
|
&& !name.trim().is_empty() {
|
||||||
if !name.trim().is_empty() {
|
|
||||||
return name.trim().to_string();
|
return name.trim().to_string();
|
||||||
}
|
}
|
||||||
}
|
if let Ok(output) = std::process::Command::new("hostname").output()
|
||||||
if let Ok(output) = std::process::Command::new("hostname").output() {
|
&& output.status.success() {
|
||||||
if output.status.success() {
|
|
||||||
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
if !s.is_empty() {
|
if !s.is_empty() {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if let Ok(user) = std::env::var("USER") {
|
if let Ok(user) = std::env::var("USER") {
|
||||||
return format!("{}-workstation", user.trim());
|
return format!("{}-workstation", user.trim());
|
||||||
}
|
}
|
||||||
|
|
@ -62,13 +59,11 @@ pub fn get_current_device_name() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_current_local_ip() -> String {
|
pub fn get_current_local_ip() -> String {
|
||||||
if let Ok(socket) = std::net::UdpSocket::bind("0.0.0.0:0") {
|
if let Ok(socket) = std::net::UdpSocket::bind("0.0.0.0:0")
|
||||||
if socket.connect("8.8.8.8:80").is_ok() {
|
&& socket.connect("8.8.8.8:80").is_ok()
|
||||||
if let Ok(addr) = socket.local_addr() {
|
&& let Ok(addr) = socket.local_addr() {
|
||||||
return addr.ip().to_string();
|
return addr.ip().to_string();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
"127.0.0.1".to_string()
|
"127.0.0.1".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,7 +135,7 @@ pub async fn get_admin_security_info(admin_email: &str) -> Result<DeviceSecurity
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
match &trusted_device_name {
|
match &trusted_device_name {
|
||||||
Some(t) => t.trim().eq_ignore_ascii_case(¤t_device.trim()),
|
Some(t) => t.trim().eq_ignore_ascii_case(current_device.trim()),
|
||||||
None => true,
|
None => true,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -149,7 +144,7 @@ pub async fn get_admin_security_info(admin_email: &str) -> Result<DeviceSecurity
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
match &trusted_ip {
|
match &trusted_ip {
|
||||||
Some(t) => t.trim().eq_ignore_ascii_case(¤t_ip.trim()),
|
Some(t) => t.trim().eq_ignore_ascii_case(current_ip.trim()),
|
||||||
None => true,
|
None => true,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,8 @@ pub fn load_env() {
|
||||||
let _ = dotenvy::from_filename("../.env");
|
let _ = dotenvy::from_filename("../.env");
|
||||||
|
|
||||||
// 3. Executable directory .env (useful in production bundle)
|
// 3. Executable directory .env (useful in production bundle)
|
||||||
if let Ok(exe_path) = env::current_exe() {
|
if let Ok(exe_path) = env::current_exe()
|
||||||
if let Some(exe_dir) = exe_path.parent() {
|
&& let Some(exe_dir) = exe_path.parent() {
|
||||||
let _ = dotenvy::from_path(exe_dir.join(".env"));
|
let _ = dotenvy::from_path(exe_dir.join(".env"));
|
||||||
if let Some(parent) = exe_dir.parent() {
|
if let Some(parent) = exe_dir.parent() {
|
||||||
let _ = dotenvy::from_path(parent.join(".env"));
|
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 {
|
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 {
|
fn build_conn_string(config: &DbConfig) -> String {
|
||||||
if let Some(url) = &config.database_url {
|
if let Some(url) = &config.database_url
|
||||||
if !url.trim().is_empty() {
|
&& !url.trim().is_empty() {
|
||||||
return url.clone();
|
return url.clone();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let mut parts = vec![
|
let mut parts = vec![
|
||||||
format!("host={}", config.host),
|
format!("host={}", config.host),
|
||||||
|
|
@ -166,11 +164,10 @@ fn build_conn_string(config: &DbConfig) -> String {
|
||||||
format!("dbname={}", config.dbname),
|
format!("dbname={}", config.dbname),
|
||||||
];
|
];
|
||||||
|
|
||||||
if let Some(ref pwd) = config.password {
|
if let Some(ref pwd) = config.password
|
||||||
if !pwd.is_empty() {
|
&& !pwd.is_empty() {
|
||||||
parts.push(format!("password={}", pwd));
|
parts.push(format!("password={}", pwd));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
parts.join(" ")
|
parts.join(" ")
|
||||||
}
|
}
|
||||||
|
|
@ -271,11 +268,10 @@ pub fn verify_password(provided: &str, stored: &str) -> bool {
|
||||||
let stored = stored.trim();
|
let stored = stored.trim();
|
||||||
|
|
||||||
// 1. Bcrypt hash check ($2a$, $2b$, $2y$)
|
// 1. Bcrypt hash check ($2a$, $2b$, $2y$)
|
||||||
if stored.starts_with("$2a$") || stored.starts_with("$2b$") || stored.starts_with("$2y$") {
|
if (stored.starts_with("$2a$") || stored.starts_with("$2b$") || stored.starts_with("$2y$"))
|
||||||
if let Ok(valid) = bcrypt::verify(provided, stored) {
|
&& let Ok(valid) = bcrypt::verify(provided, stored) {
|
||||||
return valid;
|
return valid;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 2. SHA-256 hash check (64 hex characters)
|
// 2. SHA-256 hash check (64 hex characters)
|
||||||
if stored.len() == 64 && stored.chars().all(|c| c.is_ascii_hexdigit()) {
|
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] = &[
|
const DISPOSABLE_DOMAINS: &[&str] = &[
|
||||||
"tempmail.com", "10minutemail.com", "guerrillamail.com", "mailinator.com",
|
"tempmail.com", "10minutemail.com", "guerrillamail.com", "mailinator.com",
|
||||||
"trashmail.com", "yopmail.com", "temp-mail.org", "dispostable.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();
|
let domain_lower = domain.to_lowercase();
|
||||||
if DISPOSABLE_DOMAINS.iter().any(|&d| domain_lower == d || domain_lower.ends_with(&format!(".{}", d))) {
|
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))
|
Ok((local, domain))
|
||||||
|
|
@ -612,6 +618,189 @@ pub async fn verify_domain_exists(domain: &str) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn check_mailru_api(email: &str) -> Result<bool, String> {
|
||||||
|
let url = format!("https://account.mail.ru/api/v1/user/exists?email={}", email);
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(4))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let res = client
|
||||||
|
.get(&url)
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
|
||||||
|
if let Some(exists) = json.get("body").and_then(|b| b.get("exists")).and_then(|e| e.as_bool()) {
|
||||||
|
Ok(exists)
|
||||||
|
} else {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_smtp_mailbox_rcpt(host_port: &str, email: &str) -> Result<bool, String> {
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
let fut = async {
|
||||||
|
let stream = TcpStream::connect(host_port).await.map_err(|e| e.to_string())?;
|
||||||
|
let (reader, mut writer) = stream.into_split();
|
||||||
|
let mut reader = BufReader::new(reader);
|
||||||
|
|
||||||
|
async fn read_smtp_reply<R: AsyncBufReadExt + Unpin>(reader: &mut R) -> Result<(u16, String), String> {
|
||||||
|
let mut last_line;
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
let n = reader.read_line(&mut line).await.map_err(|e| e.to_string())?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err("Connection closed".to_string());
|
||||||
|
}
|
||||||
|
last_line = line.clone();
|
||||||
|
if line.len() >= 4 && line[..3].chars().all(|c| c.is_ascii_digit()) && line.as_bytes()[3] == b' ' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if line.len() >= 3 && line[..3].chars().all(|c| c.is_ascii_digit()) && line.len() < 4 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let code = last_line.get(..3).and_then(|c| c.parse::<u16>().ok()).unwrap_or(0);
|
||||||
|
Ok((code, last_line))
|
||||||
|
}
|
||||||
|
|
||||||
|
let (code, _) = read_smtp_reply(&mut reader).await?;
|
||||||
|
if code != 220 {
|
||||||
|
return Err("Invalid banner".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.write_all(b"EHLO mail.ru\r\n").await.map_err(|e| e.to_string())?;
|
||||||
|
writer.flush().await.map_err(|e| e.to_string())?;
|
||||||
|
let (code, _) = read_smtp_reply(&mut reader).await?;
|
||||||
|
if code != 250 {
|
||||||
|
return Err("EHLO rejected".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.write_all(b"MAIL FROM:<scorporation@internet.ru>\r\n").await.map_err(|e| e.to_string())?;
|
||||||
|
writer.flush().await.map_err(|e| e.to_string())?;
|
||||||
|
let (code, _) = read_smtp_reply(&mut reader).await?;
|
||||||
|
if code != 250 {
|
||||||
|
return Err("MAIL FROM rejected".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let rcpt_cmd = format!("RCPT TO:<{}>\r\n", email);
|
||||||
|
writer.write_all(rcpt_cmd.as_bytes()).await.map_err(|e| e.to_string())?;
|
||||||
|
writer.flush().await.map_err(|e| e.to_string())?;
|
||||||
|
let (code, line) = read_smtp_reply(&mut reader).await?;
|
||||||
|
|
||||||
|
let _ = writer.write_all(b"QUIT\r\n").await;
|
||||||
|
let _ = writer.flush().await;
|
||||||
|
|
||||||
|
let line_lower = line.to_lowercase();
|
||||||
|
if code == 550 || code == 551 || code == 552 || code == 553 || code == 554
|
||||||
|
|| line_lower.contains("does not exist")
|
||||||
|
|| line_lower.contains("user not found")
|
||||||
|
|| line_lower.contains("no such user")
|
||||||
|
|| line_lower.contains("nosuchuser")
|
||||||
|
|| line_lower.contains("mailbox unavailable")
|
||||||
|
|| line_lower.contains("recipient rejected")
|
||||||
|
|| line_lower.contains("invalid recipient")
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (200..300).contains(&code) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
};
|
||||||
|
|
||||||
|
match timeout(Duration::from_secs(4), fut).await {
|
||||||
|
Ok(res) => res,
|
||||||
|
Err(_) => Err("SMTP check timeout".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn verify_mailbox_exists(email: &str) -> Result<(), String> {
|
||||||
|
let clean = email.trim().to_lowercase();
|
||||||
|
let parts: Vec<&str> = clean.split('@').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return Err("Некорректный адрес электронной почты".to_string());
|
||||||
|
}
|
||||||
|
let domain = parts[1];
|
||||||
|
|
||||||
|
// 1. Mail.ru group domains
|
||||||
|
const MAILRU_DOMAINS: &[&str] = &["mail.ru", "inbox.ru", "list.ru", "bk.ru", "internet.ru", "xmail.ru"];
|
||||||
|
if MAILRU_DOMAINS.contains(&domain) {
|
||||||
|
match check_mailru_api(&clean).await {
|
||||||
|
Ok(exists) => {
|
||||||
|
if !exists {
|
||||||
|
return Err(format!("Почтовый ящик '{}' не существует. Пожалуйста, укажите действительный почтовый ящик.", clean));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[verify_mailbox] Mail.ru API error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Google / Gmail
|
||||||
|
if domain == "gmail.com" || domain == "googlemail.com" {
|
||||||
|
match check_smtp_mailbox_rcpt("gmail-smtp-in.l.google.com:25", &clean).await {
|
||||||
|
Ok(exists) => {
|
||||||
|
if !exists {
|
||||||
|
return Err(format!("Почтовый ящик '{}' не существует. Пожалуйста, укажите действительный почтовый ящик.", clean));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[verify_mailbox] Gmail check error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Yandex
|
||||||
|
const YANDEX_DOMAINS: &[&str] = &["yandex.ru", "ya.ru", "yandex.com", "yandex.by", "yandex.kz"];
|
||||||
|
if YANDEX_DOMAINS.contains(&domain) {
|
||||||
|
match check_smtp_mailbox_rcpt("mx.yandex.ru:25", &clean).await {
|
||||||
|
Ok(exists) => {
|
||||||
|
if !exists {
|
||||||
|
return Err(format!("Почтовый ящик '{}' не существует. Пожалуйста, укажите действительный почтовый ящик.", clean));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[verify_mailbox] Yandex check error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Other domains: lookup MX or primary host
|
||||||
|
if let Ok(mut addrs) = tokio::net::lookup_host(format!("{}:25", domain)).await
|
||||||
|
&& let Some(addr) = addrs.next()
|
||||||
|
&& let Ok(exists) = check_smtp_mailbox_rcpt(&addr.to_string(), &clean).await
|
||||||
|
&& !exists {
|
||||||
|
return Err(format!("Почтовый ящик '{}' не существует. Пожалуйста, укажите действительный почтовый ящик.", clean));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_email_already_registered(email: &str) -> Result<bool, String> {
|
||||||
|
let clean = email.trim().to_lowercase();
|
||||||
|
let config = get_db_config();
|
||||||
|
let client = connect_to_db(&config).await.map_err(|e| e.to_string())?;
|
||||||
|
let check_query = format!(
|
||||||
|
"SELECT 1 FROM {} WHERE LOWER({}) = LOWER($1) LIMIT 1",
|
||||||
|
config.users_table, config.email_column
|
||||||
|
);
|
||||||
|
let row = client.query_opt(&check_query, &[&clean]).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(row.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn register_user(email: &str, password: &str, username: Option<&str>) -> AuthResponse {
|
pub async fn register_user(email: &str, password: &str, username: Option<&str>) -> AuthResponse {
|
||||||
let clean_email = email.trim();
|
let clean_email = email.trim();
|
||||||
let clean_username = username.map(|u| u.trim()).filter(|u| !u.is_empty());
|
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 config = get_db_config();
|
||||||
let client = match connect_to_db(&config).await {
|
let client = match connect_to_db(&config).await {
|
||||||
Ok(c) => c,
|
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
|
// Auto-create table if standard 'users' table
|
||||||
if config.users_table == "users" && config.email_column == "email" && config.password_column == "password" {
|
if config.users_table == "users" && config.email_column == "email" && config.password_column == "password" {
|
||||||
let create_table_sql = "
|
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("уже существует") {
|
if err_str.contains("unique") || err_str.contains("duplicate") || err_str.contains("уже существует") {
|
||||||
AuthResponse {
|
AuthResponse {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Пользователь с таким email уже существует".to_string(),
|
message: "На этот адрес электронной почты уже зарегистрирован аккаунт. На одну почту можно создать только 1 аккаунт.".to_string(),
|
||||||
user: None,
|
user: None,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -754,6 +964,7 @@ fn generate_reset_code() -> String {
|
||||||
format!("{:06}", num)
|
format!("{:06}", num)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn send_smtp_email(
|
pub async fn send_smtp_email(
|
||||||
host: &str,
|
host: &str,
|
||||||
port: u16,
|
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 config = get_db_config();
|
||||||
let client = match connect_to_db(&config).await {
|
let client = match connect_to_db(&config).await {
|
||||||
Ok(c) => c,
|
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 {
|
match client.query_opt(&check_query, &[&clean_email]).await {
|
||||||
Ok(Some(_)) => ResetRequestResponse {
|
Ok(Some(_)) => ResetRequestResponse {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Пользователь с таким адресом электронной почты уже зарегистрирован".to_string(),
|
message: "На этот адрес электронной почты уже зарегистрирован аккаунт. На одну почту можно создать только 1 аккаунт.".to_string(),
|
||||||
dev_code: None,
|
dev_code: None,
|
||||||
},
|
},
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
|
|
@ -1271,6 +1490,25 @@ mod tests {
|
||||||
assert!(verify_domain_exists("this-domain-surely-does-not-exist-123456789.ru").await.is_err());
|
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]
|
#[tokio::test]
|
||||||
async fn test_database_connection() {
|
async fn test_database_connection() {
|
||||||
let config = get_db_config();
|
let config = get_db_config();
|
||||||
|
|
|
||||||
|
|
@ -54,43 +54,178 @@ pub fn get_cinema_config() -> CinemaConfig {
|
||||||
CinemaConfig { tmdb_api_key: Some(key) }
|
CinemaConfig { tmdb_api_key: Some(key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
pub fn read_local_cinema_state(app: &AppHandle) -> CinemaState {
|
||||||
pub fn get_cinema_state(app: AppHandle) -> Result<CinemaState, String> {
|
let path = get_storage_file_path(app);
|
||||||
let path = get_storage_file_path(&app);
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(CinemaState::default());
|
return CinemaState::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
match fs::read_to_string(&path) {
|
match fs::read_to_string(&path) {
|
||||||
Ok(content) => match serde_json::from_str::<CinemaState>(&content) {
|
Ok(content) => serde_json::from_str::<CinemaState>(&content).unwrap_or_default(),
|
||||||
Ok(state) => Ok(state),
|
Err(_) => CinemaState::default(),
|
||||||
Err(e) => {
|
}
|
||||||
eprintln!("Error parsing cinema_state.json: {}", e);
|
}
|
||||||
Ok(CinemaState::default())
|
|
||||||
|
pub fn write_local_cinema_state(app: &AppHandle, state: &CinemaState) -> Result<(), String> {
|
||||||
|
let path = get_storage_file_path(app);
|
||||||
|
let json = serde_json::to_string_pretty(state).map_err(|e| format!("Ошибка сериализации данных: {}", e))?;
|
||||||
|
fs::write(&path, json).map_err(|e| format!("Не удалось сохранить локальный файл кинотеатра: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_remote_cinema_state(user_email: &str) -> Option<CinemaState> {
|
||||||
|
let email = user_email.trim().to_lowercase();
|
||||||
|
if email.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let config = crate::auth::get_db_config();
|
||||||
|
let client = crate::auth::connect_to_db(&config).await.ok()?;
|
||||||
|
let row = client
|
||||||
|
.query_opt(
|
||||||
|
"SELECT content FROM server_notes WHERE LOWER(user_email) = LOWER($1) AND title = '__cinema_state__' ORDER BY id DESC LIMIT 1",
|
||||||
|
&[&email],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if let Some(row) = row {
|
||||||
|
let content: String = row.get(0);
|
||||||
|
serde_json::from_str::<CinemaState>(&content).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_remote_cinema_state(user_email: &str, state: &CinemaState) -> Result<(), String> {
|
||||||
|
let email = user_email.trim().to_lowercase();
|
||||||
|
if email.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string(state).map_err(|e| e.to_string())?;
|
||||||
|
let config = crate::auth::get_db_config();
|
||||||
|
let client = crate::auth::connect_to_db(&config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Не удалось подключиться к базе данных: {}", e))?;
|
||||||
|
|
||||||
|
let existing = client
|
||||||
|
.query_opt(
|
||||||
|
"SELECT id FROM server_notes WHERE LOWER(user_email) = LOWER($1) AND title = '__cinema_state__' LIMIT 1",
|
||||||
|
&[&email],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if let Some(row) = existing {
|
||||||
|
let id: i64 = row.get(0);
|
||||||
|
client
|
||||||
|
.execute(
|
||||||
|
"UPDATE server_notes SET content = $1 WHERE id = $2",
|
||||||
|
&[&json, &id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
|
client
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO server_notes (user_email, title, folder, content, note_type, status) VALUES ($1, '__cinema_state__', 'system', $2, 'system', 'active')",
|
||||||
|
&[&email, &json],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merge_cinema_states(local: CinemaState, remote: Option<CinemaState>) -> CinemaState {
|
||||||
|
let remote = match remote {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return local,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Merge bookmarks: union preserving unique items
|
||||||
|
let mut bookmarks = local.bookmarks;
|
||||||
|
for bm in remote.bookmarks {
|
||||||
|
if !bookmarks.contains(&bm) {
|
||||||
|
bookmarks.push(bm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Merge history: key = (title_id, season, episode)
|
||||||
|
let mut history_map: std::collections::HashMap<(String, u32, u32), CinemaPlayback> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
|
for item in remote.history {
|
||||||
|
history_map.insert((item.title_id.clone(), item.season, item.episode), item);
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in local.history {
|
||||||
|
let key = (item.title_id.clone(), item.season, item.episode);
|
||||||
|
if let Some(existing) = history_map.get_mut(&key) {
|
||||||
|
if item.last_watched >= existing.last_watched || item.progress_seconds > existing.progress_seconds {
|
||||||
|
*existing = item;
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
Err(e) => Err(format!("Не удалось прочитать локальный файл истории кинотеатра: {}", e)),
|
history_map.insert(key, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut merged_history: Vec<CinemaPlayback> = history_map.into_values().collect();
|
||||||
|
merged_history.sort_by(|a, b| b.last_watched.cmp(&a.last_watched));
|
||||||
|
if merged_history.len() > 50 {
|
||||||
|
merged_history.truncate(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
CinemaState {
|
||||||
|
bookmarks,
|
||||||
|
history: merged_history,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_cinema_state(app: AppHandle, state: CinemaState) -> Result<bool, String> {
|
pub async fn get_cinema_state(app: AppHandle, user_email: Option<String>) -> Result<CinemaState, String> {
|
||||||
let path = get_storage_file_path(&app);
|
let local_state = read_local_cinema_state(&app);
|
||||||
match serde_json::to_string_pretty(&state) {
|
if let Some(email) = user_email {
|
||||||
Ok(json) => match fs::write(&path, json) {
|
let email_clean = email.trim().to_lowercase();
|
||||||
Ok(_) => Ok(true),
|
if !email_clean.is_empty()
|
||||||
Err(e) => Err(format!("Не удалось сохранить локальный файл кинотеатра: {}", e)),
|
&& let Some(remote_state) = fetch_remote_cinema_state(&email_clean).await {
|
||||||
},
|
let merged = merge_cinema_states(local_state, Some(remote_state));
|
||||||
Err(e) => Err(format!("Ошибка сериализации данных: {}", e)),
|
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]
|
#[tauri::command]
|
||||||
pub fn update_cinema_progress(
|
pub async fn save_cinema_state(
|
||||||
|
app: AppHandle,
|
||||||
|
state: CinemaState,
|
||||||
|
user_email: Option<String>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
write_local_cinema_state(&app, &state)?;
|
||||||
|
if let Some(email) = user_email {
|
||||||
|
let email_clean = email.trim().to_lowercase();
|
||||||
|
if !email_clean.is_empty() {
|
||||||
|
let state_to_save = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = save_remote_cinema_state(&email_clean, &state_to_save).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_cinema_progress(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
item: CinemaPlayback,
|
item: CinemaPlayback,
|
||||||
|
user_email: Option<String>,
|
||||||
) -> Result<CinemaState, String> {
|
) -> Result<CinemaState, String> {
|
||||||
let mut state = get_cinema_state(app.clone()).unwrap_or_default();
|
let mut state = read_local_cinema_state(&app);
|
||||||
|
|
||||||
// Remove older entry for the same title/season/episode if exists
|
// 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));
|
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);
|
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)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn toggle_cinema_bookmark(app: AppHandle, title_id: String) -> Result<CinemaState, String> {
|
pub async fn toggle_cinema_bookmark(
|
||||||
let mut state = get_cinema_state(app.clone()).unwrap_or_default();
|
app: AppHandle,
|
||||||
|
title_id: String,
|
||||||
|
user_email: Option<String>,
|
||||||
|
) -> Result<CinemaState, String> {
|
||||||
|
let mut state = read_local_cinema_state(&app);
|
||||||
if let Some(pos) = state.bookmarks.iter().position(|id| id == &title_id) {
|
if let Some(pos) = state.bookmarks.iter().position(|id| id == &title_id) {
|
||||||
state.bookmarks.remove(pos);
|
state.bookmarks.remove(pos);
|
||||||
} else {
|
} else {
|
||||||
state.bookmarks.push(title_id);
|
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)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,9 +392,9 @@ pub async fn get_russian_media_info(
|
||||||
("token", "45e20a5f584becf7a64dffb7174ddf"),
|
("token", "45e20a5f584becf7a64dffb7174ddf"),
|
||||||
("tmdb", tmdb_str.as_str()),
|
("tmdb", tmdb_str.as_str()),
|
||||||
];
|
];
|
||||||
if let Ok(resp) = client.get("https://api.apbugall.org/").query(&query_params).send().await {
|
if let Ok(resp) = client.get("https://api.apbugall.org/").query(&query_params).send().await
|
||||||
if resp.status().is_success() {
|
&& resp.status().is_success()
|
||||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
&& let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||||
let is_ok = json.get("status").and_then(|s| s.as_str()) == Some("success");
|
let is_ok = json.get("status").and_then(|s| s.as_str()) == Some("success");
|
||||||
if is_ok {
|
if is_ok {
|
||||||
let data_opt = json.get("data");
|
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
|
// 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"),
|
("token", "45e20a5f584becf7a64dffb7174ddf"),
|
||||||
("name", clean_title),
|
("name", clean_title),
|
||||||
];
|
];
|
||||||
if let Ok(resp) = client.get("https://api.apbugall.org/").query(&query_params).send().await {
|
if let Ok(resp) = client.get("https://api.apbugall.org/").query(&query_params).send().await
|
||||||
if resp.status().is_success() {
|
&& resp.status().is_success()
|
||||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
&& let Ok(json) = resp.json::<serde_json::Value>().await {
|
||||||
let is_ok = json.get("status").and_then(|s| s.as_str()) == Some("success");
|
let is_ok = json.get("status").and_then(|s| s.as_str()) == Some("success");
|
||||||
if is_ok {
|
if is_ok {
|
||||||
let data_opt = json.get("data");
|
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 {
|
} else {
|
||||||
format!("/movie/{}/external_ids", tmdb)
|
format!("/movie/{}/external_ids", tmdb)
|
||||||
};
|
};
|
||||||
if let Ok(val) = tmdb_query(ep).await {
|
if let Ok(val) = tmdb_query(ep).await
|
||||||
if let Some(imdb) = val.get("imdb_id").and_then(|v| v.as_str()) {
|
&& let Some(imdb) = val.get("imdb_id").and_then(|v| v.as_str()) {
|
||||||
info.imdb_id = Some(imdb.to_string());
|
info.imdb_id = Some(imdb.to_string());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(info)
|
Ok(info)
|
||||||
|
|
@ -356,11 +512,11 @@ pub async fn filter_playable_media(items: Vec<PlayableCheckItem>) -> Result<Vec<
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Ok(resp) = alloha_res {
|
if let Ok(resp) = alloha_res
|
||||||
if resp.status().is_success() {
|
&& resp.status().is_success()
|
||||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
&& let Ok(json) = resp.json::<serde_json::Value>().await
|
||||||
if json.get("status").and_then(|s| s.as_str()) == Some("success") {
|
&& json.get("status").and_then(|s| s.as_str()) == Some("success") {
|
||||||
let has_data = json.get("data").map_or(false, |d| {
|
let has_data = json.get("data").is_some_and(|d| {
|
||||||
if let Some(arr) = d.as_array() {
|
if let Some(arr) = d.as_array() {
|
||||||
!arr.is_empty()
|
!arr.is_empty()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -371,9 +527,6 @@ pub async fn filter_playable_media(items: Vec<PlayableCheckItem>) -> Result<Vec<
|
||||||
return Some(tmdb_id);
|
return Some(tmdb_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Check Kodik balancer API:
|
// 3. Check Kodik balancer API:
|
||||||
let clean_title = item.title.trim();
|
let clean_title = item.title.trim();
|
||||||
|
|
@ -387,17 +540,13 @@ pub async fn filter_playable_media(items: Vec<PlayableCheckItem>) -> Result<Vec<
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Ok(resp) = kodik_res {
|
if let Ok(resp) = kodik_res
|
||||||
if resp.status().is_success() {
|
&& resp.status().is_success()
|
||||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
&& let Ok(json) = resp.json::<serde_json::Value>().await
|
||||||
if let Some(results) = json.get("results").and_then(|r| r.as_array()) {
|
&& let Some(results) = json.get("results").and_then(|r| r.as_array())
|
||||||
if !results.is_empty() {
|
&& !results.is_empty() {
|
||||||
return Some(tmdb_id);
|
return Some(tmdb_id);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Try title search on Alloha as last check for Russian titles
|
// 4. Try title search on Alloha as last check for Russian titles
|
||||||
|
|
@ -411,15 +560,12 @@ pub async fn filter_playable_media(items: Vec<PlayableCheckItem>) -> Result<Vec<
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Ok(resp) = alloha_title_res {
|
if let Ok(resp) = alloha_title_res
|
||||||
if resp.status().is_success() {
|
&& resp.status().is_success()
|
||||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
&& let Ok(json) = resp.json::<serde_json::Value>().await
|
||||||
if json.get("status").and_then(|s| s.as_str()) == Some("success") {
|
&& json.get("status").and_then(|s| s.as_str()) == Some("success") {
|
||||||
return Some(tmdb_id);
|
return Some(tmdb_id);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If neither Alloha nor Kodik has it, and votes < 100, this title has NO working player!
|
// 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<PlayableCheckItem>) -> Result<Vec<
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_cinema_states() {
|
||||||
|
let local = CinemaState {
|
||||||
|
bookmarks: vec!["movie-1".to_string(), "movie-2".to_string()],
|
||||||
|
history: vec![CinemaPlayback {
|
||||||
|
title_id: "movie-1".to_string(),
|
||||||
|
title_name: "Movie 1".to_string(),
|
||||||
|
poster_path: None,
|
||||||
|
backdrop_path: None,
|
||||||
|
season: 1,
|
||||||
|
episode: 1,
|
||||||
|
progress_seconds: 1500.0,
|
||||||
|
duration_seconds: 3600.0,
|
||||||
|
last_watched: "2026-10-27T12:00:00Z".to_string(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let remote = CinemaState {
|
||||||
|
bookmarks: vec!["movie-2".to_string(), "movie-3".to_string()],
|
||||||
|
history: vec![
|
||||||
|
CinemaPlayback {
|
||||||
|
title_id: "movie-1".to_string(),
|
||||||
|
title_name: "Movie 1".to_string(),
|
||||||
|
poster_path: None,
|
||||||
|
backdrop_path: None,
|
||||||
|
season: 1,
|
||||||
|
episode: 1,
|
||||||
|
progress_seconds: 2400.0,
|
||||||
|
duration_seconds: 3600.0,
|
||||||
|
last_watched: "2026-10-27T13:00:00Z".to_string(),
|
||||||
|
},
|
||||||
|
CinemaPlayback {
|
||||||
|
title_id: "tv-1".to_string(),
|
||||||
|
title_name: "TV Show 1".to_string(),
|
||||||
|
poster_path: None,
|
||||||
|
backdrop_path: None,
|
||||||
|
season: 1,
|
||||||
|
episode: 2,
|
||||||
|
progress_seconds: 300.0,
|
||||||
|
duration_seconds: 1800.0,
|
||||||
|
last_watched: "2026-10-27T11:00:00Z".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let merged = merge_cinema_states(local, Some(remote));
|
||||||
|
assert_eq!(merged.bookmarks.len(), 3);
|
||||||
|
assert!(merged.bookmarks.contains(&"movie-1".to_string()));
|
||||||
|
assert!(merged.bookmarks.contains(&"movie-2".to_string()));
|
||||||
|
assert!(merged.bookmarks.contains(&"movie-3".to_string()));
|
||||||
|
|
||||||
|
assert_eq!(merged.history.len(), 2);
|
||||||
|
// movie-1 from remote should win because it has newer timestamp and more progress
|
||||||
|
let m1 = merged.history.iter().find(|h| h.title_id == "movie-1").unwrap();
|
||||||
|
assert_eq!(m1.progress_seconds, 2400.0);
|
||||||
|
assert_eq!(m1.last_watched, "2026-10-27T13:00:00Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_db_cinema_remote_sync() {
|
||||||
|
let test_email = "test_sync_cinema@example.com";
|
||||||
|
let state = CinemaState {
|
||||||
|
bookmarks: vec!["synced-title-42".to_string()],
|
||||||
|
history: vec![CinemaPlayback {
|
||||||
|
title_id: "synced-title-42".to_string(),
|
||||||
|
title_name: "Synced Title".to_string(),
|
||||||
|
poster_path: None,
|
||||||
|
backdrop_path: None,
|
||||||
|
season: 1,
|
||||||
|
episode: 1,
|
||||||
|
progress_seconds: 999.0,
|
||||||
|
duration_seconds: 1800.0,
|
||||||
|
last_watched: "2026-10-27T15:00:00Z".to_string(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let save_res = save_remote_cinema_state(test_email, &state).await;
|
||||||
|
println!("save_remote_cinema_state result: {:?}", save_res);
|
||||||
|
assert!(save_res.is_ok());
|
||||||
|
|
||||||
|
let fetched = fetch_remote_cinema_state(test_email).await;
|
||||||
|
println!("fetch_remote_cinema_state result: {:?}", fetched);
|
||||||
|
assert!(fetched.is_some());
|
||||||
|
let fetched_state = fetched.unwrap();
|
||||||
|
assert_eq!(fetched_state.bookmarks, vec!["synced-title-42".to_string()]);
|
||||||
|
assert_eq!(fetched_state.history[0].progress_seconds, 999.0);
|
||||||
|
|
||||||
|
// Clean up test record
|
||||||
|
let config = crate::auth::get_db_config();
|
||||||
|
if let Ok(client) = crate::auth::connect_to_db(&config).await {
|
||||||
|
let _ = client.execute("DELETE FROM server_notes WHERE LOWER(user_email) = LOWER($1) AND title = '__cinema_state__'", &[&test_email]).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_tmdb_query() {
|
async fn test_tmdb_query() {
|
||||||
let res = tmdb_query("/movie/157336?language=ru-RU".to_string()).await;
|
let res = tmdb_query("/movie/157336?language=ru-RU".to_string()).await;
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,11 @@ async fn register_with_code(
|
||||||
async fn validate_email(email: String) -> Result<bool, String> {
|
async fn validate_email(email: String) -> Result<bool, String> {
|
||||||
let (_, domain) = auth::validate_email_syntax(&email)?;
|
let (_, domain) = auth::validate_email_syntax(&email)?;
|
||||||
auth::verify_domain_exists(domain).await?;
|
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)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ async fn fetch_profile_with_client(
|
||||||
.map_err(|e| format!("Ошибка загрузки профиля: {}", e))?
|
.map_err(|e| format!("Ошибка загрузки профиля: {}", e))?
|
||||||
.ok_or_else(|| "Пользователь не найден в базе данных".to_string())?;
|
.ok_or_else(|| "Пользователь не найден в базе данных".to_string())?;
|
||||||
|
|
||||||
Ok(UserProfileDto {
|
let mut profile_res = UserProfileDto {
|
||||||
id: row.get(0),
|
id: row.get(0),
|
||||||
email: row.get(1),
|
email: row.get(1),
|
||||||
username: row.get(2),
|
username: row.get(2),
|
||||||
|
|
@ -105,7 +105,33 @@ async fn fetch_profile_with_client(
|
||||||
notify_sound: row.get(7),
|
notify_sound: row.get(7),
|
||||||
notify_security: row.get(8),
|
notify_security: row.get(8),
|
||||||
created_at: row.get(9),
|
created_at: row.get(9),
|
||||||
})
|
};
|
||||||
|
|
||||||
|
// Safely supplement from server_notes __profile_prefs__ if available
|
||||||
|
let note_sql = "SELECT content FROM server_notes WHERE LOWER(user_email) = LOWER($1) AND title = '__profile_prefs__' ORDER BY id DESC LIMIT 1;";
|
||||||
|
if let Ok(rows) = client.query(note_sql, &[&clean_email]).await
|
||||||
|
&& let Some(note_row) = rows.first()
|
||||||
|
&& let Some(json_str) = note_row.get::<_, Option<String>>(0)
|
||||||
|
&& let Ok(prefs) = serde_json::from_str::<serde_json::Value>(&json_str) {
|
||||||
|
if let Some(bio) = prefs.get("bio").and_then(|v| v.as_str()) {
|
||||||
|
profile_res.bio = Some(bio.to_string());
|
||||||
|
}
|
||||||
|
if let Some(avatar) = prefs.get("avatar").and_then(|v| v.as_str())
|
||||||
|
&& !avatar.trim().is_empty() {
|
||||||
|
profile_res.avatar = Some(avatar.to_string());
|
||||||
|
}
|
||||||
|
if let Some(notify_email) = prefs.get("notify_email").and_then(|v| v.as_bool()) {
|
||||||
|
profile_res.notify_email = notify_email;
|
||||||
|
}
|
||||||
|
if let Some(notify_sound) = prefs.get("notify_sound").and_then(|v| v.as_bool()) {
|
||||||
|
profile_res.notify_sound = notify_sound;
|
||||||
|
}
|
||||||
|
if let Some(notify_security) = prefs.get("notify_security").and_then(|v| v.as_bool()) {
|
||||||
|
profile_res.notify_security = notify_security;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(profile_res)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -132,29 +158,77 @@ pub async fn save_user_profile(
|
||||||
let clean_bio = profile.bio.as_deref().map(|s| s.trim());
|
let clean_bio = profile.bio.as_deref().map(|s| s.trim());
|
||||||
let avatar = profile.avatar.filter(|s| !s.trim().is_empty());
|
let avatar = profile.avatar.filter(|s| !s.trim().is_empty());
|
||||||
|
|
||||||
// Single unified UPDATE if columns exist
|
// 1. Dynamic safe UPDATE on users table (only updating columns that actually exist)
|
||||||
if cols.contains("username") || cols.contains("avatar") || cols.contains("bio") {
|
let mut set_clauses = Vec::new();
|
||||||
let update_sql = "UPDATE users SET \
|
let mut params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new();
|
||||||
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);";
|
|
||||||
|
|
||||||
|
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
|
let _ = client
|
||||||
.execute(
|
.execute(
|
||||||
update_sql,
|
"UPDATE server_notes SET content = $1 WHERE id = $2;",
|
||||||
&[
|
&[&prefs_json, ¬e_id],
|
||||||
&clean_username,
|
)
|
||||||
&avatar,
|
.await;
|
||||||
&clean_bio,
|
} else {
|
||||||
&profile.notify_email,
|
let _ = client
|
||||||
&profile.notify_sound,
|
.execute(
|
||||||
&profile.notify_security,
|
"INSERT INTO server_notes (user_email, title, folder, content, note_type, status) VALUES ($1, '__profile_prefs__', 'system', $2, 'system', 'active');",
|
||||||
&clean_email,
|
&[&clean_email, &prefs_json],
|
||||||
],
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,14 +119,12 @@ pub async fn resolve_user_folder_name(client: &tokio_postgres::Client, user_emai
|
||||||
if let Ok(Some(row)) = client
|
if let Ok(Some(row)) = client
|
||||||
.query_opt("SELECT username FROM users WHERE email = $1;", &[&user_email])
|
.query_opt("SELECT username FROM users WHERE email = $1;", &[&user_email])
|
||||||
.await
|
.await
|
||||||
{
|
&& let Ok(uname) = row.try_get::<_, String>(0) {
|
||||||
if let Some(uname) = row.try_get::<_, String>(0).ok() {
|
|
||||||
let trimmed = uname.trim();
|
let trimmed = uname.trim();
|
||||||
if !trimmed.is_empty() {
|
if !trimmed.is_empty() {
|
||||||
return trimmed.to_lowercase();
|
return trimmed.to_lowercase();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
let fallback = user_email.split('@').next().unwrap_or(user_email);
|
let fallback = user_email.split('@').next().unwrap_or(user_email);
|
||||||
fallback.to_lowercase()
|
fallback.to_lowercase()
|
||||||
}
|
}
|
||||||
|
|
@ -168,14 +166,8 @@ pub async fn resolve_folder_full_path(
|
||||||
full_path
|
full_path
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fs_ensure_dir(client: &tokio_postgres::Client, full_path: &str) {
|
pub async fn fs_ensure_dir(_client: &tokio_postgres::Client, _full_path: &str) {
|
||||||
use base64::Engine;
|
// Pure DB storage - OS shell execution disabled for safety and non-superuser compatibility
|
||||||
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_save_file(
|
pub async fn fs_save_file(
|
||||||
|
|
@ -184,18 +176,7 @@ pub async fn fs_save_file(
|
||||||
dir_path: &str,
|
dir_path: &str,
|
||||||
file_name: &str,
|
file_name: &str,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
use base64::Engine;
|
|
||||||
fs_ensure_dir(client, dir_path).await;
|
|
||||||
|
|
||||||
let full_file_path = format!("{}/{}", dir_path, file_name);
|
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
|
let _ = client
|
||||||
.execute(
|
.execute(
|
||||||
|
|
@ -207,35 +188,16 @@ pub async fn fs_save_file(
|
||||||
Ok(full_file_path)
|
Ok(full_file_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fs_delete_dir(client: &tokio_postgres::Client, dir_path: &str) {
|
pub async fn fs_delete_dir(_client: &tokio_postgres::Client, _dir_path: &str) {
|
||||||
use base64::Engine;
|
// Pure DB storage
|
||||||
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_file(client: &tokio_postgres::Client, file_path: &str) {
|
pub async fn fs_delete_file(_client: &tokio_postgres::Client, _file_path: &str) {
|
||||||
use base64::Engine;
|
// Pure DB storage
|
||||||
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_rename_path(client: &tokio_postgres::Client, old_path: &str, new_path: &str) {
|
pub async fn fs_rename_path(_client: &tokio_postgres::Client, _old_path: &str, _new_path: &str) {
|
||||||
use base64::Engine;
|
// Pure DB storage
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|
@ -269,13 +231,13 @@ pub async fn get_server_storage_stats() -> Result<StorageStats, String> {
|
||||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
// Line format: Filesystem 1B-blocks Used Available Use% Mounted on
|
// Line format: Filesystem 1B-blocks Used Available Use% Mounted on
|
||||||
// e.g. /dev/sda1 500107862016 42949672960 457158189056 9% /
|
// e.g. /dev/sda1 500107862016 42949672960 457158189056 9% /
|
||||||
if parts.len() >= 5 && parts[1].chars().all(|c| c.is_ascii_digit()) {
|
if parts.len() >= 5 && parts[1].chars().all(|c| c.is_ascii_digit())
|
||||||
if let (Ok(total), Ok(used), Ok(avail)) = (
|
&& let (Ok(total), Ok(used), Ok(avail)) = (
|
||||||
parts[1].parse::<u64>(),
|
parts[1].parse::<u64>(),
|
||||||
parts[2].parse::<u64>(),
|
parts[2].parse::<u64>(),
|
||||||
parts[3].parse::<u64>(),
|
parts[3].parse::<u64>(),
|
||||||
) {
|
)
|
||||||
if total > 0 {
|
&& total > 0 {
|
||||||
let percent = (used as f64 / total as f64) * 100.0;
|
let percent = (used as f64 / total as f64) * 100.0;
|
||||||
return Ok(StorageStats {
|
return Ok(StorageStats {
|
||||||
total_bytes: total,
|
total_bytes: total,
|
||||||
|
|
@ -287,8 +249,6 @@ pub async fn get_server_storage_stats() -> Result<StorageStats, String> {
|
||||||
free_formatted: format_bytes(avail),
|
free_formatted: format_bytes(avail),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -539,8 +499,8 @@ pub async fn delete_server_item(user_email: &str, item_id: &str) -> Result<bool,
|
||||||
|
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
} else if let Some(id_str) = item_id.strip_prefix("file_") {
|
} else if let Some(id_str) = item_id.strip_prefix("file_")
|
||||||
if let Ok(id) = id_str.parse::<i64>() {
|
&& let Ok(id) = id_str.parse::<i64>() {
|
||||||
// Delete file on physical Linux filesystem
|
// Delete file on physical Linux filesystem
|
||||||
if let Ok(Some(row)) = client
|
if let Ok(Some(row)) = client
|
||||||
.query_opt(
|
.query_opt(
|
||||||
|
|
@ -548,11 +508,9 @@ pub async fn delete_server_item(user_email: &str, item_id: &str) -> Result<bool,
|
||||||
&[&id, &user_email],
|
&[&id, &user_email],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
&& let Ok(fpath) = row.try_get::<_, String>(0) {
|
||||||
if let Some(fpath) = row.try_get::<_, String>(0).ok() {
|
|
||||||
fs_delete_file(&client, &fpath).await;
|
fs_delete_file(&client, &fpath).await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
client
|
client
|
||||||
.execute(
|
.execute(
|
||||||
|
|
@ -564,7 +522,6 @@ pub async fn delete_server_item(user_email: &str, item_id: &str) -> Result<bool,
|
||||||
|
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Err("Неверный ID элемента".to_string())
|
Err("Неверный ID элемента".to_string())
|
||||||
}
|
}
|
||||||
|
|
@ -595,9 +552,9 @@ pub async fn rename_server_item(user_email: &str, item_id: &str, new_name: &str)
|
||||||
|
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
} else if let Some(id_str) = item_id.strip_prefix("file_") {
|
} else if let Some(id_str) = item_id.strip_prefix("file_")
|
||||||
if let Ok(id) = id_str.parse::<i64>() {
|
&& let Ok(id) = id_str.parse::<i64>()
|
||||||
if let Ok(Some(row)) = client
|
&& let Ok(Some(row)) = client
|
||||||
.query_opt(
|
.query_opt(
|
||||||
"SELECT file_path, folder_id FROM server_files WHERE id = $1 AND user_email = $2;",
|
"SELECT file_path, folder_id FROM server_files WHERE id = $1 AND user_email = $2;",
|
||||||
&[&id, &user_email],
|
&[&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);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err("Неверный ID элемента".to_string())
|
Err("Неверный ID элемента".to_string())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "has",
|
"productName": "has",
|
||||||
"version": "0.0.9",
|
"version": "0.0.10",
|
||||||
"identifier": "com.has.tauri-app",
|
"identifier": "com.has.tauri-app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
|
||||||
196
src/App.css
196
src/App.css
|
|
@ -273,6 +273,202 @@ svg {
|
||||||
border-radius: 6px;
|
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 */
|
/* Center Content Area */
|
||||||
.auth-main-container {
|
.auth-main-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
225
src/App.tsx
225
src/App.tsx
|
|
@ -18,12 +18,48 @@ import { OtpInput } from "./components/OtpInput";
|
||||||
import { UpdaterBanner } from "./components/UpdaterBanner";
|
import { UpdaterBanner } from "./components/UpdaterBanner";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import Dashboard from "./pages/dashboard/Dashboard";
|
import Dashboard from "./pages/dashboard/Dashboard";
|
||||||
|
import { TvProfileSelector } from "./components/TvProfileSelector";
|
||||||
|
|
||||||
const isTauri = () => {
|
const isTauri = () => {
|
||||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
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() {
|
export default function App() {
|
||||||
|
const [isTvEnvironment, setIsTvEnvironment] = useState<boolean>(detectTvEnvironment);
|
||||||
const [mode, setMode] = useState<"login" | "register" | "reset">("login");
|
const [mode, setMode] = useState<"login" | "register" | "reset">("login");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
|
@ -36,6 +72,9 @@ export default function App() {
|
||||||
const [regStage, setRegStage] = useState<1 | 2>(1);
|
const [regStage, setRegStage] = useState<1 | 2>(1);
|
||||||
const [regCode, setRegCode] = useState("");
|
const [regCode, setRegCode] = useState("");
|
||||||
const [regCooldown, setRegCooldown] = useState(0);
|
const [regCooldown, setRegCooldown] = useState(0);
|
||||||
|
const [emailCheckLoading, setEmailCheckLoading] = useState(false);
|
||||||
|
const [emailCheckStatus, setEmailCheckStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle");
|
||||||
|
const [emailCheckMsg, setEmailCheckMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
// Password reset state
|
// Password reset state
|
||||||
const [resetStage, setResetStage] = useState<1 | 2 | 3>(1);
|
const [resetStage, setResetStage] = useState<1 | 2 | 3>(1);
|
||||||
|
|
@ -65,16 +104,34 @@ export default function App() {
|
||||||
|
|
||||||
// Check saved session on mount
|
// Check saved session on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedUser = localStorage.getItem("has_remembered_user");
|
const isTv = isTvEnvironment;
|
||||||
if (savedUser) {
|
// On TV, the user explicitly wants the profile selection screen at startup!
|
||||||
try {
|
if (!isTv) {
|
||||||
const parsed = JSON.parse(savedUser);
|
const savedUser = localStorage.getItem("has_remembered_user");
|
||||||
if (parsed?.email && parsed.email.toLowerCase() === "ne60201@gmail.com") {
|
if (savedUser) {
|
||||||
parsed.role = "admin";
|
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 {
|
} else {
|
||||||
localStorage.removeItem("has_remembered_user");
|
// 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
|
// Check DB status on startup
|
||||||
checkDatabase();
|
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
|
// Countdown timer for OTP re-send in resetStage 2
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -155,6 +227,16 @@ export default function App() {
|
||||||
|
|
||||||
if (res.success && res.user) {
|
if (res.success && res.user) {
|
||||||
setCurrentUser(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) {
|
if (rememberMe) {
|
||||||
localStorage.setItem("has_remembered_user", JSON.stringify(res.user));
|
localStorage.setItem("has_remembered_user", JSON.stringify(res.user));
|
||||||
localStorage.setItem("has_remembered_email", email.trim());
|
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());
|
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) => {
|
const handleRequestRegCode = async (e?: React.FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -192,6 +302,11 @@ export default function App() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (emailCheckStatus === "invalid") {
|
||||||
|
setError(emailCheckMsg || "Указанный адрес электронной почты не существует. Пожалуйста, укажите действительный почтовый ящик.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!username.trim()) {
|
if (!username.trim()) {
|
||||||
setError("Пожалуйста, укажите имя пользователя");
|
setError("Пожалуйста, укажите имя пользователя");
|
||||||
return;
|
return;
|
||||||
|
|
@ -404,7 +519,7 @@ export default function App() {
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UpdaterBanner />
|
<UpdaterBanner isTvEnvironment={isTvEnvironment} />
|
||||||
<Dashboard
|
<Dashboard
|
||||||
user={currentUser}
|
user={currentUser}
|
||||||
dbInfo={dbInfo}
|
dbInfo={dbInfo}
|
||||||
|
|
@ -415,10 +530,28 @@ export default function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isTvEnvironment) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<UpdaterBanner isTvEnvironment={isTvEnvironment} />
|
||||||
|
<TvProfileSelector
|
||||||
|
onSelectProfile={(user) => {
|
||||||
|
setCurrentUser(user);
|
||||||
|
localStorage.setItem("has_remembered_user", JSON.stringify(user));
|
||||||
|
}}
|
||||||
|
onSwitchToDesktop={() => {
|
||||||
|
setIsTvEnvironment(false);
|
||||||
|
localStorage.setItem("has_tv_mode", "false");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-viewport">
|
<div className="app-viewport">
|
||||||
{/* Auto-updater banner */}
|
{/* Auto-updater banner */}
|
||||||
<UpdaterBanner />
|
<UpdaterBanner isTvEnvironment={isTvEnvironment} />
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
{mode === "reset" ? (
|
{mode === "reset" ? (
|
||||||
|
|
@ -670,9 +803,34 @@ export default function App() {
|
||||||
<main className="auth-main-container">
|
<main className="auth-main-container">
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
{/* Actual Project Logo and Name */}
|
{/* Actual Project Logo and Name */}
|
||||||
<div className="brand-header">
|
<div className="brand-header" style={{ position: "relative" }}>
|
||||||
<img src={logoUrl} alt="Home Autli Server" className="brand-logo-img" />
|
<img src={logoUrl} alt="Home Autli Server" className="brand-logo-img" />
|
||||||
<span className="brand-title">Home Autli Server</span>
|
<span className="brand-title">Home Autli Server</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tv-mode-badge-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setIsTvEnvironment(true);
|
||||||
|
localStorage.setItem("has_tv_mode", "true");
|
||||||
|
}}
|
||||||
|
title="Перейти в режим Android TV (выбор профилей)"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 0,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
background: "rgba(99, 102, 241, 0.15)",
|
||||||
|
border: "1px solid rgba(99, 102, 241, 0.4)",
|
||||||
|
color: "#a5b4fc",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: "9999px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📺 ТВ Режим
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title & Subtitle */}
|
{/* Title & Subtitle */}
|
||||||
|
|
@ -795,9 +953,21 @@ export default function App() {
|
||||||
|
|
||||||
{/* Email Address */}
|
{/* Email Address */}
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label" htmlFor="email-input">
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
Электронная почта
|
<label className="form-label" htmlFor="email-input">
|
||||||
</label>
|
Электронная почта
|
||||||
|
</label>
|
||||||
|
{mode === "register" && emailCheckStatus === "valid" && (
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-success, #10b981)", fontWeight: 500 }}>
|
||||||
|
✓ Почта существует
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{mode === "register" && emailCheckStatus === "invalid" && (
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-danger, #ef4444)", fontWeight: 500 }}>
|
||||||
|
{emailCheckMsg?.includes("зарегистрирован") ? "✕ Уже зарегистрирован" : "✕ Недействительна"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="input-container">
|
<div className="input-container">
|
||||||
<div className="input-icon-left">
|
<div className="input-icon-left">
|
||||||
<MailIcon />
|
<MailIcon />
|
||||||
|
|
@ -808,11 +978,28 @@ export default function App() {
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder="user@example.com"
|
placeholder="user@example.com"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value);
|
||||||
|
if (emailCheckStatus !== "idle") {
|
||||||
|
setEmailCheckStatus("idle");
|
||||||
|
setEmailCheckMsg(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={handleEmailBlur}
|
||||||
required
|
required
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
|
{mode === "register" && emailCheckLoading && (
|
||||||
|
<div className="input-icon-right" style={{ pointerEvents: "none" }}>
|
||||||
|
<SpinnerIcon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{mode === "register" && emailCheckMsg && emailCheckStatus === "invalid" && (
|
||||||
|
<div style={{ fontSize: 11, color: "#f87171", marginTop: 4 }}>
|
||||||
|
{emailCheckMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
|
|
@ -929,6 +1116,8 @@ export default function App() {
|
||||||
setRegStage(1);
|
setRegStage(1);
|
||||||
setRegCode("");
|
setRegCode("");
|
||||||
setUsername("");
|
setUsername("");
|
||||||
|
setEmailCheckStatus("idle");
|
||||||
|
setEmailCheckMsg(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
}}
|
}}
|
||||||
|
|
@ -946,6 +1135,8 @@ export default function App() {
|
||||||
setRegStage(1);
|
setRegStage(1);
|
||||||
setRegCode("");
|
setRegCode("");
|
||||||
setUsername("");
|
setUsername("");
|
||||||
|
setEmailCheckStatus("idle");
|
||||||
|
setEmailCheckMsg(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
436
src/components/TvProfileSelector.css
Normal file
436
src/components/TvProfileSelector.css
Normal file
|
|
@ -0,0 +1,436 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
HAS - Android TV Profile Selector & Arc Navigation
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.has-tv-profile-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: radial-gradient(circle at 50% 30%, #111827 0%, #030712 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background Ambient Glow */
|
||||||
|
.has-tv-profile-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: 40%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 700px;
|
||||||
|
height: 400px;
|
||||||
|
background: radial-gradient(circle, rgba(99, 102, 241, 0.15) 0%, rgba(14, 165, 233, 0.05) 50%, transparent 80%);
|
||||||
|
filter: blur(60px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.has-tv-profile-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-profile-logo {
|
||||||
|
height: 44px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
filter: drop-shadow(0 4px 12px rgba(99, 102, 241, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-profile-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-profile-subtitle {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Arc Stage Container */
|
||||||
|
.has-tv-arc-stage {
|
||||||
|
position: relative;
|
||||||
|
width: 880px;
|
||||||
|
height: 400px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SVG Arc Line */
|
||||||
|
.has-tv-arc-svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-arc-path {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(255, 255, 255, 0.22);
|
||||||
|
stroke-width: 2.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile Items on the Arc */
|
||||||
|
.has-tv-profile-node {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
outline: none;
|
||||||
|
transition: transform 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-profile-avatar-circle {
|
||||||
|
width: 108px;
|
||||||
|
height: 108px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #cbd5e1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.5);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-profile-avatar-circle img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-profile-avatar-icon {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-profile-name {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #cbd5e1;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 140px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.8);
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-profile-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
background: #6366f1;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
margin-top: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focused Profile Node */
|
||||||
|
.has-tv-profile-node.focused {
|
||||||
|
transform: translate(-50%, -50%) scale(1.18);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-profile-node.focused .has-tv-profile-avatar-circle {
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 35px rgba(99, 102, 241, 0.9), 0 0 0 4px #ffffff;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-profile-node.focused .has-tv-profile-name {
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 0 12px rgba(99, 102, 241, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete badge on focused profile */
|
||||||
|
.has-tv-profile-delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ef4444;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-profile-delete-btn:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plus Button (Center Under Arc) */
|
||||||
|
.has-tv-plus-node {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
outline: none;
|
||||||
|
transition: transform 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-plus-circle {
|
||||||
|
width: 82px;
|
||||||
|
height: 82px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #cbd5e1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.5);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-plus-label {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #94a3b8;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-plus-node.focused {
|
||||||
|
transform: translate(-50%, -50%) scale(1.18);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-plus-node.focused .has-tv-plus-circle {
|
||||||
|
border-color: #6366f1;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #6366f1;
|
||||||
|
box-shadow: 0 0 35px rgba(99, 102, 241, 0.9), 0 0 0 4px #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-plus-node.focused .has-tv-plus-label {
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 0 10px rgba(99, 102, 241, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer Hints */
|
||||||
|
.has-tv-profile-footer {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-profile-hint-pill {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-profile-switch-desktop {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-profile-switch-desktop:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
FOCUSED 3-BUTTON TV LOGIN MODAL (Strict Remote Trap)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.has-tv-login-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(3, 7, 18, 0.85);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
animation: fadeInModal 0.2s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInModal {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-login-card {
|
||||||
|
width: 520px;
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
border: 2px solid rgba(99, 102, 241, 0.4);
|
||||||
|
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.9), 0 0 35px rgba(99, 102, 241, 0.35);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 36px 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-login-card-header {
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-login-card-title {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-login-card-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-login-form {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Exactly 3 Focusable TV Controls */
|
||||||
|
.has-tv-login-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 56px;
|
||||||
|
background: rgba(30, 41, 59, 0.8);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0 20px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-login-input::placeholder {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-login-input.focused,
|
||||||
|
.has-tv-login-input:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
background: rgba(30, 41, 59, 1);
|
||||||
|
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.35), 0 0 20px rgba(99, 102, 241, 0.6);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-login-submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 58px;
|
||||||
|
margin-top: 6px;
|
||||||
|
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 10px 25px rgba(79, 70, 229, 0.4);
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-login-submit-btn.focused,
|
||||||
|
.has-tv-login-submit-btn:focus {
|
||||||
|
border-color: #ffffff;
|
||||||
|
box-shadow: 0 0 0 4px #ffffff, 0 0 30px rgba(124, 58, 237, 0.9);
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-login-submit-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error message banner */
|
||||||
|
.has-tv-login-error-banner {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-tv-login-card-hint {
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
498
src/components/TvProfileSelector.tsx
Normal file
498
src/components/TvProfileSelector.tsx
Normal file
|
|
@ -0,0 +1,498 @@
|
||||||
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import logoUrl from "../assets/logo.png";
|
||||||
|
import type { UserDto, AuthResponse } from "../types";
|
||||||
|
import { playTvBeep, playTvSelectBeep, playTvBackBeep, playTvErrorBeep } from "../utils/tvAudio";
|
||||||
|
import "./TvProfileSelector.css";
|
||||||
|
|
||||||
|
interface TvProfileSelectorProps {
|
||||||
|
onSelectProfile: (user: UserDto) => void;
|
||||||
|
onSwitchToDesktop?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "has_tv_saved_profiles";
|
||||||
|
|
||||||
|
export const TvProfileSelector: React.FC<TvProfileSelectorProps> = ({
|
||||||
|
onSelectProfile,
|
||||||
|
onSwitchToDesktop,
|
||||||
|
}) => {
|
||||||
|
const [profiles, setProfiles] = useState<UserDto[]>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
|
||||||
|
}
|
||||||
|
// Fallback: check if standard remembered user exists
|
||||||
|
const rem = localStorage.getItem("has_remembered_user");
|
||||||
|
if (rem) {
|
||||||
|
const parsedUser = JSON.parse(rem);
|
||||||
|
if (parsedUser?.email) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify([parsedUser]));
|
||||||
|
return [parsedUser];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus state on arc: either a profile index or the central "+" button
|
||||||
|
const [focusState, setFocusState] = useState<{ type: "profile"; index: number } | { type: "plus" }>(
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved && JSON.parse(saved).length > 0) {
|
||||||
|
return { type: "profile", index: 0 };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return { type: "plus" };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3-button TV Authorization Modal State
|
||||||
|
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||||
|
const [tvEmail, setTvEmail] = useState("");
|
||||||
|
const [tvPassword, setTvPassword] = useState("");
|
||||||
|
const [tvLoginFocus, setTvLoginFocus] = useState<0 | 1 | 2>(0); // 0: Email, 1: Password, 2: Submit
|
||||||
|
const [tvLoading, setTvLoading] = useState(false);
|
||||||
|
const [tvError, setTvError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const emailInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const passwordInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const submitButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
// Synchronize focus when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoginModalOpen) {
|
||||||
|
setTvLoginFocus(0);
|
||||||
|
setTvError(null);
|
||||||
|
setTimeout(() => emailInputRef.current?.focus(), 60);
|
||||||
|
}
|
||||||
|
}, [isLoginModalOpen]);
|
||||||
|
|
||||||
|
// Handle focus changes in login modal
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoginModalOpen) return;
|
||||||
|
if (tvLoginFocus === 0) {
|
||||||
|
emailInputRef.current?.focus();
|
||||||
|
} else if (tvLoginFocus === 1) {
|
||||||
|
passwordInputRef.current?.focus();
|
||||||
|
} else if (tvLoginFocus === 2) {
|
||||||
|
submitButtonRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [tvLoginFocus, isLoginModalOpen]);
|
||||||
|
|
||||||
|
// Compute profile coordinates on the arc (matching the user's sketch)
|
||||||
|
const getCoordinates = (index: number, total: number) => {
|
||||||
|
const cx = 440;
|
||||||
|
const cy = 360;
|
||||||
|
const rx = 330;
|
||||||
|
const ry = 210;
|
||||||
|
|
||||||
|
if (total === 1) {
|
||||||
|
return { x: cx, y: cy - ry };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribute angles along the semicircle arc
|
||||||
|
const startAngle = (155 * Math.PI) / 180;
|
||||||
|
const endAngle = (25 * Math.PI) / 180;
|
||||||
|
const step = (startAngle - endAngle) / (total - 1);
|
||||||
|
const angle = startAngle - index * step;
|
||||||
|
|
||||||
|
const x = cx + rx * Math.cos(angle);
|
||||||
|
const y = cy - ry * Math.sin(angle);
|
||||||
|
return { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Login handler for 3-button TV login
|
||||||
|
const handleTvLogin = async (e?: React.FormEvent) => {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
setTvError(null);
|
||||||
|
|
||||||
|
const cleanEmail = tvEmail.trim();
|
||||||
|
if (!cleanEmail || !tvPassword) {
|
||||||
|
setTvError("Заполните почту и пароль");
|
||||||
|
playTvBeep(320, 0.08);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTvLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await invoke<AuthResponse>("login", {
|
||||||
|
email: cleanEmail,
|
||||||
|
password: tvPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.success && res.user) {
|
||||||
|
playTvBeep(780, 0.06);
|
||||||
|
// Save to TV profiles list
|
||||||
|
const updated = [
|
||||||
|
res.user,
|
||||||
|
...profiles.filter((p) => p.email.toLowerCase() !== res.user!.email.toLowerCase()),
|
||||||
|
];
|
||||||
|
setProfiles(updated);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||||
|
localStorage.setItem("has_remembered_user", JSON.stringify(res.user));
|
||||||
|
localStorage.setItem("has_remembered_email", cleanEmail);
|
||||||
|
setIsLoginModalOpen(false);
|
||||||
|
onSelectProfile(res.user);
|
||||||
|
} else {
|
||||||
|
setTvError(res.message || "Неверный логин или пароль");
|
||||||
|
playTvErrorBeep();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setTvError(`Ошибка: ${err?.message || err}`);
|
||||||
|
playTvErrorBeep();
|
||||||
|
} finally {
|
||||||
|
setTvLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Select profile directly from arc
|
||||||
|
const handleSelect = (user: UserDto) => {
|
||||||
|
playTvSelectBeep();
|
||||||
|
localStorage.setItem("has_remembered_user", JSON.stringify(user));
|
||||||
|
onSelectProfile(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove profile from TV list
|
||||||
|
const handleDeleteProfile = (e: React.MouseEvent, email: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const filtered = profiles.filter((p) => p.email.toLowerCase() !== email.toLowerCase());
|
||||||
|
setProfiles(filtered);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||||
|
setFocusState({ type: "plus" });
|
||||||
|
playTvBackBeep();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global D-pad Remote Control Key Listener
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// CASE 1: 3-Button TV Login Modal is OPEN (Strict Remote Focus Trap)
|
||||||
|
// "больше пультом дистанционного управления никуда нельзя будет нажать только на эти три кнопки"
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
if (isLoginModalOpen) {
|
||||||
|
// Back / Escape key returns to Profile Arc
|
||||||
|
if (e.key === "Escape" || e.keyCode === 27 || e.keyCode === 4) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoginModalOpen(false);
|
||||||
|
setFocusState({ type: "plus" });
|
||||||
|
playTvBackBeep();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down Arrow: Email (0) -> Password (1) -> Button (2)
|
||||||
|
if (e.key === "ArrowDown" || e.keyCode === 20) {
|
||||||
|
e.preventDefault();
|
||||||
|
setTvLoginFocus((prev) => ((prev + 1) % 3) as 0 | 1 | 2);
|
||||||
|
playTvBeep(520, 0.025);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up Arrow: Button (2) -> Password (1) -> Email (0)
|
||||||
|
if (e.key === "ArrowUp" || e.keyCode === 19) {
|
||||||
|
e.preventDefault();
|
||||||
|
setTvLoginFocus((prev) => ((prev - 1 + 3) % 3) as 0 | 1 | 2);
|
||||||
|
playTvBeep(520, 0.025);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab / Shift+Tab cycling strictly between these 3 controls
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) {
|
||||||
|
setTvLoginFocus((prev) => ((prev - 1 + 3) % 3) as 0 | 1 | 2);
|
||||||
|
} else {
|
||||||
|
setTvLoginFocus((prev) => ((prev + 1) % 3) as 0 | 1 | 2);
|
||||||
|
}
|
||||||
|
playTvBeep(520, 0.025);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter / OK (KeyCode 13, 23, 66)
|
||||||
|
if (e.key === "Enter" || e.keyCode === 13 || e.keyCode === 23 || e.keyCode === 66) {
|
||||||
|
if (tvLoginFocus === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
setTvLoginFocus(1);
|
||||||
|
playTvBeep(580, 0.03);
|
||||||
|
} else if (tvLoginFocus === 1 || tvLoginFocus === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleTvLogin();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left / Right keys stay inside the input for text editing
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// CASE 2: Arc Profile Selection Screen
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
const total = profiles.length;
|
||||||
|
|
||||||
|
// Left Arrow
|
||||||
|
if (e.key === "ArrowLeft" || e.keyCode === 21) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (focusState.type === "profile") {
|
||||||
|
if (focusState.index > 0) {
|
||||||
|
setFocusState({ type: "profile", index: focusState.index - 1 });
|
||||||
|
playTvBeep(540, 0.025);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// From plus button: jump to left-middle profile if exists
|
||||||
|
if (total > 0) {
|
||||||
|
const targetIdx = Math.max(0, Math.floor((total - 1) / 2));
|
||||||
|
setFocusState({ type: "profile", index: targetIdx });
|
||||||
|
playTvBeep(540, 0.025);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right Arrow
|
||||||
|
if (e.key === "ArrowRight" || e.keyCode === 22) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (focusState.type === "profile") {
|
||||||
|
if (focusState.index < total - 1) {
|
||||||
|
setFocusState({ type: "profile", index: focusState.index + 1 });
|
||||||
|
playTvBeep(540, 0.025);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// From plus button: jump to right-middle profile if exists
|
||||||
|
if (total > 0) {
|
||||||
|
const targetIdx = Math.min(total - 1, Math.ceil((total - 1) / 2));
|
||||||
|
setFocusState({ type: "profile", index: targetIdx });
|
||||||
|
playTvBeep(540, 0.025);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down Arrow -> moves focus to the Plus button in center
|
||||||
|
if (e.key === "ArrowDown" || e.keyCode === 20) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (focusState.type === "profile") {
|
||||||
|
setFocusState({ type: "plus" });
|
||||||
|
playTvBeep(500, 0.025);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up Arrow -> moves focus from Plus button up to nearest profile
|
||||||
|
if (e.key === "ArrowUp" || e.keyCode === 19) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (focusState.type === "plus" && total > 0) {
|
||||||
|
const targetIdx = Math.round((total - 1) / 2);
|
||||||
|
setFocusState({ type: "profile", index: targetIdx });
|
||||||
|
playTvBeep(560, 0.025);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK / Enter -> Select Profile or Open Plus Login
|
||||||
|
if (e.key === "Enter" || e.key === " " || e.keyCode === 13 || e.keyCode === 23 || e.keyCode === 66) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (focusState.type === "profile" && profiles[focusState.index]) {
|
||||||
|
handleSelect(profiles[focusState.index]);
|
||||||
|
} else if (focusState.type === "plus") {
|
||||||
|
setIsLoginModalOpen(true);
|
||||||
|
playTvBeep(640, 0.03);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete / Backspace key removes focused profile
|
||||||
|
if (e.key === "Delete" || e.keyCode === 46) {
|
||||||
|
if (focusState.type === "profile" && profiles[focusState.index]) {
|
||||||
|
e.preventDefault();
|
||||||
|
const p = profiles[focusState.index];
|
||||||
|
const filtered = profiles.filter((item) => item.email.toLowerCase() !== p.email.toLowerCase());
|
||||||
|
setProfiles(filtered);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||||
|
setFocusState({ type: "plus" });
|
||||||
|
playTvBeep(360, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isLoginModalOpen, tvLoginFocus, focusState, profiles, tvEmail, tvPassword]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="has-tv-profile-screen">
|
||||||
|
<div className="has-tv-profile-glow" />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<header className="has-tv-profile-header">
|
||||||
|
<img src={logoUrl} alt="HAS Logo" className="has-tv-profile-logo" />
|
||||||
|
<h1 className="has-tv-profile-title">Кто сейчас смотрит?</h1>
|
||||||
|
<p className="has-tv-profile-subtitle">Выберите профиль для автоматического входа</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Arc Stage with Semicircle & Profile Circles */}
|
||||||
|
<div className="has-tv-arc-stage">
|
||||||
|
{/* SVG Semicircle Arc (matching user's drawing) */}
|
||||||
|
<svg className="has-tv-arc-svg" viewBox="0 0 880 400" preserveAspectRatio="none">
|
||||||
|
<path d="M 110 360 A 330 210 0 0 1 770 360" className="has-tv-arc-path" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Profile Avatars along the Arc */}
|
||||||
|
{profiles.map((user, idx) => {
|
||||||
|
const coords = getCoordinates(idx, profiles.length);
|
||||||
|
const isFocused = focusState.type === "profile" && focusState.index === idx;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${user.email}-${idx}`}
|
||||||
|
type="button"
|
||||||
|
className={`has-tv-profile-node ${isFocused ? "focused" : ""}`}
|
||||||
|
style={{ left: `${coords.x}px`, top: `${coords.y}px` }}
|
||||||
|
onClick={() => handleSelect(user)}
|
||||||
|
onMouseEnter={() => setFocusState({ type: "profile", index: idx })}
|
||||||
|
title={`Войти как ${user.username || user.email}`}
|
||||||
|
>
|
||||||
|
<div className="has-tv-profile-avatar-circle">
|
||||||
|
{user.avatar ? (
|
||||||
|
<img src={user.avatar} alt={user.username || user.email} />
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="has-tv-profile-avatar-icon"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete badge visible on hover/focus */}
|
||||||
|
{isFocused && (
|
||||||
|
<span
|
||||||
|
className="has-tv-profile-delete-btn"
|
||||||
|
onClick={(e) => handleDeleteProfile(e, user.email)}
|
||||||
|
title="Удалить профиль с ТВ"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="has-tv-profile-name">
|
||||||
|
{user.username || user.email.split("@")[0]}
|
||||||
|
</span>
|
||||||
|
{user.role === "admin" && <span className="has-tv-profile-badge">ADMIN</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Plus Button: Positioned in Center under the Arc */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`has-tv-plus-node ${focusState.type === "plus" ? "focused" : ""}`}
|
||||||
|
style={{ left: "440px", top: "340px" }}
|
||||||
|
onClick={() => {
|
||||||
|
setIsLoginModalOpen(true);
|
||||||
|
playTvBeep(640, 0.03);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setFocusState({ type: "plus" })}
|
||||||
|
title="Добавить новый профиль"
|
||||||
|
>
|
||||||
|
<div className="has-tv-plus-circle">+</div>
|
||||||
|
<span className="has-tv-plus-label">Добавить</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Navigation Hints */}
|
||||||
|
<footer className="has-tv-profile-footer">
|
||||||
|
<span className="has-tv-profile-hint-pill">
|
||||||
|
◄ ► Влево / Вправо • ▼ Кнопка + • OK Вход в аккаунт
|
||||||
|
</span>
|
||||||
|
{onSwitchToDesktop && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-tv-profile-switch-desktop"
|
||||||
|
onClick={onSwitchToDesktop}
|
||||||
|
>
|
||||||
|
💻 Обычный режим ПК
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* ==================================================================
|
||||||
|
STRICT 3-BUTTON TV LOGIN MODAL
|
||||||
|
"два поля почта и пароль с кнопкой входа больше пультом дистанционного
|
||||||
|
управления никуда нельзя будет нажать только на эти три кнопки"
|
||||||
|
================================================================== */}
|
||||||
|
{isLoginModalOpen && (
|
||||||
|
<div
|
||||||
|
className="has-tv-login-overlay"
|
||||||
|
onClick={() => {
|
||||||
|
setIsLoginModalOpen(false);
|
||||||
|
setFocusState({ type: "plus" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="has-tv-login-card" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="has-tv-login-card-header">
|
||||||
|
<h2 className="has-tv-login-card-title">Добавить профиль</h2>
|
||||||
|
<p className="has-tv-login-card-desc">
|
||||||
|
Введите данные для авторизации нового аккаунта на ТВ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="has-tv-login-form" onSubmit={handleTvLogin}>
|
||||||
|
{/* Field 1: Email */}
|
||||||
|
<input
|
||||||
|
ref={emailInputRef}
|
||||||
|
type="email"
|
||||||
|
value={tvEmail}
|
||||||
|
onChange={(e) => setTvEmail(e.target.value)}
|
||||||
|
placeholder="Почта"
|
||||||
|
className={`has-tv-login-input ${tvLoginFocus === 0 ? "focused" : ""}`}
|
||||||
|
tabIndex={0}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Field 2: Password */}
|
||||||
|
<input
|
||||||
|
ref={passwordInputRef}
|
||||||
|
type="password"
|
||||||
|
value={tvPassword}
|
||||||
|
onChange={(e) => setTvPassword(e.target.value)}
|
||||||
|
placeholder="Пароль"
|
||||||
|
className={`has-tv-login-input ${tvLoginFocus === 1 ? "focused" : ""}`}
|
||||||
|
tabIndex={0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tvError && <div className="has-tv-login-error-banner">{tvError}</div>}
|
||||||
|
|
||||||
|
{/* Button 3: Submit Button */}
|
||||||
|
<button
|
||||||
|
ref={submitButtonRef}
|
||||||
|
type="submit"
|
||||||
|
disabled={tvLoading}
|
||||||
|
className={`has-tv-login-submit-btn ${tvLoginFocus === 2 ? "focused" : ""}`}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{tvLoading ? "Подключение..." : "Войти"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="has-tv-login-card-hint">
|
||||||
|
▲ ▼ Пульт: перемещение • OK: выбор • Назад (Back / Esc): отмена
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,11 +1,51 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { playTvBeep } from "../utils/tvAudio";
|
||||||
|
|
||||||
interface UpdateInfo {
|
interface UpdateInfo {
|
||||||
version: string;
|
version: string;
|
||||||
body: string | null;
|
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<boolean>(() => isTvEnvironment ?? detectTvEnvironment());
|
||||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
@ -13,91 +53,280 @@ export function UpdaterBanner() {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
// Strictly two buttons for TV remote navigation: "update" | "skip"
|
||||||
if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) {
|
const [tvFocusedBtn, setTvFocusedBtn] = useState<"update" | "skip">("update");
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof isTvEnvironment === "boolean") {
|
||||||
|
setIsTv(isTvEnvironment);
|
||||||
|
}
|
||||||
|
}, [isTvEnvironment]);
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const checkForUpdates = async () => {
|
const checkForUpdates = async () => {
|
||||||
try {
|
// 1. Try official Tauri updater plugin first (Desktop)
|
||||||
const { check } = await import("@tauri-apps/plugin-updater");
|
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) {
|
||||||
const update = await check();
|
try {
|
||||||
if (!cancelled && update?.available) {
|
const { check } = await import("@tauri-apps/plugin-updater");
|
||||||
setUpdateAvailable(true);
|
const update = await check();
|
||||||
setUpdateInfo({
|
if (!cancelled && update?.available) {
|
||||||
version: update.version,
|
setUpdateAvailable(true);
|
||||||
body: update.body ?? null,
|
setUpdateInfo({
|
||||||
});
|
version: update.version,
|
||||||
setDismissed(false);
|
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
|
// 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);
|
const interval = setInterval(checkForUpdates, 60 * 1000);
|
||||||
|
|
||||||
// 3. Check whenever window gains focus
|
// 3. Check whenever window gains focus
|
||||||
const handleFocus = () => checkForUpdates();
|
const handleFocus = () => checkForUpdates();
|
||||||
window.addEventListener("focus", handleFocus);
|
window.addEventListener("focus", handleFocus);
|
||||||
|
|
||||||
// 4. Custom event for manual checks from UI
|
// 4. Custom event for manual checks from UI or simulation
|
||||||
window.addEventListener("has-check-update", handleFocus);
|
window.addEventListener("has-check-update", handleFocus);
|
||||||
|
|
||||||
|
const handleSimulateUpdate = (e: any) => {
|
||||||
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
window.removeEventListener("focus", handleFocus);
|
window.removeEventListener("focus", handleFocus);
|
||||||
window.removeEventListener("has-check-update", handleFocus);
|
window.removeEventListener("has-check-update", handleFocus);
|
||||||
|
window.removeEventListener("has-simulate-update", handleSimulateUpdate);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleInstall = async () => {
|
const handleInstall = useCallback(async () => {
|
||||||
if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) return;
|
|
||||||
|
|
||||||
setDownloading(true);
|
setDownloading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
|
||||||
try {
|
// Desktop Tauri plugin install
|
||||||
const { check } = await import("@tauri-apps/plugin-updater");
|
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) {
|
||||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
try {
|
||||||
|
const { check } = await import("@tauri-apps/plugin-updater");
|
||||||
|
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||||
|
|
||||||
const update = await check();
|
const update = await check();
|
||||||
if (!update?.available) return;
|
if (update?.available) {
|
||||||
|
let downloaded = 0;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
let downloaded = 0;
|
await update.downloadAndInstall((event: any) => {
|
||||||
let total = 0;
|
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) => {
|
await relaunch();
|
||||||
if (event.event === "Started") {
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
} catch (e: unknown) {
|
||||||
|
console.warn("[Updater] Desktop updater failed, falling back to download:", e);
|
||||||
await relaunch();
|
}
|
||||||
} catch (e: unknown) {
|
|
||||||
setError(`Ошибка обновления: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
setDownloading(false);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
// 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;
|
if (!updateAvailable || dismissed) return null;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 1. ANDROID TV: Full-screen Centered Modal with EXACTLY Two Buttons
|
||||||
|
// =========================================================================
|
||||||
|
if (isTv) {
|
||||||
|
return (
|
||||||
|
<div className="has-tv-updater-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="has-tv-updater-card">
|
||||||
|
<div className="has-tv-updater-icon-ring">🚀</div>
|
||||||
|
<h2 className="has-tv-updater-title">Доступно обновление</h2>
|
||||||
|
<div className="has-tv-updater-version-tag">
|
||||||
|
<span>Вышла новая версия v{updateInfo?.version || "1.0.0"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="has-tv-updater-desc">
|
||||||
|
{updateInfo?.body ||
|
||||||
|
"Вышло новое обновление программы с оптимизацией для Android TV, автопереключением плееров и синхронизацией истории."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{downloading ? (
|
||||||
|
<div className="has-tv-updater-progress-container">
|
||||||
|
<div className="has-tv-updater-progress-track">
|
||||||
|
<div
|
||||||
|
className="has-tv-updater-progress-bar"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="has-tv-updater-progress-text">
|
||||||
|
Загрузка и установка обновления: {progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="has-tv-updater-buttons">
|
||||||
|
{/* Button 1: Обновить */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`has-tv-updater-btn btn-update ${
|
||||||
|
tvFocusedBtn === "update" ? "focused" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
playTvBeep(680, 0.05);
|
||||||
|
handleInstall();
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setTvFocusedBtn("update")}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<span>🚀 Обновить</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Button 2: Пропустить */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`has-tv-updater-btn btn-skip ${
|
||||||
|
tvFocusedBtn === "skip" ? "focused" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
playTvBeep(420, 0.04);
|
||||||
|
setDismissed(true);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setTvFocusedBtn("skip")}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<span>Пропустить</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="updater-error" style={{ marginTop: 16 }}>{error}</div>}
|
||||||
|
|
||||||
|
<div className="has-tv-updater-remote-hint">
|
||||||
|
◄ ► Стрелки на пульте: переключение • OK: подтвердить выбор • Back: пропустить
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. DESKTOP: Classic Bottom-Right Popup Banner
|
||||||
|
// =========================================================================
|
||||||
return (
|
return (
|
||||||
<div className="updater-banner">
|
<div className="updater-banner">
|
||||||
<div className="updater-banner-content">
|
<div className="updater-banner-content">
|
||||||
|
|
|
||||||
|
|
@ -904,6 +904,45 @@
|
||||||
transform: scale(1.08);
|
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 */
|
/* Unreleased movie player card */
|
||||||
.has-cinema-unreleased-player-view {
|
.has-cinema-unreleased-player-view {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@ import Hls from "hls.js";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import {
|
||||||
|
playTvBeep as playTvBeepAudio,
|
||||||
|
playTvNavBeep,
|
||||||
|
playTvSelectBeep,
|
||||||
|
playTvBackBeep,
|
||||||
|
} from "../../utils/tvAudio";
|
||||||
import type {
|
import type {
|
||||||
CinemaState,
|
CinemaState,
|
||||||
CinemaConfig,
|
CinemaConfig,
|
||||||
|
|
@ -540,7 +546,6 @@ export default function Cinema({
|
||||||
}, [isTvEnvironment, isTvMode]);
|
}, [isTvEnvironment, isTvMode]);
|
||||||
const [tvToast, setTvToast] = useState<{ text: string; icon?: string } | null>(null);
|
const [tvToast, setTvToast] = useState<{ text: string; icon?: string } | null>(null);
|
||||||
const tvToastTimerRef = useRef<any>(null);
|
const tvToastTimerRef = useRef<any>(null);
|
||||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
|
||||||
|
|
||||||
const showTvToast = useCallback((text: string, icon = "📺", duration = 2000) => {
|
const showTvToast = useCallback((text: string, icon = "📺", duration = 2000) => {
|
||||||
if (tvToastTimerRef.current) clearTimeout(tvToastTimerRef.current);
|
if (tvToastTimerRef.current) clearTimeout(tvToastTimerRef.current);
|
||||||
|
|
@ -551,29 +556,7 @@ export default function Cinema({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const playTvBeep = useCallback((freq = 580, duration = 0.035) => {
|
const playTvBeep = useCallback((freq = 580, duration = 0.035) => {
|
||||||
try {
|
playTvBeepAudio(freq, duration);
|
||||||
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
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleTvMode = useCallback(() => {
|
const toggleTvMode = useCallback(() => {
|
||||||
|
|
@ -631,6 +614,13 @@ export default function Cinema({
|
||||||
const hlsInstanceRef = useRef<Hls | null>(null);
|
const hlsInstanceRef = useRef<Hls | null>(null);
|
||||||
const aniLibriaReleasesCache = useRef<Map<number, any>>(new Map());
|
const aniLibriaReleasesCache = useRef<Map<number, any>>(new Map());
|
||||||
|
|
||||||
|
// Automatic Mirror Watchdog & Failover System (zero dropped playback)
|
||||||
|
const triedMirrorsRef = useRef<Set<BalancerMirror>>(new Set());
|
||||||
|
const mirrorWatchdogTimerRef = useRef<any>(null);
|
||||||
|
const [isMirrorConnecting, setIsMirrorConnecting] = useState<boolean>(false);
|
||||||
|
const [allMirrorsFailed, setAllMirrorsFailed] = useState<boolean>(false);
|
||||||
|
const currentPlaybackRef = useRef<{ currentTime: number; duration: number }>({ currentTime: 0, duration: 0 });
|
||||||
|
|
||||||
// Detailed Movie / Series Information Modal State
|
// Detailed Movie / Series Information Modal State
|
||||||
const [detailsMedia, setDetailsMedia] = useState<CinemaMediaItem | null>(null);
|
const [detailsMedia, setDetailsMedia] = useState<CinemaMediaItem | null>(null);
|
||||||
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
||||||
|
|
@ -980,12 +970,12 @@ export default function Cinema({
|
||||||
})
|
})
|
||||||
.catch((err) => console.warn("Failed to get cinema config:", err));
|
.catch((err) => console.warn("Failed to get cinema config:", err));
|
||||||
|
|
||||||
invoke<CinemaState>("get_cinema_state")
|
invoke<CinemaState>("get_cinema_state", { userEmail: currentUser?.email })
|
||||||
.then((state) => {
|
.then((state) => {
|
||||||
if (state) setCinemaState(state);
|
if (state) setCinemaState(state);
|
||||||
})
|
})
|
||||||
.catch((err) => console.warn("Failed to get local cinema state:", err));
|
.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)
|
// Search TMDB live across all movies, series, and anime (only released and verified playable titles)
|
||||||
const executeSearch = useCallback(
|
const executeSearch = useCallback(
|
||||||
|
|
@ -1085,12 +1075,13 @@ export default function Cinema({
|
||||||
|
|
||||||
const isBookmarked = cinemaState.bookmarks.includes(selectedMedia.id);
|
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 () => {
|
const handleToggleBookmark = async () => {
|
||||||
if (isTauri) {
|
if (isTauri) {
|
||||||
try {
|
try {
|
||||||
const nextState = await invoke<CinemaState>("toggle_cinema_bookmark", {
|
const nextState = await invoke<CinemaState>("toggle_cinema_bookmark", {
|
||||||
titleId: selectedMedia.id,
|
titleId: selectedMedia.id,
|
||||||
|
userEmail: currentUser?.email,
|
||||||
});
|
});
|
||||||
setCinemaState(nextState);
|
setCinemaState(nextState);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -1243,6 +1234,81 @@ export default function Cinema({
|
||||||
};
|
};
|
||||||
}, [selectedMirror, anilibriaStreamUrl]);
|
}, [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<BalancerMirror, string> = {
|
||||||
|
vidlink_pro: "Сервер 1: VidLink Pro (1080p)",
|
||||||
|
kodik_ru: "Сервер 2: Kodik (Студии озвучки)",
|
||||||
|
autoembed: "Сервер 3: AutoEmbed",
|
||||||
|
vidsrc_pm: "Сервер 4: VidSrc Pro",
|
||||||
|
multiembed: "Сервер 5: MultiStream VIP",
|
||||||
|
vidsrc_to: "Сервер 6: VidSrc To",
|
||||||
|
alloha_ru: "Сервер 7: Alloha",
|
||||||
|
anilibria: "Сервер 8: AniLibria HLS",
|
||||||
|
embess_ru: "Embess",
|
||||||
|
smotrim_ru: "Smotrim",
|
||||||
|
};
|
||||||
|
|
||||||
|
showTvToast(`Автопереключение: ${labels[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)
|
// Start playback for a media item (movie or specific series episode)
|
||||||
const startPlayback = (media: CinemaMediaItem, seasonNum: number = 1, episode?: CinemaEpisode) => {
|
const startPlayback = (media: CinemaMediaItem, seasonNum: number = 1, episode?: CinemaEpisode) => {
|
||||||
setSelectedMedia(media);
|
setSelectedMedia(media);
|
||||||
|
|
@ -1267,6 +1333,22 @@ export default function Cinema({
|
||||||
};
|
};
|
||||||
setPlayingEpisode(ep);
|
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 =
|
const isAnime =
|
||||||
media.genre?.toLowerCase().includes("аниме") ||
|
media.genre?.toLowerCase().includes("аниме") ||
|
||||||
media.genre?.toLowerCase().includes("cartoon") ||
|
media.genre?.toLowerCase().includes("cartoon") ||
|
||||||
|
|
@ -1283,6 +1365,18 @@ export default function Cinema({
|
||||||
setCurrentImdbId(null);
|
setCurrentImdbId(null);
|
||||||
setCurrentAllohaUrl(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:
|
// Default to verified reliable high-speed 1080p stream:
|
||||||
if (isAnime) {
|
if (isAnime) {
|
||||||
if (anilibriaStreamUrl) {
|
if (anilibriaStreamUrl) {
|
||||||
|
|
@ -1390,6 +1484,7 @@ export default function Cinema({
|
||||||
try {
|
try {
|
||||||
const nextState = await invoke<CinemaState>("toggle_cinema_bookmark", {
|
const nextState = await invoke<CinemaState>("toggle_cinema_bookmark", {
|
||||||
titleId: mediaId,
|
titleId: mediaId,
|
||||||
|
userEmail: currentUser?.email,
|
||||||
});
|
});
|
||||||
setCinemaState(nextState);
|
setCinemaState(nextState);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -1476,21 +1571,19 @@ export default function Cinema({
|
||||||
|
|
||||||
// Quick 1-click multi-source switcher with automatic fallback
|
// Quick 1-click multi-source switcher with automatic fallback
|
||||||
const handleCycleMirror = () => {
|
const handleCycleMirror = () => {
|
||||||
const mirrors: BalancerMirror[] = [
|
if (mirrorWatchdogTimerRef.current) {
|
||||||
"vidlink_pro",
|
clearTimeout(mirrorWatchdogTimerRef.current);
|
||||||
"kodik_ru",
|
mirrorWatchdogTimerRef.current = null;
|
||||||
"autoembed",
|
}
|
||||||
"vidsrc_pm",
|
|
||||||
"multiembed",
|
|
||||||
"vidsrc_to",
|
|
||||||
];
|
|
||||||
if (currentAllohaUrl) mirrors.push("alloha_ru");
|
|
||||||
if (anilibriaStreamUrl) mirrors.push("anilibria");
|
|
||||||
|
|
||||||
|
const mirrors = getAvailableMirrorsList();
|
||||||
const currentIndex = mirrors.indexOf(selectedMirror);
|
const currentIndex = mirrors.indexOf(selectedMirror);
|
||||||
const nextIndex = (currentIndex + 1) % mirrors.length;
|
const nextIndex = (currentIndex + 1) % mirrors.length;
|
||||||
const nextMirror = mirrors[nextIndex];
|
const nextMirror = mirrors[nextIndex];
|
||||||
setSelectedMirror(nextMirror);
|
setSelectedMirror(nextMirror);
|
||||||
|
setAllMirrorsFailed(false);
|
||||||
|
setIsMirrorConnecting(true);
|
||||||
|
triedMirrorsRef.current.add(nextMirror);
|
||||||
|
|
||||||
const labels: Record<BalancerMirror, string> = {
|
const labels: Record<BalancerMirror, string> = {
|
||||||
vidlink_pro: "Сервер 1: VidLink Pro (1080p)",
|
vidlink_pro: "Сервер 1: VidLink Pro (1080p)",
|
||||||
|
|
@ -1506,6 +1599,10 @@ export default function Cinema({
|
||||||
};
|
};
|
||||||
showTvToast(`Переключено на: ${labels[nextMirror] || nextMirror}`, "🔄", 2200);
|
showTvToast(`Переключено на: ${labels[nextMirror] || nextMirror}`, "🔄", 2200);
|
||||||
resetHudTimer();
|
resetHudTimer();
|
||||||
|
|
||||||
|
mirrorWatchdogTimerRef.current = setTimeout(() => {
|
||||||
|
handleAutoSwitchNextMirror("Сервер не ответил за 8 секунд");
|
||||||
|
}, 8000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save playback progress on time update
|
// Save playback progress on time update
|
||||||
|
|
@ -1514,6 +1611,7 @@ export default function Cinema({
|
||||||
const currentTime = videoRef.current.currentTime;
|
const currentTime = videoRef.current.currentTime;
|
||||||
const duration = videoRef.current.duration;
|
const duration = videoRef.current.duration;
|
||||||
const activeMedia = playingMedia || selectedMedia;
|
const activeMedia = playingMedia || selectedMedia;
|
||||||
|
currentPlaybackRef.current = { currentTime, duration };
|
||||||
|
|
||||||
if (duration > 0 && isTauri) {
|
if (duration > 0 && isTauri) {
|
||||||
const payload: CinemaPlayback = {
|
const payload: CinemaPlayback = {
|
||||||
|
|
@ -1523,13 +1621,13 @@ export default function Cinema({
|
||||||
backdrop_path: activeMedia.backdrop_url,
|
backdrop_path: activeMedia.backdrop_url,
|
||||||
season: playingSeasonNumber,
|
season: playingSeasonNumber,
|
||||||
episode: playingEpisode.number,
|
episode: playingEpisode.number,
|
||||||
progress_seconds: currentTime,
|
progress_seconds: Math.round(currentTime),
|
||||||
duration_seconds: duration,
|
duration_seconds: Math.round(duration),
|
||||||
last_watched: new Date().toISOString(),
|
last_watched: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (Math.floor(currentTime) % 5 === 0) {
|
if (Math.floor(currentTime) % 5 === 0) {
|
||||||
invoke<CinemaState>("update_cinema_progress", { item: payload })
|
invoke<CinemaState>("update_cinema_progress", { item: payload, userEmail: currentUser?.email })
|
||||||
.then((st) => setCinemaState(st))
|
.then((st) => setCinemaState(st))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
@ -1539,6 +1637,13 @@ export default function Cinema({
|
||||||
// Close player and save exact position
|
// Close player and save exact position
|
||||||
const handleClosePlayer = useCallback(() => {
|
const handleClosePlayer = useCallback(() => {
|
||||||
const activeMedia = playingMedia || selectedMedia;
|
const activeMedia = playingMedia || selectedMedia;
|
||||||
|
if (mirrorWatchdogTimerRef.current) {
|
||||||
|
clearTimeout(mirrorWatchdogTimerRef.current);
|
||||||
|
mirrorWatchdogTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setIsMirrorConnecting(false);
|
||||||
|
triedMirrorsRef.current.clear();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isTauri && isFullscreen) {
|
if (isTauri && isFullscreen) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1548,11 +1653,19 @@ export default function Cinema({
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
if (videoRef.current && playingEpisode && isTauri) {
|
if (playingEpisode && isTauri && activeMedia) {
|
||||||
try {
|
try {
|
||||||
const currentTime = videoRef.current.currentTime;
|
let currentTime = currentPlaybackRef.current.currentTime;
|
||||||
const duration = videoRef.current.duration;
|
let duration = currentPlaybackRef.current.duration;
|
||||||
if (duration > 0 && activeMedia) {
|
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 = {
|
const payload: CinemaPlayback = {
|
||||||
title_id: activeMedia.id,
|
title_id: activeMedia.id,
|
||||||
title_name: activeMedia.title,
|
title_name: activeMedia.title,
|
||||||
|
|
@ -1560,11 +1673,11 @@ export default function Cinema({
|
||||||
backdrop_path: activeMedia.backdrop_url,
|
backdrop_path: activeMedia.backdrop_url,
|
||||||
season: playingSeasonNumber,
|
season: playingSeasonNumber,
|
||||||
episode: playingEpisode.number,
|
episode: playingEpisode.number,
|
||||||
progress_seconds: currentTime,
|
progress_seconds: Math.round(currentTime),
|
||||||
duration_seconds: duration,
|
duration_seconds: Math.round(duration),
|
||||||
last_watched: new Date().toISOString(),
|
last_watched: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
invoke<CinemaState>("update_cinema_progress", { item: payload })
|
invoke<CinemaState>("update_cinema_progress", { item: payload, userEmail: currentUser?.email })
|
||||||
.then((st) => setCinemaState(st))
|
.then((st) => setCinemaState(st))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
@ -1598,7 +1711,99 @@ export default function Cinema({
|
||||||
setSelectedKodikLink(null);
|
setSelectedKodikLink(null);
|
||||||
setRussianDubNotice(null);
|
setRussianDubNotice(null);
|
||||||
setAnilibriaStreamUrl(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<CinemaState>("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
|
// D-Pad Spatial Navigation & TV Remote Key Handler
|
||||||
const handleRemoteKeyDown = useCallback(
|
const handleRemoteKeyDown = useCallback(
|
||||||
|
|
@ -1623,6 +1828,13 @@ export default function Cinema({
|
||||||
e.key === "BrowserBack" ||
|
e.key === "BrowserBack" ||
|
||||||
e.keyCode === 4 ||
|
e.keyCode === 4 ||
|
||||||
e.keyCode === 27;
|
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 =
|
const isPlayPause =
|
||||||
e.key === "MediaPlayPause" || e.keyCode === 85 || e.keyCode === 179;
|
e.key === "MediaPlayPause" || e.keyCode === 85 || e.keyCode === 179;
|
||||||
const isFastForward = e.key === "MediaFastForward" || e.keyCode === 90;
|
const isFastForward = e.key === "MediaFastForward" || e.keyCode === 90;
|
||||||
|
|
@ -1640,7 +1852,7 @@ export default function Cinema({
|
||||||
document.querySelector<HTMLElement>(".has-cinema-tv-nav-item");
|
document.querySelector<HTMLElement>(".has-cinema-tv-nav-item");
|
||||||
if (navBtn) {
|
if (navBtn) {
|
||||||
navBtn.focus();
|
navBtn.focus();
|
||||||
playTvBeep(640, 0.03);
|
playTvNavBeep();
|
||||||
}
|
}
|
||||||
}, 30);
|
}, 30);
|
||||||
}
|
}
|
||||||
|
|
@ -1672,7 +1884,7 @@ export default function Cinema({
|
||||||
}
|
}
|
||||||
handleClosePlayer();
|
handleClosePlayer();
|
||||||
showTvToast("Плеер закрыт", "✕", 1500);
|
showTvToast("Плеер закрыт", "✕", 1500);
|
||||||
playTvBeep(400, 0.05);
|
playTvBackBeep();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1690,22 +1902,83 @@ export default function Cinema({
|
||||||
const backBtn = document.querySelector<HTMLElement>(".has-cinema-player-btn-back");
|
const backBtn = document.querySelector<HTMLElement>(".has-cinema-player-btn-back");
|
||||||
if (backBtn) {
|
if (backBtn) {
|
||||||
backBtn.focus();
|
backBtn.focus();
|
||||||
playTvBeep(640, 0.03);
|
playTvNavBeep();
|
||||||
}
|
}
|
||||||
}, 40);
|
}, 40);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pressing OK / Enter when HUD is idle wakes up the full HUD
|
// Pressing ArrowDown on TV remote reveals HUD and focuses server mirror pills / episodes
|
||||||
if ((e.key === "Enter" || e.keyCode === 13) && !isHudVisible && !videoRef.current) {
|
if (
|
||||||
e.preventDefault();
|
e.key === "ArrowDown" ||
|
||||||
setIsHudVisible(true);
|
e.keyCode === 20 ||
|
||||||
resetHudTimer();
|
e.keyCode === 40
|
||||||
setTimeout(() => {
|
) {
|
||||||
const backBtn = document.querySelector<HTMLElement>(".has-cinema-player-btn-back");
|
const isAtBottom = document.activeElement?.closest(".has-cinema-player-server-pill, .has-cinema-player-hud-btn");
|
||||||
if (backBtn) backBtn.focus();
|
if (!isAtBottom || !isHudVisible) {
|
||||||
}, 40);
|
e.preventDefault();
|
||||||
return;
|
setIsHudVisible(true);
|
||||||
|
resetHudTimer();
|
||||||
|
setTimeout(() => {
|
||||||
|
const serverPill =
|
||||||
|
document.querySelector<HTMLElement>(".has-cinema-player-server-pill.active") ||
|
||||||
|
document.querySelector<HTMLElement>(".has-cinema-player-server-pill") ||
|
||||||
|
document.querySelector<HTMLElement>(".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)) {
|
if (isPlayPause || (e.code === "Space" && !isInput)) {
|
||||||
|
|
@ -1718,7 +1991,7 @@ export default function Cinema({
|
||||||
videoRef.current.pause();
|
videoRef.current.pause();
|
||||||
showTvToast("Пауза", "⏸", 1200);
|
showTvToast("Пауза", "⏸", 1200);
|
||||||
}
|
}
|
||||||
playTvBeep(560, 0.04);
|
playTvSelectBeep();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1727,7 +2000,7 @@ export default function Cinema({
|
||||||
isFastForward ||
|
isFastForward ||
|
||||||
(e.key === "ArrowRight" &&
|
(e.key === "ArrowRight" &&
|
||||||
!isInput &&
|
!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) {
|
if (videoRef.current) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -1736,7 +2009,7 @@ export default function Cinema({
|
||||||
videoRef.current.currentTime + 10
|
videoRef.current.currentTime + 10
|
||||||
);
|
);
|
||||||
showTvToast("+10 сек", "⏩", 1000);
|
showTvToast("+10 сек", "⏩", 1000);
|
||||||
playTvBeep(520, 0.03);
|
playTvNavBeep();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1745,7 +2018,7 @@ export default function Cinema({
|
||||||
isRewind ||
|
isRewind ||
|
||||||
(e.key === "ArrowLeft" &&
|
(e.key === "ArrowLeft" &&
|
||||||
!isInput &&
|
!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) {
|
if (videoRef.current) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -1754,7 +2027,24 @@ export default function Cinema({
|
||||||
videoRef.current.currentTime - 10
|
videoRef.current.currentTime - 10
|
||||||
);
|
);
|
||||||
showTvToast("-10 сек", "⏪", 1000);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2128,7 +2418,20 @@ export default function Cinema({
|
||||||
|
|
||||||
// Switch mirror manually
|
// Switch mirror manually
|
||||||
const handleSelectMirror = async (mirrorId: BalancerMirror) => {
|
const handleSelectMirror = async (mirrorId: BalancerMirror) => {
|
||||||
|
if (mirrorWatchdogTimerRef.current) {
|
||||||
|
clearTimeout(mirrorWatchdogTimerRef.current);
|
||||||
|
mirrorWatchdogTimerRef.current = null;
|
||||||
|
}
|
||||||
setSelectedMirror(mirrorId);
|
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") {
|
if (mirrorId === "anilibria") {
|
||||||
const activeMedia = playingMedia || selectedMedia;
|
const activeMedia = playingMedia || selectedMedia;
|
||||||
const epNum = playingEpisode?.number || 1;
|
const epNum = playingEpisode?.number || 1;
|
||||||
|
|
@ -2136,6 +2439,11 @@ export default function Cinema({
|
||||||
if (stream) {
|
if (stream) {
|
||||||
setAnilibriaStreamUrl(stream);
|
setAnilibriaStreamUrl(stream);
|
||||||
setRussianDubNotice("🌸 Подключен поток AniLibria Full HD (Studio Band).");
|
setRussianDubNotice("🌸 Подключен поток AniLibria Full HD (Studio Band).");
|
||||||
|
if (mirrorWatchdogTimerRef.current) {
|
||||||
|
clearTimeout(mirrorWatchdogTimerRef.current);
|
||||||
|
mirrorWatchdogTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setIsMirrorConnecting(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setAnilibriaStreamUrl(null);
|
setAnilibriaStreamUrl(null);
|
||||||
|
|
@ -2905,6 +3213,38 @@ export default function Cinema({
|
||||||
].find((item) => item.id === h.title_id);
|
].find((item) => item.id === h.title_id);
|
||||||
if (found) {
|
if (found) {
|
||||||
handleMediaCardClick(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"
|
data-tv-focusable="true"
|
||||||
|
|
@ -2921,6 +3261,38 @@ export default function Cinema({
|
||||||
].find((item) => item.id === h.title_id);
|
].find((item) => item.id === h.title_id);
|
||||||
if (found) {
|
if (found) {
|
||||||
handleMediaCardClick(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 */}
|
{/* Clean 100% Video Canvas */}
|
||||||
<div className="has-cinema-video-frame">
|
<div className="has-cinema-video-frame">
|
||||||
|
{isMirrorConnecting && (
|
||||||
|
<div className="has-cinema-mirror-connecting-banner">
|
||||||
|
<div className="has-cinema-loading-spinner small" />
|
||||||
|
<span>Защита от сбоев: подключение к серверу...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(playingMedia || selectedMedia).is_unreleased ? (
|
{(playingMedia || selectedMedia).is_unreleased ? (
|
||||||
<div className="has-cinema-unreleased-player-view">
|
<div className="has-cinema-unreleased-player-view">
|
||||||
<div className="has-cinema-unreleased-card">
|
<div className="has-cinema-unreleased-card">
|
||||||
|
|
@ -3846,6 +4225,38 @@ export default function Cinema({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : allMirrorsFailed ? (
|
||||||
|
<div className="has-cinema-unreleased-player-view">
|
||||||
|
<div className="has-cinema-unreleased-card">
|
||||||
|
<span className="unreleased-icon">🛡️</span>
|
||||||
|
<h3>Все доступные серверы были проверены</h3>
|
||||||
|
<p>
|
||||||
|
Видеопоток временно не ответил ни на одном из зеркал (возможно блокировка провайдера). Вы можете запустить повторную проверку или вернуться в каталог.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", gap: "12px", justifyContent: "center", marginTop: "14px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-btn-play"
|
||||||
|
onClick={() => {
|
||||||
|
triedMirrorsRef.current.clear();
|
||||||
|
setAllMirrorsFailed(false);
|
||||||
|
handleSelectMirror("vidlink_pro");
|
||||||
|
}}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
🔄 Попробовать заново
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="failover-btn"
|
||||||
|
onClick={handleClosePlayer}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
← Закрыть плеер
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : selectedMirror === "anilibria" ? (
|
) : selectedMirror === "anilibria" ? (
|
||||||
anilibriaStreamUrl ? (
|
anilibriaStreamUrl ? (
|
||||||
<video
|
<video
|
||||||
|
|
@ -3876,6 +4287,8 @@ export default function Cinema({
|
||||||
allow="autoplay *; fullscreen *; encrypted-media *; picture-in-picture *; clipboard-write *"
|
allow="autoplay *; fullscreen *; encrypted-media *; picture-in-picture *; clipboard-write *"
|
||||||
referrerPolicy="no-referrer-when-downgrade"
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
|
onLoad={handleIframeLoaded}
|
||||||
|
onError={() => handleAutoSwitchNextMirror("Сервер вернул ошибку")}
|
||||||
{...{ webkitallowfullscreen: "true", mozallowfullscreen: "true" }}
|
{...{ webkitallowfullscreen: "true", mozallowfullscreen: "true" }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, lazy, Suspense } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { UserDto, DbStatus, SafeDbInfo, ServerUserDto, UserProfileDto } from "../../types";
|
import type { UserDto, DbStatus, SafeDbInfo, ServerUserDto, UserProfileDto } from "../../types";
|
||||||
import logoUrl from "../../assets/logo.png";
|
import logoUrl from "../../assets/logo.png";
|
||||||
import {
|
import {
|
||||||
HomeNavIcon,
|
HomeNavIcon,
|
||||||
StorageNavIcon,
|
|
||||||
UsersNavIcon,
|
UsersNavIcon,
|
||||||
SettingsNavIcon,
|
SettingsNavIcon,
|
||||||
HelpNavIcon,
|
HelpNavIcon,
|
||||||
|
|
@ -15,14 +14,42 @@ import {
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
} from "../../components/Icons";
|
} from "../../components/Icons";
|
||||||
import Home from "../home/Home";
|
import Home from "../home/Home";
|
||||||
import Storage from "../storage/Storage";
|
|
||||||
import Users from "../users/Users";
|
import Users from "../users/Users";
|
||||||
import Settings from "../settings/Settings";
|
import Settings from "../settings/Settings";
|
||||||
import Help from "../help/Help";
|
import Help from "../help/Help";
|
||||||
import Profile from "../profile/Profile";
|
import Profile from "../profile/Profile";
|
||||||
import Cinema from "../cinema/Cinema";
|
|
||||||
import "./Dashboard.css";
|
import "./Dashboard.css";
|
||||||
|
|
||||||
|
const Cinema = lazy(() => import("../cinema/Cinema"));
|
||||||
|
|
||||||
|
function CinemaLoadingFallback() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: "70vh",
|
||||||
|
color: "#94a3b8",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
border: "3px solid rgba(129, 140, 248, 0.2)",
|
||||||
|
borderTopColor: "#818cf8",
|
||||||
|
borderRadius: "50%",
|
||||||
|
animation: "has-spin 0.8s linear infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 500 }}>Загрузка Кинотеатра...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
user: UserDto;
|
user: UserDto;
|
||||||
dbInfo: SafeDbInfo | null;
|
dbInfo: SafeDbInfo | null;
|
||||||
|
|
@ -30,7 +57,7 @@ interface DashboardProps {
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type NavTab = "home" | "storage" | "cinema" | "users" | "settings" | "profile" | "help";
|
type NavTab = "cinema" | "home" | "users" | "settings" | "profile" | "help";
|
||||||
|
|
||||||
// Helper for Smart TV / Android TV detection
|
// Helper for Smart TV / Android TV detection
|
||||||
const detectTvEnvironment = (): boolean => {
|
const detectTvEnvironment = (): boolean => {
|
||||||
|
|
@ -78,9 +105,7 @@ export default function Dashboard({
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const [isTvEnvironment, setIsTvEnvironment] = useState<boolean>(detectTvEnvironment);
|
const [isTvEnvironment, setIsTvEnvironment] = useState<boolean>(detectTvEnvironment);
|
||||||
const [activeTab, setActiveTab] = useState<NavTab>(() => {
|
const [activeTab, setActiveTab] = useState<NavTab>("cinema");
|
||||||
return detectTvEnvironment() ? "cinema" : "home";
|
|
||||||
});
|
|
||||||
const [serverUsers, setServerUsers] = useState<ServerUserDto[]>([]);
|
const [serverUsers, setServerUsers] = useState<ServerUserDto[]>([]);
|
||||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||||
|
|
@ -168,14 +193,14 @@ export default function Dashboard({
|
||||||
(u.role?.toLowerCase() === "admin" || u.role?.toLowerCase() === "администратор" || u.id === 1)
|
(u.role?.toLowerCase() === "admin" || u.role?.toLowerCase() === "администратор" || u.id === 1)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Non-admin redirection: users and separate cinema tabs are reserved for admin
|
// Non-admin redirection: users and home (server stats) tabs are reserved for admin
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isUserAdmin && (activeTab === "users" || activeTab === "cinema")) {
|
if (!isUserAdmin && (activeTab === "users" || activeTab === "home")) {
|
||||||
setActiveTab("home");
|
setActiveTab("cinema");
|
||||||
}
|
}
|
||||||
}, [isUserAdmin, activeTab]);
|
}, [isUserAdmin, activeTab]);
|
||||||
|
|
||||||
const isCinemaView = activeTab === "cinema" || (!isUserAdmin && activeTab === "home");
|
const isCinemaView = activeTab === "cinema";
|
||||||
|
|
||||||
const loadServerUsers = useCallback(async () => {
|
const loadServerUsers = useCallback(async () => {
|
||||||
if (!isTauri) return;
|
if (!isTauri) return;
|
||||||
|
|
@ -283,42 +308,26 @@ export default function Dashboard({
|
||||||
<nav className="has-nav-menu">
|
<nav className="has-nav-menu">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`has-nav-item ${activeTab === "home" ? "active" : ""}`}
|
className={`has-nav-item ${activeTab === "cinema" ? "active" : ""}`}
|
||||||
onClick={() => handleNavClick("home")}
|
onClick={() => handleNavClick("cinema")}
|
||||||
title={isCollapsed ? (isUserAdmin ? "Панель сервера" : "Кинотеатр") : undefined}
|
title={isCollapsed ? "Кинотеатр" : undefined}
|
||||||
>
|
>
|
||||||
<div className="has-nav-item-left">
|
<div className="has-nav-item-left">
|
||||||
{isUserAdmin ? (
|
<CinemaNavIcon className="w-5 h-5" width={20} height={20} />
|
||||||
<HomeNavIcon className="w-5 h-5" width={20} height={20} />
|
{!isCollapsed && <span>Кинотеатр</span>}
|
||||||
) : (
|
|
||||||
<CinemaNavIcon className="w-5 h-5" width={20} height={20} />
|
|
||||||
)}
|
|
||||||
{!isCollapsed && <span>{isUserAdmin ? "Панель сервера" : "Кинотеатр"}</span>}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`has-nav-item ${activeTab === "storage" ? "active" : ""}`}
|
|
||||||
onClick={() => handleNavClick("storage")}
|
|
||||||
title={isCollapsed ? "Хранилище" : undefined}
|
|
||||||
>
|
|
||||||
<div className="has-nav-item-left">
|
|
||||||
<StorageNavIcon className="w-5 h-5" width={20} height={20} />
|
|
||||||
{!isCollapsed && <span>Хранилище</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isUserAdmin && (
|
{isUserAdmin && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`has-nav-item ${activeTab === "cinema" ? "active" : ""}`}
|
className={`has-nav-item ${activeTab === "home" ? "active" : ""}`}
|
||||||
onClick={() => handleNavClick("cinema")}
|
onClick={() => handleNavClick("home")}
|
||||||
title={isCollapsed ? "Кинотеатр" : undefined}
|
title={isCollapsed ? "Панель сервера" : undefined}
|
||||||
>
|
>
|
||||||
<div className="has-nav-item-left">
|
<div className="has-nav-item-left">
|
||||||
<CinemaNavIcon className="w-5 h-5" width={20} height={20} />
|
<HomeNavIcon className="w-5 h-5" width={20} height={20} />
|
||||||
{!isCollapsed && <span>Кинотеатр</span>}
|
{!isCollapsed && <span>Панель сервера</span>}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -427,7 +436,6 @@ export default function Dashboard({
|
||||||
<div>
|
<div>
|
||||||
<h1 className="has-header-title">
|
<h1 className="has-header-title">
|
||||||
{activeTab === "home" && "Обзор сервера"}
|
{activeTab === "home" && "Обзор сервера"}
|
||||||
{activeTab === "storage" && "Файловое хранилище"}
|
|
||||||
{activeTab === "users" && "Пользователи системы"}
|
{activeTab === "users" && "Пользователи системы"}
|
||||||
{activeTab === "settings" && "Настройки системы"}
|
{activeTab === "settings" && "Настройки системы"}
|
||||||
{activeTab === "help" && "Помощь и поддержка"}
|
{activeTab === "help" && "Помощь и поддержка"}
|
||||||
|
|
@ -467,33 +475,24 @@ export default function Dashboard({
|
||||||
|
|
||||||
{/* Content Body Rendering Separate Page Components */}
|
{/* Content Body Rendering Separate Page Components */}
|
||||||
<div className="has-content-body">
|
<div className="has-content-body">
|
||||||
{activeTab === "home" && (
|
{activeTab === "cinema" && (
|
||||||
isUserAdmin ? (
|
<Suspense fallback={<CinemaLoadingFallback />}>
|
||||||
<Home
|
|
||||||
user={currentUser}
|
|
||||||
displayName={displayName}
|
|
||||||
dbInfo={dbInfo}
|
|
||||||
dbStatus={dbStatus}
|
|
||||||
usersCount={serverUsers.length}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Cinema
|
<Cinema
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
onNavigateTab={(tab) => handleNavClick(tab as NavTab)}
|
onNavigateTab={(tab) => handleNavClick(tab as NavTab)}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
isTvEnvironment={isTvEnvironment}
|
isTvEnvironment={isTvEnvironment}
|
||||||
/>
|
/>
|
||||||
)
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === "storage" && <Storage currentUser={currentUser} />}
|
{activeTab === "home" && isUserAdmin && (
|
||||||
|
<Home
|
||||||
{activeTab === "cinema" && isUserAdmin && (
|
user={currentUser}
|
||||||
<Cinema
|
displayName={displayName}
|
||||||
currentUser={currentUser}
|
dbInfo={dbInfo}
|
||||||
onNavigateTab={(tab) => handleNavClick(tab as NavTab)}
|
dbStatus={dbStatus}
|
||||||
onLogout={onLogout}
|
usersCount={serverUsers.length}
|
||||||
isTvEnvironment={isTvEnvironment}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
79
src/utils/tvAudio.ts
Normal file
79
src/utils/tvAudio.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
/**
|
||||||
|
* High-performance, zero-leak WebAudio Singleton for Android TV & Desktop UI navigation.
|
||||||
|
* Prevents "The number of hardware contexts provided by the Platform has been exceeded" crash.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let sharedAudioCtx: AudioContext | null = null;
|
||||||
|
|
||||||
|
export function getSharedAudioContext(): AudioContext | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!sharedAudioCtx) {
|
||||||
|
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
|
||||||
|
if (AudioCtx) {
|
||||||
|
sharedAudioCtx = new AudioCtx();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sharedAudioCtx && sharedAudioCtx.state === "suspended") {
|
||||||
|
sharedAudioCtx.resume().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sharedAudioCtx;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-interaction wakeup listener to ensure audio is unblocked on TV remote press
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const resumeAudio = () => {
|
||||||
|
if (sharedAudioCtx && sharedAudioCtx.state === "suspended") {
|
||||||
|
sharedAudioCtx.resume().catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", resumeAudio, { passive: true, once: false });
|
||||||
|
window.addEventListener("click", resumeAudio, { passive: true, once: false });
|
||||||
|
window.addEventListener("touchstart", resumeAudio, { passive: true, once: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playTvBeep(freq = 580, duration = 0.035, volume = 0.04): void {
|
||||||
|
try {
|
||||||
|
const ctx = getSharedAudioContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = "sine";
|
||||||
|
osc.frequency.setValueAtTime(freq, ctx.currentTime);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(volume, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
|
||||||
|
osc.start();
|
||||||
|
osc.stop(ctx.currentTime + duration);
|
||||||
|
} catch {
|
||||||
|
// Graceful fallback if audio is blocked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playTvNavBeep(): void {
|
||||||
|
playTvBeep(640, 0.025, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playTvSelectBeep(): void {
|
||||||
|
playTvBeep(750, 0.045, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playTvBackBeep(): void {
|
||||||
|
playTvBeep(430, 0.04, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playTvErrorBeep(): void {
|
||||||
|
playTvBeep(220, 0.08, 0.05);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue