feat(release): v0.0.16 - deep recursive iframe window tree broadcast, fix remote D-pad settings drawer navigation, auto-switch Kodik for movies

This commit is contained in:
Nikita 2026-10-28 23:12:57 +05:00
parent 9e9694ed19
commit 2ab0457568
5 changed files with 397 additions and 278 deletions

View file

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

View file

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

View file

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

View file

@ -88,7 +88,7 @@ const isNewerSemver = (remote: string, current: string): boolean => {
export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) { export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
const [isTv, setIsTv] = useState<boolean>(() => isTvEnvironment ?? detectTvEnvironment()); const [isTv, setIsTv] = useState<boolean>(() => isTvEnvironment ?? detectTvEnvironment());
const [currentAppVersion, setCurrentAppVersion] = useState<string>("0.0.15"); const [currentAppVersion, setCurrentAppVersion] = useState<string>("0.0.16");
const [updateAvailable, setUpdateAvailable] = useState(false); const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null); const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);

View file

@ -654,55 +654,78 @@ export default function Cinema({
const [isPlayingState, setIsPlayingState] = useState(true); const [isPlayingState, setIsPlayingState] = useState(true);
// Dedicated multi-protocol command dispatcher to balancer iframes // Robust multi-level recursive window broadcast to reach nested balancer iframes
const sendIframePlaybackCommand = useCallback((command: "play" | "pause" | "toggle") => { const broadcastToWindowTree = useCallback((payload: any) => {
const iframe = document.querySelector<HTMLIFrameElement>(".has-cinema-iframe-elem"); const iframe = document.querySelector<HTMLIFrameElement>(".has-cinema-iframe-elem");
if (!iframe || !iframe.contentWindow) return; if (!iframe || !iframe.contentWindow) return;
const win = iframe.contentWindow; const send = (target: Window) => {
// Never focus iframe directly — focus must strictly remain on our app controls for TV remote
const post = (payload: any) => {
try { try {
win.postMessage(payload, "*"); target.postMessage(payload, "*");
} catch {} } catch {}
try { try {
win.postMessage(JSON.stringify(payload), "*"); if (typeof payload === "object") {
target.postMessage(JSON.stringify(payload), "*");
}
} catch {} } catch {}
}; };
const rootWin = iframe.contentWindow;
send(rootWin);
// Broadcast down into subframes (VidLink, Alloha, Playerjs, AutoEmbed nested wrappers)
try {
if (rootWin.frames && rootWin.frames.length > 0) {
for (let i = 0; i < rootWin.frames.length; i++) {
try {
send(rootWin.frames[i]);
if (rootWin.frames[i].frames && rootWin.frames[i].frames.length > 0) {
for (let j = 0; j < rootWin.frames[i].frames.length; j++) {
try {
send(rootWin.frames[i].frames[j]);
} catch {}
}
}
} catch {}
}
}
} catch {}
}, []);
// Dedicated multi-protocol command dispatcher to balancer iframes
const sendIframePlaybackCommand = useCallback((command: "play" | "pause" | "toggle") => {
const isPlay = command === "play"; const isPlay = command === "play";
const isToggle = command === "toggle"; const isToggle = command === "toggle";
// 1. Kodik Player API (Supports both object and stringified commands) // 1. Kodik Player API (Supports both object and stringified commands)
post({ key: "kodik_player_api", value: { method: isToggle ? "toggle" : (isPlay ? "play" : "pause") } }); broadcastToWindowTree({ key: "kodik_player_api", value: { method: isToggle ? "toggle" : (isPlay ? "play" : "pause") } });
if (!isToggle) { if (!isToggle) {
post({ key: "kodik_player_api", value: { method: isPlay ? "play" : "pause" } }); broadcastToWindowTree({ key: "kodik_player_api", value: { method: isPlay ? "play" : "pause" } });
} }
// 2. Playerjs API (Used by Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka) // 2. Playerjs API (Used by Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka)
post({ api: isToggle ? "toggle" : (isPlay ? "play" : "pause") }); broadcastToWindowTree({ api: isToggle ? "toggle" : (isPlay ? "play" : "pause") });
if (!isToggle) { if (!isToggle) {
post({ api: isPlay ? "play" : "pause" }); broadcastToWindowTree({ api: isPlay ? "play" : "pause" });
} }
// 3. Alloha API // 3. Alloha API
post({ action: isPlay ? "play" : "pause" }); broadcastToWindowTree({ action: isPlay ? "play" : "pause" });
post({ method: isPlay ? "play" : "pause" }); broadcastToWindowTree({ method: isPlay ? "play" : "pause" });
// 4. VidLink Pro / Video.js API // 4. VidLink Pro / Video.js API
post({ type: isPlay ? "PLAYER_PLAY" : "PLAYER_PAUSE" }); broadcastToWindowTree({ type: isPlay ? "PLAYER_PLAY" : "PLAYER_PAUSE" });
post({ type: isPlay ? "play" : "pause" }); broadcastToWindowTree({ type: isPlay ? "play" : "pause" });
post({ action: isPlay ? "play" : "pause" }); broadcastToWindowTree({ action: isPlay ? "play" : "pause" });
broadcastToWindowTree({ event: isPlay ? "play" : "pause" });
// 5. YouTube / VidSrc / AutoEmbed HTML5 API // 5. YouTube / VidSrc / AutoEmbed HTML5 API
post({ event: "command", func: isPlay ? "playVideo" : "pauseVideo", args: "" }); broadcastToWindowTree({ event: "command", func: isPlay ? "playVideo" : "pauseVideo", args: "" });
// 6. Generic HTML5 postMessage // 6. Generic HTML5 postMessage
post({ method: isPlay ? "play" : "pause" }); broadcastToWindowTree({ method: isPlay ? "play" : "pause" });
post({ command: isPlay ? "play" : "pause" }); broadcastToWindowTree({ command: isPlay ? "play" : "pause" });
}, []); }, [broadcastToWindowTree]);
const setPlayerVolumeMax = useCallback((showToast: boolean = false) => { const setPlayerVolumeMax = useCallback((showToast: boolean = false) => {
// 1. Native HTML5 Video Element // 1. Native HTML5 Video Element
@ -713,58 +736,50 @@ export default function Cinema({
} catch {} } catch {}
} }
// 2. Iframe Players (Kodik, Alloha, Playerjs, VidLink, YouTube/AutoEmbed, etc.) // 2. Kodik API
const iframe = document.querySelector<HTMLIFrameElement>(".has-cinema-iframe-elem"); broadcastToWindowTree({ key: "kodik_player_api", value: { method: "volume", volume: 1 } });
if (iframe && iframe.contentWindow) { broadcastToWindowTree({ key: "kodik_player_api", value: { method: "volume", volume: 100 } });
const win = iframe.contentWindow; broadcastToWindowTree({ key: "kodik_player_api", value: { method: "unmute" } });
const post = (payload: any) => {
try { win.postMessage(payload, "*"); } catch {}
try { win.postMessage(JSON.stringify(payload), "*"); } catch {}
};
// 1. Kodik API // 3. Playerjs API (Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka)
post({ key: "kodik_player_api", value: { method: "volume", volume: 1 } }); broadcastToWindowTree({ api: "volume", set: 100 });
post({ key: "kodik_player_api", value: { method: "volume", volume: 100 } }); broadcastToWindowTree({ api: "volume", set: 1 });
post({ key: "kodik_player_api", value: { method: "unmute" } }); broadcastToWindowTree({ api: "volume", volume: 100 });
broadcastToWindowTree({ api: "volume", volume: 1 });
broadcastToWindowTree({ api: "unmute" });
// 2. Playerjs API (Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka) // 4. Alloha API
post({ api: "volume", volume: 1 }); broadcastToWindowTree({ action: "volume", volume: 1 });
post({ api: "volume", volume: 100 }); broadcastToWindowTree({ action: "volume", value: 100 });
post({ api: "volume", value: 100 }); broadcastToWindowTree({ action: "volume", set: 100 });
post({ api: "unmute" }); broadcastToWindowTree({ action: "unmute" });
broadcastToWindowTree({ method: "volume", value: 1 });
broadcastToWindowTree({ method: "setVolume", value: 100 });
broadcastToWindowTree({ method: "unmute" });
// 3. Alloha API // 5. VidLink Pro / Video.js API
post({ action: "volume", volume: 1 }); broadcastToWindowTree({ type: "VOLUME", volume: 1 });
post({ action: "volume", value: 100 }); broadcastToWindowTree({ type: "SET_VOLUME", data: { volume: 1 } });
post({ action: "unmute" }); broadcastToWindowTree({ type: "SET_VOLUME", data: { volume: 100 } });
post({ method: "volume", value: 1 }); broadcastToWindowTree({ type: "unmute" });
post({ method: "setVolume", value: 100 }); broadcastToWindowTree({ action: "volume", value: 1 });
post({ method: "unmute" }); broadcastToWindowTree({ action: "unmute" });
// 4. VidLink Pro / Video.js API // 6. YouTube / VidSrc / AutoEmbed HTML5 API
post({ type: "VOLUME", volume: 1 }); broadcastToWindowTree({ event: "command", func: "setVolume", args: [100] });
post({ type: "SET_VOLUME", data: { volume: 1 } }); broadcastToWindowTree({ event: "command", func: "unMute", args: "" });
post({ type: "SET_VOLUME", data: { volume: 100 } });
post({ type: "unmute" });
post({ action: "volume", value: 1 });
post({ action: "unmute" });
// 5. YouTube / VidSrc / AutoEmbed HTML5 API // 7. Generic HTML5 postMessage
post({ event: "command", func: "setVolume", args: [100] }); broadcastToWindowTree({ method: "setVolume", volume: 100 });
post({ event: "command", func: "unMute", args: "" }); broadcastToWindowTree({ method: "setVolume", value: 100 });
broadcastToWindowTree({ method: "unmute" });
// 6. Generic HTML5 postMessage broadcastToWindowTree({ command: "setVolume", volume: 100 });
post({ method: "setVolume", volume: 100 }); broadcastToWindowTree({ command: "unmute" });
post({ method: "setVolume", value: 100 });
post({ method: "unmute" });
post({ command: "setVolume", volume: 100 });
post({ command: "unmute" });
}
if (showToast) { if (showToast) {
showTvToast("Громкость плеера: 100%", "🔊", 1500); showTvToast("Громкость плеера: 100%", "🔊", 1500);
} }
}, [showTvToast]); }, [broadcastToWindowTree, showTvToast]);
const triggerPlayerPlay = useCallback(() => { const triggerPlayerPlay = useCallback(() => {
if (videoRef.current) { if (videoRef.current) {
@ -775,6 +790,17 @@ export default function Cinema({
videoRef.current.play().catch(() => {}); videoRef.current.play().catch(() => {});
} else { } else {
sendIframePlaybackCommand("play"); 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);
}
} }
setPlayerVolumeMax(false); setPlayerVolumeMax(false);
setIsPlayingState(true); setIsPlayingState(true);
@ -804,8 +830,9 @@ export default function Cinema({
} else { } else {
triggerPlayerPlay(); triggerPlayerPlay();
} }
sendIframePlaybackCommand("toggle");
} }
}, [isPlayingState, triggerPlayerPlay, triggerPlayerPause]); }, [isPlayingState, triggerPlayerPlay, triggerPlayerPause, sendIframePlaybackCommand]);
const triggerPlayerSeek = useCallback((secondsDelta: number) => { const triggerPlayerSeek = useCallback((secondsDelta: number) => {
if (videoRef.current) { if (videoRef.current) {
@ -815,53 +842,54 @@ export default function Cinema({
); );
currentPlaybackRef.current.currentTime = videoRef.current.currentTime; currentPlaybackRef.current.currentTime = videoRef.current.currentTime;
} else { } else {
const iframe = document.querySelector<HTMLIFrameElement>(".has-cinema-iframe-elem"); const curTime = currentPlaybackRef.current.currentTime || 0;
if (iframe && iframe.contentWindow) { const targetTime = Math.max(0, curTime + secondsDelta);
const win = iframe.contentWindow; currentPlaybackRef.current.currentTime = targetTime;
const post = (payload: any) => {
try { win.postMessage(payload, "*"); } catch {}
try { win.postMessage(JSON.stringify(payload), "*"); } catch {}
};
const curTime = currentPlaybackRef.current.currentTime || 0; // 1. Kodik API (Seek to absolute seconds & relative seek delta)
const targetTime = Math.max(0, curTime + secondsDelta); broadcastToWindowTree({ key: "kodik_player_api", value: { method: "seek", time: targetTime } });
currentPlaybackRef.current.currentTime = 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) // 2. Playerjs API (Used by Alloha, Kinoplay, Collaps, VideoCDN, Ashdi, HDRezka)
post({ key: "kodik_player_api", value: { method: "seek", time: targetTime } }); broadcastToWindowTree({ api: "seek", set: targetTime });
post({ key: "kodik_player_api", value: { method: "seek_delta", time: secondsDelta } }); broadcastToWindowTree({ api: "seek", set: secondsDelta > 0 ? `+${secondsDelta}` : `${secondsDelta}` });
post({ key: "kodik_player_api", value: { method: secondsDelta > 0 ? "forward" : "rewind", time: Math.abs(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) // 3. Alloha API
post({ api: "seek", time: targetTime }); broadcastToWindowTree({ action: "seek", time: targetTime });
post({ api: "seek", delta: secondsDelta }); broadcastToWindowTree({ action: "seek", delta: secondsDelta });
post({ api: "seek_delta", time: secondsDelta }); broadcastToWindowTree({ method: "seek", time: targetTime });
post({ api: "seek", time: secondsDelta > 0 ? `+${secondsDelta}` : `${secondsDelta}` }); broadcastToWindowTree({ method: "seekTo", value: targetTime });
// 3. Alloha API // 4. VidLink Pro / Video.js API
post({ action: "seek", time: targetTime }); broadcastToWindowTree({ type: "SEEK", data: { time: targetTime } });
post({ action: "seek", delta: secondsDelta }); broadcastToWindowTree({ type: "PLAYER_SEEK", time: targetTime });
post({ method: "seek", time: targetTime }); broadcastToWindowTree({ type: "seek", time: targetTime });
post({ method: "seekTo", value: targetTime }); broadcastToWindowTree({ action: "seek", time: targetTime });
broadcastToWindowTree({ action: "seek", delta: secondsDelta });
// 4. VidLink Pro / Video.js API // 5. YouTube / VidSrc / AutoEmbed HTML5 API
post({ type: "SEEK", data: { time: targetTime } }); broadcastToWindowTree({ event: "command", func: "seekTo", args: [targetTime, true] });
post({ type: "PLAYER_SEEK", time: targetTime }); broadcastToWindowTree({ event: "command", func: "seek", args: [targetTime] });
post({ type: "seek", time: targetTime }); broadcastToWindowTree({ action: "seekTo", value: targetTime });
post({ action: "seek", time: targetTime });
post({ action: "seek", delta: secondsDelta });
// 5. YouTube / VidSrc / AutoEmbed HTML5 API // 6. Generic HTML5 postMessage
post({ event: "command", func: "seekTo", args: [targetTime, true] }); broadcastToWindowTree({ method: "seek", time: targetTime });
post({ event: "command", func: "seek", args: [targetTime] }); broadcastToWindowTree({ command: "seek", time: targetTime });
post({ action: "seekTo", value: targetTime });
// 6. Generic HTML5 postMessage
post({ method: "seek", time: targetTime });
post({ command: "seek", time: targetTime });
}
} }
showTvToast(secondsDelta > 0 ? `+${secondsDelta} сек` : `${secondsDelta} сек`, secondsDelta > 0 ? "⏩" : "⏪", 1000); showTvToast(secondsDelta > 0 ? `+${secondsDelta} сек` : `${secondsDelta} сек`, secondsDelta > 0 ? "⏩" : "⏪", 1000);
}, [broadcastToWindowTree, showTvToast]);
// Give TV remote control directly to iframe if a user needs to interact with in-player controls
const triggerIframeDirectFocus = useCallback(() => {
const iframe = document.querySelector<HTMLIFrameElement>(".has-cinema-iframe-elem");
if (iframe) {
iframe.focus();
showTvToast("Управление передано плееру. Нажмите [Back] на пульте для возврата в меню HAS", "🎮", 3000);
}
}, [showTvToast]); }, [showTvToast]);
// Universal helper to convert a TMDB item into CinemaMediaItem // Universal helper to convert a TMDB item into CinemaMediaItem
@ -1653,6 +1681,9 @@ export default function Cinema({
setTimeout(() => { setTimeout(() => {
setPlayerVolumeMax(false); setPlayerVolumeMax(false);
}, 2800); }, 2800);
setTimeout(() => {
setPlayerVolumeMax(false);
}, 4500);
}, [sendIframePlaybackCommand, setPlayerVolumeMax]); }, [sendIframePlaybackCommand, setPlayerVolumeMax]);
// Start playback for a media item (movie or specific series episode) // Start playback for a media item (movie or specific series episode)
@ -1797,8 +1828,8 @@ export default function Cinema({
.catch(() => {}); .catch(() => {});
} }
// Search Kodik in background for anime or studio translations // Search Kodik in background and auto-switch to clean stream if Russian dubbing is found
searchKodikDubbings(media.original_title || media.title, isAnime); searchKodikDubbings(media.original_title || media.title, true);
}; };
// Card Click Action: Opens rich media details modal (DOES NOT start player immediately) // Card Click Action: Opens rich media details modal (DOES NOT start player immediately)
@ -2244,193 +2275,212 @@ export default function Cinema({
return; return;
} }
// 1.2 When HUD is HIDDEN: // 1.1.1 If Settings or Episodes drawer is open in player:
if (!isHudVisible) { if (showPlayerSettings || showPlayerEpisodes) {
// OK / Space / PlayPause: Toggle Play/Pause and show HUD if (isOk) {
if (isOk || isPlayPause || e.code === "Space") { const active = document.activeElement as HTMLElement | null;
if (
active &&
(active.tagName === "BUTTON" ||
active.getAttribute("role") === "button" ||
active.hasAttribute("data-tv-focusable"))
) {
e.preventDefault();
playTvSelectBeep();
active.click();
return;
}
}
// Do NOT run HUD navigation! Skip HUD logic so spatial navigation below handles drawer chips and buttons!
} else {
// 1.2 When HUD is HIDDEN:
if (!isHudVisible) {
// OK / Space / PlayPause: Toggle Play/Pause and show HUD
if (isOk || isPlayPause || e.code === "Space") {
e.preventDefault();
triggerPlayerPlayPause();
setIsHudVisible(true);
setTimeout(() => {
const playBtn = document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary");
if (playBtn) playBtn.focus();
}, 30);
return;
}
// Left Arrow: Rewind 10s
if (e.key === "ArrowLeft" || e.keyCode === 21 || e.keyCode === 37) {
e.preventDefault();
triggerPlayerSeek(-10);
return;
}
// Right Arrow: Forward 10s
if (e.key === "ArrowRight" || e.keyCode === 22 || e.keyCode === 39) {
e.preventDefault();
triggerPlayerSeek(10);
return;
}
// Up Arrow: Reveal HUD and focus Back button
if (e.key === "ArrowUp" || e.keyCode === 19 || e.keyCode === 38) {
e.preventDefault();
setIsHudVisible(true);
setTimeout(() => {
const backBtn = document.querySelector<HTMLElement>(".has-cinema-player-btn-back");
if (backBtn) backBtn.focus();
}, 30);
return;
}
// Down Arrow: Reveal HUD and focus Play/Pause button
if (e.key === "ArrowDown" || e.keyCode === 20 || e.keyCode === 40) {
e.preventDefault();
setIsHudVisible(true);
setTimeout(() => {
const playBtn = document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary");
if (playBtn) playBtn.focus();
}, 30);
return;
}
}
// 1.3 When HUD is VISIBLE:
// OK / Space button
if (isOk) {
const active = document.activeElement as HTMLElement | null;
const isButtonFocused =
active &&
(active.tagName === "BUTTON" ||
active.getAttribute("role") === "button" ||
active.hasAttribute("data-tv-focusable"));
if (isButtonFocused && active) {
e.preventDefault();
playTvSelectBeep();
active.click();
return;
}
e.preventDefault(); e.preventDefault();
triggerPlayerPlayPause(); triggerPlayerPlayPause();
setIsHudVisible(true);
setTimeout(() => {
const playBtn = document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary");
if (playBtn) playBtn.focus();
}, 30);
return; return;
} }
// Left Arrow: Rewind 10s if (isPlayPause || (e.code === "Space" && !isInput)) {
if (e.key === "ArrowLeft" || e.keyCode === 21 || e.keyCode === 37) {
e.preventDefault(); e.preventDefault();
triggerPlayerSeek(-10); triggerPlayerPlayPause();
return; return;
} }
// Right Arrow: Forward 10s if (isFastForward) {
if (e.key === "ArrowRight" || e.keyCode === 22 || e.keyCode === 39) {
e.preventDefault(); e.preventDefault();
triggerPlayerSeek(10); triggerPlayerSeek(10);
return; return;
} }
// Up Arrow: Reveal HUD and focus Back button if (isRewind) {
if (e.key === "ArrowUp" || e.keyCode === 19 || e.keyCode === 38) {
e.preventDefault(); e.preventDefault();
setIsHudVisible(true); triggerPlayerSeek(-10);
setTimeout(() => {
const backBtn = document.querySelector<HTMLElement>(".has-cinema-player-btn-back");
if (backBtn) backBtn.focus();
}, 30);
return; return;
} }
// Down Arrow: Reveal HUD and focus Play/Pause button // Two-row deterministic navigation within Player HUD:
if (e.key === "ArrowDown" || e.keyCode === 20 || e.keyCode === 40) {
e.preventDefault();
setIsHudVisible(true);
setTimeout(() => {
const playBtn = document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary");
if (playBtn) playBtn.focus();
}, 30);
return;
}
}
// 1.3 When HUD is VISIBLE:
// OK / Space button
if (isOk) {
const active = document.activeElement as HTMLElement | null; const active = document.activeElement as HTMLElement | null;
const isButtonFocused = const topRowButtons = Array.from(
active && document.querySelectorAll<HTMLElement>('.has-cinema-player-top-hud [data-tv-row="top"]')
(active.tagName === "BUTTON" || ).filter((b) => b.offsetParent !== null);
active.getAttribute("role") === "button" || const bottomRowButtons = Array.from(
active.hasAttribute("data-tv-focusable")); document.querySelectorAll<HTMLElement>('.has-cinema-player-bottom-hud [data-tv-row="bottom"]')
).filter((b) => b.offsetParent !== null);
if (isButtonFocused && active) { const currentTopIndex = topRowButtons.indexOf(active as any);
e.preventDefault(); const currentBottomIndex = bottomRowButtons.indexOf(active as any);
playTvSelectBeep(); const isTopRow = currentTopIndex !== -1;
active.click(); const isBottomRow = currentBottomIndex !== -1;
return;
}
e.preventDefault(); // If focus was lost, any arrow immediately recovers focus to primary Play/Pause button
triggerPlayerPlayPause(); if (!isTopRow && !isBottomRow) {
return; if (
} e.key === "ArrowUp" || e.keyCode === 19 || e.keyCode === 38 ||
e.key === "ArrowDown" || e.keyCode === 20 || e.keyCode === 40 ||
if (isPlayPause || (e.code === "Space" && !isInput)) { e.key === "ArrowLeft" || e.keyCode === 21 || e.keyCode === 37 ||
e.preventDefault(); e.key === "ArrowRight" || e.keyCode === 22 || e.keyCode === 39
triggerPlayerPlayPause(); ) {
return; e.preventDefault();
} const target =
e.key === "ArrowUp"
if (isFastForward) { ? (topRowButtons[0] || document.querySelector<HTMLElement>(".has-cinema-player-btn-back"))
e.preventDefault(); : (document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary") || bottomRowButtons[0]);
triggerPlayerSeek(10); if (target) {
return; target.focus();
} playTvNavBeep();
}
if (isRewind) { return;
e.preventDefault();
triggerPlayerSeek(-10);
return;
}
// Two-row deterministic navigation within Player HUD:
const active = document.activeElement as HTMLElement | null;
const topRowButtons = Array.from(
document.querySelectorAll<HTMLElement>('.has-cinema-player-top-hud [data-tv-row="top"]')
).filter((b) => b.offsetParent !== null);
const bottomRowButtons = Array.from(
document.querySelectorAll<HTMLElement>('.has-cinema-player-bottom-hud [data-tv-row="bottom"]')
).filter((b) => b.offsetParent !== null);
const currentTopIndex = topRowButtons.indexOf(active as any);
const currentBottomIndex = bottomRowButtons.indexOf(active as any);
const isTopRow = currentTopIndex !== -1;
const isBottomRow = currentBottomIndex !== -1;
// If focus was lost, any arrow immediately recovers focus to primary Play/Pause button
if (!isTopRow && !isBottomRow) {
if (
e.key === "ArrowUp" || e.keyCode === 19 || e.keyCode === 38 ||
e.key === "ArrowDown" || e.keyCode === 20 || e.keyCode === 40 ||
e.key === "ArrowLeft" || e.keyCode === 21 || e.keyCode === 37 ||
e.key === "ArrowRight" || e.keyCode === 22 || e.keyCode === 39
) {
e.preventDefault();
const target =
e.key === "ArrowUp"
? (topRowButtons[0] || document.querySelector<HTMLElement>(".has-cinema-player-btn-back"))
: (document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary") || bottomRowButtons[0]);
if (target) {
target.focus();
playTvNavBeep();
} }
return;
} }
}
// Horizontal & vertical navigation inside Top Row // Horizontal & vertical navigation inside Top Row
if (isTopRow) { if (isTopRow) {
if (e.key === "ArrowRight" || e.keyCode === 22 || e.keyCode === 39) { if (e.key === "ArrowRight" || e.keyCode === 22 || e.keyCode === 39) {
e.preventDefault(); e.preventDefault();
const nextIdx = Math.min(topRowButtons.length - 1, currentTopIndex + 1); const nextIdx = Math.min(topRowButtons.length - 1, currentTopIndex + 1);
topRowButtons[nextIdx]?.focus(); topRowButtons[nextIdx]?.focus();
playTvNavBeep();
return;
}
if (e.key === "ArrowLeft" || e.keyCode === 21 || e.keyCode === 37) {
e.preventDefault();
const prevIdx = Math.max(0, currentTopIndex - 1);
topRowButtons[prevIdx]?.focus();
playTvNavBeep();
return;
}
if (e.key === "ArrowDown" || e.keyCode === 20 || e.keyCode === 40) {
e.preventDefault();
const bottomTarget =
document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary") ||
bottomRowButtons[0];
if (bottomTarget) {
bottomTarget.focus();
playTvNavBeep(); playTvNavBeep();
return;
}
if (e.key === "ArrowLeft" || e.keyCode === 21 || e.keyCode === 37) {
e.preventDefault();
const prevIdx = Math.max(0, currentTopIndex - 1);
topRowButtons[prevIdx]?.focus();
playTvNavBeep();
return;
}
if (e.key === "ArrowDown" || e.keyCode === 20 || e.keyCode === 40) {
e.preventDefault();
const bottomTarget =
document.querySelector<HTMLElement>(".has-cinema-player-ctrl-btn.primary") ||
bottomRowButtons[0];
if (bottomTarget) {
bottomTarget.focus();
playTvNavBeep();
}
return;
} }
return;
} }
}
// Horizontal & vertical navigation inside Bottom Row // Horizontal & vertical navigation inside Bottom Row
if (isBottomRow) { if (isBottomRow) {
if (e.key === "ArrowRight" || e.keyCode === 22 || e.keyCode === 39) { if (e.key === "ArrowRight" || e.keyCode === 22 || e.keyCode === 39) {
e.preventDefault(); e.preventDefault();
const nextIdx = Math.min(bottomRowButtons.length - 1, currentBottomIndex + 1); const nextIdx = Math.min(bottomRowButtons.length - 1, currentBottomIndex + 1);
bottomRowButtons[nextIdx]?.focus(); bottomRowButtons[nextIdx]?.focus();
playTvNavBeep();
return;
}
if (e.key === "ArrowLeft" || e.keyCode === 21 || e.keyCode === 37) {
e.preventDefault();
const prevIdx = Math.max(0, currentBottomIndex - 1);
bottomRowButtons[prevIdx]?.focus();
playTvNavBeep();
return;
}
if (e.key === "ArrowUp" || e.keyCode === 19 || e.keyCode === 38) {
e.preventDefault();
const isRightSide = currentBottomIndex >= Math.floor(bottomRowButtons.length / 2);
const topTarget = isRightSide
? (document.querySelector<HTMLElement>(".has-cinema-player-hud-btn") || topRowButtons[topRowButtons.length - 1])
: (document.querySelector<HTMLElement>(".has-cinema-player-btn-back") || topRowButtons[0]);
if (topTarget) {
topTarget.focus();
playTvNavBeep(); playTvNavBeep();
return;
}
if (e.key === "ArrowLeft" || e.keyCode === 21 || e.keyCode === 37) {
e.preventDefault();
const prevIdx = Math.max(0, currentBottomIndex - 1);
bottomRowButtons[prevIdx]?.focus();
playTvNavBeep();
return;
}
if (e.key === "ArrowUp" || e.keyCode === 19 || e.keyCode === 38) {
e.preventDefault();
const isRightSide = currentBottomIndex >= Math.floor(bottomRowButtons.length / 2);
const topTarget = isRightSide
? (document.querySelector<HTMLElement>(".has-cinema-player-hud-btn") || topRowButtons[topRowButtons.length - 1])
: (document.querySelector<HTMLElement>(".has-cinema-player-btn-back") || topRowButtons[0]);
if (topTarget) {
topTarget.focus();
playTvNavBeep();
}
return;
}
if (e.key === "ArrowDown" || e.keyCode === 20 || e.keyCode === 40) {
e.preventDefault();
setIsHudVisible(false);
return;
} }
return;
}
if (e.key === "ArrowDown" || e.keyCode === 20 || e.keyCode === 40) {
e.preventDefault();
setIsHudVisible(false);
return;
} }
} }
} }
@ -2804,14 +2854,35 @@ export default function Cinema({
useEffect(() => { useEffect(() => {
if (showPlayerSettings) { if (showPlayerSettings) {
const t = setTimeout(() => { const t = setTimeout(() => {
const firstFocus = document.querySelector<HTMLElement>( const firstFocus =
".has-cinema-player-settings-sheet [data-tv-focusable='true']" document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet .has-cinema-drawer-chip") ||
); document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet .has-cinema-drawer-option-btn") ||
if (firstFocus) firstFocus.focus(); document.querySelector<HTMLElement>(".has-cinema-player-settings-sheet [data-tv-focusable='true']");
}, 50); if (firstFocus) {
firstFocus.focus();
playTvBeep(640, 0.025);
}
}, 60);
return () => clearTimeout(t); return () => clearTimeout(t);
} }
}, [showPlayerSettings]); }, [showPlayerSettings, playTvBeep]);
// Auto-focus current or first episode when episodes drawer opens
useEffect(() => {
if (showPlayerEpisodes) {
const t = setTimeout(() => {
const targetEp =
document.querySelector<HTMLElement>(".has-cinema-player-episodes-sheet .has-cinema-drawer-chip.active") ||
document.querySelector<HTMLElement>(".has-cinema-player-episodes-sheet .has-cinema-drawer-chip") ||
document.querySelector<HTMLElement>(".has-cinema-player-episodes-sheet [data-tv-focusable='true']");
if (targetEp) {
targetEp.focus();
playTvBeep(640, 0.025);
}
}, 60);
return () => clearTimeout(t);
}
}, [showPlayerEpisodes, playTvBeep]);
// Handle Android TV hardware back & browser popstate for details modal // Handle Android TV hardware back & browser popstate for details modal
useEffect(() => { useEffect(() => {
@ -4947,6 +5018,54 @@ export default function Cinema({
⏩ Быстрое управление и экран ⏩ Быстрое управление и экран
</h4> </h4>
<div className="has-cinema-drawer-chips-wrap"> <div className="has-cinema-drawer-chips-wrap">
<button
type="button"
className="has-cinema-drawer-chip primary"
onClick={() => {
triggerPlayerPlayPause();
setShowPlayerSettings(false);
}}
data-tv-focusable="true"
title="Воспроизведение / Пауза"
>
{isPlayingState ? "⏸ Пауза" : "▶ Воспроизведение"}
</button>
<button
type="button"
className="has-cinema-drawer-chip"
onClick={() => {
triggerPlayerSeek(-10);
setShowPlayerSettings(false);
}}
data-tv-focusable="true"
title="Перемотать на 10 секунд назад"
>
⏪ -10с
</button>
<button
type="button"
className="has-cinema-drawer-chip"
onClick={() => {
triggerPlayerSeek(10);
setShowPlayerSettings(false);
}}
data-tv-focusable="true"
title="Перемотать на 10 секунд вперед"
>
⏩ +10с
</button>
<button
type="button"
className="has-cinema-drawer-chip"
onClick={() => {
setPlayerVolumeMax(true);
setShowPlayerSettings(false);
}}
data-tv-focusable="true"
title="Выставить громкость плеера на максимум (100%)"
>
🔊 Громкость 100%
</button>
<button <button
type="button" type="button"
className="has-cinema-drawer-chip" className="has-cinema-drawer-chip"
@ -4957,7 +5076,7 @@ export default function Cinema({
data-tv-focusable="true" data-tv-focusable="true"
title="Перемотать заставку на 20 секунд вперед" title="Перемотать заставку на 20 секунд вперед"
> >
⏩ Пропуск рекламы / заставки (+20с) ⏩ Пропуск рекламы (+20с)
</button> </button>
<button <button
type="button" type="button"
@ -4975,13 +5094,13 @@ export default function Cinema({
type="button" type="button"
className="has-cinema-drawer-chip" className="has-cinema-drawer-chip"
onClick={() => { onClick={() => {
setPlayerVolumeMax(true); triggerIframeDirectFocus();
setShowPlayerSettings(false); setShowPlayerSettings(false);
}} }}
data-tv-focusable="true" data-tv-focusable="true"
title="Выставить громкость плеера на максимум (100%)" title="Передать управление с пульта напрямую в плеер"
> >
🔊 Громкость 100% 🎮 Управление в плеере
</button> </button>
<button <button
type="button" type="button"