feat(release): v0.0.11 - watch history, webview adblock, clean studio audio priority & kettle noise elimination
This commit is contained in:
parent
4d7fab0166
commit
5cd3183787
10 changed files with 697 additions and 111 deletions
110
index.html
110
index.html
|
|
@ -7,9 +7,10 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<title>has</title>
|
<title>has</title>
|
||||||
<!-- HAS Cinema AdGuard & Anti-Popup Shield -->
|
<!-- HAS Cinema AdGuard & Anti-Popup Shield (WebView Network & DOM Interceptor) -->
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
|
// 1. Anti-Popup & Tab Hijack Guard
|
||||||
try {
|
try {
|
||||||
const fakeWindow = {
|
const fakeWindow = {
|
||||||
closed: true,
|
closed: true,
|
||||||
|
|
@ -25,26 +26,117 @@
|
||||||
};
|
};
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
// 2. Comprehensive Ad & Casino Blacklist Patterns (Riobet, 1xBet, Vavada, Bet networks)
|
||||||
const AD_PATTERNS = [
|
const AD_PATTERNS = [
|
||||||
/1xbet/i, /vavada/i, /mostbet/i, /pin-up/i, /bet365/i, /casino/i,
|
/riobet/i, /1xbet/i, /vavada/i, /mostbet/i, /pin-up/i, /bet365/i, /casino/i,
|
||||||
/adsterra/i, /propellerads/i, /popcash/i, /monetag/i, /clickadu/i,
|
/adsterra/i, /propellerads/i, /popcash/i, /monetag/i, /clickadu/i,
|
||||||
/exoclick/i, /juicyads/i, /adcash/i, /trafficjunky/i, /syndication/i,
|
/exoclick/i, /juicyads/i, /adcash/i, /trafficjunky/i, /syndication/i,
|
||||||
/delivembd/i, /betandyou/i, /melbet/i, /linebet/i, /fonbet/i, /parimatch/i
|
/delivembd/i, /betandyou/i, /melbet/i, /linebet/i, /fonbet/i, /parimatch/i,
|
||||||
|
/advmaker/i, /trafox/i, /adsupply/i, /marketgid/i, /mgid/i, /yadro\.ru/i,
|
||||||
|
/betboom/i, /vulkan/i, /joycasino/i, /admiral/i, /champion/i
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const VAST_PATTERNS = [
|
||||||
|
/\/vast/i, /\/vpaid/i, /vast\.xml/i, /vpaid\.js/i, /adservice/i,
|
||||||
|
/doubleclick\.net/i, /aniview/i, /adform/i, /adman/i
|
||||||
|
];
|
||||||
|
|
||||||
|
function isAdUrl(url) {
|
||||||
|
if (!url || typeof url !== "string") return false;
|
||||||
|
for (let i = 0; i < AD_PATTERNS.length; i++) {
|
||||||
|
if (AD_PATTERNS[i].test(url)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVastUrl(url) {
|
||||||
|
if (!url || typeof url !== "string") return false;
|
||||||
|
for (let i = 0; i < VAST_PATTERNS.length; i++) {
|
||||||
|
if (VAST_PATTERNS[i].test(url)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Network Interceptor: Block VAST & Casino Ad Requests in fetch()
|
||||||
|
if (typeof window.fetch === "function") {
|
||||||
|
const origFetch = window.fetch;
|
||||||
|
window.fetch = function(input, init) {
|
||||||
|
const url = typeof input === "string" ? input : (input && input.url ? input.url : "");
|
||||||
|
if (isAdUrl(url)) {
|
||||||
|
console.warn("[HAS AdGuard] Blocked ad fetch:", url);
|
||||||
|
return Promise.reject(new Error("Blocked by HAS AdGuard"));
|
||||||
|
}
|
||||||
|
if (isVastUrl(url)) {
|
||||||
|
console.warn("[HAS AdGuard] Intercepted VAST ad request, bypassing:", url);
|
||||||
|
// Return empty VAST 3.0 response to trigger instant ad bypass in video players
|
||||||
|
return Promise.resolve(new Response(
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?><VAST version="3.0"/>',
|
||||||
|
{ status: 200, headers: { "Content-Type": "text/xml" } }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return origFetch.apply(this, arguments);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Network Interceptor: Block VAST & Casino Ad Requests in XMLHttpRequest
|
||||||
|
if (typeof window.XMLHttpRequest === "function") {
|
||||||
|
const origOpen = XMLHttpRequest.prototype.open;
|
||||||
|
const origSend = XMLHttpRequest.prototype.send;
|
||||||
|
XMLHttpRequest.prototype.open = function(method, url) {
|
||||||
|
this._hasUrl = typeof url === "string" ? url : "";
|
||||||
|
return origOpen.apply(this, arguments);
|
||||||
|
};
|
||||||
|
XMLHttpRequest.prototype.send = function() {
|
||||||
|
if (isAdUrl(this._hasUrl)) {
|
||||||
|
console.warn("[HAS AdGuard] Blocked XHR ad request:", this._hasUrl);
|
||||||
|
this.abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isVastUrl(this._hasUrl)) {
|
||||||
|
console.warn("[HAS AdGuard] Mocking empty VAST for XHR:", this._hasUrl);
|
||||||
|
Object.defineProperty(this, "responseText", { value: '<?xml version="1.0" encoding="UTF-8"?><VAST version="3.0"/>' });
|
||||||
|
Object.defineProperty(this, "responseXML", {
|
||||||
|
value: new DOMParser().parseFromString('<VAST version="3.0"/>', "text/xml")
|
||||||
|
});
|
||||||
|
Object.defineProperty(this, "status", { value: 200 });
|
||||||
|
Object.defineProperty(this, "readyState", { value: 4 });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof this.onreadystatechange === "function") this.onreadystatechange();
|
||||||
|
if (typeof this.onload === "function") this.onload();
|
||||||
|
}, 10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return origSend.apply(this, arguments);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Click Interceptor for ad redirects
|
||||||
document.addEventListener("click", function(e) {
|
document.addEventListener("click", function(e) {
|
||||||
const a = e.target && e.target.closest ? e.target.closest("a") : null;
|
const a = e.target && e.target.closest ? e.target.closest("a") : null;
|
||||||
if (a && a.href) {
|
if (a && a.href && isAdUrl(a.href)) {
|
||||||
for (let i = 0; i < AD_PATTERNS.length; i++) {
|
|
||||||
if (AD_PATTERNS[i].test(a.href)) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
console.warn("[HAS AdGuard] Intercepted ad redirect:", a.href);
|
console.warn("[HAS AdGuard] Intercepted ad redirect:", a.href);
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
|
// 6. Automatic Ad-Skip Button Clicker
|
||||||
|
setInterval(function() {
|
||||||
|
try {
|
||||||
|
const skipSelectors = [
|
||||||
|
".skip-ad", ".ad-skip", ".videoAdUiSkipButton",
|
||||||
|
"button.skip", "[class*='skip-button']", "[class*='skip_ad']",
|
||||||
|
".preroll-skip", "#skip-ad-button", ".ad-skip-btn"
|
||||||
|
];
|
||||||
|
for (let i = 0; i < skipSelectors.length; i++) {
|
||||||
|
const btn = document.querySelector(skipSelectors[i]);
|
||||||
|
if (btn && typeof btn.click === "function") {
|
||||||
|
console.log("[HAS AdGuard] Auto-clicking skip button:", skipSelectors[i]);
|
||||||
|
btn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, 300);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "has",
|
"name": "has",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.10",
|
"version": "0.0.11",
|
||||||
"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.10"
|
version = "0.0.11"
|
||||||
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.10"
|
version = "0.0.11"
|
||||||
description = "has"
|
description = "has"
|
||||||
authors = ["autli"]
|
authors = ["autli"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,53 @@ pub async fn update_cinema_progress(
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn remove_cinema_history_item(
|
||||||
|
app: AppHandle,
|
||||||
|
title_id: String,
|
||||||
|
season: u32,
|
||||||
|
episode: u32,
|
||||||
|
user_email: Option<String>,
|
||||||
|
) -> Result<CinemaState, String> {
|
||||||
|
let mut state = read_local_cinema_state(&app);
|
||||||
|
state.history.retain(|h| !(h.title_id == title_id && h.season == season && h.episode == episode));
|
||||||
|
let _ = write_local_cinema_state(&app, &state);
|
||||||
|
|
||||||
|
if let Some(email) = user_email {
|
||||||
|
let email_clean = email.trim().to_lowercase();
|
||||||
|
if !email_clean.is_empty() {
|
||||||
|
let state_to_save = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = save_remote_cinema_state(&email_clean, &state_to_save).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_cinema_history(
|
||||||
|
app: AppHandle,
|
||||||
|
user_email: Option<String>,
|
||||||
|
) -> Result<CinemaState, String> {
|
||||||
|
let mut state = read_local_cinema_state(&app);
|
||||||
|
state.history.clear();
|
||||||
|
let _ = write_local_cinema_state(&app, &state);
|
||||||
|
|
||||||
|
if let Some(email) = user_email {
|
||||||
|
let email_clean = email.trim().to_lowercase();
|
||||||
|
if !email_clean.is_empty() {
|
||||||
|
let state_to_save = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = save_remote_cinema_state(&email_clean, &state_to_save).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn toggle_cinema_bookmark(
|
pub async fn toggle_cinema_bookmark(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,8 @@ pub fn run() {
|
||||||
cinema::get_cinema_state,
|
cinema::get_cinema_state,
|
||||||
cinema::save_cinema_state,
|
cinema::save_cinema_state,
|
||||||
cinema::update_cinema_progress,
|
cinema::update_cinema_progress,
|
||||||
|
cinema::remove_cinema_history_item,
|
||||||
|
cinema::clear_cinema_history,
|
||||||
cinema::toggle_cinema_bookmark,
|
cinema::toggle_cinema_bookmark,
|
||||||
cinema::tmdb_query,
|
cinema::tmdb_query,
|
||||||
cinema::get_russian_media_info,
|
cinema::get_russian_media_info,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "has",
|
"productName": "has",
|
||||||
"version": "0.0.10",
|
"version": "0.0.11",
|
||||||
"identifier": "com.has.tauri-app",
|
"identifier": "com.has.tauri-app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
|
||||||
const resp = await fetch("https://github.com/autli5/has/releases/latest/download/latest.json");
|
const resp = await fetch("https://github.com/autli5/has/releases/latest/download/latest.json");
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!cancelled && data?.version && data.version !== "0.0.10") {
|
if (!cancelled && data?.version && data.version !== "0.0.11") {
|
||||||
setUpdateAvailable(true);
|
setUpdateAvailable(true);
|
||||||
setUpdateInfo({
|
setUpdateInfo({
|
||||||
version: data.version,
|
version: data.version,
|
||||||
|
|
|
||||||
|
|
@ -800,6 +800,28 @@
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Quick Ad / Intro Skip Button */
|
||||||
|
.has-cinema-player-hud-btn.ad-skip-pill {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 7px 13px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||||
|
color: #fde68a;
|
||||||
|
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-player-hud-btn.ad-skip-pill:hover,
|
||||||
|
.has-cinema-player-hud-btn.ad-skip-pill:focus-visible,
|
||||||
|
.has-cinema-player-hud-btn.ad-skip-pill[data-tv-focused="true"] {
|
||||||
|
background: rgba(245, 158, 11, 0.35) !important;
|
||||||
|
border-color: #f59e0b !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
outline: 3px solid #f59e0b !important;
|
||||||
|
box-shadow: 0 0 20px rgba(245, 158, 11, 0.8) !important;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
/* Server selector pills in top HUD */
|
/* Server selector pills in top HUD */
|
||||||
.has-cinema-player-hud-servers {
|
.has-cinema-player-hud-servers {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -3119,6 +3141,177 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================================
|
||||||
|
WATCH HISTORY (ИСТОРИЯ ПРОСМОТРОВ) STYLES
|
||||||
|
======================================================== */
|
||||||
|
.has-cinema-history-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-history-subtitle {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-clear-history-btn {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.28);
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-clear-history-btn:hover,
|
||||||
|
.has-cinema-clear-history-btn:focus-visible,
|
||||||
|
.has-tv-mode .has-cinema-clear-history-btn:focus {
|
||||||
|
background: rgba(239, 68, 68, 0.25) !important;
|
||||||
|
border-color: #ef4444 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
outline: 2px solid #ef4444 !important;
|
||||||
|
outline-offset: 2px !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-row-more-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-row-more-btn:hover,
|
||||||
|
.has-cinema-row-more-btn:focus-visible,
|
||||||
|
.has-tv-mode .has-cinema-row-more-btn:focus {
|
||||||
|
background: rgba(99, 102, 241, 0.25) !important;
|
||||||
|
border-color: #818cf8 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
outline: 2px solid #818cf8 !important;
|
||||||
|
outline-offset: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* History Card Enhancements */
|
||||||
|
.has-cinema-history-card {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-history-item-del-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(15, 23, 42, 0.75);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
color: #cbd5e1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
z-index: 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-media-card:hover .has-cinema-history-item-del-btn,
|
||||||
|
.has-cinema-grid-card:hover .has-cinema-history-item-del-btn,
|
||||||
|
.has-cinema-media-card:focus-within .has-cinema-history-item-del-btn,
|
||||||
|
.has-cinema-grid-card:focus-within .has-cinema-history-item-del-btn,
|
||||||
|
.has-tv-mode .has-cinema-media-card:focus .has-cinema-history-item-del-btn,
|
||||||
|
.has-tv-mode .has-cinema-grid-card:focus .has-cinema-history-item-del-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-history-item-del-btn:hover,
|
||||||
|
.has-cinema-history-item-del-btn:focus-visible {
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #ef4444;
|
||||||
|
transform: scale(1.15);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-history-status-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(15, 23, 42, 0.85);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border: 1px solid rgba(129, 140, 248, 0.35);
|
||||||
|
color: #c7d2fe;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-history-status-badge.completed {
|
||||||
|
background: rgba(6, 78, 59, 0.85);
|
||||||
|
border-color: rgba(52, 211, 153, 0.4);
|
||||||
|
color: #6ee7b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-history-percent-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-history-card-sub {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-history-ep-info {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-cinema-history-date {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -577,7 +577,9 @@ export default function Cinema({
|
||||||
const [, setConfig] = useState<CinemaConfig>({ tmdb_api_key: null });
|
const [, setConfig] = useState<CinemaConfig>({ tmdb_api_key: null });
|
||||||
|
|
||||||
// Navigation Filter
|
// Navigation Filter
|
||||||
const [activeTab, setActiveTab] = useState<"home" | "movies" | "series" | "anime" | "bookmarks" | "search">("home");
|
const [activeTab, setActiveTab] = useState<
|
||||||
|
"home" | "movies" | "series" | "anime" | "bookmarks" | "history" | "search"
|
||||||
|
>("home");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<CinemaMediaItem[]>([]);
|
const [searchResults, setSearchResults] = useState<CinemaMediaItem[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
@ -1097,6 +1099,122 @@ export default function Cinema({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Remove individual item from watch history
|
||||||
|
const handleRemoveHistoryItem = async (e: React.MouseEvent, h: CinemaPlayback) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isTauri) {
|
||||||
|
try {
|
||||||
|
const nextState = await invoke<CinemaState>("remove_cinema_history_item", {
|
||||||
|
titleId: h.title_id,
|
||||||
|
season: h.season,
|
||||||
|
episode: h.episode,
|
||||||
|
userEmail: currentUser?.email,
|
||||||
|
});
|
||||||
|
setCinemaState(nextState);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error removing history item:", err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCinemaState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
history: prev.history.filter(
|
||||||
|
(item) => !(item.title_id === h.title_id && item.season === h.season && item.episode === h.episode)
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
showTvToast("Удалено из истории", "🗑", 1200);
|
||||||
|
playTvBackBeep();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear all watch history
|
||||||
|
const handleClearHistory = async () => {
|
||||||
|
if (isTauri) {
|
||||||
|
try {
|
||||||
|
const nextState = await invoke<CinemaState>("clear_cinema_history", {
|
||||||
|
userEmail: currentUser?.email,
|
||||||
|
});
|
||||||
|
setCinemaState(nextState);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error clearing history:", err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCinemaState((prev) => ({ ...prev, history: [] }));
|
||||||
|
}
|
||||||
|
showTvToast("История просмотров очищена", "🗑", 1500);
|
||||||
|
playTvBackBeep();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date for history view
|
||||||
|
const formatHistoryDate = (isoString?: string): string => {
|
||||||
|
if (!isoString) return "";
|
||||||
|
try {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
if (diffMins < 5) return "Только что";
|
||||||
|
if (diffMins < 60) return `${diffMins} мин назад`;
|
||||||
|
if (diffHours < 24 && date.getDate() === now.getDate()) {
|
||||||
|
return `Сегодня, ${date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}`;
|
||||||
|
}
|
||||||
|
if (diffDays === 1 || (diffHours < 48 && date.getDate() === now.getDate() - 1)) {
|
||||||
|
return `Вчера, ${date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}`;
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Play an item directly from history
|
||||||
|
const handlePlayHistoryItem = (h: CinemaPlayback) => {
|
||||||
|
const found = [
|
||||||
|
...allTrending,
|
||||||
|
...allMoviesList,
|
||||||
|
...allSeriesList,
|
||||||
|
...allAnimeList,
|
||||||
|
].find((item) => item.id === h.title_id);
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
handleMediaCardClick(found);
|
||||||
|
} else {
|
||||||
|
const tmdbMatch = h.title_id.match(/tmdb-(movie|tv)-(\d+)/);
|
||||||
|
const isTv = tmdbMatch ? tmdbMatch[1] === "tv" : h.season > 0 && h.episode > 0;
|
||||||
|
const tmdbId = tmdbMatch ? parseInt(tmdbMatch[2], 10) : undefined;
|
||||||
|
const fallbackItem: CinemaMediaItem = {
|
||||||
|
id: h.title_id,
|
||||||
|
tmdb_id: tmdbId,
|
||||||
|
media_type: isTv ? "tv" : "movie",
|
||||||
|
title: h.title_name,
|
||||||
|
poster_url: h.poster_path || INITIAL_LIBRARY[0].poster_url,
|
||||||
|
backdrop_url: h.backdrop_path || INITIAL_LIBRARY[0].backdrop_url,
|
||||||
|
genre: isTv ? "Сериал" : "Фильм",
|
||||||
|
synopsis: `Продолжение просмотра: ${h.title_name}`,
|
||||||
|
release_year: "2024",
|
||||||
|
rating: 8.5,
|
||||||
|
is_original: false,
|
||||||
|
seasons: [
|
||||||
|
{
|
||||||
|
season_number: h.season || 1,
|
||||||
|
episodes: [
|
||||||
|
{
|
||||||
|
id: `ep-${h.title_id}-${h.season}-${h.episode}`,
|
||||||
|
number: h.episode || 1,
|
||||||
|
title: `Серия ${h.episode || 1}`,
|
||||||
|
thumbnail: h.poster_path || INITIAL_LIBRARY[0].poster_url,
|
||||||
|
duration_minutes: Math.round((h.duration_seconds || 3600) / 60),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
handleMediaCardClick(fallbackItem);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Get progress for a specific episode
|
// Get progress for a specific episode
|
||||||
const getEpisodeProgress = (seasonNum: number, episodeNum: number): number => {
|
const getEpisodeProgress = (seasonNum: number, episodeNum: number): number => {
|
||||||
const found = cinemaState.history.find(
|
const found = cinemaState.history.find(
|
||||||
|
|
@ -1406,13 +1524,11 @@ export default function Cinema({
|
||||||
if (info.imdb_id) setCurrentImdbId(info.imdb_id);
|
if (info.imdb_id) setCurrentImdbId(info.imdb_id);
|
||||||
if (info.iframe_url) {
|
if (info.iframe_url) {
|
||||||
setCurrentAllohaUrl(info.iframe_url);
|
setCurrentAllohaUrl(info.iframe_url);
|
||||||
if (!isAnime) {
|
// Do not automatically switch to Alloha to avoid Riobet ads and low-bitrate audio hiss
|
||||||
setSelectedMirror("alloha_ru");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!isAnime) {
|
if (!isAnime) {
|
||||||
setRussianDubNotice(
|
setRussianDubNotice(
|
||||||
`🇷🇺 Русская озвучка подключена: «${info.russian_title || media.title}» воспроизводится с полным русским дубляжем.`
|
`🇷🇺 Доступно: «${info.russian_title || media.title}» в чистом качестве.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1428,9 +1544,6 @@ export default function Cinema({
|
||||||
if (d?.id_imdb) setCurrentImdbId(d.id_imdb);
|
if (d?.id_imdb) setCurrentImdbId(d.id_imdb);
|
||||||
if (d?.iframe) {
|
if (d?.iframe) {
|
||||||
setCurrentAllohaUrl(d.iframe);
|
setCurrentAllohaUrl(d.iframe);
|
||||||
if (!isAnime) {
|
|
||||||
setSelectedMirror("alloha_ru");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -2514,6 +2627,24 @@ export default function Cinema({
|
||||||
}
|
}
|
||||||
const finalDubbings = Array.from(uniqueStudios.values());
|
const finalDubbings = Array.from(uniqueStudios.values());
|
||||||
|
|
||||||
|
// Prioritize official studio dubbings and clean audio tracks over amateur mic overdubs
|
||||||
|
finalDubbings.sort((a, b) => {
|
||||||
|
const titleA = (a.translation?.title || "").toLowerCase();
|
||||||
|
const titleB = (b.translation?.title || "").toLowerCase();
|
||||||
|
|
||||||
|
const getQualityScore = (t: string) => {
|
||||||
|
if (t.includes("полный дубляж") || t.includes("дубляж") || t.includes("чистый звук")) return 100;
|
||||||
|
if (t.includes("пифагор") || t.includes("мосфильм") || t.includes("невафильм") || t.includes("кириллица")) return 95;
|
||||||
|
if (t.includes("red head sound") || t.includes("rhs") || t.includes("flarrow films")) return 90;
|
||||||
|
if (t.includes("lostfilm") || t.includes("hdrezka") || t.includes("кубик в кубе") || t.includes("newstudio")) return 80;
|
||||||
|
if (t.includes("tvshows") || t.includes("кураж-бамбей") || t.includes("alexfilm")) return 70;
|
||||||
|
if (t.includes("coldfilm") || t.includes("baibako")) return 15; // Low-bitrate noisy voiceover
|
||||||
|
return 50;
|
||||||
|
};
|
||||||
|
|
||||||
|
return getQualityScore(titleB) - getQualityScore(titleA);
|
||||||
|
});
|
||||||
|
|
||||||
if (finalDubbings.length > 0) {
|
if (finalDubbings.length > 0) {
|
||||||
setKodikResults(finalDubbings);
|
setKodikResults(finalDubbings);
|
||||||
setSelectedKodikLink(finalDubbings[0].link);
|
setSelectedKodikLink(finalDubbings[0].link);
|
||||||
|
|
@ -2758,6 +2889,25 @@ export default function Cinema({
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`has-cinema-tv-nav-item ${activeTab === "history" && !searchQuery ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("history");
|
||||||
|
setSearchQuery("");
|
||||||
|
setIsTvNavOpen(false);
|
||||||
|
playTvSelectBeep();
|
||||||
|
setTimeout(() => focusFirstContentCard(), 60);
|
||||||
|
}}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
title="История просмотров"
|
||||||
|
>
|
||||||
|
<span className="has-cinema-tv-nav-icon">🕒</span>
|
||||||
|
<span className="has-cinema-tv-nav-label">
|
||||||
|
История {cinemaState.history.length > 0 && `(${cinemaState.history.length})`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{onNavigateTab && (
|
{onNavigateTab && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -2857,6 +3007,18 @@ export default function Cinema({
|
||||||
>
|
>
|
||||||
Мой список {cinemaState.bookmarks.length > 0 && `(${cinemaState.bookmarks.length})`}
|
Мой список {cinemaState.bookmarks.length > 0 && `(${cinemaState.bookmarks.length})`}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`has-cinema-nav-item ${activeTab === "history" && !searchQuery ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("history");
|
||||||
|
setSearchQuery("");
|
||||||
|
playTvSelectBeep();
|
||||||
|
}}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
🕒 История {cinemaState.history.length > 0 && `(${cinemaState.history.length})`}
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="has-cinema-header-right">
|
<div className="has-cinema-header-right">
|
||||||
|
|
@ -3193,7 +3355,20 @@ export default function Cinema({
|
||||||
{/* Row 1: Continue Watching */}
|
{/* Row 1: Continue Watching */}
|
||||||
{cinemaState.history.length > 0 && activeTab === "home" && (
|
{cinemaState.history.length > 0 && activeTab === "home" && (
|
||||||
<div className="has-cinema-row-block">
|
<div className="has-cinema-row-block">
|
||||||
<h2 className="has-cinema-row-title">Продолжить просмотр</h2>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
|
||||||
|
<h2 className="has-cinema-row-title" style={{ margin: 0 }}>Продолжить просмотр</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-row-more-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("history");
|
||||||
|
playTvSelectBeep();
|
||||||
|
}}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
Вся история ({cinemaState.history.length}) →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="has-cinema-cards-scroll">
|
<div className="has-cinema-cards-scroll">
|
||||||
{cinemaState.history.map((h, i) => {
|
{cinemaState.history.map((h, i) => {
|
||||||
const percent =
|
const percent =
|
||||||
|
|
@ -3203,97 +3378,15 @@ export default function Cinema({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${h.title_id}-${i}`}
|
key={`${h.title_id}-${i}`}
|
||||||
className="has-cinema-media-card"
|
className="has-cinema-media-card has-cinema-history-card"
|
||||||
onClick={() => {
|
onClick={() => handlePlayHistoryItem(h)}
|
||||||
const found = [
|
|
||||||
...allTrending,
|
|
||||||
...allMoviesList,
|
|
||||||
...allSeriesList,
|
|
||||||
...allAnimeList,
|
|
||||||
].find((item) => item.id === h.title_id);
|
|
||||||
if (found) {
|
|
||||||
handleMediaCardClick(found);
|
|
||||||
} else {
|
|
||||||
const tmdbMatch = h.title_id.match(/tmdb-(movie|tv)-(\d+)/);
|
|
||||||
const isTv = tmdbMatch ? tmdbMatch[1] === "tv" : h.season > 0 && h.episode > 0;
|
|
||||||
const tmdbId = tmdbMatch ? parseInt(tmdbMatch[2], 10) : undefined;
|
|
||||||
const fallbackItem: CinemaMediaItem = {
|
|
||||||
id: h.title_id,
|
|
||||||
tmdb_id: tmdbId,
|
|
||||||
media_type: isTv ? "tv" : "movie",
|
|
||||||
title: h.title_name,
|
|
||||||
poster_url: h.poster_path || INITIAL_LIBRARY[0].poster_url,
|
|
||||||
backdrop_url: h.backdrop_path || INITIAL_LIBRARY[0].backdrop_url,
|
|
||||||
genre: isTv ? "Сериал" : "Фильм",
|
|
||||||
synopsis: `Продолжение просмотра: ${h.title_name}`,
|
|
||||||
release_year: "2024",
|
|
||||||
rating: 8.5,
|
|
||||||
is_original: false,
|
|
||||||
seasons: [
|
|
||||||
{
|
|
||||||
season_number: h.season || 1,
|
|
||||||
episodes: [
|
|
||||||
{
|
|
||||||
id: `ep-${h.title_id}-${h.season}-${h.episode}`,
|
|
||||||
number: h.episode || 1,
|
|
||||||
title: `Серия ${h.episode || 1}`,
|
|
||||||
thumbnail: h.poster_path || INITIAL_LIBRARY[0].poster_url,
|
|
||||||
duration_minutes: Math.round((h.duration_seconds || 3600) / 60),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
handleMediaCardClick(fallbackItem);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
data-tv-focusable="true"
|
data-tv-focusable="true"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) {
|
if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const found = [
|
handlePlayHistoryItem(h);
|
||||||
...allTrending,
|
|
||||||
...allMoviesList,
|
|
||||||
...allSeriesList,
|
|
||||||
...allAnimeList,
|
|
||||||
].find((item) => item.id === h.title_id);
|
|
||||||
if (found) {
|
|
||||||
handleMediaCardClick(found);
|
|
||||||
} else {
|
|
||||||
const tmdbMatch = h.title_id.match(/tmdb-(movie|tv)-(\d+)/);
|
|
||||||
const isTv = tmdbMatch ? tmdbMatch[1] === "tv" : h.season > 0 && h.episode > 0;
|
|
||||||
const tmdbId = tmdbMatch ? parseInt(tmdbMatch[2], 10) : undefined;
|
|
||||||
const fallbackItem: CinemaMediaItem = {
|
|
||||||
id: h.title_id,
|
|
||||||
tmdb_id: tmdbId,
|
|
||||||
media_type: isTv ? "tv" : "movie",
|
|
||||||
title: h.title_name,
|
|
||||||
poster_url: h.poster_path || INITIAL_LIBRARY[0].poster_url,
|
|
||||||
backdrop_url: h.backdrop_path || INITIAL_LIBRARY[0].backdrop_url,
|
|
||||||
genre: isTv ? "Сериал" : "Фильм",
|
|
||||||
synopsis: `Продолжение просмотра: ${h.title_name}`,
|
|
||||||
release_year: "2024",
|
|
||||||
rating: 8.5,
|
|
||||||
is_original: false,
|
|
||||||
seasons: [
|
|
||||||
{
|
|
||||||
season_number: h.season || 1,
|
|
||||||
episodes: [
|
|
||||||
{
|
|
||||||
id: `ep-${h.title_id}-${h.season}-${h.episode}`,
|
|
||||||
number: h.episode || 1,
|
|
||||||
title: `Серия ${h.episode || 1}`,
|
|
||||||
thumbnail: h.poster_path || INITIAL_LIBRARY[0].poster_url,
|
|
||||||
duration_minutes: Math.round((h.duration_seconds || 3600) / 60),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
handleMediaCardClick(fallbackItem);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -3304,6 +3397,15 @@ export default function Cinema({
|
||||||
className="has-cinema-media-thumb"
|
className="has-cinema-media-thumb"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-history-item-del-btn"
|
||||||
|
onClick={(e) => handleRemoveHistoryItem(e, h)}
|
||||||
|
title="Удалить из истории"
|
||||||
|
aria-label="Удалить из истории"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
<div className="has-cinema-media-play-overlay">
|
<div className="has-cinema-media-play-overlay">
|
||||||
<div className="has-cinema-media-play-circle">▶</div>
|
<div className="has-cinema-media-play-circle">▶</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3313,6 +3415,7 @@ export default function Cinema({
|
||||||
style={{ width: `${percent}%` }}
|
style={{ width: `${percent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="has-cinema-history-percent-badge">{percent}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="has-cinema-media-meta">
|
<div className="has-cinema-media-meta">
|
||||||
<span className="has-cinema-media-title">{h.title_name}</span>
|
<span className="has-cinema-media-title">{h.title_name}</span>
|
||||||
|
|
@ -3536,6 +3639,134 @@ export default function Cinema({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Row 7: Watch History List */}
|
||||||
|
{activeTab === "history" && (
|
||||||
|
<div className="has-cinema-row-block">
|
||||||
|
<div className="has-cinema-history-header">
|
||||||
|
<div>
|
||||||
|
<h2 className="has-cinema-row-title" style={{ margin: 0 }}>История просмотров</h2>
|
||||||
|
<span className="has-cinema-history-subtitle">
|
||||||
|
{cinemaState.history.length === 0
|
||||||
|
? "Прогресс не зафиксирован"
|
||||||
|
: `${cinemaState.history.length} ${
|
||||||
|
cinemaState.history.length === 1
|
||||||
|
? "запись"
|
||||||
|
: cinemaState.history.length < 5
|
||||||
|
? "записи"
|
||||||
|
: "записей"
|
||||||
|
} • Синхронизировано на всех устройствах`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{cinemaState.history.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-clear-history-btn"
|
||||||
|
onClick={handleClearHistory}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
🗑 Очистить историю
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cinemaState.history.length === 0 ? (
|
||||||
|
<div className="has-cinema-empty-bookmarks">
|
||||||
|
<span style={{ fontSize: 32 }}>🕒</span>
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 600 }}>В истории пока ничего нет</span>
|
||||||
|
<span className="has-cinema-empty-sub">
|
||||||
|
Фильмы и серии, которые вы начнёте смотреть, будут автоматически сохраняться здесь с точным прогрессом.
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-btn-primary"
|
||||||
|
style={{ marginTop: 14, width: "fit-content", alignSelf: "center", padding: "8px 24px" }}
|
||||||
|
onClick={() => setActiveTab("home")}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
🎬 Перейти в каталог
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="has-cinema-grid-cards">
|
||||||
|
{cinemaState.history.map((h, i) => {
|
||||||
|
const percent =
|
||||||
|
h.duration_seconds > 0
|
||||||
|
? Math.min(100, Math.round((h.progress_seconds / h.duration_seconds) * 100))
|
||||||
|
: 0;
|
||||||
|
const remainingSecs = Math.max(0, h.duration_seconds - h.progress_seconds);
|
||||||
|
const remainingMins = Math.round(remainingSecs / 60);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${h.title_id}-${h.season}-${h.episode}-${i}`}
|
||||||
|
className="has-cinema-grid-card has-cinema-history-card"
|
||||||
|
onClick={() => handlePlayHistoryItem(h)}
|
||||||
|
data-tv-focusable="true"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePlayHistoryItem(h);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="has-cinema-media-thumb-box">
|
||||||
|
<img
|
||||||
|
src={h.poster_path || h.backdrop_path || INITIAL_LIBRARY[0].poster_url}
|
||||||
|
alt={h.title_name}
|
||||||
|
className="has-cinema-media-thumb"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={`has-cinema-history-status-badge ${percent >= 90 ? "completed" : ""}`}>
|
||||||
|
{percent >= 90 ? "✓ Просмотрено" : `~${remainingMins} мин`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-history-item-del-btn"
|
||||||
|
onClick={(e) => handleRemoveHistoryItem(e, h)}
|
||||||
|
title="Удалить из истории"
|
||||||
|
aria-label="Удалить из истории"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="has-cinema-media-play-overlay">
|
||||||
|
<div className="has-cinema-media-play-circle">▶</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="has-cinema-ep-progress-bar">
|
||||||
|
<div
|
||||||
|
className="has-cinema-ep-progress-fill"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="has-cinema-history-percent-badge">{percent}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="has-cinema-media-meta">
|
||||||
|
<span className="has-cinema-media-title">{h.title_name}</span>
|
||||||
|
<div className="has-cinema-history-card-sub">
|
||||||
|
<span className="has-cinema-history-ep-info">
|
||||||
|
{h.season > 0 ? `Сезон ${h.season}, Серия ${h.episode}` : "Фильм"}
|
||||||
|
</span>
|
||||||
|
{h.last_watched && (
|
||||||
|
<span className="has-cinema-history-date">
|
||||||
|
{formatHistoryDate(h.last_watched)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -4122,6 +4353,27 @@ export default function Cinema({
|
||||||
🛡️ {isAdBlockEnabled ? "Без рекламы" : "Реклама: Вкл"}
|
🛡️ {isAdBlockEnabled ? "Без рекламы" : "Реклама: Вкл"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Quick Skip Ad / Intro Pill */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="has-cinema-player-hud-btn ad-skip-pill"
|
||||||
|
onClick={() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = Math.min(
|
||||||
|
videoRef.current.duration || 999999,
|
||||||
|
videoRef.current.currentTime + 20
|
||||||
|
);
|
||||||
|
}
|
||||||
|
showTvToast("Пропуск рекламы (+20 сек)", "⏩", 1500);
|
||||||
|
playTvSelectBeep();
|
||||||
|
resetHudTimer();
|
||||||
|
}}
|
||||||
|
title="Пропустить рекламу казино или вступительную заставку (+20 сек)"
|
||||||
|
data-tv-focusable="true"
|
||||||
|
>
|
||||||
|
⏩ Пропуск (+20с)
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Single Settings button (Contains voiceover studios & mirrors) */}
|
{/* Single Settings button (Contains voiceover studios & mirrors) */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue