has/src/pages/cinema/Cinema.tsx

4204 lines
172 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef, useCallback } from "react";
import Hls from "hls.js";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { openUrl } from "@tauri-apps/plugin-opener";
import type {
CinemaState,
CinemaConfig,
CinemaMediaItem,
CinemaSeason,
CinemaEpisode,
CinemaPlayback,
UserDto,
} from "../../types";
import "./Cinema.css";
interface CinemaProps {
currentUser: UserDto;
onNavigateTab?: (tab: string) => void;
onLogout?: () => void;
isTvEnvironment?: boolean;
}
const buildAnimeSeasons = (seasonConfigs: { season_number: number; count: number }[], thumb: string): CinemaSeason[] => {
return seasonConfigs.map((cfg) => ({
season_number: cfg.season_number,
episodes: Array.from({ length: cfg.count }, (_, i) => ({
id: `demonic-s${cfg.season_number}-e${i + 1}`,
number: i + 1,
title: `Серия ${i + 1}`,
thumbnail: thumb,
duration_minutes: 24,
})),
}));
};
const formatCurrency = (amount?: number): string | null => {
if (!amount || amount <= 0) return null;
if (amount >= 1_000_000_000) {
return `$${(amount / 1_000_000_000).toFixed(2)} млрд ($${amount.toLocaleString("ru-RU")})`;
}
if (amount >= 1_000_000) {
return `$${(amount / 1_000_000).toFixed(1)} млн ($${amount.toLocaleString("ru-RU")})`;
}
return `$${amount.toLocaleString("ru-RU")}`;
};
const formatRuntime = (minutes?: number): string | null => {
if (!minutes || minutes <= 0) return null;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0 && mins > 0) return `${hours} ч ${mins} мин`;
if (hours > 0) return `${hours} ч`;
return `${mins} мин`;
};
// Accurate media category label (never confuses live-action series with anime)
const getMediaCategoryLabel = (media: CinemaMediaItem | null | undefined): string => {
if (!media) return "Сериал";
const g = (media.genre || "").toLowerCase();
const isAnime = g.includes("аниме") || g.includes("anime");
const isCartoon = g.includes("мультфильм") || g.includes("анимация") || g.includes("animation");
if (media.media_type === "movie") {
if (isAnime) return "Аниме-фильм";
if (isCartoon) return "Мультфильм";
return "Фильм";
}
// TV / Series
if (isAnime) return "Аниме-сериал";
if (isCartoon) return "Мультсериал";
return "Сериал";
};
const getMediaBadgeLabel = (media: CinemaMediaItem | null | undefined): string => {
return getMediaCategoryLabel(media).toUpperCase();
};
// Initial Curated Fallback Library
const INITIAL_LIBRARY: CinemaMediaItem[] = [
{
id: "demonic-slash",
tmdb_id: 85937,
media_type: "tv",
title: "Клинок, рассекающий демонов",
original_title: "Kimetsu no Yaiba",
backdrop_url: "https://images.unsplash.com/photo-1578632767115-351597cf2477?q=80&w=1600&auto=format&fit=crop",
poster_url: "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=600&auto=format&fit=crop",
release_year: 2024,
seasons_count: 4,
genre: "Аниме / Сёнэн",
rating: 4.9,
tagline: "Разруби свою судьбу клинком",
countries: "Япония",
director: "Харуо Сотодзаки",
cast: [
{ id: 21, name: "Нацуки Ханаэ", character: "Танджиро Камадо" },
{ id: 22, name: "Акари Кито", character: "Незуко Камадо" },
{ id: 23, name: "Хиро Симоно", character: "Зеницу Агацума" },
{ id: 24, name: "Ёсицугу Мацуока", character: "Иноске Хашибира" },
],
synopsis:
"Танджиро Камадо становится истребителем демонов, чтобы спасти свою сестру Незуко, превращенную в демона, и отомстить за гибель своей семьи.",
is_original: true,
seasons: buildAnimeSeasons(
[
{ season_number: 1, count: 26 },
{ season_number: 2, count: 11 },
{ season_number: 3, count: 11 },
{ season_number: 4, count: 8 },
],
"https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=600&auto=format&fit=crop"
),
},
{
id: "dune-part-two",
tmdb_id: 693134,
media_type: "movie",
title: "Дюна: Часть вторая",
original_title: "Dune: Part Two",
backdrop_url: "https://images.unsplash.com/photo-1509198397868-475647b2a1e5?q=80&w=1600&auto=format&fit=crop",
poster_url: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=600&auto=format&fit=crop",
release_year: 2024,
duration_minutes: 166,
genre: "Фантастика / Приключения",
rating: 4.9,
tagline: "Да пребудет с вами сила пустыни",
budget: 190000000,
revenue: 711844358,
countries: "США, Канада",
director: "Дени Вильнёв",
cast: [
{ id: 1, name: "Тимоти Шаламе", character: "Пол Атрейдес" },
{ id: 2, name: "Зендея", character: "Чани" },
{ id: 3, name: "Ребекка Фергюсон", character: "Леди Джессика" },
{ id: 4, name: "Хавьер Бардем", character: "Стилгар" },
{ id: 5, name: "Остин Батлер", character: "Фейд-Раута" },
{ id: 6, name: "Флоренс Пью", character: "Принцесса Ирулан" },
],
synopsis:
"Герцог Пол Атрейдес объединяется с фременами, чтобы отомстить заговорщикам, уничтожившим его семью. Эпическое продолжение борьбы за Арракис.",
is_original: false,
seasons: [],
},
{
id: "interstellar",
tmdb_id: 157336,
media_type: "movie",
title: "Интерстеллар",
original_title: "Interstellar",
backdrop_url: "https://images.unsplash.com/photo-1506703719100-a0f3a48c0f86?q=80&w=1600&auto=format&fit=crop",
poster_url: "https://images.unsplash.com/photo-1451187580459-43490279c0fa?q=80&w=600&auto=format&fit=crop",
release_year: 2014,
duration_minutes: 169,
genre: "Фантастика / Драма",
rating: 4.9,
tagline: "Следующий шаг человечества станет величайшим",
budget: 165000000,
revenue: 701729206,
countries: "США, Великобритания",
director: "Кристофер Нолан",
cast: [
{ id: 11, name: "Мэттью Макконахи", character: "Купер" },
{ id: 12, name: "Энн Хэтэуэй", character: "Бренд" },
{ id: 13, name: "Джессика Честейн", character: "Мёрф" },
{ id: 14, name: "Майкл Кейн", character: "Профессор Бренд" },
{ id: 15, name: "Мэтт Дэймон", character: "Манн" },
],
synopsis:
"Когда засуха приводит человечество к глобальному продовольственному кризису, группа ученых отправляется сквозь червоточину в поисках нового дома для людей.",
is_original: false,
seasons: [],
},
{
id: "arcane",
tmdb_id: 94605,
media_type: "tv",
title: "Аркейн",
original_title: "Arcane: League of Legends",
backdrop_url: "https://images.unsplash.com/photo-1563089145-599997674d42?q=80&w=1600&auto=format&fit=crop",
poster_url: "https://images.unsplash.com/photo-1579783902614-a3fb3927b675?q=80&w=600&auto=format&fit=crop",
release_year: 2024,
seasons_count: 2,
genre: "Анимация / Экшен",
rating: 4.9,
tagline: "Каждая легенда с чего-то начинается",
countries: "США, Франция",
director: "Паскаль Шаррю, Арно Делорд",
cast: [
{ id: 31, name: "Хейли Стайнфелд", character: "Вай" },
{ id: 32, name: "Элла Пернелл", character: "Джинкс" },
{ id: 33, name: "Кэти Льюнг", character: "Кейтлин" },
],
synopsis:
"История двух сестер, Джинкс и Вай, оказавшихся по разные стороны баррикад в войне между технологическим Пилтовером и преступным Зауном.",
is_original: false,
seasons: [
{
season_number: 1,
episodes: Array.from({ length: 9 }, (_, i) => ({
id: `arcane-1-${i + 1}`,
number: i + 1,
title: `Серия ${i + 1}`,
thumbnail: "https://images.unsplash.com/photo-1563089145-599997674d42?q=80&w=600&auto=format&fit=crop",
duration_minutes: 41,
})),
},
{
season_number: 2,
episodes: Array.from({ length: 9 }, (_, i) => ({
id: `arcane-2-${i + 1}`,
number: i + 1,
title: `Серия ${i + 1}`,
thumbnail: "https://images.unsplash.com/photo-1563089145-599997674d42?q=80&w=600&auto=format&fit=crop",
duration_minutes: 42,
})),
},
],
},
{
id: "breaking-bad",
tmdb_id: 1396,
media_type: "tv",
title: "Во все тяжкие",
original_title: "Breaking Bad",
backdrop_url: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=1600&auto=format&fit=crop",
poster_url: "https://images.unsplash.com/photo-1509198397868-475647b2a1e5?q=80&w=600&auto=format&fit=crop",
release_year: 2013,
seasons_count: 5,
genre: "Криминал / Драма",
rating: 5.0,
synopsis:
"Школьный учитель химии Уолтер Уайт узнает о смертельном диагнозе и вместе с бывшим учеником начинает варить метамфетамин, чтобы обеспечить семью.",
is_original: false,
seasons: [
{
season_number: 1,
episodes: Array.from({ length: 7 }, (_, i) => ({
id: `bb-1-${i + 1}`,
number: i + 1,
title: `Серия ${i + 1}`,
thumbnail: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=600&auto=format&fit=crop",
duration_minutes: 48,
})),
},
{
season_number: 2,
episodes: Array.from({ length: 13 }, (_, i) => ({
id: `bb-2-${i + 1}`,
number: i + 1,
title: `Серия ${i + 1}`,
thumbnail: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=600&auto=format&fit=crop",
duration_minutes: 47,
})),
},
{
season_number: 3,
episodes: Array.from({ length: 13 }, (_, i) => ({
id: `bb-3-${i + 1}`,
number: i + 1,
title: `Серия ${i + 1}`,
thumbnail: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=600&auto=format&fit=crop",
duration_minutes: 47,
})),
},
{
season_number: 4,
episodes: Array.from({ length: 13 }, (_, i) => ({
id: `bb-4-${i + 1}`,
number: i + 1,
title: `Серия ${i + 1}`,
thumbnail: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=600&auto=format&fit=crop",
duration_minutes: 47,
})),
},
{
season_number: 5,
episodes: Array.from({ length: 16 }, (_, i) => ({
id: `bb-5-${i + 1}`,
number: i + 1,
title: `Серия ${i + 1}`,
thumbnail: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=600&auto=format&fit=crop",
duration_minutes: 47,
})),
},
],
},
{
id: "oppenheimer",
tmdb_id: 872585,
media_type: "movie",
title: "Оппенгеймер",
original_title: "Oppenheimer",
backdrop_url: "https://images.unsplash.com/photo-1509198397868-475647b2a1e5?q=80&w=1600&auto=format&fit=crop",
poster_url: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=600&auto=format&fit=crop",
release_year: 2023,
duration_minutes: 180,
genre: "Драма / История",
rating: 4.9,
synopsis: "История американского физика-теоретика Роберта Оппенгеймера, руководителя Манхэттенского проекта во время Второй мировой войны.",
is_original: false,
seasons: [],
},
{
id: "jujutsu-kaisen",
tmdb_id: 95479,
media_type: "tv",
title: "Магическая битва",
original_title: "Jujutsu Kaisen",
backdrop_url: "https://images.unsplash.com/photo-1578632767115-351597cf2477?q=80&w=1600&auto=format&fit=crop",
poster_url: "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=600&auto=format&fit=crop",
release_year: 2020,
seasons_count: 2,
genre: "Аниме / Экшен",
rating: 4.9,
synopsis: "Юдзи Итадори проглатывает палец могущественного демона Рёмэна Сукуны и вступает в Токийский магический колледж.",
is_original: true,
seasons: buildAnimeSeasons(
[
{ season_number: 1, count: 24 },
{ season_number: 2, count: 23 },
],
"https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=600&auto=format&fit=crop"
),
},
];
interface RawTmdbItem {
id: number;
title?: string;
name?: string;
original_name?: string;
original_title?: string;
media_type?: string;
poster_path?: string;
backdrop_path?: string;
overview?: string;
release_date?: string;
first_air_date?: string;
vote_average?: number;
vote_count?: number;
genre_ids?: number[];
number_of_seasons?: number;
status?: string;
}
// Filter out titles that have not yet been released or are unstreamable announcements
const isMediaReleased = (item: RawTmdbItem): boolean => {
const today = new Date().toISOString().slice(0, 10);
const dateStr = item.release_date || item.first_air_date;
// 1. If release date is in the future, it is not yet released
if (dateStr && dateStr > today) {
return false;
}
// 2. If it has no release date at all or invalid year
if (!dateStr || dateStr.trim().length < 4) {
return false;
}
// 3. Status checks: upcoming or unproduced titles
if (
item.status &&
(item.status === "Planned" ||
item.status === "In Production" ||
item.status === "Post Production" ||
item.status === "Rumored")
) {
return false;
}
// 4. Low-vote / announcement filter: titles with < 20 votes on TMDB
// are almost exclusively unreleased festival announcements or dead unstreamable metadata
if (item.vote_count !== undefined && item.vote_count < 20) {
return false;
}
// 5. Very low rating with low votes filter
if (item.vote_average !== undefined && item.vote_average < 3.5 && (item.vote_count || 0) < 50) {
return false;
}
return true;
};
interface KodikItem {
id: string;
title: string;
title_orig?: string;
link: string;
translation: {
id: number;
title: string;
type: string;
};
}
// Known Kinopoisk IDs for top popular titles (instant 0ms resolution for Russian dubbing)
const KNOWN_KP_MAP: Record<number, number> = {
157336: 258687, // Интерстеллар
693134: 4465403, // Дюна: Часть вторая
438631: 409424, // Дюна
94605: 1318855, // Аркейн
1396: 404900, // Во все тяжкие
85937: 1112136, // Клинок, рассекающий демонов
872585: 4664634, // Оппенгеймер
155: 111543, // Тёмный рыцарь
27205: 447301, // Начало
550: 361, // Бойцовский клуб
680: 342, // Криминальное чтиво
1399: 464963, // Игра престолов
60625: 685246, // Рик и Морти
1429: 749374, // Атака титанов
95479: 1227958, // Магическая битва
106379: 1399708, // Фоллаут
76479: 949804, // Пацаны
100088: 1354148, // Одни из нас
66732: 915196, // Очень странные дела
119051: 1373510, // Уэнсдей
87108: 1227803, // Чернобыль
126308: 1219417, // Сёгун
93405: 1309570, // Игра в кальмара
299534: 843650, // Мстители: Финал
299536: 843649, // Мстители: Война бесконечности
24428: 263531, // Мстители
634649: 1309599, // Человек-паук: Нет пути домой
19995: 251733, // Аватар
76600: 505898, // Аватар: Путь воды
597: 2213, // Титаник
120: 328, // Властелин колец: Братство Кольца
121: 312, // Властелин колец: Две крепости
122: 3498, // Властелин колец: Возвращение Короля
671: 689, // Гарри Поттер и философский камень
672: 688, // Гарри Поттер и Тайная комната
673: 324, // Гарри Поттер и узник Азкабана
674: 8408, // Гарри Поттер и Кубок огня
675: 48356, // Гарри Поттер и Орден Феникса
767: 86621, // Гарри Поттер и Принц-полукровка
12444: 276762, // Гарри Поттер и Дары Смерти: Часть 1
12445: 407636, // Гарри Поттер и Дары Смерти: Часть 2
389: 349, // 12 разгневанных мужчин
278: 326, // Побег из Шоушенка
238: 325, // Крёстный отец
424: 329, // Список Шиндлера
13: 448, // Форрест Гамп
129: 370, // Унесённые призраками
496243: 1043758, // Паразиты
493529: 1009436, // Джон Уик 3
603692: 1267348, // Джон Уик 4
245891: 762738, // Джон Уик
324552: 885658, // Джон Уик 2
533535: 1318973, // Дэдпул и Росомаха
293660: 462360, // Дэдпул
383498: 977288, // Дэдпул 2
};
interface RussianMediaInfo {
kp_id?: number | null;
imdb_id?: string | null;
iframe_url?: string | null;
russian_title?: string | null;
}
type BalancerMirror =
| "vidlink_pro"
| "kodik_ru"
| "autoembed"
| "vidsrc_pm"
| "multiembed"
| "vidsrc_to"
| "alloha_ru"
| "anilibria"
| "embess_ru"
| "smotrim_ru";
// Reliable image URL helper with proxy caching (bypasses Russian ISP / Cloudflare blocks on image.tmdb.org)
const formatMediaImg = (path: string | undefined, size: "w500" | "original" = "w500"): string => {
if (!path) return size === "original" ? INITIAL_LIBRARY[0].backdrop_url : INITIAL_LIBRARY[0].poster_url;
if (path.startsWith("http")) return path;
return `https://wsrv.nl/?url=https://image.tmdb.org/t/p/${size}${path}`;
};
// Detect Android TV / Smart TV user agent or previous user preference
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 Cinema({
currentUser,
onNavigateTab,
onLogout,
isTvEnvironment = false,
}: CinemaProps) {
const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
// Android TV / Remote Control (10-Foot UI) State
const [isTvMode, setIsTvMode] = useState<boolean>(() => {
return isTvEnvironment || detectTvEnvironment();
});
const [isTvNavOpen, setIsTvNavOpen] = useState(false);
const lastFocusedCardRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isTvEnvironment && !isTvMode) {
setIsTvMode(true);
}
}, [isTvEnvironment, isTvMode]);
const [tvToast, setTvToast] = useState<{ text: string; icon?: string } | null>(null);
const tvToastTimerRef = useRef<any>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const showTvToast = useCallback((text: string, icon = "📺", duration = 2000) => {
if (tvToastTimerRef.current) clearTimeout(tvToastTimerRef.current);
setTvToast({ text, icon });
tvToastTimerRef.current = setTimeout(() => {
setTvToast(null);
}, duration);
}, []);
const playTvBeep = useCallback((freq = 580, duration = 0.035) => {
try {
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
if (!AudioCtx) return;
if (!audioCtxRef.current) {
audioCtxRef.current = new AudioCtx();
}
const ctx = audioCtxRef.current;
if (ctx.state === "suspended") {
ctx.resume().catch(() => {});
}
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, ctx.currentTime);
gain.gain.setValueAtTime(0.04, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + duration);
} catch {
// AudioContext might be blocked until user gesture, ignore safely
}
}, []);
const toggleTvMode = useCallback(() => {
setIsTvMode((prev) => {
const next = !prev;
localStorage.setItem("has_tv_mode", String(next));
showTvToast(next ? "ТВ-Режим включен (10-Foot UI)" : "ПК-Режим включен", "📺", 2500);
playTvBeep(next ? 750 : 400, 0.05);
return next;
});
}, [showTvToast, playTvBeep]);
// Local Cinema State (Bookmarks & Progress saved on user's machine)
const [cinemaState, setCinemaState] = useState<CinemaState>({
bookmarks: [],
history: [],
});
const [, setConfig] = useState<CinemaConfig>({ tmdb_api_key: null });
// Navigation Filter
const [activeTab, setActiveTab] = useState<"home" | "movies" | "series" | "anime" | "bookmarks" | "search">("home");
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<CinemaMediaItem[]>([]);
const [isSearching, setIsSearching] = useState(false);
// Active Selected Hero Item
const [selectedMedia, setSelectedMedia] = useState<CinemaMediaItem>(INITIAL_LIBRARY[0]);
const [selectedSeason, setSelectedSeason] = useState<number>(1);
// Dynamic Content Collections from TMDB
const [trendingAll, setTrendingAll] = useState<CinemaMediaItem[]>([]);
const [popularMovies, setPopularMovies] = useState<CinemaMediaItem[]>([]);
const [popularSeries, setPopularSeries] = useState<CinemaMediaItem[]>([]);
const [popularAnime, setPopularAnime] = useState<CinemaMediaItem[]>([]);
// Video Player Modal State
const [playingMedia, setPlayingMedia] = useState<CinemaMediaItem | null>(null);
const [playingEpisode, setPlayingEpisode] = useState<CinemaEpisode | null>(null);
const [playingSeasonNumber, setPlayingSeasonNumber] = useState<number>(1);
const playerMode: "balancer" | "direct" = "balancer";
const [selectedMirror, setSelectedMirror] = useState<BalancerMirror>("vidlink_pro");
const [currentKpId, setCurrentKpId] = useState<number | null>(null);
const [currentImdbId, setCurrentImdbId] = useState<string | null>(null);
const [currentAllohaUrl, setCurrentAllohaUrl] = useState<string | null>(null);
const [isAdBlockEnabled, setIsAdBlockEnabled] = useState<boolean>(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const [kodikResults, setKodikResults] = useState<KodikItem[]>([]);
const [selectedKodikLink, setSelectedKodikLink] = useState<string | null>(null);
const [isSearchingKodik, setIsSearchingKodik] = useState(false);
const [russianDubNotice, setRussianDubNotice] = useState<string | null>(null);
const [anilibriaStreamUrl, setAnilibriaStreamUrl] = useState<string | null>(null);
const [isLoadingAniLibria, setIsLoadingAniLibria] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
const topRef = useRef<HTMLDivElement | null>(null);
const hlsInstanceRef = useRef<Hls | null>(null);
const aniLibriaReleasesCache = useRef<Map<number, any>>(new Map());
// Detailed Movie / Series Information Modal State
const [detailsMedia, setDetailsMedia] = useState<CinemaMediaItem | null>(null);
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [showTrailerModal, setShowTrailerModal] = useState(false);
// Simplified Video Player HUD & Controls State
const [isHudVisible, setIsHudVisible] = useState(true);
const [showPlayerSettings, setShowPlayerSettings] = useState(false);
const [showPlayerEpisodes, setShowPlayerEpisodes] = useState(false);
const hudTimeoutRef = useRef<any>(null);
const resetHudTimer = useCallback(() => {
setIsHudVisible(true);
if (hudTimeoutRef.current) {
clearTimeout(hudTimeoutRef.current);
}
hudTimeoutRef.current = setTimeout(() => {
setShowPlayerSettings((settingsOpen) => {
setShowPlayerEpisodes((episodesOpen) => {
if (!settingsOpen && !episodesOpen) {
setIsHudVisible(false);
}
return episodesOpen;
});
return settingsOpen;
});
}, 3500);
}, []);
// Universal helper to convert a TMDB item into CinemaMediaItem
const mapTmdbItem = (item: RawTmdbItem, forcedType?: "movie" | "tv"): CinemaMediaItem => {
let isTv = false;
if (forcedType) {
isTv = forcedType === "tv";
} else if (item.media_type) {
isTv = item.media_type === "tv";
} else {
isTv = !item.title && (!!item.name || !!item.first_air_date);
}
const title = item.title || item.name || item.original_title || item.original_name || "Без названия";
const backdrop = formatMediaImg(item.backdrop_path || item.poster_path, "original");
const poster = formatMediaImg(item.poster_path || item.backdrop_path, "w500");
const year = (item.release_date || item.first_air_date || "").slice(0, 4) || "2024";
if (!isTv) {
return {
id: `tmdb-movie-${item.id}`,
tmdb_id: item.id,
media_type: "movie",
title,
original_title: item.original_name || item.original_title,
backdrop_url: backdrop,
poster_url: poster,
release_year: year,
seasons_count: undefined,
duration_minutes: 120,
genre: "Фильм",
rating: item.vote_average ? Math.round(item.vote_average * 10) / 10 : 8.0,
vote_count: item.vote_count,
synopsis: item.overview?.trim() || "Описание фильма из международной базы TMDB.",
is_original: false,
seasons: [],
};
}
const initialSeasonsCount = item.number_of_seasons && item.number_of_seasons > 0 ? item.number_of_seasons : 1;
const seasonsList: CinemaSeason[] = [];
for (let s = 1; s <= initialSeasonsCount; s++) {
seasonsList.push({
season_number: s,
episodes: [
{
id: `tmdb-${item.id}-s${s}-e1`,
number: 1,
title: "Серия 1",
thumbnail: poster,
duration_minutes: 45,
},
],
});
}
return {
id: `tmdb-tv-${item.id}`,
tmdb_id: item.id,
media_type: "tv",
title,
original_title: item.original_name || item.original_title,
backdrop_url: backdrop,
poster_url: poster,
release_year: year,
seasons_count: initialSeasonsCount,
duration_minutes: undefined,
genre: "Сериал",
rating: item.vote_average ? Math.round(item.vote_average * 10) / 10 : 8.0,
vote_count: item.vote_count,
synopsis: item.overview?.trim() || "Описание сериала из международной базы TMDB.",
is_original: false,
seasons: seasonsList,
};
};
// Safe TMDB query via Rust backend (bypasses RU 403 blocks and CORS)
const queryTmdb = useCallback(async (endpoint: string): Promise<any> => {
if (!isTauri) return null;
try {
return await invoke<any>("tmdb_query", { endpoint });
} catch (err) {
console.warn(`Error querying TMDB endpoint ${endpoint}:`, err);
return null;
}
}, [isTauri]);
// Verifies that titles returned from TMDB actually have active players/streams on CDN balancers
const verifyPlayableBatch = useCallback(
async (items: CinemaMediaItem[]): Promise<CinemaMediaItem[]> => {
if (!isTauri || items.length === 0) return items;
try {
const checkPayload = items.map((i) => ({
tmdb_id: i.tmdb_id || 0,
title: i.title,
original_title: i.original_title,
media_type: i.media_type || "movie",
vote_count: i.vote_count,
release_year: i.release_year ? String(i.release_year) : undefined,
}));
const verifiedIds = await invoke<number[]>("filter_playable_media", { items: checkPayload });
const verifiedSet = new Set(verifiedIds);
return items.filter(
(i) => i.tmdb_id && (verifiedSet.has(i.tmdb_id) || (KNOWN_KP_MAP as any)[i.tmdb_id])
);
} catch (err) {
console.warn("Error verifying playable media:", err);
return items;
}
},
[isTauri]
);
// Load complete rich details (budget, revenue, director, cast, trailer, etc.)
const fetchMediaDetails = useCallback(async (item: CinemaMediaItem) => {
if (!item.tmdb_id) return;
try {
const isTv = item.media_type === "tv";
const endpoint = isTv
? `/tv/${item.tmdb_id}?language=ru-RU&append_to_response=credits,videos`
: `/movie/${item.tmdb_id}?language=ru-RU&append_to_response=credits,videos`;
const data = await queryTmdb(endpoint);
if (data) {
const cast = (data.credits?.cast || []).slice(0, 16).map((c: any) => ({
id: c.id,
name: c.name || c.original_name,
original_name: c.original_name,
character: c.character,
profile_path: c.profile_path ? formatMediaImg(c.profile_path, "w500") : null,
}));
const director = isTv
? (data.created_by && data.created_by.length > 0
? data.created_by.map((c: any) => c.name).join(", ")
: data.credits?.crew?.find((c: any) => c.job === "Director" || c.job === "Executive Producer")?.name)
: data.credits?.crew?.find((c: any) => c.job === "Director")?.name;
const countries = (data.production_countries || []).map((c: any) => c.name).join(", ");
const genres = (data.genres || []).map((g: any) => g.name).join(", ");
const runtime = data.runtime || (data.episode_run_time && data.episode_run_time[0]) || item.duration_minutes;
const videos = data.videos?.results || [];
const trailer =
videos.find((v: any) => v.site === "YouTube" && v.type === "Trailer" && (v.iso_639_1 === "ru" || (v.name || "").toLowerCase().includes("рус"))) ||
videos.find((v: any) => v.site === "YouTube" && v.type === "Trailer") ||
videos.find((v: any) => v.site === "YouTube");
let realSeasons: CinemaSeason[] = [];
let realSeasonsCount: number | undefined = undefined;
if (isTv) {
if (Array.isArray(data.seasons) && data.seasons.length > 0) {
const regularSeasons = data.seasons.filter((s: any) => s.season_number > 0);
const validSeasons = regularSeasons.length > 0 ? regularSeasons : data.seasons;
realSeasonsCount = data.number_of_seasons || validSeasons.length;
realSeasons = validSeasons.map((s: any) => {
const epCount = s.episode_count && s.episode_count > 0 ? s.episode_count : 1;
const seasonThumb = s.poster_path ? formatMediaImg(s.poster_path, "w500") : item.poster_url;
return {
season_number: s.season_number,
episodes: Array.from({ length: epCount }, (_, idx) => ({
id: `tmdb-${item.tmdb_id}-s${s.season_number}-e${idx + 1}`,
number: idx + 1,
title: `Серия ${idx + 1}`,
thumbnail: seasonThumb,
duration_minutes: (data.episode_run_time && data.episode_run_time[0]) || 45,
})),
};
});
} else {
realSeasonsCount = data.number_of_seasons || 1;
realSeasons = [
{
season_number: 1,
episodes: [
{
id: `tmdb-${item.tmdb_id}-s1-e1`,
number: 1,
title: "Серия 1",
thumbnail: item.poster_url,
duration_minutes: 45,
},
],
},
];
}
}
const today = new Date().toISOString().slice(0, 10);
const releaseDate = data.release_date || data.first_air_date;
const isFuture = releaseDate && releaseDate > today;
const isUnreleasedStatus = data.status === "Planned" || data.status === "In Production" || data.status === "Post Production";
const isUnreleased = !!(isFuture || isUnreleasedStatus);
const updateItem = (prev: CinemaMediaItem): CinemaMediaItem => ({
...prev,
tagline: data.tagline || prev.tagline,
budget: data.budget && data.budget > 0 ? data.budget : prev.budget,
revenue: data.revenue && data.revenue > 0 ? data.revenue : prev.revenue,
duration_minutes: runtime || prev.duration_minutes,
countries: countries || prev.countries,
genre: genres || prev.genre,
director: director || prev.director,
cast: cast.length > 0 ? cast : prev.cast,
trailer_youtube_key: trailer?.key || prev.trailer_youtube_key,
synopsis: data.overview?.trim() || prev.synopsis,
rating: data.vote_average ? Math.round(data.vote_average * 10) / 10 : prev.rating,
seasons: isTv ? realSeasons : [],
seasons_count: isTv ? realSeasonsCount : undefined,
is_unreleased: isUnreleased,
release_date_full: releaseDate || prev.release_date_full,
});
setDetailsMedia((prev) => (prev && prev.id === item.id ? updateItem(prev) : prev));
setSelectedMedia((prev) => (prev && prev.id === item.id ? updateItem(prev) : prev));
if (isTv) {
loadTvSeasonsDetails(item.tmdb_id, 1);
}
}
// Also query Russian media info for Kinopoisk ID / rating
if (isTauri && item.tmdb_id) {
invoke<RussianMediaInfo>("get_russian_media_info", {
tmdbId: item.tmdb_id,
isTv: item.media_type === "tv",
title: item.title || item.original_title,
}).then((info) => {
if (info?.kp_id) {
setDetailsMedia((prev) => (prev && prev.id === item.id ? { ...prev, kinopoisk_rating: info.kp_id ? "КП" : undefined } : prev));
setSelectedMedia((prev) => (prev && prev.id === item.id ? { ...prev, kinopoisk_rating: info.kp_id ? "КП" : undefined } : prev));
}
}).catch(() => {});
}
} catch (e) {
console.warn("Could not load full media details:", e);
} finally {
setIsLoadingDetails(false);
}
}, [queryTmdb, isTauri]);
// Load All Catalogs (Released Movies, Series, Anime, Trending) on Mount
const loadFullCatalog = useCallback(async () => {
try {
const today = new Date().toISOString().slice(0, 10);
// 1. Trending All (Top 40 - page 1 & 2, filtered by released status & active streams)
const [tData1, tData2] = await Promise.all([
queryTmdb("/trending/all/week?language=ru-RU&page=1"),
queryTmdb("/trending/all/week?language=ru-RU&page=2"),
]);
const allTrendingRaw = [...(tData1?.results || []), ...(tData2?.results || [])];
if (allTrendingRaw.length > 0) {
const mapped = allTrendingRaw
.filter((i: RawTmdbItem) => (i.title || i.name) && isMediaReleased(i) && (i.vote_count || 0) >= 30)
.map((i: RawTmdbItem) => mapTmdbItem(i));
const verified = await verifyPlayableBatch(mapped);
setTrendingAll(verified);
if (verified.length > 0) {
setSelectedMedia(verified[0]);
}
}
// 2. Popular Released Movies (only those with digital/physical distribution and vote_count >= 100)
const [mData1, mData2] = await Promise.all([
queryTmdb(`/discover/movie?language=ru-RU&sort_by=popularity.desc&primary_release_date.lte=${today}&vote_count.gte=100&with_release_type=4|5|6&page=1`),
queryTmdb(`/discover/movie?language=ru-RU&sort_by=popularity.desc&primary_release_date.lte=${today}&vote_count.gte=100&with_release_type=4|5|6&page=2`),
]);
const allMoviesRaw = [...(mData1?.results || []), ...(mData2?.results || [])];
if (allMoviesRaw.length > 0) {
const mapped = allMoviesRaw
.filter((i: RawTmdbItem) => (i.title || i.name) && isMediaReleased(i))
.map((i: RawTmdbItem) => mapTmdbItem(i, "movie"));
const verified = await verifyPlayableBatch(mapped);
setPopularMovies(verified);
}
// 3. Popular Released TV Shows (first_air_date <= today and vote_count >= 50)
const [tvData1, tvData2] = await Promise.all([
queryTmdb(`/discover/tv?language=ru-RU&sort_by=popularity.desc&first_air_date.lte=${today}&vote_count.gte=50&page=1`),
queryTmdb(`/discover/tv?language=ru-RU&sort_by=popularity.desc&first_air_date.lte=${today}&vote_count.gte=50&page=2`),
]);
const allTvRaw = [...(tvData1?.results || []), ...(tvData2?.results || [])];
if (allTvRaw.length > 0) {
const mapped = allTvRaw
.filter((i: RawTmdbItem) => (i.title || i.name) && isMediaReleased(i))
.map((i: RawTmdbItem) => mapTmdbItem(i, "tv"));
const verified = await verifyPlayableBatch(mapped);
setPopularSeries(verified);
}
// 4. Genuine Anime (Japanese Animation, vote_count >= 30)
const [aData1, aData2] = await Promise.all([
queryTmdb(`/discover/tv?with_genres=16&with_original_language=ja&sort_by=popularity.desc&first_air_date.lte=${today}&vote_count.gte=30&language=ru-RU&page=1`),
queryTmdb(`/discover/tv?with_genres=16&with_original_language=ja&sort_by=popularity.desc&first_air_date.lte=${today}&vote_count.gte=30&language=ru-RU&page=2`),
]);
const allAnimeRaw = [...(aData1?.results || []), ...(aData2?.results || [])];
if (allAnimeRaw.length > 0) {
const mapped = allAnimeRaw
.filter((i: RawTmdbItem) => (i.title || i.name) && isMediaReleased(i))
.map((i: RawTmdbItem) => mapTmdbItem(i, "tv"));
const verified = await verifyPlayableBatch(mapped);
setPopularAnime(verified);
}
} catch (err) {
console.warn("Failed to load catalog from TMDB:", err);
}
}, [queryTmdb, verifyPlayableBatch]);
// Load config, state, and full catalog on mount
useEffect(() => {
if (!isTauri) return;
invoke<CinemaConfig>("get_cinema_config")
.then((cfg) => {
setConfig(cfg);
loadFullCatalog();
})
.catch((err) => console.warn("Failed to get cinema config:", err));
invoke<CinemaState>("get_cinema_state")
.then((state) => {
if (state) setCinemaState(state);
})
.catch((err) => console.warn("Failed to get local cinema state:", err));
}, [isTauri, loadFullCatalog]);
// Search TMDB live across all movies, series, and anime (only released and verified playable titles)
const executeSearch = useCallback(
async (query: string) => {
if (!query.trim()) {
setSearchResults([]);
return;
}
setIsSearching(true);
try {
const data = await queryTmdb(
`/search/multi?query=${encodeURIComponent(query)}&language=ru-RU&page=1`
);
let rawResults = (data?.results || []).filter(
(r: RawTmdbItem) => (r.media_type === "movie" || r.media_type === "tv") && (r.title || r.name) && isMediaReleased(r)
);
if (rawResults.length === 0) {
// Fallback search with en-US query if Russian gave 0 results
const dataEn = await queryTmdb(
`/search/multi?query=${encodeURIComponent(query)}&language=en-US&page=1`
);
if (dataEn?.results) {
rawResults = dataEn.results.filter(
(r: RawTmdbItem) => (r.media_type === "movie" || r.media_type === "tv") && (r.title || r.name) && isMediaReleased(r)
);
}
}
if (rawResults.length > 0) {
const mapped = rawResults
.sort((a: any, b: any) => (b.vote_count || b.popularity || 0) - (a.vote_count || a.popularity || 0))
.map((r: RawTmdbItem) => mapTmdbItem(r));
const verified = await verifyPlayableBatch(mapped);
setSearchResults(verified);
} else {
setSearchResults([]);
}
} catch (err) {
console.warn("Search error:", err);
} finally {
setIsSearching(false);
}
},
[queryTmdb, verifyPlayableBatch]
);
useEffect(() => {
const timer = setTimeout(() => {
if (searchQuery.trim().length >= 2) {
executeSearch(searchQuery);
} else {
setSearchResults([]);
}
}, 300);
return () => clearTimeout(timer);
}, [searchQuery, executeSearch]);
// When a TV series is selected, load its real seasons and episodes from TMDB
const loadTvSeasonsDetails = async (tmdbId: number, seasonNumber: number) => {
try {
const data = await queryTmdb(`/tv/${tmdbId}/season/${seasonNumber}?language=ru-RU`);
if (data?.episodes && data.episodes.length > 0) {
const loadedEpisodes: CinemaEpisode[] = data.episodes.map((ep: any) => ({
id: `tmdb-${tmdbId}-s${seasonNumber}-e${ep.episode_number}`,
number: ep.episode_number,
title: ep.name ? `${ep.name}` : `Серия ${ep.episode_number}`,
thumbnail: ep.still_path
? formatMediaImg(ep.still_path, "w500")
: selectedMedia.poster_url,
duration_minutes: ep.runtime || 45,
description: ep.overview || undefined,
}));
const applyEpisodes = (prev: CinemaMediaItem): CinemaMediaItem => {
if (prev.tmdb_id !== tmdbId) return prev;
const seasonExists = prev.seasons.some((s) => s.season_number === seasonNumber);
const nextSeasons = seasonExists
? prev.seasons.map((s) => (s.season_number === seasonNumber ? { ...s, episodes: loadedEpisodes } : s))
: [...prev.seasons, { season_number: seasonNumber, episodes: loadedEpisodes }];
return {
...prev,
seasons: nextSeasons,
};
};
setSelectedMedia((prev) => applyEpisodes(prev));
setDetailsMedia((prev) => (prev ? applyEpisodes(prev) : prev));
setPlayingMedia((prev) => (prev ? applyEpisodes(prev) : prev));
}
} catch (err) {
console.warn("Failed to load season details:", err);
}
};
const isBookmarked = cinemaState.bookmarks.includes(selectedMedia.id);
// Toggle Bookmark (Saved locally on user's computer in cinema_state.json)
const handleToggleBookmark = async () => {
if (isTauri) {
try {
const nextState = await invoke<CinemaState>("toggle_cinema_bookmark", {
titleId: selectedMedia.id,
});
setCinemaState(nextState);
} catch (err) {
console.error("Error toggling bookmark:", err);
}
} else {
setCinemaState((prev) => ({
...prev,
bookmarks: prev.bookmarks.includes(selectedMedia.id)
? prev.bookmarks.filter((id) => id !== selectedMedia.id)
: [...prev.bookmarks, selectedMedia.id],
}));
}
};
// Get progress for a specific episode
const getEpisodeProgress = (seasonNum: number, episodeNum: number): number => {
const found = cinemaState.history.find(
(h) => h.title_id === selectedMedia.id && h.season === seasonNum && h.episode === episodeNum
);
if (!found || found.duration_seconds <= 0) return 0;
return Math.min(100, Math.round((found.progress_seconds / found.duration_seconds) * 100));
};
const DEMON_SLAYER_SEASON_RELEASES: Record<number, number> = {
1: 8325, // Season 1 (2019, 26 eps)
2: 9093, // Season 2 (Entertainment District, 11 eps)
3: 9406, // Season 3 (Swordsmith Village, 11 eps)
4: 9660, // Season 4 (Hashira Training, 8 eps)
5: 10144, // Season 5 (Infinity Castle, 2025)
};
// Fetch AniLibria direct Full HD HLS stream (.m3u8) without regional blocks
const fetchAniLibriaEpisodeStream = useCallback(
async (media: CinemaMediaItem, seasonNum: number, episodeNum: number): Promise<string | null> => {
setIsLoadingAniLibria(true);
try {
let releaseId: number | null = null;
const tLower = media.title.toLowerCase();
const origLower = (media.original_title || "").toLowerCase();
if (tLower.includes("клинок") || origLower.includes("kimetsu")) {
releaseId = DEMON_SLAYER_SEASON_RELEASES[seasonNum] || 8325;
} else {
// Search AniLibria by title
const q = encodeURIComponent(media.title);
const searchRes = await fetch(`https://anilibria.top/api/v1/app/search/releases?query=${q}`);
if (searchRes.ok) {
const searchData = await searchRes.json();
if (Array.isArray(searchData) && searchData.length > 0) {
releaseId = searchData[0].id;
}
}
if (!releaseId && media.original_title) {
const qOrig = encodeURIComponent(media.original_title);
const searchRes2 = await fetch(`https://anilibria.top/api/v1/app/search/releases?query=${qOrig}`);
if (searchRes2.ok) {
const searchData2 = await searchRes2.json();
if (Array.isArray(searchData2) && searchData2.length > 0) {
releaseId = searchData2[0].id;
}
}
}
}
if (!releaseId) {
return null;
}
// Cache release details
let relData = aniLibriaReleasesCache.current.get(releaseId);
if (!relData) {
const relRes = await fetch(`https://anilibria.top/api/v1/anime/releases/${releaseId}`);
if (relRes.ok) {
relData = await relRes.json();
aniLibriaReleasesCache.current.set(releaseId, relData);
}
}
if (relData?.episodes && relData.episodes.length > 0) {
const targetEp =
relData.episodes.find((e: any) => e.ordinal === episodeNum) ||
relData.episodes[episodeNum - 1] ||
relData.episodes[0];
return targetEp.hls_1080 || targetEp.hls_720 || targetEp.hls_480 || null;
}
return null;
} catch (err) {
console.warn("AniLibria fetch error:", err);
return null;
} finally {
setIsLoadingAniLibria(false);
}
},
[]
);
// Initialize HLS.js on the video element for native in-app streaming
useEffect(() => {
if (selectedMirror !== "anilibria" || !anilibriaStreamUrl || !videoRef.current) {
if (hlsInstanceRef.current) {
hlsInstanceRef.current.destroy();
hlsInstanceRef.current = null;
}
return;
}
const video = videoRef.current;
if (Hls.isSupported()) {
if (hlsInstanceRef.current) {
hlsInstanceRef.current.destroy();
}
const hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 60,
});
hlsInstanceRef.current = hls;
hls.loadSource(anilibriaStreamUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => {});
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
console.warn("HLS fatal error, falling back to VidSrc Pro:", data);
hls.destroy();
hlsInstanceRef.current = null;
setAnilibriaStreamUrl(null);
setSelectedMirror("vidsrc_to");
setRussianDubNotice("⚠️ Поток AniLibria временно недоступен. Переключено на проверенный Full HD плеер VidSrc Pro.");
}
});
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = anilibriaStreamUrl;
video.play().catch(() => {});
video.onerror = () => {
setAnilibriaStreamUrl(null);
setSelectedMirror("vidsrc_to");
setRussianDubNotice("⚠️ Ошибка воспроизведения AniLibria. Переключено на VidSrc Pro.");
};
}
return () => {
if (hlsInstanceRef.current) {
hlsInstanceRef.current.destroy();
hlsInstanceRef.current = null;
}
};
}, [selectedMirror, anilibriaStreamUrl]);
// Start playback for a media item (movie or specific series episode)
const startPlayback = (media: CinemaMediaItem, seasonNum: number = 1, episode?: CinemaEpisode) => {
setSelectedMedia(media);
setPlayingMedia(media);
setPlayingSeasonNumber(seasonNum);
setRussianDubNotice(null);
setAnilibriaStreamUrl(null);
setIsHudVisible(true);
setShowPlayerSettings(false);
setShowPlayerEpisodes(false);
resetHudTimer();
const ep =
episode ||
media.seasons.find((s) => s.season_number === seasonNum)?.episodes[0] ||
media.seasons[0]?.episodes[0] || {
id: `play-${media.id}-s${seasonNum}`,
number: 1,
title: media.media_type === "movie" ? "Полный фильм" : "Серия 1",
thumbnail: media.poster_url,
duration_minutes: media.duration_minutes || (media.media_type === "movie" ? 120 : 45),
};
setPlayingEpisode(ep);
const isAnime =
media.genre?.toLowerCase().includes("аниме") ||
media.genre?.toLowerCase().includes("cartoon") ||
media.title.toLowerCase().includes("клинок") ||
(media.original_title || "").toLowerCase().includes("kimetsu");
// Instantly check known Kinopoisk ID for zero-latency Russian dubbing
const knownKp = media.tmdb_id ? KNOWN_KP_MAP[media.tmdb_id] : null;
if (knownKp) {
setCurrentKpId(knownKp);
} else {
setCurrentKpId(null);
}
setCurrentImdbId(null);
setCurrentAllohaUrl(null);
// Default to verified reliable high-speed 1080p stream:
if (isAnime) {
if (anilibriaStreamUrl) {
setSelectedMirror("anilibria");
} else if (kodikResults.length > 0) {
setSelectedMirror("kodik_ru");
} else {
setSelectedMirror("vidlink_pro");
}
showTvToast("🌸 Сервер: Kodik (Аниме / Озвучка)", "🎙️", 1800);
} else {
// Default to VidLink Pro (ultra reliable, resolves all TMDB movies/series in 1080p)
setSelectedMirror("vidlink_pro");
showTvToast("⚡ Запущен Сервер 1 (Full HD 1080p)", "🎬", 1800);
}
// Resolve Kinopoisk ID & Russian media data asynchronously via Rust backend or web fetch
if (isTauri && media.tmdb_id) {
invoke<RussianMediaInfo>("get_russian_media_info", {
tmdbId: media.tmdb_id,
isTv: media.media_type === "tv",
title: media.title || media.original_title,
})
.then((info) => {
if (info) {
if (info.kp_id) setCurrentKpId(Number(info.kp_id));
if (info.imdb_id) setCurrentImdbId(info.imdb_id);
if (info.iframe_url) {
setCurrentAllohaUrl(info.iframe_url);
if (!isAnime) {
setSelectedMirror("alloha_ru");
}
}
if (!isAnime) {
setRussianDubNotice(
`🇷🇺 Русская озвучка подключена: «${info.russian_title || media.title}» воспроизводится с полным русским дубляжем.`
);
}
}
})
.catch(() => {});
} else if (media.tmdb_id) {
fetch(`https://api.apbugall.org/?token=45e20a5f584becf7a64dffb7174ddf&tmdb=${media.tmdb_id}`)
.then((r) => r.json())
.then((data) => {
if (data?.data) {
const d = Array.isArray(data.data) ? data.data[0] : data.data;
if (d?.id_kp) setCurrentKpId(Number(d.id_kp));
if (d?.id_imdb) setCurrentImdbId(d.id_imdb);
if (d?.iframe) {
setCurrentAllohaUrl(d.iframe);
if (!isAnime) {
setSelectedMirror("alloha_ru");
}
}
}
})
.catch(() => {});
}
// Search Kodik in background for anime or studio translations
searchKodikDubbings(media.original_title || media.title, isAnime);
};
// Card Click Action: Opens rich media details modal (DOES NOT start player immediately)
const handleMediaCardClick = (item: CinemaMediaItem) => {
lastFocusedCardRef.current = document.activeElement as HTMLElement | null;
setSelectedMedia(item);
setDetailsMedia(item);
setSelectedSeason(1);
setIsLoadingDetails(true);
fetchMediaDetails(item);
if (item.media_type === "tv" && item.tmdb_id) {
loadTvSeasonsDetails(item.tmdb_id, 1);
}
playTvBeep(520, 0.035);
};
const focusFirstContentCard = useCallback(() => {
setTimeout(() => {
const target =
document.querySelector<HTMLElement>(".has-cinema-btn-play") ||
document.querySelector<HTMLElement>(".has-cinema-media-card") ||
document.querySelector<HTMLElement>(".has-cinema-grid-card");
if (target) {
target.focus();
target.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
playTvBeep(640, 0.03);
}
}, 50);
}, [playTvBeep]);
// Play button from Details Modal
const handlePlayFromDetails = (seasonNum: number = 1, episode?: CinemaEpisode) => {
if (!detailsMedia) return;
const media = detailsMedia;
startPlayback(media, seasonNum, episode);
};
// Toggle bookmark directly from details modal
const handleToggleBookmarkDetails = async () => {
if (!detailsMedia) return;
const mediaId = detailsMedia.id;
if (isTauri) {
try {
const nextState = await invoke<CinemaState>("toggle_cinema_bookmark", {
titleId: mediaId,
});
setCinemaState(nextState);
} catch (err) {
console.error("Error toggling bookmark:", err);
}
} else {
setCinemaState((prev) => ({
...prev,
bookmarks: prev.bookmarks.includes(mediaId)
? prev.bookmarks.filter((id) => id !== mediaId)
: [...prev.bookmarks, mediaId],
}));
}
playTvBeep(560, 0.03);
};
// Auto-focus the Play button when Details modal opens (for Android TV Remote D-Pad)
useEffect(() => {
if (detailsMedia && !playingEpisode) {
const timer = setTimeout(() => {
const playBtn = document.querySelector<HTMLElement>(".has-cinema-details-btn-play");
if (playBtn) {
playBtn.focus();
}
}, 50);
return () => clearTimeout(timer);
}
}, [detailsMedia, playingEpisode]);
// Play a specific episode
const handlePlayEpisode = (seasonNum: number, episode: CinemaEpisode) => {
startPlayback(selectedMedia, seasonNum, episode);
};
// Play button on Hero Banner
const handleHeroPlay = () => {
if (selectedMedia.is_unreleased) {
showTvToast("Фильм ещё не вышел в цифровой прокат", "⏳", 3000);
return;
}
if (selectedMedia.media_type === "movie") {
startPlayback(selectedMedia, 1);
return;
}
const lastHistory = cinemaState.history.find((h) => h.title_id === selectedMedia.id);
if (lastHistory) {
const seasonObj = selectedMedia.seasons.find((s) => s.season_number === lastHistory.season);
const epObj = seasonObj?.episodes.find((e) => e.number === lastHistory.episode);
if (epObj) {
startPlayback(selectedMedia, lastHistory.season, epObj);
return;
}
}
const s1 = selectedMedia.seasons[0];
if (s1 && s1.episodes.length > 0) {
startPlayback(selectedMedia, 1, s1.episodes[0]);
} else {
startPlayback(selectedMedia, 1);
}
};
// Next / Previous episode navigation right inside the player
const handleNextEpisode = () => {
if (!playingEpisode) return;
const currentMedia = playingMedia || selectedMedia;
const currentSeason =
currentMedia.seasons.find((s) => s.season_number === playingSeasonNumber) || currentMedia.seasons[0];
const nextEp = currentSeason.episodes.find((e) => e.number === playingEpisode.number + 1);
if (nextEp) {
startPlayback(currentMedia, playingSeasonNumber, nextEp);
}
};
const handlePrevEpisode = () => {
if (!playingEpisode || playingEpisode.number <= 1) return;
const currentMedia = playingMedia || selectedMedia;
const currentSeason =
currentMedia.seasons.find((s) => s.season_number === playingSeasonNumber) || currentMedia.seasons[0];
const prevEp = currentSeason.episodes.find((e) => e.number === playingEpisode.number - 1);
if (prevEp) {
startPlayback(currentMedia, playingSeasonNumber, prevEp);
}
};
// Quick 1-click multi-source switcher with automatic fallback
const handleCycleMirror = () => {
const mirrors: BalancerMirror[] = [
"vidlink_pro",
"kodik_ru",
"autoembed",
"vidsrc_pm",
"multiembed",
"vidsrc_to",
];
if (currentAllohaUrl) mirrors.push("alloha_ru");
if (anilibriaStreamUrl) mirrors.push("anilibria");
const currentIndex = mirrors.indexOf(selectedMirror);
const nextIndex = (currentIndex + 1) % mirrors.length;
const nextMirror = mirrors[nextIndex];
setSelectedMirror(nextMirror);
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[nextMirror] || nextMirror}`, "🔄", 2200);
resetHudTimer();
};
// Save playback progress on time update
const handleVideoTimeUpdate = () => {
if (!videoRef.current || !playingEpisode) return;
const currentTime = videoRef.current.currentTime;
const duration = videoRef.current.duration;
const activeMedia = playingMedia || selectedMedia;
if (duration > 0 && 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: currentTime,
duration_seconds: duration,
last_watched: new Date().toISOString(),
};
if (Math.floor(currentTime) % 5 === 0) {
invoke<CinemaState>("update_cinema_progress", { item: payload })
.then((st) => setCinemaState(st))
.catch(() => {});
}
}
};
// Close player and save exact position
const handleClosePlayer = useCallback(() => {
const activeMedia = playingMedia || selectedMedia;
try {
if (isTauri && isFullscreen) {
try {
getCurrentWindow().setFullscreen(false).catch(() => {});
} catch {}
setIsFullscreen(false);
}
} catch {}
if (videoRef.current && playingEpisode && isTauri) {
try {
const currentTime = videoRef.current.currentTime;
const duration = videoRef.current.duration;
if (duration > 0 && activeMedia) {
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: currentTime,
duration_seconds: duration,
last_watched: new Date().toISOString(),
};
invoke<CinemaState>("update_cinema_progress", { item: payload })
.then((st) => setCinemaState(st))
.catch(() => {});
}
} catch {}
}
if (hlsInstanceRef.current) {
try {
hlsInstanceRef.current.destroy();
} catch {}
hlsInstanceRef.current = null;
}
if (hudTimeoutRef.current) {
clearTimeout(hudTimeoutRef.current);
}
setIsHudVisible(true);
setShowPlayerSettings(false);
setShowPlayerEpisodes(false);
// Return user back cleanly
setDetailsMedia(null);
if (activeMedia) {
setSelectedMedia(activeMedia);
}
setPlayingEpisode(null);
setPlayingMedia(null);
setCurrentKpId(null);
setCurrentImdbId(null);
setCurrentAllohaUrl(null);
setKodikResults([]);
setSelectedKodikLink(null);
setRussianDubNotice(null);
setAnilibriaStreamUrl(null);
}, [isTauri, isFullscreen, playingEpisode, playingMedia, selectedMedia, playingSeasonNumber]);
// D-Pad Spatial Navigation & TV Remote Key Handler
const handleRemoteKeyDown = useCallback(
(e: KeyboardEvent) => {
// Auto-activate TV mode immediately on ANY remote control button press
const isRemoteKey =
[19, 20, 21, 22, 23, 4, 82, 85, 179].includes(e.keyCode) ||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key);
if (isRemoteKey && !isTvMode) {
setIsTvMode(true);
localStorage.setItem("has_tv_mode", "true");
}
const isInput =
document.activeElement instanceof HTMLInputElement ||
document.activeElement instanceof HTMLTextAreaElement;
// Android TV / Remote key mappings
const isBack =
e.key === "Escape" ||
e.key === "GoBack" ||
e.key === "BrowserBack" ||
e.keyCode === 4 ||
e.keyCode === 27;
const isPlayPause =
e.key === "MediaPlayPause" || e.keyCode === 85 || e.keyCode === 179;
const isFastForward = e.key === "MediaFastForward" || e.keyCode === 90;
const isRewind = e.key === "MediaRewind" || e.keyCode === 89;
// Remote Menu key (Android TV remote Menu button / ContextMenu)
if (e.key === "Menu" || e.keyCode === 82 || e.key === "ContextMenu") {
e.preventDefault();
setIsTvNavOpen((prev) => {
const next = !prev;
if (next) {
setTimeout(() => {
const navBtn =
document.querySelector<HTMLElement>(".has-cinema-tv-nav-item.active") ||
document.querySelector<HTMLElement>(".has-cinema-tv-nav-item");
if (navBtn) {
navBtn.focus();
playTvBeep(640, 0.03);
}
}, 30);
}
return next;
});
return;
}
// F10 key toggles TV mode fallback
if (e.key === "F10") {
e.preventDefault();
toggleTvMode();
return;
}
// 1. In-player remote control
if (playingEpisode !== null) {
resetHudTimer();
if (isBack) {
e.preventDefault();
if (showPlayerSettings) {
setShowPlayerSettings(false);
return;
}
if (showPlayerEpisodes) {
setShowPlayerEpisodes(false);
return;
}
handleClosePlayer();
showTvToast("Плеер закрыт", "✕", 1500);
playTvBeep(400, 0.05);
return;
}
// Pressing ArrowUp / Menu / Info on TV remote reveals HUD and focuses "← Назад" button
if (
e.key === "ArrowUp" ||
e.keyCode === 19 ||
e.keyCode === 38 ||
e.key === "Info"
) {
e.preventDefault();
setIsHudVisible(true);
resetHudTimer();
setTimeout(() => {
const backBtn = document.querySelector<HTMLElement>(".has-cinema-player-btn-back");
if (backBtn) {
backBtn.focus();
playTvBeep(640, 0.03);
}
}, 40);
return;
}
// Pressing OK / Enter when HUD is idle wakes up the full HUD
if ((e.key === "Enter" || e.keyCode === 13) && !isHudVisible && !videoRef.current) {
e.preventDefault();
setIsHudVisible(true);
resetHudTimer();
setTimeout(() => {
const backBtn = document.querySelector<HTMLElement>(".has-cinema-player-btn-back");
if (backBtn) backBtn.focus();
}, 40);
return;
}
if (isPlayPause || (e.code === "Space" && !isInput)) {
e.preventDefault();
if (videoRef.current) {
if (videoRef.current.paused) {
videoRef.current.play().catch(() => {});
showTvToast("Воспроизведение", "▶", 1200);
} else {
videoRef.current.pause();
showTvToast("Пауза", "⏸", 1200);
}
playTvBeep(560, 0.04);
}
return;
}
if (
isFastForward ||
(e.key === "ArrowRight" &&
!isInput &&
!document.activeElement?.closest(".has-cinema-player-modal"))
) {
if (videoRef.current) {
e.preventDefault();
videoRef.current.currentTime = Math.min(
videoRef.current.duration || 999999,
videoRef.current.currentTime + 10
);
showTvToast("+10 сек", "⏩", 1000);
playTvBeep(520, 0.03);
return;
}
}
if (
isRewind ||
(e.key === "ArrowLeft" &&
!isInput &&
!document.activeElement?.closest(".has-cinema-player-modal"))
) {
if (videoRef.current) {
e.preventDefault();
videoRef.current.currentTime = Math.max(
0,
videoRef.current.currentTime - 10
);
showTvToast("-10 сек", "⏪", 1000);
playTvBeep(480, 0.03);
return;
}
}
}
// 2. Global Back button outside player
if (isBack) {
if (isTvNavOpen) {
e.preventDefault();
setIsTvNavOpen(false);
setTimeout(() => {
if (lastFocusedCardRef.current && document.contains(lastFocusedCardRef.current)) {
lastFocusedCardRef.current.focus();
lastFocusedCardRef.current.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "center",
});
playTvBeep(640, 0.03);
} else {
focusFirstContentCard();
}
}, 40);
return;
}
if (showTrailerModal) {
e.preventDefault();
setShowTrailerModal(false);
showTvToast("Трейлер закрыт", "✕", 1000);
playTvBeep(450, 0.04);
return;
}
if (detailsMedia) {
e.preventDefault();
setDetailsMedia(null);
showTvToast("Возврат к списку", "↩", 1000);
playTvBeep(450, 0.04);
setTimeout(() => {
if (lastFocusedCardRef.current && document.contains(lastFocusedCardRef.current)) {
lastFocusedCardRef.current.focus();
lastFocusedCardRef.current.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "center",
});
}
}, 40);
return;
}
if (searchQuery.trim().length > 0) {
e.preventDefault();
setSearchQuery("");
showTvToast("Поиск очищен", "🔍", 1200);
playTvBeep(450, 0.04);
focusFirstContentCard();
return;
}
if (activeTab !== "home") {
e.preventDefault();
setActiveTab("home");
showTvToast("Каталог", "🏠", 1200);
playTvBeep(450, 0.04);
focusFirstContentCard();
return;
}
// At home root on TV, Back button summons the navigation drawer
if (isTvMode && !isTvNavOpen) {
e.preventDefault();
lastFocusedCardRef.current = document.activeElement as HTMLElement | null;
setIsTvNavOpen(true);
setTimeout(() => {
const targetNav =
document.querySelector<HTMLElement>(".has-cinema-tv-nav-item.active") ||
document.querySelector<HTMLElement>(".has-cinema-tv-nav-item");
if (targetNav) {
targetNav.focus();
playTvBeep(640, 0.03);
}
}, 40);
return;
}
}
// 3. D-Pad Directional Arrows Navigation
const isArrow =
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key) ||
[19, 20, 21, 22, 37, 38, 39, 40].includes(e.keyCode);
if (isArrow) {
if (isInput && e.key !== "ArrowDown") {
return; // Let user type and navigate within text input
}
let direction: "up" | "down" | "left" | "right" = "down";
if (e.key === "ArrowUp" || e.keyCode === 19 || e.keyCode === 38)
direction = "up";
else if (
e.key === "ArrowDown" ||
e.keyCode === 20 ||
e.keyCode === 40
)
direction = "down";
else if (
e.key === "ArrowLeft" ||
e.keyCode === 21 ||
e.keyCode === 37
)
direction = "left";
else if (
e.key === "ArrowRight" ||
e.keyCode === 22 ||
e.keyCode === 39
)
direction = "right";
const detailsEl = document.querySelector<HTMLElement>(".has-cinema-details-modal");
const playerEl = document.querySelector<HTMLElement>(".has-cinema-player-modal");
const trailerEl = document.querySelector<HTMLElement>(".has-cinema-trailer-modal");
const settingsSheet = document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet");
const episodesSheet = document.querySelector<HTMLElement>(".has-cinema-player-episodes-sheet");
// If focus is currently inside the TV Navigation drawer
const currentActive = document.activeElement as HTMLElement | null;
if (currentActive && currentActive.closest(".has-cinema-tv-nav-drawer")) {
if (direction === "right") {
e.preventDefault();
setIsTvNavOpen(false);
setTimeout(() => {
if (lastFocusedCardRef.current && document.contains(lastFocusedCardRef.current)) {
lastFocusedCardRef.current.focus();
lastFocusedCardRef.current.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "center",
});
playTvBeep(640, 0.03);
} else {
focusFirstContentCard();
}
}, 40);
return;
}
}
let focusables = Array.from(
document.querySelectorAll<HTMLElement>(
'[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-player-hud-btn, .has-cinema-player-btn-back, .has-cinema-drawer-option-btn, .has-cinema-drawer-chip, .has-cinema-drawer-ep-btn, .has-cinema-drawer-close-btn, .has-cinema-drawer-btn-primary, .has-cinema-drawer-btn-secondary, .has-cinema-player-pill-nav, .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'
)
).filter((el) => {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return false;
const style = window.getComputedStyle(el);
return (
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0"
);
});
// TV navigation drawer focus isolation
if (isTvNavOpen) {
const drawerEl = document.querySelector<HTMLElement>(".has-cinema-tv-nav-drawer");
if (drawerEl) {
focusables = focusables.filter((el) => drawerEl.contains(el));
}
} else {
// When drawer is closed, NEVER include drawer items in focusable pool
focusables = focusables.filter((el) => !el.closest(".has-cinema-tv-nav-drawer"));
}
// In TV mode, top header is hidden, so never include header elements
if (isTvMode) {
focusables = focusables.filter((el) => !el.closest(".has-cinema-header"));
}
// Trap focus inside modal or drawer if open
if (settingsSheet) {
focusables = focusables.filter((el) => settingsSheet.contains(el));
} else if (episodesSheet) {
focusables = focusables.filter((el) => episodesSheet.contains(el));
} else if (trailerEl) {
focusables = focusables.filter((el) => trailerEl.contains(el));
} else if (playerEl) {
focusables = focusables.filter((el) => playerEl.contains(el));
} else if (detailsEl) {
focusables = focusables.filter((el) => detailsEl.contains(el));
}
if (focusables.length === 0) return;
const current = document.activeElement as HTMLElement | null;
if (!current || !focusables.includes(current)) {
const initial =
focusables.find((el) => el.classList.contains("has-cinema-btn-play")) ||
focusables.find((el) => el.classList.contains("has-cinema-nav-item")) ||
focusables[0];
if (initial) {
e.preventDefault();
initial.focus();
initial.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "center",
});
playTvBeep(580, 0.03);
}
return;
}
const curRect = current.getBoundingClientRect();
const curCx = curRect.left + curRect.width / 2;
const curCy = curRect.top + curRect.height / 2;
let bestEl: HTMLElement | null = null;
let minScore = Infinity;
for (const candidate of focusables) {
if (candidate === current) continue;
const cRect = candidate.getBoundingClientRect();
const cCx = cRect.left + cRect.width / 2;
const cCy = cRect.top + cRect.height / 2;
const dx = cCx - curCx;
const dy = cCy - curCy;
if (direction === "up" && dy >= -8) continue;
if (direction === "down" && dy <= 8) continue;
if (direction === "left" && dx >= -8) continue;
if (direction === "right" && dx <= 8) continue;
let score = Infinity;
if (direction === "up" || direction === "down") {
score = Math.abs(dy) + Math.abs(dx) * 1.7;
} else {
score = Math.abs(dx) + Math.abs(dy) * 2.4;
}
if (score < minScore) {
minScore = score;
bestEl = candidate;
}
}
if (bestEl) {
e.preventDefault();
bestEl.focus();
bestEl.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "center",
});
playTvBeep(640, 0.025);
} else {
// If no element found in this direction outside modals:
// When moving LEFT from the leftmost card, summon the TV Navigation Drawer!
if (!detailsEl && !playerEl && !trailerEl && !settingsSheet && !episodesSheet) {
if (direction === "left") {
e.preventDefault();
lastFocusedCardRef.current = current;
setIsTvNavOpen(true);
setTimeout(() => {
const targetNav =
document.querySelector<HTMLElement>(".has-cinema-tv-nav-item.active") ||
document.querySelector<HTMLElement>(".has-cinema-tv-nav-item");
if (targetNav) {
targetNav.focus();
playTvBeep(640, 0.03);
}
}, 40);
}
}
}
}
},
[
isTvMode,
isTvNavOpen,
playingEpisode,
detailsMedia,
showTrailerModal,
showPlayerSettings,
showPlayerEpisodes,
searchQuery,
activeTab,
handleClosePlayer,
showTvToast,
playTvBeep,
toggleTvMode,
resetHudTimer,
focusFirstContentCard,
]
);
useEffect(() => {
window.addEventListener("keydown", handleRemoteKeyDown);
return () => {
window.removeEventListener("keydown", handleRemoteKeyDown);
};
}, [handleRemoteKeyDown]);
// Handle Android TV hardware back & browser popstate to guarantee return to movie details card
useEffect(() => {
if (playingEpisode !== null) {
window.history.pushState({ cinemaPlayer: true }, "");
const handlePopState = (e: PopStateEvent) => {
e.preventDefault();
if (showPlayerSettings) {
setShowPlayerSettings(false);
window.history.pushState({ cinemaPlayer: true }, "");
return;
}
if (showPlayerEpisodes) {
setShowPlayerEpisodes(false);
window.history.pushState({ cinemaPlayer: true }, "");
return;
}
handleClosePlayer();
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}
}, [playingEpisode, showPlayerSettings, showPlayerEpisodes, handleClosePlayer]);
// Handle Android TV hardware back & browser popstate for details modal
useEffect(() => {
if (detailsMedia !== null && playingEpisode === null) {
window.history.pushState({ cinemaDetails: true }, "");
const handlePopState = (e: PopStateEvent) => {
e.preventDefault();
if (showTrailerModal) {
setShowTrailerModal(false);
window.history.pushState({ cinemaDetails: true }, "");
return;
}
setDetailsMedia(null);
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}
}, [detailsMedia, playingEpisode, showTrailerModal]);
// Toggle Desktop Fullscreen mode
const handleToggleFullscreen = async () => {
if (isTauri) {
try {
const win = getCurrentWindow();
const current = await win.isFullscreen();
await win.setFullscreen(!current);
setIsFullscreen(!current);
} catch {
setIsFullscreen((prev) => !prev);
}
} else {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
setIsFullscreen(true);
} else {
document.exitFullscreen().catch(() => {});
setIsFullscreen(false);
}
}
};
// Switch mirror manually
const handleSelectMirror = async (mirrorId: BalancerMirror) => {
setSelectedMirror(mirrorId);
if (mirrorId === "anilibria") {
const activeMedia = playingMedia || selectedMedia;
const epNum = playingEpisode?.number || 1;
const stream = await fetchAniLibriaEpisodeStream(activeMedia, playingSeasonNumber, epNum);
if (stream) {
setAnilibriaStreamUrl(stream);
setRussianDubNotice("🌸 Подключен поток AniLibria Full HD (Studio Band).");
}
} else {
setAnilibriaStreamUrl(null);
}
};
// Title matching helper to eliminate unrelated fuzzy search results
const isMatchingTitle = (target: string, candidate: string): boolean => {
if (!target || !candidate) return false;
const cleanTarget = target.toLowerCase().replace(/[^a-zа-я0-9\s]/gi, " ").trim();
const cleanCandidate = candidate.toLowerCase().replace(/[^a-zа-я0-9\s]/gi, " ").trim();
if (cleanTarget === cleanCandidate) return true;
const targetWords = cleanTarget.split(/\s+/).filter((w) => w.length > 2);
if (targetWords.length === 0) return false;
const minWordsToCheck = Math.min(2, targetWords.length);
return targetWords.slice(0, minWordsToCheck).every((w) => cleanCandidate.includes(w));
};
// Search Kodik API for Russian studio voiceovers (LostFilm, HDRezka, Studio Band, AniLibria, AniDUB, Дубляж)
const searchKodikDubbings = async (title: string, autoSwitch: boolean = false) => {
setIsSearchingKodik(true);
setRussianDubNotice(null);
const activeMedia = playingMedia || selectedMedia;
const targetTitle = activeMedia.title;
const targetOrig = activeMedia.original_title || title;
try {
let rawResults: KodikItem[] = [];
// 1. Try search by original title first
if (targetOrig) {
const q = encodeURIComponent(targetOrig);
const res = await fetch(`https://kodik-api.com/search?token=41dd95f84c21719b09d6c71182237a25&title=${q}`);
if (res.ok) {
const data = await res.json();
if (Array.isArray(data.results)) {
rawResults = data.results;
}
}
}
// 2. Try search by Russian title if needed
if (rawResults.length === 0 && targetTitle) {
const qRu = encodeURIComponent(targetTitle);
const resRu = await fetch(`https://kodik-api.com/search?token=41dd95f84c21719b09d6c71182237a25&title=${qRu}`);
if (resRu.ok) {
const dataRu = await resRu.json();
if (Array.isArray(dataRu.results)) {
rawResults = dataRu.results;
}
}
}
// 3. Strict filtering: ONLY include results that actually match this title!
const matchingResults = rawResults.filter((r) => {
const matchRu = isMatchingTitle(targetTitle, r.title);
const matchOrig = isMatchingTitle(targetOrig, r.title_orig || r.title);
return matchRu || matchOrig;
});
// 4. Deduplicate by translation studio name
const uniqueStudios = new Map<string, KodikItem>();
for (const item of matchingResults) {
const studioName = item.translation?.title?.trim() || "Озвучка";
if (!uniqueStudios.has(studioName)) {
uniqueStudios.set(studioName, item);
}
}
const finalDubbings = Array.from(uniqueStudios.values());
if (finalDubbings.length > 0) {
setKodikResults(finalDubbings);
setSelectedKodikLink(finalDubbings[0].link);
if (autoSwitch) {
setSelectedMirror("kodik_ru");
setRussianDubNotice(`🇷🇺 Найдено ${finalDubbings.length} студий русской озвучки для «${targetTitle}».`);
}
} else {
setKodikResults([]);
setSelectedKodikLink(null);
if (autoSwitch) {
setSelectedMirror("vidlink_pro");
setRussianDubNotice("🇷🇺 Для данного фильма русская аудиодорожка встроена в плеер VidLink Pro (нажмите на шестеренку ⚙️ или значок аудио в правом нижнем углу плеера).");
}
}
} catch {
setKodikResults([]);
setSelectedKodikLink(null);
if (autoSwitch) {
setSelectedMirror("vidlink_pro");
setRussianDubNotice("🌍 Переключено на основной плеер VidLink Pro.");
}
} finally {
setIsSearchingKodik(false);
}
};
const currentSeasonData: CinemaSeason =
selectedMedia.seasons.find((s) => s.season_number === selectedSeason) || selectedMedia.seasons[0];
// Balancer embed URL with multiple verified mirror selection
const getBalancerEmbedUrl = (): string => {
const media = playingMedia || selectedMedia;
const tmdb = media.tmdb_id;
const isTv = media.media_type === "tv";
const epNum = playingEpisode?.number || 1;
const sNum = playingSeasonNumber || 1;
const kpId = currentKpId || (tmdb ? KNOWN_KP_MAP[tmdb] : null);
const imdbId = currentImdbId;
switch (selectedMirror) {
case "vidlink_pro":
return isTv
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?primaryColor=6366f1`
: `https://vidlink.pro/movie/${tmdb || 693134}?primaryColor=6366f1`;
case "kodik_ru":
if (selectedKodikLink) {
return selectedKodikLink.startsWith("//") ? `https:${selectedKodikLink}` : selectedKodikLink;
}
if (kodikResults.length > 0 && kodikResults[0]?.link) {
const l = kodikResults[0].link;
return l.startsWith("//") ? `https:${l}` : l;
}
return isTv
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?primaryColor=6366f1`
: `https://vidlink.pro/movie/${tmdb || 693134}?primaryColor=6366f1`;
case "autoembed":
return isTv
? `https://player.autoembed.co/embed/tv/${tmdb || 693134}/${sNum}/${epNum}`
: `https://player.autoembed.co/embed/movie/${tmdb || 693134}`;
case "vidsrc_pm":
return isTv
? `https://vidsrc.pm/embed/tv/${tmdb || 693134}/${sNum}/${epNum}`
: `https://vidsrc.pm/embed/movie/${tmdb || 693134}`;
case "multiembed":
return isTv
? `https://multiembed.mov/?video_id=${tmdb || 693134}&tmdb=1&s=${sNum}&e=${epNum}`
: `https://multiembed.mov/?video_id=${tmdb || 693134}&tmdb=1`;
case "vidsrc_to":
return isTv
? `https://vidsrc.to/embed/tv/${tmdb || 693134}/${sNum}/${epNum}`
: `https://vidsrc.to/embed/movie/${tmdb || 693134}`;
case "alloha_ru":
if (currentAllohaUrl) {
return currentAllohaUrl.startsWith("//") ? `https:${currentAllohaUrl}` : currentAllohaUrl;
}
if (kpId) {
return `https://theatre.stravers.live/?kp=${kpId}`;
}
if (imdbId) {
return `https://theatre.stravers.live/?imdb=${imdbId}`;
}
return isTv
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?primaryColor=6366f1`
: `https://vidlink.pro/movie/${tmdb || 693134}?primaryColor=6366f1`;
default:
return isTv
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?primaryColor=6366f1`
: `https://vidlink.pro/movie/${tmdb || 693134}?primaryColor=6366f1`;
}
};
const balancerEmbedUrl = getBalancerEmbedUrl();
// Combine live TMDB catalog with initial fallbacks
const allMoviesList = popularMovies.length > 0 ? popularMovies : INITIAL_LIBRARY.filter((i) => i.media_type === "movie");
const allSeriesList = popularSeries.length > 0 ? popularSeries : INITIAL_LIBRARY.filter((i) => i.media_type === "tv");
const allAnimeList = popularAnime.length > 0 ? popularAnime : INITIAL_LIBRARY.filter((i) => i.genre.includes("Аниме") || i.genre.includes("Cartoon"));
const allTrending = trendingAll.length > 0 ? trendingAll : INITIAL_LIBRARY;
const bookmarkedItems = [
...trendingAll,
...popularMovies,
...popularSeries,
...popularAnime,
...INITIAL_LIBRARY,
].filter((item, index, self) => cinemaState.bookmarks.includes(item.id) && self.findIndex((s) => s.id === item.id) === index);
return (
<div className={`has-cinema-container ${isTvMode ? "has-tv-mode" : ""}`} ref={topRef}>
{/* Semi-transparent Backdrop for TV Navigation Drawer */}
{isTvMode && isTvNavOpen && (
<div
className="has-cinema-tv-nav-backdrop"
onClick={() => setIsTvNavOpen(false)}
/>
)}
{/* ========================================================
ANDROID TV LEANBACK NAVIGATION DRAWER (Zona TV Experience)
(Summoned only by moving Left from the leftmost card)
======================================================== */}
{isTvMode && (
<aside
className={`has-cinema-tv-nav-drawer ${isTvNavOpen ? "open" : ""}`}
aria-label="Навигация для ТВ"
>
<div className="has-cinema-tv-nav-header">
<div className="has-cinema-tv-nav-logo">
<span className="has-cinema-tv-nav-logo-badge">HAS</span>
<span className="has-cinema-tv-nav-logo-text">Кинотеатр</span>
</div>
</div>
<div className="has-cinema-tv-nav-list">
<button
type="button"
className={`has-cinema-tv-nav-item ${activeTab === "search" || searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("search");
setIsTvNavOpen(false);
setTimeout(() => {
const s = document.querySelector<HTMLInputElement>(".has-cinema-tv-search-input-field") || document.querySelector<HTMLInputElement>(".has-cinema-search-input");
if (s) s.focus();
}, 60);
}}
data-tv-focusable="true"
title="Поиск"
>
<span className="has-cinema-tv-nav-icon">🔍</span>
<span className="has-cinema-tv-nav-label">Поиск</span>
</button>
<button
type="button"
className={`has-cinema-tv-nav-item ${activeTab === "home" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("home");
setSearchQuery("");
setIsTvNavOpen(false);
setTimeout(() => focusFirstContentCard(), 60);
}}
data-tv-focusable="true"
title="Каталог"
>
<span className="has-cinema-tv-nav-icon">🏠</span>
<span className="has-cinema-tv-nav-label">Каталог</span>
</button>
<button
type="button"
className={`has-cinema-tv-nav-item ${activeTab === "movies" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("movies");
setSearchQuery("");
setIsTvNavOpen(false);
setTimeout(() => focusFirstContentCard(), 60);
}}
data-tv-focusable="true"
title="Фильмы"
>
<span className="has-cinema-tv-nav-icon">🎬</span>
<span className="has-cinema-tv-nav-label">Фильмы</span>
</button>
<button
type="button"
className={`has-cinema-tv-nav-item ${activeTab === "series" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("series");
setSearchQuery("");
setIsTvNavOpen(false);
setTimeout(() => focusFirstContentCard(), 60);
}}
data-tv-focusable="true"
title="Сериалы"
>
<span className="has-cinema-tv-nav-icon">📺</span>
<span className="has-cinema-tv-nav-label">Сериалы</span>
</button>
<button
type="button"
className={`has-cinema-tv-nav-item ${activeTab === "anime" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("anime");
setSearchQuery("");
setIsTvNavOpen(false);
setTimeout(() => focusFirstContentCard(), 60);
}}
data-tv-focusable="true"
title="Аниме"
>
<span className="has-cinema-tv-nav-icon">🌸</span>
<span className="has-cinema-tv-nav-label">Аниме</span>
</button>
<button
type="button"
className={`has-cinema-tv-nav-item ${activeTab === "bookmarks" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("bookmarks");
setSearchQuery("");
setIsTvNavOpen(false);
setTimeout(() => focusFirstContentCard(), 60);
}}
data-tv-focusable="true"
title="Мой список"
>
<span className="has-cinema-tv-nav-icon">⭐</span>
<span className="has-cinema-tv-nav-label">
Мой список {cinemaState.bookmarks.length > 0 && `(${cinemaState.bookmarks.length})`}
</span>
</button>
{onNavigateTab && (
<button
type="button"
className="has-cinema-tv-nav-item"
onClick={() => {
setIsTvNavOpen(false);
onNavigateTab("home");
}}
data-tv-focusable="true"
title="Панель сервера"
>
<span className="has-cinema-tv-nav-icon">🖥️</span>
<span className="has-cinema-tv-nav-label">Панель сервера</span>
</button>
)}
{onLogout && (
<button
type="button"
className="has-cinema-tv-nav-item logout"
onClick={() => {
setIsTvNavOpen(false);
onLogout();
}}
data-tv-focusable="true"
title="Выйти"
>
<span className="has-cinema-tv-nav-icon">🚪</span>
<span className="has-cinema-tv-nav-label">Выйти</span>
</button>
)}
</div>
<div className="has-cinema-tv-nav-footer">
<span>▶ Вправо или Назад — закрыть меню</span>
</div>
</aside>
)}
{/* ========================================================
1. TOP NAVIGATION BAR (For Desktop / Mobile only; hidden in TV Mode)
======================================================== */}
<header className="has-cinema-header">
<nav className="has-cinema-nav-links">
<button
type="button"
className={`has-cinema-nav-item ${activeTab === "home" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("home");
setSearchQuery("");
}}
data-tv-focusable="true"
>
Каталог
</button>
<button
type="button"
className={`has-cinema-nav-item ${activeTab === "movies" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("movies");
setSearchQuery("");
}}
data-tv-focusable="true"
>
Фильмы
</button>
<button
type="button"
className={`has-cinema-nav-item ${activeTab === "series" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("series");
setSearchQuery("");
}}
data-tv-focusable="true"
>
Сериалы
</button>
<button
type="button"
className={`has-cinema-nav-item ${activeTab === "anime" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("anime");
setSearchQuery("");
}}
data-tv-focusable="true"
>
Аниме
</button>
<button
type="button"
className={`has-cinema-nav-item ${activeTab === "bookmarks" && !searchQuery ? "active" : ""}`}
onClick={() => {
setActiveTab("bookmarks");
setSearchQuery("");
}}
data-tv-focusable="true"
>
Мой список {cinemaState.bookmarks.length > 0 && `(${cinemaState.bookmarks.length})`}
</button>
</nav>
<div className="has-cinema-header-right">
{/* Universal Search Box */}
<div className="has-cinema-search-wrapper">
<div className="has-cinema-search-box">
<svg width={15} height={15} viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="11" cy="11" r="8" strokeWidth={2} />
<line x1="21" y1="21" x2="16.65" y2="16.65" strokeWidth={2} strokeLinecap="round" />
</svg>
<input
type="text"
placeholder="Поиск любых фильмов и сериалов..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="has-cinema-search-input"
data-tv-focusable="true"
/>
{isSearching && <span className="has-cinema-search-spinner" />}
{searchQuery && (
<button
type="button"
className="has-cinema-search-clear-btn"
onClick={() => setSearchQuery("")}
data-tv-focusable="true"
>
✕
</button>
)}
</div>
</div>
{/* User profile avatar */}
<div className="has-cinema-user-avatar" title={currentUser.email}>
{currentUser.avatar ? (
<img src={currentUser.avatar} alt="User" className="has-cinema-user-avatar-img" />
) : (
<span className="has-cinema-user-avatar-text">
{(currentUser.username || currentUser.email || "U").slice(0, 2).toUpperCase()}
</span>
)}
</div>
</div>
</header>
{/* ========================================================
TV SEARCH VIEW (When activeTab === "search" in TV Mode)
======================================================== */}
{isTvMode && activeTab === "search" && (
<section className="has-cinema-tv-search-page">
<div className="has-cinema-tv-search-box-wrap">
<span className="has-cinema-tv-search-lens">🔍</span>
<input
type="text"
className="has-cinema-tv-search-input-field"
placeholder="Введите название фильма, сериала или аниме..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
data-tv-focusable="true"
autoFocus
/>
{searchQuery && (
<button
type="button"
className="has-cinema-tv-search-clear-btn"
onClick={() => setSearchQuery("")}
data-tv-focusable="true"
title="Очистить"
>
✕
</button>
)}
</div>
{searchQuery.trim().length === 0 && (
<div className="has-cinema-tv-search-hint">
<span>💡 Введите название с клавиатуры или пульта. Нажмите Влево с края для вызова меню.</span>
</div>
)}
</section>
)}
{/* ========================================================
SEARCH RESULTS FULL GRID VIEW (When Searching)
======================================================== */}
{searchQuery.trim().length >= 2 ? (
<section className="has-cinema-search-page-section">
<div className="has-cinema-search-header-row">
<h1 className="has-cinema-search-page-title">
Результаты поиска по запросу «{searchQuery}»
</h1>
<span className="has-cinema-search-count-badge">
Найдено: {searchResults.length}
</span>
</div>
{searchResults.length === 0 ? (
<div className="has-cinema-empty-bookmarks">
<span>{isSearching ? "Поиск в международной базе фильмов..." : "Ничего не найдено по данному запросу."}</span>
<span className="has-cinema-empty-sub">
Попробуйте ввести оригинальное название на английском или уточнить запрос.
</span>
</div>
) : (
<div className="has-cinema-grid-cards">
{searchResults.map((item) => (
<div
key={item.id}
className="has-cinema-grid-card"
onClick={() => handleMediaCardClick(item)}
data-tv-focusable="true"
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) {
e.preventDefault();
handleMediaCardClick(item);
}
}}
>
<div className="has-cinema-media-thumb-box">
<img src={item.poster_url} alt={item.title} className="has-cinema-media-thumb" loading="lazy" />
<div className="has-cinema-media-badge-rating">★ {item.rating}</div>
<div className="has-cinema-media-play-overlay">
<div className="has-cinema-media-play-circle">▶</div>
</div>
</div>
<div className="has-cinema-media-meta">
<span className="has-cinema-media-title">{item.title}</span>
<span className="has-cinema-media-sub">
{item.release_year} • {getMediaCategoryLabel(item)}
</span>
</div>
</div>
))}
</div>
)}
</section>
) : (
<>
{/* ========================================================
2. HERO FEATURED BANNER
======================================================== */}
<section className="has-cinema-hero">
<div className="has-cinema-hero-bg-wrapper">
<img
src={selectedMedia.backdrop_url}
alt={selectedMedia.title}
className="has-cinema-hero-bg-img"
/>
<div className="has-cinema-hero-gradient-overlay" />
<div className="has-cinema-hero-bottom-fade" />
</div>
<div className="has-cinema-hero-content">
{/* Badge */}
<div className="has-cinema-category-pill">
{getMediaCategoryLabel(selectedMedia)}
</div>
{/* Title */}
<h1 className="has-cinema-hero-title">{selectedMedia.title}</h1>
{/* Metadata Row */}
<div className="has-cinema-meta-row">
<span className="has-cinema-meta-year">{selectedMedia.release_year}</span>
{selectedMedia.media_type !== "movie" && selectedMedia.seasons_count && (
<span className="has-cinema-meta-seasons">
{selectedMedia.seasons_count}{" "}
{selectedMedia.seasons_count === 1
? "сезон"
: selectedMedia.seasons_count < 5
? "сезона"
: "сезонов"}
</span>
)}
{selectedMedia.duration_minutes && (
<span className="has-cinema-meta-duration">{selectedMedia.duration_minutes} мин</span>
)}
<span className="has-cinema-meta-genre-pill">{selectedMedia.genre}</span>
<div className="has-cinema-meta-stars">
<span className="has-cinema-stars-gold">★★★★★</span>
<span className="has-cinema-rating-num">{selectedMedia.rating}</span>
</div>
</div>
{/* Synopsis */}
<p className="has-cinema-hero-synopsis">{selectedMedia.synopsis}</p>
{/* Action Buttons: Play + Bookmark + Info */}
<div className="has-cinema-hero-actions">
<button
type="button"
className="has-cinema-btn-play"
onClick={handleHeroPlay}
data-tv-focusable="true"
>
<svg width={18} height={18} viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
<span>
{selectedMedia.media_type === "movie" ? "Смотреть фильм" : "Смотреть S1 E1"}
</span>
</button>
<button
type="button"
className={`has-cinema-btn-circle ${isBookmarked ? "bookmarked" : ""}`}
onClick={handleToggleBookmark}
title={isBookmarked ? "Удалить из списка" : "Добавить в список"}
data-tv-focusable="true"
>
{isBookmarked ? "✓" : "+"}
</button>
<button
type="button"
className="has-cinema-btn-info-wide"
onClick={() => handleMediaCardClick(selectedMedia)}
title="Подробная информация о тайтле (актеры, бюджет, рейтинг)"
data-tv-focusable="true"
>
<svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
<span>О фильме</span>
</button>
<button
type="button"
className="has-cinema-btn-circle"
title="Информация о тайтле"
onClick={() => handleMediaCardClick(selectedMedia)}
data-tv-focusable="true"
>
ⓘ
</button>
</div>
</div>
</section>
{/* ========================================================
3. SEASONS TABS & EPISODES (For TV Series & Anime)
======================================================== */}
{selectedMedia.media_type !== "movie" && selectedMedia.seasons.length > 0 && (
<section className="has-cinema-seasons-section">
<div className="has-cinema-season-tabs">
{selectedMedia.seasons.map((season) => (
<button
key={season.season_number}
type="button"
className={`has-cinema-season-tab ${
selectedSeason === season.season_number ? "active" : ""
}`}
onClick={() => {
setSelectedSeason(season.season_number);
if (selectedMedia.tmdb_id) {
loadTvSeasonsDetails(selectedMedia.tmdb_id, season.season_number);
}
}}
data-tv-focusable="true"
>
Сезон {season.season_number}
</button>
))}
</div>
{/* Horizontal Carousel of Episodes */}
<div className="has-cinema-episodes-carousel">
{currentSeasonData.episodes.map((ep) => {
const progress = getEpisodeProgress(selectedSeason, ep.number);
return (
<div
key={ep.id}
className="has-cinema-episode-card"
onClick={() => handlePlayEpisode(selectedSeason, ep)}
data-tv-focusable="true"
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) {
e.preventDefault();
handlePlayEpisode(selectedSeason, ep);
}
}}
title={`Смотреть: Сезон ${selectedSeason}, Серия ${ep.number} — ${ep.title}`}
>
<div className="has-cinema-ep-thumb-box">
<img src={ep.thumbnail} alt={ep.title} className="has-cinema-ep-thumb" />
<div className="has-cinema-ep-card-badge">S</div>
<div className="has-cinema-ep-hover-play">
<svg width={32} height={32} viewBox="0 0 24 24" fill="white">
<circle
cx="12"
cy="12"
r="11"
fill="rgba(0,0,0,0.5)"
stroke="white"
strokeWidth={1.5}
/>
<path d="M10 8l6 4-6 4V8z" />
</svg>
</div>
</div>
<div className="has-cinema-ep-info">
<div className="has-cinema-ep-title">
{ep.title.startsWith("Серия") ? ep.title : `${ep.number}. ${ep.title}`}
</div>
{ep.duration_minutes && (
<div className="has-cinema-ep-duration">{ep.duration_minutes} мин</div>
)}
</div>
<div className="has-cinema-ep-progress-bar">
<div
className="has-cinema-ep-progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
})}
</div>
</section>
)}
{/* ========================================================
4. FULL CATALOG ROWS & COLLECTIONS (All Movies & Series)
======================================================== */}
<div className="has-cinema-rows-container">
{/* Row 1: Continue Watching */}
{cinemaState.history.length > 0 && activeTab === "home" && (
<div className="has-cinema-row-block">
<h2 className="has-cinema-row-title">Продолжить просмотр</h2>
<div className="has-cinema-cards-scroll">
{cinemaState.history.map((h, i) => {
const percent =
h.duration_seconds > 0
? Math.min(100, Math.round((h.progress_seconds / h.duration_seconds) * 100))
: 0;
return (
<div
key={`${h.title_id}-${i}`}
className="has-cinema-media-card"
onClick={() => {
const found = [
...allTrending,
...allMoviesList,
...allSeriesList,
...allAnimeList,
].find((item) => item.id === h.title_id);
if (found) {
handleMediaCardClick(found);
}
}}
data-tv-focusable="true"
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) {
e.preventDefault();
const found = [
...allTrending,
...allMoviesList,
...allSeriesList,
...allAnimeList,
].find((item) => item.id === h.title_id);
if (found) {
handleMediaCardClick(found);
}
}
}}
>
<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-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>
</div>
<div className="has-cinema-media-meta">
<span className="has-cinema-media-title">{h.title_name}</span>
<span className="has-cinema-media-sub">
{h.season > 0 ? `Сезон ${h.season}, Серия ${h.episode}` : "Фильм"}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Row 2: In Trend (All Movies & Series) */}
{activeTab === "home" && allTrending.length > 0 && (
<div className="has-cinema-row-block">
<h2 className="has-cinema-row-title">В тренде на этой неделе</h2>
<div className="has-cinema-cards-scroll">
{allTrending.map((item) => (
<div
key={item.id}
className="has-cinema-media-card"
onClick={() => handleMediaCardClick(item)}
data-tv-focusable="true"
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) {
e.preventDefault();
handleMediaCardClick(item);
}
}}
>
<div className="has-cinema-media-thumb-box">
<img src={item.poster_url} alt={item.title} className="has-cinema-media-thumb" loading="lazy" />
<div className="has-cinema-media-badge-rating">★ {item.rating}</div>
<div className="has-cinema-media-play-overlay">
<div className="has-cinema-media-play-circle">▶</div>
</div>
</div>
<div className="has-cinema-media-meta">
<span className="has-cinema-media-title">{item.title}</span>
<span className="has-cinema-media-sub">
{item.release_year} • {getMediaCategoryLabel(item)}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Row 3: All Movies */}
{(activeTab === "home" || activeTab === "movies") && (
<div className="has-cinema-row-block">
<h2 className="has-cinema-row-title">
{activeTab === "movies" ? "Все популярные фильмы" : "Фильмы"}
</h2>
<div className={activeTab === "movies" ? "has-cinema-grid-cards" : "has-cinema-cards-scroll"}>
{allMoviesList.map((movie) => (
<div
key={movie.id}
className={activeTab === "movies" ? "has-cinema-grid-card" : "has-cinema-media-card"}
onClick={() => handleMediaCardClick(movie)}
data-tv-focusable="true"
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) {
e.preventDefault();
handleMediaCardClick(movie);
}
}}
>
<div className="has-cinema-media-thumb-box">
<img src={movie.poster_url} alt={movie.title} className="has-cinema-media-thumb" loading="lazy" />
<div className="has-cinema-media-badge-rating">★ {movie.rating}</div>
<div className="has-cinema-media-play-overlay">
<div className="has-cinema-media-play-circle">▶</div>
</div>
</div>
<div className="has-cinema-media-meta">
<span className="has-cinema-media-title">{movie.title}</span>
<span className="has-cinema-media-sub">
{movie.release_year} • {movie.genre}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Row 4: All TV Series */}
{(activeTab === "home" || activeTab === "series") && (
<div className="has-cinema-row-block">
<h2 className="has-cinema-row-title">
{activeTab === "series" ? "Все популярные сериалы" : "Сериалы"}
</h2>
<div className={activeTab === "series" ? "has-cinema-grid-cards" : "has-cinema-cards-scroll"}>
{allSeriesList.map((series) => (
<div
key={series.id}
className={activeTab === "series" ? "has-cinema-grid-card" : "has-cinema-media-card"}
onClick={() => handleMediaCardClick(series)}
data-tv-focusable="true"
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) {
e.preventDefault();
handleMediaCardClick(series);
}
}}
>
<div className="has-cinema-media-thumb-box">
<img src={series.poster_url} alt={series.title} className="has-cinema-media-thumb" loading="lazy" />
<div className="has-cinema-media-badge-rating">★ {series.rating}</div>
<div className="has-cinema-media-play-overlay">
<div className="has-cinema-media-play-circle">▶</div>
</div>
</div>
<div className="has-cinema-media-meta">
<span className="has-cinema-media-title">{series.title}</span>
<span className="has-cinema-media-sub">
{series.release_year} • {series.seasons_count || 1} сезонов
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Row 5: All Anime */}
{(activeTab === "home" || activeTab === "anime") && (
<div className="has-cinema-row-block">
<h2 className="has-cinema-row-title">
{activeTab === "anime" ? "Все популярные аниме" : "Аниме & Мультфильмы"}
</h2>
<div className={activeTab === "anime" ? "has-cinema-grid-cards" : "has-cinema-cards-scroll"}>
{allAnimeList.map((anime) => (
<div
key={anime.id}
className={activeTab === "anime" ? "has-cinema-grid-card" : "has-cinema-media-card"}
onClick={() => handleMediaCardClick(anime)}
data-tv-focusable="true"
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) {
e.preventDefault();
handleMediaCardClick(anime);
}
}}
>
<div className="has-cinema-media-thumb-box">
<img src={anime.poster_url} alt={anime.title} className="has-cinema-media-thumb" loading="lazy" />
<div className="has-cinema-media-badge-rating">★ {anime.rating}</div>
<div className="has-cinema-media-play-overlay">
<div className="has-cinema-media-play-circle">▶</div>
</div>
</div>
<div className="has-cinema-media-meta">
<span className="has-cinema-media-title">{anime.title}</span>
<span className="has-cinema-media-sub">
{anime.release_year} • {anime.genre}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Row 6: My Bookmarks List */}
{activeTab === "bookmarks" && (
<div className="has-cinema-row-block">
<h2 className="has-cinema-row-title">Мой список закладок</h2>
{bookmarkedItems.length === 0 ? (
<div className="has-cinema-empty-bookmarks">
<span>В вашем списке пока нет сохраненных фильмов или сериалов.</span>
<span className="has-cinema-empty-sub">
Нажмите на кнопку <strong>+</strong> на баннере любого тайтла, чтобы добавить его сюда.
</span>
</div>
) : (
<div className="has-cinema-grid-cards">
{bookmarkedItems.map((item) => (
<div
key={item.id}
className="has-cinema-grid-card"
onClick={() => handleMediaCardClick(item)}
data-tv-focusable="true"
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) {
e.preventDefault();
handleMediaCardClick(item);
}
}}
>
<div className="has-cinema-media-thumb-box">
<img src={item.poster_url} alt={item.title} className="has-cinema-media-thumb" loading="lazy" />
<div className="has-cinema-media-badge-rating">★ {item.rating}</div>
<div className="has-cinema-media-play-overlay">
<div className="has-cinema-media-play-circle">▶</div>
</div>
</div>
<div className="has-cinema-media-meta">
<span className="has-cinema-media-title">{item.title}</span>
<span className="has-cinema-media-sub">
{item.release_year} • {getMediaCategoryLabel(item)}
</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</>
)}
{/* ========================================================
4.5. DETAILED MOVIE & SERIES INFORMATION MODAL
======================================================== */}
{detailsMedia && !playingEpisode && (
<div
className="has-cinema-details-overlay"
onClick={() => setDetailsMedia(null)}
>
<div
className="has-cinema-details-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
{/* Background Backdrop with Gradient Fades */}
<div className="has-cinema-details-backdrop-wrap">
<img
src={detailsMedia.backdrop_url || detailsMedia.poster_url}
alt={detailsMedia.title}
className="has-cinema-details-backdrop-img"
/>
<div className="has-cinema-details-backdrop-gradient" />
<button
type="button"
className="has-cinema-details-close-btn"
onClick={() => setDetailsMedia(null)}
title="Закрыть (Back / Esc)"
data-tv-focusable="true"
>
✕
</button>
</div>
<div className="has-cinema-details-body">
{/* Top Hero Grid: Poster on Left, Main Info on Right */}
<div className="has-cinema-details-hero">
<div className="has-cinema-details-poster-box">
<img
src={detailsMedia.poster_url}
alt={detailsMedia.title}
className="has-cinema-details-poster-img"
/>
<div className="has-cinema-details-poster-badges">
<span className="has-cinema-badge-rating">
★ {detailsMedia.rating}
</span>
{detailsMedia.kinopoisk_rating && (
<span className="has-cinema-badge-kp">
{detailsMedia.kinopoisk_rating}
</span>
)}
<span className="has-cinema-badge-type">
{getMediaBadgeLabel(detailsMedia)}
</span>
</div>
</div>
<div className="has-cinema-details-info-col">
<div className="has-cinema-details-type-pill">
{getMediaCategoryLabel(detailsMedia)}
</div>
<h1 className="has-cinema-details-title">{detailsMedia.title}</h1>
{detailsMedia.original_title && detailsMedia.original_title !== detailsMedia.title && (
<h2 className="has-cinema-details-orig-title">
{detailsMedia.original_title}
</h2>
)}
{detailsMedia.tagline && (
<p className="has-cinema-details-tagline">
«{detailsMedia.tagline}»
</p>
)}
{/* Quick Stats Badges */}
<div className="has-cinema-details-meta-row">
<span className="has-cinema-details-chip chip-year">
📅 {detailsMedia.release_year}
</span>
{detailsMedia.duration_minutes && detailsMedia.duration_minutes > 0 && (
<span className="has-cinema-details-chip chip-time">
⏱ {formatRuntime(detailsMedia.duration_minutes)}
</span>
)}
{detailsMedia.seasons_count && detailsMedia.media_type !== "movie" && (
<span className="has-cinema-details-chip chip-seasons">
📺 Сезонов: {detailsMedia.seasons_count}
</span>
)}
{detailsMedia.genre && (
<span className="has-cinema-details-chip chip-genre">
🎭 {detailsMedia.genre}
</span>
)}
{detailsMedia.countries && (
<span className="has-cinema-details-chip chip-country">
🌍 {detailsMedia.countries}
</span>
)}
</div>
{/* Financial & Production Specs Grid */}
<div className="has-cinema-details-specs-grid">
<div className="has-cinema-spec-item">
<span className="has-cinema-spec-label">Рейтинг:</span>
<span className="has-cinema-spec-value rating-highlight">
★ {detailsMedia.rating} / 10
</span>
</div>
{detailsMedia.director && (
<div className="has-cinema-spec-item">
<span className="has-cinema-spec-label">
{detailsMedia.media_type === "movie" ? "Режиссер:" : "Создатели / Режиссер:"}
</span>
<span className="has-cinema-spec-value">{detailsMedia.director}</span>
</div>
)}
{detailsMedia.media_type === "movie" && detailsMedia.budget && detailsMedia.budget > 0 ? (
<div className="has-cinema-spec-item">
<span className="has-cinema-spec-label">Бюджет фильма:</span>
<span className="has-cinema-spec-value">
💰 {formatCurrency(detailsMedia.budget)}
</span>
</div>
) : null}
{detailsMedia.media_type === "movie" && detailsMedia.revenue && detailsMedia.revenue > 0 ? (
<div className="has-cinema-spec-item">
<span className="has-cinema-spec-label">Сборы в мире:</span>
<span className="has-cinema-spec-value revenue-highlight">
💵 {formatCurrency(detailsMedia.revenue)}
</span>
</div>
) : null}
{detailsMedia.media_type === "movie" && !detailsMedia.budget && !detailsMedia.revenue && (
<div className="has-cinema-spec-item">
<span className="has-cinema-spec-label">Формат:</span>
<span className="has-cinema-spec-value">🎬 Полнометражный фильм</span>
</div>
)}
</div>
{detailsMedia.is_unreleased && (
<div
style={{
margin: "12px 0 16px 0",
padding: "10px 14px",
borderRadius: 10,
background: "rgba(234, 179, 8, 0.12)",
border: "1px solid rgba(234, 179, 8, 0.3)",
color: "#fde047",
fontSize: 13,
lineHeight: 1.5,
}}
>
⏳ <strong>Премьера ожидается:</strong> Фильм запланирован к выходу (
{detailsMedia.release_date_full || detailsMedia.release_year}). Цифровой плеер
станет доступен сразу после официального цифрового релиза.
</div>
)}
{/* Action Buttons: Play + Bookmark + Trailer */}
<div className="has-cinema-details-actions-row">
<button
type="button"
className="has-cinema-details-btn-play"
style={
detailsMedia.is_unreleased
? { opacity: 0.6, cursor: "not-allowed", filter: "grayscale(0.4)" }
: undefined
}
onClick={() => {
if (detailsMedia.is_unreleased) {
showTvToast("Фильм ещё не вышел в цифровой прокат", "⏳", 3000);
return;
}
handlePlayFromDetails(1, detailsMedia.seasons[0]?.episodes[0]);
}}
data-tv-focusable="true"
>
<svg width={20} height={20} viewBox="0 0 24 24" fill="currentColor">
{detailsMedia.is_unreleased ? (
<path d="M12 2a10 10 0 100 20 10 10 0 000-20zm1 14h-2v-2h2v2zm0-4h-2V7h2v5z" />
) : (
<path d="M8 5v14l11-7z" />
)}
</svg>
<span>
{detailsMedia.is_unreleased
? "Скоро в кино"
: detailsMedia.media_type === "movie"
? "Смотреть фильм"
: "Смотреть 1 серию"}
</span>
</button>
<button
type="button"
className={`has-cinema-details-btn-bookmark ${
cinemaState.bookmarks.includes(detailsMedia.id) ? "active" : ""
}`}
onClick={handleToggleBookmarkDetails}
data-tv-focusable="true"
>
{cinemaState.bookmarks.includes(detailsMedia.id) ? "✓ В списке" : "+ В закладки"}
</button>
{detailsMedia.trailer_youtube_key && (
<button
type="button"
className="has-cinema-details-btn-trailer"
onClick={() => setShowTrailerModal(true)}
data-tv-focusable="true"
>
▶ Трейлер
</button>
)}
{isLoadingDetails && (
<span className="has-cinema-details-loading-badge">Загрузка данных...</span>
)}
</div>
{/* Description / Synopsis */}
<div className="has-cinema-details-synopsis-section">
<h3 className="has-cinema-details-section-title">
{detailsMedia.media_type === "movie" ? "О фильме" : "О сериале"}
</h3>
<p className="has-cinema-details-synopsis-text">
{detailsMedia.synopsis}
</p>
</div>
</div>
</div>
{/* Actors / Cast Carousel */}
{detailsMedia.cast && detailsMedia.cast.length > 0 && (
<div className="has-cinema-details-cast-section">
<h3 className="has-cinema-details-section-title">В главных ролях (Актеры)</h3>
<div className="has-cinema-details-cast-carousel">
{detailsMedia.cast.map((actor) => (
<div
key={actor.id}
className="has-cinema-actor-card"
data-tv-focusable="true"
tabIndex={0}
title={`${actor.name}${actor.character ? ` — ${actor.character}` : ""}`}
>
<div className="has-cinema-actor-photo-box">
{actor.profile_path ? (
<img
src={actor.profile_path}
alt={actor.name}
className="has-cinema-actor-photo"
loading="lazy"
/>
) : (
<div className="has-cinema-actor-photo-placeholder">
{actor.name.slice(0, 1).toUpperCase()}
</div>
)}
</div>
<div className="has-cinema-actor-name">{actor.name}</div>
{actor.character && (
<div className="has-cinema-actor-character">{actor.character}</div>
)}
</div>
))}
</div>
</div>
)}
{/* ========================================================
IF TV SERIES OR ANIME: SHOW SEASONS & EPISODES
IF MOVIE: ABSOLUTELY NO SEASONS OR EPISODES!
======================================================== */}
{detailsMedia.media_type !== "movie" && detailsMedia.seasons.length > 0 && (
<div className="has-cinema-details-seasons-section">
<h3 className="has-cinema-details-section-title">Сезоны и серии</h3>
<div className="has-cinema-season-tabs">
{detailsMedia.seasons.map((season) => (
<button
key={season.season_number}
type="button"
className={`has-cinema-season-tab ${
selectedSeason === season.season_number ? "active" : ""
}`}
onClick={() => {
setSelectedSeason(season.season_number);
if (detailsMedia.tmdb_id) {
loadTvSeasonsDetails(detailsMedia.tmdb_id, season.season_number);
}
}}
data-tv-focusable="true"
>
Сезон {season.season_number}
</button>
))}
</div>
<div className="has-cinema-episodes-carousel">
{((detailsMedia.seasons.find((s) => s.season_number === selectedSeason) ||
detailsMedia.seasons[0])?.episodes || []).map((ep) => (
<div
key={ep.id}
className="has-cinema-episode-card"
onClick={() => handlePlayFromDetails(selectedSeason, ep)}
data-tv-focusable="true"
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) {
e.preventDefault();
handlePlayFromDetails(selectedSeason, ep);
}
}}
>
<div className="has-cinema-ep-thumb-box">
<img src={ep.thumbnail} alt={ep.title} className="has-cinema-ep-thumb" />
<div className="has-cinema-ep-hover-play">
<div className="has-cinema-media-play-circle">▶</div>
</div>
</div>
<div className="has-cinema-ep-info">
<div className="has-cinema-ep-title">
{ep.number}. {ep.title}
</div>
{ep.duration_minutes && (
<div className="has-cinema-ep-duration">{ep.duration_minutes} мин</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* ========================================================
TRAILER MODAL
======================================================== */}
{showTrailerModal && detailsMedia?.trailer_youtube_key && (
<div
className="has-cinema-trailer-modal-overlay"
onClick={() => setShowTrailerModal(false)}
>
<div
className="has-cinema-trailer-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div className="has-cinema-trailer-header">
<span className="has-cinema-trailer-title">
Трейлер: {detailsMedia.title}
</span>
<button
type="button"
className="has-cinema-trailer-close-btn"
onClick={() => setShowTrailerModal(false)}
data-tv-focusable="true"
>
✕ Закрыть
</button>
</div>
<div className="has-cinema-trailer-video-box">
<iframe
src={`https://www.youtube-nocookie.com/embed/${detailsMedia.trailer_youtube_key}?autoplay=1&rel=0`}
title={`Трейлер ${detailsMedia.title}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="has-cinema-trailer-iframe"
/>
</div>
</div>
</div>
)}
{/* ========================================================
5. UNIVERSAL STREAMING VIDEO PLAYER (CLEAN & MINIMAL)
======================================================== */}
{playingEpisode && (
<div
className={`has-cinema-player-modal ${isFullscreen ? "fullscreen-mode" : ""} ${
!isHudVisible ? "hud-hidden" : ""
}`}
onMouseMove={resetHudTimer}
onClick={resetHudTimer}
>
{/* Top Edge Sensor: triggers HUD immediately when cursor moves to top 90px of screen */}
<div
className="has-cinema-player-top-sensor"
onMouseEnter={() => {
setIsHudVisible(true);
resetHudTimer();
}}
onMouseMove={resetHudTimer}
/>
{/* Elegant Floating Top HUD Bar */}
<div
className={`has-cinema-player-top-hud ${!isHudVisible ? "hud-hidden" : ""}`}
onMouseEnter={() => {
setIsHudVisible(true);
resetHudTimer();
}}
onMouseMove={resetHudTimer}
>
<div className="has-cinema-player-hud-left">
<button
type="button"
className="has-cinema-player-btn-back"
onClick={(e) => {
e.stopPropagation();
handleClosePlayer();
}}
onTouchEnd={(e) => {
e.stopPropagation();
handleClosePlayer();
}}
onMouseEnter={() => {
setIsHudVisible(true);
resetHudTimer();
}}
onFocus={() => {
setIsHudVisible(true);
resetHudTimer();
}}
title="Назад к информации о фильме (Esc / Back / ▲)"
data-tv-focusable="true"
>
← Назад
</button>
<div className="has-cinema-player-title-box">
<span className="has-cinema-player-hud-title">
{(playingMedia || selectedMedia).title}
</span>
{(playingMedia || selectedMedia).media_type !== "movie" ? (
<span className="has-cinema-player-hud-subtitle">
Сезон {playingSeasonNumber}, Серия {playingEpisode.number}: {playingEpisode.title}
</span>
) : (
<span className="has-cinema-player-hud-subtitle">Полный фильм</span>
)}
</div>
{(playingMedia || selectedMedia).media_type !== "movie" && (
<div className="has-cinema-player-quick-ep-nav">
<button
type="button"
className="has-cinema-player-pill-nav"
disabled={playingEpisode.number <= 1}
onClick={handlePrevEpisode}
title="Предыдущая серия"
data-tv-focusable="true"
>
◀ Пред.
</button>
<button
type="button"
className="has-cinema-player-pill-nav"
onClick={handleNextEpisode}
title="Следующая серия"
data-tv-focusable="true"
>
След. ▶
</button>
</div>
)}
</div>
<div className="has-cinema-player-hud-right">
{/* Episodes button for Series only */}
{(playingMedia || selectedMedia).media_type !== "movie" && (
<button
type="button"
className={`has-cinema-player-hud-btn ${showPlayerEpisodes ? "active" : ""}`}
onClick={() => {
setShowPlayerEpisodes((prev) => !prev);
setShowPlayerSettings(false);
resetHudTimer();
}}
title="Список серий"
data-tv-focusable="true"
>
📺 Серии
</button>
)}
{/* Server selector buttons right in the HUD */}
<div className="has-cinema-player-hud-servers">
<button
type="button"
className={`has-cinema-player-server-pill ${selectedMirror === "vidlink_pro" ? "active" : ""}`}
onClick={() => handleSelectMirror("vidlink_pro")}
title="Сервер 1: Скоростной Full HD 1080p"
data-tv-focusable="true"
>
⚡ Сервер 1 (1080p)
</button>
<button
type="button"
className={`has-cinema-player-server-pill ${selectedMirror === "kodik_ru" ? "active" : ""}`}
onClick={() => handleSelectMirror("kodik_ru")}
title="Сервер 2: Студии дубляжа (Kodik)"
data-tv-focusable="true"
>
🎙️ Сервер 2 (Дубляж)
</button>
<button
type="button"
className={`has-cinema-player-server-pill ${selectedMirror === "autoembed" ? "active" : ""}`}
onClick={() => handleSelectMirror("autoembed")}
title="Сервер 3: AutoEmbed"
data-tv-focusable="true"
>
🌐 Сервер 3
</button>
<button
type="button"
className={`has-cinema-player-server-pill ${selectedMirror === "vidsrc_pm" ? "active" : ""}`}
onClick={() => handleSelectMirror("vidsrc_pm")}
title="Сервер 4: VidSrc"
data-tv-focusable="true"
>
🎬 Сервер 4
</button>
<button
type="button"
className={`has-cinema-player-server-pill ${selectedMirror === "multiembed" ? "active" : ""}`}
onClick={() => handleSelectMirror("multiembed")}
title="Сервер 5: MultiStream"
data-tv-focusable="true"
>
🚀 Сервер 5
</button>
</div>
{/* Quick mirror switcher */}
<button
type="button"
className="has-cinema-player-hud-btn"
onClick={handleCycleMirror}
title="Переключить на следующий сервер (если текущий заблокирован или не загружается)"
data-tv-focusable="true"
>
🔄 Сменить источник
</button>
{/* AdBlock Guard toggle */}
<button
type="button"
className={`has-cinema-player-hud-btn adblock-pill ${isAdBlockEnabled ? "active" : ""}`}
onClick={() => {
setIsAdBlockEnabled((prev) => !prev);
showTvToast(
!isAdBlockEnabled
? "🛡️ Блокировка рекламы и казино ВКЛ"
: "⚠️ Блокировка рекламы ВЫКЛ",
"🛡️",
1800
);
resetHudTimer();
}}
title={isAdBlockEnabled ? "Блокировщик рекламы активен (всплывающие окна и казино заблокированы)" : "Включить блокировку рекламы"}
data-tv-focusable="true"
>
🛡️ {isAdBlockEnabled ? "Без рекламы" : "Реклама: Вкл"}
</button>
{/* Single Settings button (Contains voiceover studios & mirrors) */}
<button
type="button"
className={`has-cinema-player-hud-btn ${showPlayerSettings ? "active" : ""}`}
onClick={() => {
setShowPlayerSettings((prev) => !prev);
setShowPlayerEpisodes(false);
resetHudTimer();
}}
title="Озвучка, качество и серверы"
data-tv-focusable="true"
>
⚙️ Озвучка / Сервер
</button>
{/* Fullscreen toggle */}
<button
type="button"
className="has-cinema-player-hud-btn icon-only"
onClick={handleToggleFullscreen}
title={isFullscreen ? "Выйти из полноэкранного режима" : "Развернуть во весь экран"}
data-tv-focusable="true"
>
{isFullscreen ? "🗗" : "⛶"}
</button>
{/* Close */}
<button
type="button"
className="has-cinema-player-hud-btn icon-only close"
onClick={(e) => {
e.stopPropagation();
handleClosePlayer();
}}
onTouchEnd={(e) => {
e.stopPropagation();
handleClosePlayer();
}}
title="Закрыть плеер"
data-tv-focusable="true"
>
✕
</button>
</div>
</div>
{/* Quick Fallback Switcher (Always accessible if video fails to load) */}
<div className={`has-cinema-player-bottom-failover ${!isHudVisible ? "hud-hidden" : ""}`}>
<span className="failover-hint">Не работает плеер?</span>
<button
type="button"
className="failover-btn"
onClick={handleCycleMirror}
data-tv-focusable="true"
title="Переключить на следующий доступный сервер"
>
🔄 Сменить сервер
</button>
</div>
{/* Clean 100% Video Canvas */}
<div className="has-cinema-video-frame">
{(playingMedia || selectedMedia).is_unreleased ? (
<div className="has-cinema-unreleased-player-view">
<div className="has-cinema-unreleased-card">
<span className="unreleased-icon">⏳</span>
<h3>Фильм ещё не вышел в цифровой прокат</h3>
<p>
«{(playingMedia || selectedMedia).title}» ещё находится в производстве или кинотеатральном прокате (релиз: {(playingMedia || selectedMedia).release_date_full || (playingMedia || selectedMedia).release_year || "2026"}).
</p>
{(playingMedia || selectedMedia).trailer_youtube_key ? (
<div className="unreleased-trailer-box">
<iframe
src={`https://www.youtube-nocookie.com/embed/${(playingMedia || selectedMedia).trailer_youtube_key}?autoplay=1&rel=0`}
title="Официальный трейлер"
className="unreleased-trailer-frame"
allow="autoplay; fullscreen"
allowFullScreen
/>
</div>
) : (
<p style={{ color: "#64748b", fontSize: "13px" }}>
Цифровой плеер подключится автоматически, как только фильм выйдет в мировой релиз.
</p>
)}
<button
type="button"
className="has-cinema-btn-play"
onClick={handleClosePlayer}
style={{ marginTop: 12 }}
data-tv-focusable="true"
>
← Вернуться в каталог
</button>
</div>
</div>
) : selectedMirror === "anilibria" ? (
anilibriaStreamUrl ? (
<video
ref={videoRef}
controls
autoPlay
playsInline
onTimeUpdate={handleVideoTimeUpdate}
className="has-cinema-video-elem"
/>
) : (
<div className="has-cinema-player-loading">
<div className="has-cinema-loading-spinner" />
<span>{isLoadingAniLibria ? "Подключение к Full HD дубляжу AniLibria..." : "Загрузка видеопотока..."}</span>
</div>
)
) : playerMode === "balancer" ? (
<iframe
key={`${selectedMirror}-${balancerEmbedUrl}-${isAdBlockEnabled}`}
src={balancerEmbedUrl}
title={(playingMedia || selectedMedia).title}
className="has-cinema-iframe-elem"
sandbox={
isAdBlockEnabled
? "allow-scripts allow-same-origin allow-forms allow-presentation"
: undefined
}
allow="autoplay *; fullscreen *; encrypted-media *; picture-in-picture *; clipboard-write *"
referrerPolicy="no-referrer-when-downgrade"
allowFullScreen
{...{ webkitallowfullscreen: "true", mozallowfullscreen: "true" }}
/>
) : (
<video
ref={videoRef}
src={
playingEpisode.stream_url ||
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
}
controls
autoPlay
onTimeUpdate={handleVideoTimeUpdate}
className="has-cinema-video-elem"
/>
)}
</div>
{/* ========================================================
CLEAN DRAWER: SETTINGS & AUDIO DUBBINGS
======================================================== */}
{showPlayerSettings && (
<div
className="has-cinema-player-drawer-backdrop"
onClick={() => setShowPlayerSettings(false)}
>
<div
className="has-cinema-player-settings-sheet"
onClick={(e) => e.stopPropagation()}
>
<div className="has-cinema-drawer-header">
<div className="has-cinema-drawer-title">
<span>⚙️ Настройки воспроизведения</span>
<span className="has-cinema-drawer-subtitle">
{(playingMedia || selectedMedia).title}
</span>
</div>
<button
type="button"
className="has-cinema-drawer-close-btn"
onClick={() => setShowPlayerSettings(false)}
data-tv-focusable="true"
>
✕
</button>
</div>
<div className="has-cinema-drawer-body">
{/* Voiceover / Audio Track Section */}
<div className="has-cinema-drawer-section">
<h4 className="has-cinema-drawer-section-title">
🎙️ Русская озвучка и студии перевода
</h4>
{russianDubNotice && (
<div className="has-cinema-drawer-notice-box">
<span>{russianDubNotice}</span>
</div>
)}
{isSearchingKodik && (
<div className="has-cinema-drawer-searching">
<span>🔍 Поиск вариантов озвучки и студий...</span>
</div>
)}
<div className="has-cinema-drawer-options-grid">
<button
type="button"
className={`has-cinema-drawer-option-btn ${
selectedMirror === "vidlink_pro" ? "active" : ""
}`}
onClick={() => {
handleSelectMirror("vidlink_pro");
showTvToast("Включен Сервер 1: Full HD (1080p)", "⚡", 1500);
}}
data-tv-focusable="true"
>
<span className="option-name">⚡ Сервер 1: VidLink Pro (1080p)</span>
<span className="option-desc">Высокая скорость, Full HD качество, стабильный поток</span>
</button>
<button
type="button"
className={`has-cinema-drawer-option-btn ${
selectedMirror === "kodik_ru" ? "active" : ""
}`}
onClick={() => {
handleSelectMirror("kodik_ru");
showTvToast("Включен Сервер 2: Kodik (Дубляж)", "🎙️", 1500);
}}
data-tv-focusable="true"
>
<span className="option-name">🎙️ Сервер 2: Kodik (Студии дубляжа)</span>
<span className="option-desc">Профессиональный русский дубляж (LostFilm, Red Head Sound, HDRezka)</span>
</button>
{currentAllohaUrl && (
<button
type="button"
className={`has-cinema-drawer-option-btn ${
selectedMirror === "alloha_ru" ? "active" : ""
}`}
onClick={() => {
handleSelectMirror("alloha_ru");
showTvToast("Включен Сервер 3: Alloha", "🔊", 1500);
}}
data-tv-focusable="true"
>
<span className="option-name">🔊 Сервер 3: Alloha Player</span>
<span className="option-desc">Российский балансер кинолент</span>
</button>
)}
{/* Anime-specific AniLibria HLS stream */}
{((playingMedia || selectedMedia).genre.toLowerCase().includes("аниме") ||
(playingMedia || selectedMedia).title.toLowerCase().includes("клинок")) && (
<button
type="button"
className={`has-cinema-drawer-option-btn ${
selectedMirror === "anilibria" ? "active" : ""
}`}
onClick={() => {
handleSelectMirror("anilibria");
showTvToast("Включен HLS поток AniLibria", "🌸", 1500);
}}
data-tv-focusable="true"
>
<span className="option-name">🌸 AniLibria (Прямой HLS)</span>
<span className="option-desc">Официальный дубляж AniLibria / Studio Band</span>
</button>
)}
</div>
{/* Kodik studio voiceovers list if available */}
{kodikResults.length > 0 && (
<div className="has-cinema-drawer-subgroup">
<span className="has-cinema-drawer-sublabel">Доступные варианты озвучки (Kodik):</span>
<div className="has-cinema-drawer-chips-wrap">
{kodikResults.map((item) => (
<button
key={item.id}
type="button"
className={`has-cinema-drawer-chip ${
selectedMirror === "kodik_ru" && selectedKodikLink === item.link ? "active" : ""
}`}
onClick={() => {
setSelectedKodikLink(item.link);
setSelectedMirror("kodik_ru");
showTvToast(`Озвучка: ${item.translation.title}`, "🎙️", 1500);
}}
data-tv-focusable="true"
>
{item.translation.title || "Озвучка"}
</button>
))}
</div>
</div>
)}
</div>
{/* Fallback Mirrors / International Servers */}
<div className="has-cinema-drawer-section">
<h4 className="has-cinema-drawer-section-title">
⚡ Запасные международные серверы
</h4>
<div className="has-cinema-drawer-chips-wrap">
<button
type="button"
className={`has-cinema-drawer-chip ${selectedMirror === "vidlink_pro" ? "active" : ""}`}
onClick={() => {
handleSelectMirror("vidlink_pro");
showTvToast("Сервер: VidLink Pro", "⚡", 1500);
}}
data-tv-focusable="true"
>
⚡ VidLink Pro
</button>
<button
type="button"
className={`has-cinema-drawer-chip ${selectedMirror === "vidsrc_to" ? "active" : ""}`}
onClick={() => {
handleSelectMirror("vidsrc_to");
showTvToast("Сервер: VidSrc Pro", "🎬", 1500);
}}
data-tv-focusable="true"
>
🎬 VidSrc Pro (1080p)
</button>
<button
type="button"
className={`has-cinema-drawer-chip ${selectedMirror === "autoembed" ? "active" : ""}`}
onClick={() => {
handleSelectMirror("autoembed");
showTvToast("Сервер: AutoEmbed", "🌐", 1500);
}}
data-tv-focusable="true"
>
🌐 AutoEmbed
</button>
</div>
</div>
{/* AdBlock Guard Section */}
<div className="has-cinema-drawer-section">
<h4 className="has-cinema-drawer-section-title">
🛡️ Блокировка рекламы и всплывающих окон
</h4>
<button
type="button"
className={`has-cinema-drawer-option-btn ${isAdBlockEnabled ? "active" : ""}`}
onClick={() => {
setIsAdBlockEnabled((prev) => !prev);
showTvToast(!isAdBlockEnabled ? "🛡️ Защита от рекламы ВКЛ" : "⚠️ Защита от рекламы ВЫКЛ", "🛡️", 2000);
}}
data-tv-focusable="true"
>
<span className="option-name">
{isAdBlockEnabled ? "🛡️ Блокировщик рекламы: ВКЛЮЧЕН" : "⚠️ Блокировщик рекламы: ВЫКЛЮЧЕН"}
</span>
<span className="option-desc">
{isAdBlockEnabled
? "Защита активна: блокируются всплывающие окна, казино 1xBet, редиректы и баннеры"
: "Защита отключена: разрешены все всплывающие окна балансера"}
</span>
</button>
</div>
{/* External Tools */}
<div className="has-cinema-drawer-section actions">
<button
type="button"
className="has-cinema-drawer-btn-secondary"
onClick={() => {
if (isTauri) {
openUrl(balancerEmbedUrl).catch(() => window.open(balancerEmbedUrl, "_blank"));
} else {
window.open(balancerEmbedUrl, "_blank");
}
}}
data-tv-focusable="true"
>
↗ Открыть видео в браузере
</button>
<button
type="button"
className="has-cinema-drawer-btn-primary"
onClick={() => setShowPlayerSettings(false)}
data-tv-focusable="true"
>
✓ Готово
</button>
</div>
</div>
</div>
</div>
)}
{/* ========================================================
CLEAN DRAWER: EPISODES SELECTION (SERIES ONLY)
======================================================== */}
{showPlayerEpisodes && (playingMedia || selectedMedia).media_type !== "movie" && (
<div
className="has-cinema-player-drawer-backdrop"
onClick={() => setShowPlayerEpisodes(false)}
>
<div
className="has-cinema-player-episodes-sheet"
onClick={(e) => e.stopPropagation()}
>
<div className="has-cinema-drawer-header">
<div className="has-cinema-drawer-title">
<span>Выбор серии (Сезон {playingSeasonNumber})</span>
<span className="has-cinema-drawer-subtitle">
{(playingMedia || selectedMedia).title}
</span>
</div>
<button
type="button"
className="has-cinema-drawer-close-btn"
onClick={() => setShowPlayerEpisodes(false)}
data-tv-focusable="true"
>
✕
</button>
</div>
<div className="has-cinema-drawer-episodes-grid">
{((playingMedia || selectedMedia).seasons.find(
(s) => s.season_number === playingSeasonNumber
)?.episodes || []).map((ep) => (
<button
key={ep.id}
type="button"
className={`has-cinema-drawer-ep-btn ${
playingEpisode?.number === ep.number ? "active" : ""
}`}
onClick={() => {
startPlayback(playingMedia || selectedMedia, playingSeasonNumber, ep);
setShowPlayerEpisodes(false);
}}
data-tv-focusable="true"
>
<span className="ep-num">Серия {ep.number}</span>
<span className="ep-title">{ep.title}</span>
{ep.duration_minutes && (
<span className="ep-time">{ep.duration_minutes} мин</span>
)}
</button>
))}
</div>
</div>
</div>
)}
</div>
)}
{/* High-visibility TV On-Screen Toast */}
{tvToast && (
<div className="has-cinema-tv-toast">
<span className="has-cinema-tv-toast-icon">{tvToast.icon || "📺"}</span>
<span className="has-cinema-tv-toast-text">{tvToast.text}</span>
</div>
)}
</div>
);
}