feat(release): v0.0.11 - watch history, webview adblock, clean studio audio priority & kettle noise elimination

This commit is contained in:
Nikita 2026-10-27 22:17:17 +05:00
parent 4d7fab0166
commit d50d3e9a48
10 changed files with 697 additions and 111 deletions

View file

@ -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>

View file

@ -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
View file

@ -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",

View file

@ -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"

View file

@ -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,

View file

@ -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,

View file

@ -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",

View file

@ -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,

View file

@ -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;
}

View file

@ -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"