Files
ESH-Media/src/components/Header.tsx
eshmeshek 461e7ed737 fix(1.0.5): revert Sec-CH-UA spoof (white pages), add DevTools shortcut
- Sec-CH-UA / Sec-CH-UA-Mobile / Sec-CH-UA-Platform header overrides on
  every request in 1.0.4 broke page rendering (all views white). Reverted
  to image-Referer-only behavior from 1.0.3. The Google "embedded browser"
  fix in 1.0.4 came primarily from the adblock whitelist (which IS kept)
  — Sec-CH-UA spoofing was the suspect for the regression.
- Ctrl+Shift+I and F12 now open DevTools on the main shell and on every
  in-app browser view. Always-on so kiosk machines can be debugged
  without leaving kiosk mode.
- Restore session sequenced (await 150ms between tabs) to avoid concurrent
  create-view races where multiple setLoader/addChild interleaved.
- Update banner now shows error state with a "Повторить" button instead
  of hiding it, so install-update failures are visible to the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:24:38 +03:00

345 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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 React, { useState, useEffect, useRef } from 'react'
import Settings from './Settings'
import { AppEntry } from './Settings'
interface HeaderProps {
activeApp: string
setActiveApp: (name: string) => void
onAppsChange: (apps: AppEntry[]) => void
onMovieSearch: (query: string) => void
onBookmark: (title: string, url: string, poster: string, source: string, siteIcon?: string) => void
onBookmarkRemove: (index: number) => void
bookmarks: import('./Settings').Bookmark[]
openedFromSearch?: boolean
onBackToSearch?: () => void
}
const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange, onMovieSearch, onBookmark, onBookmarkRemove, bookmarks, openedFromSearch, onBackToSearch }) => {
const [isCollapsed, setIsCollapsed] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [leftDisabled, setLeftDisabled] = useState(true)
const [rightDisabled, setRightDisabled] = useState(true)
const [refreshDisabled, setRefreshDisabled] = useState(true)
const [showSettings, setShowSettings] = useState(false)
const [currentUrl, setCurrentUrl] = useState<string>('')
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const activeAppRef = useRef(activeApp)
useEffect(() => { activeAppRef.current = activeApp }, [activeApp])
useEffect(() => {
if (!window.electron) return
const offCloseApp = window.electron.on('closeApp', () => {
window.electron!.removeView(activeAppRef.current)
setIsCollapsed(false)
setActiveApp('home')
})
const offWebButtons = window.electron.on('updateWebButtons', (app: { historyPosition: number; history: string[] }) => {
setLeftDisabled(app.historyPosition === 0)
setRightDisabled(app.historyPosition === app.history.length - 1)
setRefreshDisabled(false)
setCurrentUrl(app.history[app.historyPosition] || '')
})
return () => { offCloseApp(); offWebButtons() }
}, [setActiveApp])
const closeCurrentApp = () => {
window.electron?.confirm('Закрыть приложение?', 'closeApp')
}
const openSettings = () => {
if (appOpen) window.electron?.hideView()
setShowSettings(true)
}
const closeSettings = () => {
setShowSettings(false)
if (appOpen) window.electron?.showView(activeApp)
}
const toggleCollapse = () => {
if (isCollapsed) {
setIsCollapsed(false)
setIsHovered(false)
window.electron?.expandWithHeader()
} else {
setIsCollapsed(true)
window.electron?.collapseWithHeader()
}
}
const handleMouseEnter = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
if (isCollapsed) {
setIsHovered(true)
window.electron?.expandWithHeader()
}
}, 150)
}
const handleMouseLeave = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
if (isCollapsed) {
setIsHovered(false)
window.electron?.collapseWithHeader()
}
}, 150)
}
const backwardPage = () => {
setLeftDisabled(true)
setRightDisabled(true)
setRefreshDisabled(true)
window.electron?.backwardPage()
}
const forwardPage = () => {
setLeftDisabled(true)
setRightDisabled(true)
setRefreshDisabled(true)
window.electron?.forwardPage()
}
const refreshPage = () => {
setRefreshDisabled(true)
window.electron?.refreshPage()
}
const appOpen = activeApp !== 'home' && activeApp !== 'movie-search'
const showSearchIcon = activeApp === 'home' || activeApp === 'movie-search'
const [isKiosk, setIsKiosk] = useState(true)
type UpdateStatus =
| { state: 'available'; version: string; currentVersion?: string }
| { state: 'downloading'; percent: number; bytesPerSecond?: number; transferred?: number; total?: number; version?: string; currentVersion?: string }
| { state: 'ready'; version: string; currentVersion?: string }
| { state: 'manual'; version: string; currentVersion?: string; installerUrl: string; installerName?: string }
| { state: 'error'; message: string }
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
useEffect(() => {
if (!window.electron) return
const off = window.electron.on('update-status', (info: UpdateStatus) => {
setUpdateStatus(prev => {
// Preserve version across state transitions when payload omits it (download-progress)
if (info.state === 'downloading' && prev && 'version' in prev && prev.version) {
return { ...info, version: info.version || prev.version, currentVersion: info.currentVersion || ('currentVersion' in prev ? prev.currentVersion : undefined) }
}
return info
})
})
return off
}, [])
useEffect(() => {
window.electron?.isKiosk().then(k => setIsKiosk(k))
}, [])
const toggleKiosk = () => {
window.electron?.toggleKiosk().then((newState: boolean) => setIsKiosk(newState))
}
const [isBookmarked, setIsBookmarked] = useState(false)
const handleBookmark = async () => {
const page = await window.electron?.getCurrentPage()
if (!page) return
let pageHost = ''
try { pageHost = new URL(page.url).hostname } catch (_) {}
// Match by full URL — different movies on same site must not collide.
const existingIdx = bookmarks.findIndex(b => b.url === page.url)
if (existingIdx !== -1) {
onBookmarkRemove(existingIdx)
setIsBookmarked(false)
return
}
// Pull og:image / JSON-LD poster from the live page (specific to this movie).
const meta = await window.electron?.getPageMeta?.().catch(() => null)
const poster = meta?.poster || ''
const title = (meta?.title || page.name || '').trim() || page.name
onBookmark(title, page.url, poster, pageHost, page.imageUrl || '')
setIsBookmarked(true)
}
useEffect(() => {
if (!appOpen || !currentUrl) { setIsBookmarked(false); return }
setIsBookmarked(bookmarks.some(b => b.url === currentUrl))
}, [currentUrl, bookmarks, appOpen])
return (
<>
<div
className={`header ${isCollapsed && !isHovered ? 'collapsed' : 'expanded'}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{(!isCollapsed || isHovered) && (
<>
<div className="header-left">
<div className="header-btn" onClick={toggleKiosk} title={isKiosk ? 'Выйти из режима киоска' : 'Режим киоска'}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#aaa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{isKiosk ? (
<>
<polyline points="4 14 10 14 10 20" />
<polyline points="20 10 14 10 14 4" />
<line x1="10" y1="14" x2="3" y2="21" />
<line x1="21" y1="3" x2="14" y2="10" />
</>
) : (
<>
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</>
)}
</svg>
</div>
<div className="header-btn" onClick={openSettings} title="Настройки">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#aaa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</div>
</div>
<div className="header-center">
{appOpen && openedFromSearch && onBackToSearch && (
<button className="header-btn nav-btn" onClick={onBackToSearch} title="Вернуться к результатам поиска">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#e53935" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
<span style={{ fontSize: '12px', color: '#e53935', marginLeft: 2 }}>Поиск</span>
</button>
)}
{appOpen && (
<>
<button
className={`header-btn nav-btn${leftDisabled ? ' disabled' : ''}`}
onClick={leftDisabled ? undefined : backwardPage}
title="Назад"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<button
className={`header-btn nav-btn${rightDisabled ? ' disabled' : ''}`}
onClick={rightDisabled ? undefined : forwardPage}
title="Вперёд"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<button
className={`header-btn nav-btn${refreshDisabled ? ' disabled' : ''}`}
onClick={refreshDisabled ? undefined : refreshPage}
title="Обновить"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
<button className="header-btn nav-btn" onClick={handleBookmark} title={isBookmarked ? 'Удалить закладку' : 'В закладки'}>
<svg width="18" height="18" viewBox="0 0 24 24" fill={isBookmarked ? '#f5c518' : 'none'} stroke={isBookmarked ? '#f5c518' : 'currentColor'} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
className="header-btn nav-btn"
onClick={toggleCollapse}
title={isCollapsed ? 'Развернуть шапку' : 'Свернуть шапку'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
{isCollapsed
? <polyline points="6 9 12 15 18 9" />
: <polyline points="18 15 12 9 6 15" />
}
</svg>
</button>
</>
)}
</div>
<div className="header-right">
{showSearchIcon && (
<button
className={`header-btn nav-btn${activeApp === 'movie-search' ? ' active' : ''}`}
onClick={() => onMovieSearch('')}
title="Поиск фильмов"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
)}
{appOpen && (
<button className="header-btn header-close-btn" onClick={closeCurrentApp} title="Закрыть приложение">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
</>
)}
</div>
{updateStatus && (
<div className={`update-banner${updateStatus.state === 'error' ? ' error' : ''}`}>
{updateStatus.state === 'available' && (
<>
<span className="update-banner-spinner" />
<span>Загружается обновление {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''}</span>
</>
)}
{updateStatus.state === 'error' && (
<>
<span>Ошибка обновления: {updateStatus.message}</span>
<button className="update-banner-btn" onClick={() => window.electron?.checkUpdateNow?.()}>
Повторить
</button>
</>
)}
{updateStatus.state === 'downloading' && (
<>
<span>Скачивается {updateStatus.version || 'обновление'}: {updateStatus.percent}%</span>
<div className="update-banner-progress">
<div className="update-banner-progress-bar" style={{ width: `${updateStatus.percent}%` }} />
</div>
</>
)}
{updateStatus.state === 'ready' && (
<>
<span>Версия {updateStatus.version} готова к установке</span>
<button className="update-banner-btn" onClick={() => window.electron?.installUpdate?.()}>
Установить и перезапустить
</button>
</>
)}
{updateStatus.state === 'manual' && (
<>
<span>Доступна {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''}</span>
<button className="update-banner-btn" onClick={() => window.electron?.createView('Обновление', updateStatus.installerUrl, '', 1.0, false)}>
Скачать установщик
</button>
</>
)}
<button className="update-banner-close" onClick={() => setUpdateStatus(null)}></button>
</div>
)}
{showSettings && (
<Settings onClose={closeSettings} onAppsChange={onAppsChange} />
)}
</>
)
}
export default Header