Compare commits

..

No commits in common. "main" and "v0.0.16" have entirely different histories.

7 changed files with 217 additions and 337 deletions

View file

@ -1,7 +1,7 @@
{
"name": "has",
"private": true,
"version": "0.0.19",
"version": "0.0.16",
"type": "module",
"scripts": {
"dev": "vite",

View file

@ -1,6 +1,6 @@
[package]
name = "has"
version = "0.0.19"
version = "0.0.16"
description = "has"
authors = ["autli"]
edition = "2024"

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "has",
"version": "0.0.19",
"version": "0.0.16",
"identifier": "com.has.tauri-app",
"build": {
"beforeDevCommand": "npm run dev",

View file

@ -27,6 +27,8 @@ const isTauri = () => {
// Helper for Smart TV / Android TV detection
const detectTvEnvironment = (): boolean => {
if (typeof window === "undefined") return false;
const saved = localStorage.getItem("has_tv_mode");
if (saved !== null) return saved === "true";
const ua = (navigator.userAgent || "").toLowerCase();
const isTvUa =
ua.includes("android tv") ||
@ -53,17 +55,6 @@ const detectTvEnvironment = (): boolean => {
const isAndroid = ua.includes("android");
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
if (isAndroid && noTouch) return true;
// On pure desktop (macOS / Windows / Desktop Linux), default to desktop PC mode
const isDesktop =
(ua.includes("windows nt") || ua.includes("macintosh") || (ua.includes("linux") && !ua.includes("android"))) &&
!isTvUa;
if (isDesktop) {
return localStorage.getItem("has_tv_mode_manual") === "true";
}
const saved = localStorage.getItem("has_tv_mode");
if (saved !== null) return saved === "true";
return false;
};
@ -153,6 +144,21 @@ export default function App() {
checkDatabase();
}, [isTvEnvironment]);
// Activate TV mode immediately upon any remote control key press
useEffect(() => {
const handleRemoteKey = (e: KeyboardEvent) => {
const isRemoteKey =
[19, 20, 21, 22, 23, 4, 82, 85, 179].includes(e.keyCode) ||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key);
if (isRemoteKey && !isTvEnvironment) {
setIsTvEnvironment(true);
localStorage.setItem("has_tv_mode", "true");
}
};
window.addEventListener("keydown", handleRemoteKey);
return () => window.removeEventListener("keydown", handleRemoteKey);
}, [isTvEnvironment]);
// Countdown timer for OTP re-send in resetStage 2
useEffect(() => {
if (resetStage !== 2) return;
@ -519,7 +525,6 @@ export default function App() {
dbInfo={dbInfo}
dbStatus={dbStatus}
onLogout={handleLogout}
isTvEnvironment={isTvEnvironment}
/>
</>
);
@ -537,7 +542,6 @@ export default function App() {
onSwitchToDesktop={() => {
setIsTvEnvironment(false);
localStorage.setItem("has_tv_mode", "false");
localStorage.setItem("has_tv_mode_manual", "false");
}}
/>
</>
@ -808,7 +812,6 @@ export default function App() {
onClick={() => {
setIsTvEnvironment(true);
localStorage.setItem("has_tv_mode", "true");
localStorage.setItem("has_tv_mode_manual", "true");
}}
title="Перейти в режим Android TV (выбор профилей)"
style={{

View file

@ -22,6 +22,8 @@ interface UpdaterBannerProps {
// Helper for Smart TV / Android TV detection
const detectTvEnvironment = (): boolean => {
if (typeof window === "undefined") return false;
const saved = localStorage.getItem("has_tv_mode");
if (saved !== null) return saved === "true";
const ua = (navigator.userAgent || "").toLowerCase();
const isTvUa =
ua.includes("android tv") ||
@ -48,17 +50,6 @@ const detectTvEnvironment = (): boolean => {
const isAndroid = ua.includes("android");
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
if (isAndroid && noTouch) return true;
// On pure desktop (macOS / Windows / Desktop Linux), default to desktop PC mode
const isDesktop =
(ua.includes("windows nt") || ua.includes("macintosh") || (ua.includes("linux") && !ua.includes("android"))) &&
!isTvUa;
if (isDesktop) {
return localStorage.getItem("has_tv_mode_manual") === "true";
}
const saved = localStorage.getItem("has_tv_mode");
if (saved !== null) return saved === "true";
return false;
};
@ -97,7 +88,7 @@ const isNewerSemver = (remote: string, current: string): boolean => {
export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
const [isTv, setIsTv] = useState<boolean>(() => isTvEnvironment ?? detectTvEnvironment());
const [currentAppVersion, setCurrentAppVersion] = useState<string>("0.0.19");
const [currentAppVersion, setCurrentAppVersion] = useState<string>("0.0.16");
const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [downloading, setDownloading] = useState(false);

View file

@ -493,6 +493,8 @@ const formatMediaImg = (path: string | undefined, size: "w500" | "original" = "w
// 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") ||
@ -519,17 +521,6 @@ const detectTvEnvironment = (): boolean => {
const isAndroid = ua.includes("android");
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
if (isAndroid && noTouch) return true;
// On pure desktop (macOS / Windows / Desktop Linux), default to desktop PC mode
const isDesktop =
(ua.includes("windows nt") || ua.includes("macintosh") || (ua.includes("linux") && !ua.includes("android"))) &&
!isTvUa;
if (isDesktop) {
return localStorage.getItem("has_tv_mode_manual") === "true";
}
const saved = localStorage.getItem("has_tv_mode");
if (saved !== null) return saved === "true";
return false;
};
@ -549,10 +540,10 @@ export default function Cinema({
const lastFocusedCardRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isTvEnvironment !== isTvMode) {
setIsTvMode(isTvEnvironment);
if (isTvEnvironment && !isTvMode) {
setIsTvMode(true);
}
}, [isTvEnvironment]);
}, [isTvEnvironment, isTvMode]);
const [tvToast, setTvToast] = useState<{ text: string; icon?: string } | null>(null);
const tvToastTimerRef = useRef<any>(null);
@ -572,7 +563,6 @@ export default function Cinema({
setIsTvMode((prev) => {
const next = !prev;
localStorage.setItem("has_tv_mode", String(next));
localStorage.setItem("has_tv_mode_manual", String(next));
showTvToast(next ? "ТВ-Режим включен (10-Foot UI)" : "ПК-Режим включен", "📺", 2500);
playTvBeep(next ? 750 : 400, 0.05);
return next;
@ -707,39 +697,34 @@ export default function Cinema({
const isPlay = command === "play";
const isToggle = command === "toggle";
// Отправляем команды многократно для надежности (разные плееры реагируют в разное время)
for (let i = 0; i < 3; i++) {
setTimeout(() => {
// 1. Kodik Player API (Supports both object and stringified commands)
broadcastToWindowTree({ key: "kodik_player_api", value: { method: isToggle ? "toggle" : (isPlay ? "play" : "pause") } });
if (!isToggle) {
broadcastToWindowTree({ key: "kodik_player_api", value: { method: isPlay ? "play" : "pause" } });
}
// 2. Playerjs API (Used by Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka)
broadcastToWindowTree({ api: isToggle ? "toggle" : (isPlay ? "play" : "pause") });
if (!isToggle) {
broadcastToWindowTree({ api: isPlay ? "play" : "pause" });
}
// 3. Alloha API
broadcastToWindowTree({ action: isPlay ? "play" : "pause" });
broadcastToWindowTree({ method: isPlay ? "play" : "pause" });
// 4. VidLink Pro / Video.js API
broadcastToWindowTree({ type: isPlay ? "PLAYER_PLAY" : "PLAYER_PAUSE" });
broadcastToWindowTree({ type: isPlay ? "play" : "pause" });
broadcastToWindowTree({ action: isPlay ? "play" : "pause" });
broadcastToWindowTree({ event: isPlay ? "play" : "pause" });
// 5. YouTube / VidSrc / AutoEmbed HTML5 API
broadcastToWindowTree({ event: "command", func: isPlay ? "playVideo" : "pauseVideo", args: "" });
// 6. Generic HTML5 postMessage
broadcastToWindowTree({ method: isPlay ? "play" : "pause" });
broadcastToWindowTree({ command: isPlay ? "play" : "pause" });
}, i * 100);
// 1. Kodik Player API (Supports both object and stringified commands)
broadcastToWindowTree({ key: "kodik_player_api", value: { method: isToggle ? "toggle" : (isPlay ? "play" : "pause") } });
if (!isToggle) {
broadcastToWindowTree({ key: "kodik_player_api", value: { method: isPlay ? "play" : "pause" } });
}
// 2. Playerjs API (Used by Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka)
broadcastToWindowTree({ api: isToggle ? "toggle" : (isPlay ? "play" : "pause") });
if (!isToggle) {
broadcastToWindowTree({ api: isPlay ? "play" : "pause" });
}
// 3. Alloha API
broadcastToWindowTree({ action: isPlay ? "play" : "pause" });
broadcastToWindowTree({ method: isPlay ? "play" : "pause" });
// 4. VidLink Pro / Video.js API
broadcastToWindowTree({ type: isPlay ? "PLAYER_PLAY" : "PLAYER_PAUSE" });
broadcastToWindowTree({ type: isPlay ? "play" : "pause" });
broadcastToWindowTree({ action: isPlay ? "play" : "pause" });
broadcastToWindowTree({ event: isPlay ? "play" : "pause" });
// 5. YouTube / VidSrc / AutoEmbed HTML5 API
broadcastToWindowTree({ event: "command", func: isPlay ? "playVideo" : "pauseVideo", args: "" });
// 6. Generic HTML5 postMessage
broadcastToWindowTree({ method: isPlay ? "play" : "pause" });
broadcastToWindowTree({ command: isPlay ? "play" : "pause" });
}, [broadcastToWindowTree]);
const setPlayerVolumeMax = useCallback((showToast: boolean = false) => {
@ -770,21 +755,12 @@ export default function Cinema({
broadcastToWindowTree({ action: "unmute" });
broadcastToWindowTree({ method: "volume", value: 1 });
broadcastToWindowTree({ method: "setVolume", value: 100 });
broadcastToWindowTree({ method: "setVolume", volume: 100 });
broadcastToWindowTree({ method: "unmute" });
broadcastToWindowTree({ method: "unMute" });
// 5. JW Player / VidLink Pro / Video.js API
broadcastToWindowTree({ method: "setMute", value: false });
broadcastToWindowTree({ method: "setMute", mute: false });
broadcastToWindowTree({ method: "setVolume", value: 100 });
broadcastToWindowTree({ method: "setVolume", volume: 100 });
broadcastToWindowTree({ action: "setMute", value: false });
broadcastToWindowTree({ action: "setVolume", value: 100 });
broadcastToWindowTree({ command: "setMute", value: false });
broadcastToWindowTree({ type: "SET_VOLUME", volume: 100 });
// 5. VidLink Pro / Video.js API
broadcastToWindowTree({ type: "VOLUME", volume: 1 });
broadcastToWindowTree({ type: "SET_VOLUME", data: { volume: 1 } });
broadcastToWindowTree({ type: "SET_VOLUME", data: { volume: 100 } });
broadcastToWindowTree({ type: "UNMUTE" });
broadcastToWindowTree({ type: "unmute" });
broadcastToWindowTree({ action: "volume", value: 1 });
broadcastToWindowTree({ action: "unmute" });
@ -797,7 +773,6 @@ export default function Cinema({
broadcastToWindowTree({ method: "setVolume", volume: 100 });
broadcastToWindowTree({ method: "setVolume", value: 100 });
broadcastToWindowTree({ method: "unmute" });
broadcastToWindowTree({ method: "unMute" });
broadcastToWindowTree({ command: "setVolume", volume: 100 });
broadcastToWindowTree({ command: "unmute" });
@ -815,17 +790,19 @@ export default function Cinema({
videoRef.current.play().catch(() => {});
} else {
sendIframePlaybackCommand("play");
// Awaken iframe user activation without permanently trapping focus
const iframe = document.querySelector<HTMLIFrameElement>(".has-cinema-iframe-elem");
if (iframe) {
try {
iframe.focus();
} catch {}
setTimeout(() => {
const playBtn = document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary");
if (playBtn) playBtn.focus();
}, 120);
}
}
// Установка громкости 100% каждый раз при воспроизведении
setPlayerVolumeMax(false);
setTimeout(() => setPlayerVolumeMax(false), 400);
setTimeout(() => setPlayerVolumeMax(false), 1200);
setIsPlayingState(true);
showTvToast("Воспроизведение", "▶", 1200);
}, [sendIframePlaybackCommand, setPlayerVolumeMax, showTvToast]);
@ -869,44 +846,39 @@ export default function Cinema({
const targetTime = Math.max(0, curTime + secondsDelta);
currentPlaybackRef.current.currentTime = targetTime;
// Отправляем команды перемотки несколько раз для надежности
for (let i = 0; i < 3; i++) {
setTimeout(() => {
// 1. Kodik API (Seek to absolute seconds & relative seek delta)
broadcastToWindowTree({ key: "kodik_player_api", value: { method: "seek", time: targetTime } });
broadcastToWindowTree({ key: "kodik_player_api", value: { method: "seek_delta", time: secondsDelta } });
broadcastToWindowTree({ key: "kodik_player_api", value: { method: secondsDelta > 0 ? "forward" : "rewind", time: Math.abs(secondsDelta) } });
// 1. Kodik API (Seek to absolute seconds & relative seek delta)
broadcastToWindowTree({ key: "kodik_player_api", value: { method: "seek", time: targetTime } });
broadcastToWindowTree({ key: "kodik_player_api", value: { method: "seek_delta", time: secondsDelta } });
broadcastToWindowTree({ key: "kodik_player_api", value: { method: secondsDelta > 0 ? "forward" : "rewind", time: Math.abs(secondsDelta) } });
// 2. Playerjs API (Used by Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka)
broadcastToWindowTree({ api: "seek", set: targetTime });
broadcastToWindowTree({ api: "seek", set: secondsDelta > 0 ? `+${secondsDelta}` : `${secondsDelta}` });
broadcastToWindowTree({ api: "seek", time: targetTime });
broadcastToWindowTree({ api: "seek", delta: secondsDelta });
broadcastToWindowTree({ api: "seek_delta", time: secondsDelta });
// 2. Playerjs API (Used by Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka)
broadcastToWindowTree({ api: "seek", set: targetTime });
broadcastToWindowTree({ api: "seek", set: secondsDelta > 0 ? `+${secondsDelta}` : `${secondsDelta}` });
broadcastToWindowTree({ api: "seek", time: targetTime });
broadcastToWindowTree({ api: "seek", delta: secondsDelta });
broadcastToWindowTree({ api: "seek_delta", time: secondsDelta });
// 3. Alloha API
broadcastToWindowTree({ action: "seek", time: targetTime });
broadcastToWindowTree({ action: "seek", delta: secondsDelta });
broadcastToWindowTree({ method: "seek", time: targetTime });
broadcastToWindowTree({ method: "seekTo", value: targetTime });
// 3. Alloha API
broadcastToWindowTree({ action: "seek", time: targetTime });
broadcastToWindowTree({ action: "seek", delta: secondsDelta });
broadcastToWindowTree({ method: "seek", time: targetTime });
broadcastToWindowTree({ method: "seekTo", value: targetTime });
// 4. VidLink Pro / Video.js API
broadcastToWindowTree({ type: "SEEK", data: { time: targetTime } });
broadcastToWindowTree({ type: "PLAYER_SEEK", time: targetTime });
broadcastToWindowTree({ type: "seek", time: targetTime });
broadcastToWindowTree({ action: "seek", time: targetTime });
broadcastToWindowTree({ action: "seek", delta: secondsDelta });
// 4. VidLink Pro / Video.js API
broadcastToWindowTree({ type: "SEEK", data: { time: targetTime } });
broadcastToWindowTree({ type: "PLAYER_SEEK", time: targetTime });
broadcastToWindowTree({ type: "seek", time: targetTime });
broadcastToWindowTree({ action: "seek", time: targetTime });
broadcastToWindowTree({ action: "seek", delta: secondsDelta });
// 5. YouTube / VidSrc / AutoEmbed HTML5 API
broadcastToWindowTree({ event: "command", func: "seekTo", args: [targetTime, true] });
broadcastToWindowTree({ event: "command", func: "seek", args: [targetTime] });
broadcastToWindowTree({ action: "seekTo", value: targetTime });
// 5. YouTube / VidSrc / AutoEmbed HTML5 API
broadcastToWindowTree({ event: "command", func: "seekTo", args: [targetTime, true] });
broadcastToWindowTree({ event: "command", func: "seek", args: [targetTime] });
broadcastToWindowTree({ action: "seekTo", value: targetTime });
// 6. Generic HTML5 postMessage
broadcastToWindowTree({ method: "seek", time: targetTime });
broadcastToWindowTree({ command: "seek", time: targetTime });
}, i * 100);
}
// 6. Generic HTML5 postMessage
broadcastToWindowTree({ method: "seek", time: targetTime });
broadcastToWindowTree({ command: "seek", time: targetTime });
}
showTvToast(secondsDelta > 0 ? `+${secondsDelta} сек` : `${secondsDelta} сек`, secondsDelta > 0 ? "⏩" : "⏪", 1000);
}, [broadcastToWindowTree, showTvToast]);
@ -1141,21 +1113,16 @@ export default function Cinema({
}
}
// Also query Russian media info for Kinopoisk ID / rating & pre-resolve stream
// 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) {
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 (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));
}
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(() => {});
}
@ -1717,11 +1684,6 @@ export default function Cinema({
setTimeout(() => {
setPlayerVolumeMax(false);
}, 4500);
// Агрессивная установка громкости каждые 3 секунды (первые 15 секунд)
const volumeInterval = setInterval(() => {
setPlayerVolumeMax(false);
}, 3000);
setTimeout(() => clearInterval(volumeInterval), 15000);
}, [sendIframePlaybackCommand, setPlayerVolumeMax]);
// Start playback for a media item (movie or specific series episode)
@ -1782,7 +1744,11 @@ export default function Cinema({
const knownKp = media.tmdb_id ? KNOWN_KP_MAP[media.tmdb_id] : null;
if (knownKp) {
setCurrentKpId(knownKp);
} else {
setCurrentKpId(null);
}
setCurrentImdbId(null);
setCurrentAllohaUrl(null);
// Reset mirror tracking and watchdog for fresh playback session
triedMirrorsRef.current.clear();
@ -1796,27 +1762,20 @@ export default function Cinema({
handleAutoSwitchNextMirror("Сервер не ответил за 8 секунд (автопереключение)");
}, 8000);
// Prioritize reliable Russian servers with full remote control and working audio:
// Default to verified reliable high-speed 1080p stream:
if (isAnime) {
if (anilibriaStreamUrl) {
setSelectedMirror("anilibria");
} else if (kodikResults.length > 0) {
setSelectedMirror("kodik_ru");
} else {
setSelectedMirror("kodik_ru");
setSelectedMirror("vidlink_pro");
}
showTvToast("🌸 Сервер: Kodik (Аниме / Озвучка)", "🎙️", 1800);
} else {
if (currentAllohaUrl || knownKp || currentKpId) {
setSelectedMirror("alloha_ru");
showTvToast("🔊 Сервер: Alloha (Русский дубляж)", "🎬", 1800);
} else if (kodikResults.length > 0) {
setSelectedMirror("kodik_ru");
showTvToast("🎙️ Сервер: Kodik (Студии дубляжа)", "🎬", 1800);
} else {
setSelectedMirror("alloha_ru");
showTvToast("⚡ Запуск плеера Full HD", "🎬", 1800);
}
// 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
@ -1838,13 +1797,6 @@ export default function Cinema({
clearTimeout(mirrorWatchdogTimerRef.current);
mirrorWatchdogTimerRef.current = null;
}
} else if (info.kp_id) {
setSelectedMirror("alloha_ru");
setIsMirrorConnecting(false);
if (mirrorWatchdogTimerRef.current) {
clearTimeout(mirrorWatchdogTimerRef.current);
mirrorWatchdogTimerRef.current = null;
}
}
if (!isAnime) {
setRussianDubNotice(
@ -2024,16 +1976,6 @@ export default function Cinema({
const activeMedia = playingMedia || selectedMedia;
currentPlaybackRef.current = { currentTime, duration };
// Постоянно поддерживаем громкость на 100% для нативного видео
try {
if (videoRef.current.volume !== 1.0) {
videoRef.current.volume = 1.0;
}
if (videoRef.current.muted) {
videoRef.current.muted = false;
}
} catch {}
if (duration > 0 && isTauri) {
const payload: CinemaPlayback = {
title_id: activeMedia.id,
@ -2177,18 +2119,6 @@ export default function Cinema({
return () => clearInterval(interval);
}, [playingEpisode, playerMode, allMirrorsFailed, playingMedia, selectedMedia, playingSeasonNumber, isTauri, currentUser?.email]);
// Постоянный мониторинг и поддержание громкости на 100% во время воспроизведения
useEffect(() => {
if (!playingEpisode) return;
// Устанавливаем громкость каждые 2 секунды во время воспроизведения
const volumeKeeper = setInterval(() => {
setPlayerVolumeMax(false);
}, 2000);
return () => clearInterval(volumeKeeper);
}, [playingEpisode, setPlayerVolumeMax]);
// Listen to postMessage from embedded players (VidLink, Kodik, Alloha)
useEffect(() => {
const handleWindowMessage = (e: MessageEvent) => {
@ -2260,18 +2190,40 @@ export default function Cinema({
// 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;
// Remote Menu key (ONLY in TV Mode, and NEVER for the 'R' key on PC keyboard!)
const isMenuKey =
!isInput &&
(e.key === "Menu" ||
e.key === "ContextMenu" ||
(isTvMode && e.keyCode === 82 && e.key !== "r" && e.key !== "R" && e.code !== "KeyR"));
// Android TV / Remote key mappings
const isBack =
e.key === "Escape" ||
e.key === "GoBack" ||
e.key === "BrowserBack" ||
e.keyCode === 4 ||
e.keyCode === 27;
const isOk =
e.key === "Enter" ||
e.key === "Select" ||
e.key === "Accept" ||
e.keyCode === 13 ||
e.keyCode === 23 ||
e.keyCode === 66;
const isPlayPause =
e.key === "MediaPlayPause" || e.keyCode === 85 || e.keyCode === 179;
const isFastForward = e.key === "MediaFastForward" || e.keyCode === 90;
const isRewind = e.key === "MediaRewind" || e.keyCode === 89;
if (isTvMode && isMenuKey) {
// 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;
@ -2298,29 +2250,6 @@ export default function Cinema({
return;
}
// Android TV / Remote key mappings
const isBack =
e.key === "Escape" ||
e.key === "GoBack" ||
e.key === "BrowserBack" ||
(isTvMode && e.keyCode === 4) ||
e.keyCode === 27;
const isOk =
e.key === "Enter" ||
e.key === "Select" ||
e.key === "Accept" ||
e.keyCode === 13 ||
(isTvMode && (e.keyCode === 23 || (e.keyCode === 66 && e.code !== "KeyB")));
const isPlayPause =
e.key === "MediaPlayPause" ||
(isTvMode && (e.keyCode === 85 || e.keyCode === 179) && e.code !== "KeyU");
const isFastForward =
e.key === "MediaFastForward" ||
(isTvMode && e.keyCode === 90 && e.code !== "KeyZ");
const isRewind =
e.key === "MediaRewind" ||
(isTvMode && e.keyCode === 89 && e.code !== "KeyY");
// 1. In-player remote control
if (playingEpisode !== null) {
resetHudTimer();
@ -2871,38 +2800,28 @@ export default function Cinema({
};
}, [handleRemoteKeyDown]);
// Auto-focus first interactive element when player settings drawer opens
// Guard TV remote focus: Never let iframe capture or trap remote control keys
useEffect(() => {
if (showPlayerSettings) {
const timer = setTimeout(() => {
const firstBtn =
document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet .has-cinema-drawer-chip.primary") ||
document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet .has-cinema-drawer-chip") ||
document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet [data-tv-focusable='true']");
if (firstBtn) {
firstBtn.focus();
}
}, 50);
return () => clearTimeout(timer);
}
}, [showPlayerSettings]);
if (playingEpisode !== null) {
const handleWindowBlur = () => {
setTimeout(() => {
if (document.activeElement?.tagName === "IFRAME") {
(document.activeElement as HTMLElement).blur();
}
window.focus();
const target =
document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary") ||
document.querySelector<HTMLElement>(".has-cinema-player-btn-back");
if (target) {
target.focus();
}
}, 30);
};
// Auto-focus current episode when episodes drawer opens
useEffect(() => {
if (showPlayerEpisodes) {
const timer = setTimeout(() => {
const activeEp =
document.querySelector<HTMLElement>(".has-cinema-player-episodes-sheet .has-cinema-drawer-ep-btn.active") ||
document.querySelector<HTMLElement>(".has-cinema-player-episodes-sheet .has-cinema-drawer-ep-btn") ||
document.querySelector<HTMLElement>(".has-cinema-player-episodes-sheet [data-tv-focusable='true']");
if (activeEp) {
activeEp.focus();
activeEp.scrollIntoView({ behavior: "auto", block: "center", inline: "nearest" });
}
}, 50);
return () => clearTimeout(timer);
window.addEventListener("blur", handleWindowBlur);
return () => window.removeEventListener("blur", handleWindowBlur);
}
}, [showPlayerEpisodes]);
}, [playingEpisode]);
// Handle Android TV hardware back & browser popstate to guarantee return to movie details card
useEffect(() => {
@ -3129,16 +3048,24 @@ export default function Cinema({
setKodikResults(finalDubbings);
setSelectedKodikLink(finalDubbings[0].link);
if (autoSwitch) {
setSelectedMirror((prev) => (prev !== "alloha_ru" && prev !== "anilibria" ? "kodik_ru" : prev));
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);
}
@ -3170,8 +3097,8 @@ export default function Cinema({
switch (selectedMirror) {
case "vidlink_pro":
return isTv
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=1&muted=0&volume=100&primaryColor=6366f1`
: `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=1&muted=0&volume=100&primaryColor=6366f1`;
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=true&primaryColor=6366f1`
: `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=true&primaryColor=6366f1`;
case "kodik_ru":
if (selectedKodikLink) {
@ -3182,16 +3109,9 @@ export default function Cinema({
const l = kodikResults[0].link.startsWith("//") ? `https:${kodikResults[0].link}` : kodikResults[0].link;
return appendAutoplay(l);
}
if (currentAllohaUrl) {
const l = currentAllohaUrl.startsWith("//") ? `https:${currentAllohaUrl}` : currentAllohaUrl;
return appendAutoplay(l);
}
if (kpId) {
return `https://theatre.stravers.live/?kp=${kpId}&autoplay=1`;
}
return isTv
? `https://player.autoembed.co/embed/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=1`
: `https://player.autoembed.co/embed/movie/${tmdb || 693134}?autoplay=1`;
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=true&primaryColor=6366f1`
: `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=true&primaryColor=6366f1`;
case "autoembed":
return isTv
@ -3224,24 +3144,14 @@ export default function Cinema({
if (imdbId) {
return `https://theatre.stravers.live/?imdb=${imdbId}&autoplay=1`;
}
if (tmdb) {
return `https://theatre.stravers.live/?tmdb=${tmdb}&autoplay=1`;
}
return isTv
? `https://player.autoembed.co/embed/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=1`
: `https://player.autoembed.co/embed/movie/${tmdb || 693134}?autoplay=1`;
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=true&primaryColor=6366f1`
: `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=true&primaryColor=6366f1`;
default:
if (currentAllohaUrl) {
const l = currentAllohaUrl.startsWith("//") ? `https:${currentAllohaUrl}` : currentAllohaUrl;
return appendAutoplay(l);
}
if (kpId) {
return `https://theatre.stravers.live/?kp=${kpId}&autoplay=1`;
}
return isTv
? `https://player.autoembed.co/embed/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=1`
: `https://player.autoembed.co/embed/movie/${tmdb || 693134}?autoplay=1`;
? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?autoplay=true&primaryColor=6366f1`
: `https://vidlink.pro/movie/${tmdb || 693134}?autoplay=true&primaryColor=6366f1`;
}
};
@ -4934,33 +4844,6 @@ export default function Cinema({
⏩ +10с
</button>
<button
type="button"
className="has-cinema-player-ctrl-btn"
onClick={() => {
setPlayerVolumeMax(true);
resetHudTimer();
}}
title="Максимальная громкость 100%"
data-tv-focusable="true"
data-tv-row="bottom"
>
🔊 100%
</button>
<button
type="button"
className="has-cinema-player-ctrl-btn"
onClick={() => {
triggerIframeDirectFocus();
}}
title="Управление пультом напрямую внутри плеера"
data-tv-focusable="true"
data-tv-row="bottom"
>
🎮 Плеер
</button>
{(playingMedia || selectedMedia).media_type !== "movie" && (
<button
type="button"
@ -5037,7 +4920,7 @@ export default function Cinema({
onClick={() => {
triedMirrorsRef.current.clear();
setAllMirrorsFailed(false);
handleSelectMirror("alloha_ru");
handleSelectMirror("vidlink_pro");
}}
data-tv-focusable="true"
>
@ -5079,7 +4962,7 @@ export default function Cinema({
allow="autoplay *; fullscreen *; encrypted-media *; picture-in-picture *; clipboard-write *"
referrerPolicy="no-referrer-when-downgrade"
allowFullScreen
tabIndex={0}
tabIndex={-1}
onLoad={handleIframeLoaded}
onError={() => handleAutoSwitchNextMirror("Сервер вернул ошибку")}
{...{ webkitallowfullscreen: "true", mozallowfullscreen: "true" }}
@ -5140,6 +5023,7 @@ export default function Cinema({
className="has-cinema-drawer-chip primary"
onClick={() => {
triggerPlayerPlayPause();
setShowPlayerSettings(false);
}}
data-tv-focusable="true"
title="Воспроизведение / Пауза"
@ -5151,6 +5035,7 @@ export default function Cinema({
className="has-cinema-drawer-chip"
onClick={() => {
triggerPlayerSeek(-10);
setShowPlayerSettings(false);
}}
data-tv-focusable="true"
title="Перемотать на 10 секунд назад"
@ -5162,6 +5047,7 @@ export default function Cinema({
className="has-cinema-drawer-chip"
onClick={() => {
triggerPlayerSeek(10);
setShowPlayerSettings(false);
}}
data-tv-focusable="true"
title="Перемотать на 10 секунд вперед"
@ -5173,6 +5059,7 @@ export default function Cinema({
className="has-cinema-drawer-chip"
onClick={() => {
setPlayerVolumeMax(true);
setShowPlayerSettings(false);
}}
data-tv-focusable="true"
title="Выставить громкость плеера на максимум (100%)"
@ -5184,6 +5071,7 @@ export default function Cinema({
className="has-cinema-drawer-chip"
onClick={() => {
triggerPlayerSeek(20);
setShowPlayerSettings(false);
}}
data-tv-focusable="true"
title="Перемотать заставку на 20 секунд вперед"
@ -5195,6 +5083,7 @@ export default function Cinema({
className="has-cinema-drawer-chip"
onClick={() => {
triggerPlayerSeek(85);
setShowPlayerSettings(false);
}}
data-tv-focusable="true"
title="Перемотать опенинг / интро на 85 секунд вперед"
@ -5278,16 +5167,16 @@ export default function Cinema({
<button
type="button"
className={`has-cinema-drawer-option-btn ${
selectedMirror === "alloha_ru" ? "active" : ""
selectedMirror === "vidlink_pro" ? "active" : ""
}`}
onClick={() => {
handleSelectMirror("alloha_ru");
showTvToast("Включен Сервер 1: Alloha (Русский дубляж)", "🔊", 1500);
handleSelectMirror("vidlink_pro");
showTvToast("Включен Сервер 1: Full HD (1080p)", "⚡", 1500);
}}
data-tv-focusable="true"
>
<span className="option-name">🔊 Сервер 1: Alloha (Русский дубляж)</span>
<span className="option-desc">Полная поддержка пульта, автозапуск, русская озвучка</span>
<span className="option-name">⚡ Сервер 1: VidLink Pro (1080p)</span>
<span className="option-desc">Высокая скорость, Full HD качество, стабильный поток</span>
</button>
<button
@ -5297,7 +5186,7 @@ export default function Cinema({
}`}
onClick={() => {
handleSelectMirror("kodik_ru");
showTvToast("Включен Сервер 2: Kodik (Студии дубляжа)", "🎙️", 1500);
showTvToast("Включен Сервер 2: Kodik (Дубляж)", "🎙️", 1500);
}}
data-tv-focusable="true"
>
@ -5305,20 +5194,22 @@ export default function Cinema({
<span className="option-desc">Профессиональный русский дубляж (LostFilm, Red Head Sound, HDRezka)</span>
</button>
<button
type="button"
className={`has-cinema-drawer-option-btn ${
selectedMirror === "vidlink_pro" ? "active" : ""
}`}
onClick={() => {
handleSelectMirror("vidlink_pro");
showTvToast("Включен Сервер 3: VidLink Pro (1080p)", "⚡", 1500);
}}
data-tv-focusable="true"
>
<span className="option-name">⚡ Сервер 3: VidLink Pro (1080p)</span>
<span className="option-desc">Высокая скорость, Full HD качество, стабильный поток</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("аниме") ||

View file

@ -55,7 +55,6 @@ interface DashboardProps {
dbInfo: SafeDbInfo | null;
dbStatus: DbStatus | null;
onLogout: () => void;
isTvEnvironment?: boolean;
}
type NavTab = "cinema" | "home" | "users" | "settings" | "profile" | "help";
@ -63,6 +62,8 @@ type NavTab = "cinema" | "home" | "users" | "settings" | "profile" | "help";
// Helper for Smart TV / Android TV detection
const detectTvEnvironment = (): boolean => {
if (typeof window === "undefined") return false;
const saved = localStorage.getItem("has_tv_mode");
if (saved !== null) return saved === "true";
const ua = (navigator.userAgent || "").toLowerCase();
const isTvUa =
ua.includes("android tv") ||
@ -89,17 +90,6 @@ const detectTvEnvironment = (): boolean => {
const isAndroid = ua.includes("android");
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
if (isAndroid && noTouch) return true;
// On pure desktop (macOS / Windows / Desktop Linux), default to desktop PC mode
const isDesktop =
(ua.includes("windows nt") || ua.includes("macintosh") || (ua.includes("linux") && !ua.includes("android"))) &&
!isTvUa;
if (isDesktop) {
return localStorage.getItem("has_tv_mode_manual") === "true";
}
const saved = localStorage.getItem("has_tv_mode");
if (saved !== null) return saved === "true";
return false;
};
@ -108,23 +98,13 @@ export default function Dashboard({
dbInfo,
dbStatus,
onLogout,
isTvEnvironment: propIsTv,
}: DashboardProps) {
const [currentUser, setCurrentUser] = useState<UserDto>(user);
useEffect(() => {
setCurrentUser(user);
}, [user]);
const [isTvEnvironment, setIsTvEnvironment] = useState<boolean>(() => {
return propIsTv !== undefined ? propIsTv : detectTvEnvironment();
});
useEffect(() => {
if (propIsTv !== undefined && propIsTv !== isTvEnvironment) {
setIsTvEnvironment(propIsTv);
}
}, [propIsTv, isTvEnvironment]);
const [isTvEnvironment, setIsTvEnvironment] = useState<boolean>(detectTvEnvironment);
const [activeTab, setActiveTab] = useState<NavTab>("cinema");
const [serverUsers, setServerUsers] = useState<ServerUserDto[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
@ -133,6 +113,21 @@ export default function Dashboard({
});
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
// Dynamic D-Pad Remote Control Key Listener (Auto-detects TV when user presses remote buttons)
useEffect(() => {
const handleRemoteDpadKey = (e: KeyboardEvent) => {
const isRemoteKey =
[19, 20, 21, 22, 23, 4, 82, 85, 179].includes(e.keyCode) ||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key);
if (isRemoteKey && !isTvEnvironment) {
setIsTvEnvironment(true);
localStorage.setItem("has_tv_mode", "true");
}
};
window.addEventListener("keydown", handleRemoteDpadKey);
return () => window.removeEventListener("keydown", handleRemoteDpadKey);
}, [isTvEnvironment]);
const handleNavClick = (tab: NavTab) => {
setActiveTab(tab);
setMobileMenuOpen(false);