ESH-Media v1.0.11 — kiosk media browser for elderly users

Electron-based kiosk desktop app: large-tile launcher for YouTube, RuTube,
movie sites and Google services, designed for low-tech grandparent use.

Features:
  - WebContentsView-per-app tabbed browsing with session persistence
  - per-app proxy routing (Clash/V2Ray friendly, useProxy flag)
  - cliqz-electron adblocker with whitelist for OAuth/integrity domains
  - TMDB-backed movie search across kinogo / hdrezka / filmix
  - bookmark posters auto-fetched from og:image / JSON-LD
  - electron-updater wired to Gitea releases API (latest.yml + .blockmap)
  - cross-domain navigation confirms via custom WebContentsView dialogs
  - kiosk window with hidden menu, Ctrl+Shift+I devtools shortcut
  - Trusted Types disabled engine-wide so adblocker scriptlets work on YouTube

Google OAuth handling (the hard-won part):
  Google's anti-abuse JS rejects WebContentsView + custom session settings
  as "embedded browser". So accounts.google.com opens in a top-level
  BrowserWindow popup in a dedicated persist:google-login partition that
  we never call setProxy/setUserAgent on — it inherits Windows system
  proxy and the default Electron-tagged UA, both of which Google accepts.
  After login, .google.com/.youtube.com cookies migrate into the parent
  view's session and the view reloads to pick up the logged-in state.

Session restore: only the last-active tab attaches to the window; other
tabs load silently in the background and become instantly visible when
the user clicks them in the sidebar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 00:46:02 +03:00
commit 1c7bb75a05
69 changed files with 12035 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
dist-electron/
release/
*.log
.DS_Store
.claude/

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"DockerRun.DisableDockerrc": true
}

87
README.md Normal file
View File

@@ -0,0 +1,87 @@
# ESH-Media
Десктопное приложение на Electron + React. Запускает веб-сервисы в отдельных WebContentsView, поиск и обзор фильмов через TMDB, встроенная блокировка рекламы.
## Стек
- Electron 32
- React 18 + TypeScript
- Vite
- @cliqz/adblocker-electron
## Запуск
```bash
npm install
npm run dev
```
## Сборка
```bash
# Windows (installer + zip)
npm run build:win
# Linux (AppImage + deb)
npm run build:linux
```
Артефакты в папке `release/`.
> Linux-сборку нужно запускать на Linux-машине.
## Настройка
Настройки открываются кнопкой в левом верхнем углу приложения.
### Приложения
Список сайтов, которые отображаются на главном экране в виде карточек. Для каждого можно указать:
- **Название** — отображается под иконкой
- **URL** — адрес сайта, открывается в отдельном WebContentsView
- **URL иконки** — картинка для карточки (необязательно)
- **Прокси** — использовать ли прокси для этого сайта (переключатель включается отдельно для каждого)
### Прокси
Приложение поддерживает HTTP/HTTPS/SOCKS5 прокси. Настраивается в разделе "Прокси" — указываешь хост и порт. Прокси применяется не глобально, а поприложенно: для каждого сайта в списке есть отдельный переключатель. Это позволяет, например, открывать заблокированные сайты через прокси, а остальные — напрямую.
Конфигурация прокси сохраняется в файл `~/.ESH-Media.json` и применяется при следующем запуске автоматически.
### Поиск фильмов
- **TMDB API Key** — ключ для поиска метаданных, постеров и обзора по фильтрам. Получить бесплатно на [themoviedb.org](https://www.themoviedb.org/settings/api). Поддерживаются как обычные API-ключи, так и Bearer-токены.
- **Сайты** — список фильмовых сайтов, на которых будет производиться поиск после выбора фильма из TMDB. Поддерживаются движки DLE (kinogo, lordfilm и зеркала), HDRezka, Filmix. Тип определяется автоматически по домену.
Если раздел "Сайты" пустой, приложение попробует использовать подходящие сайты из раздела "Приложения".
## Конфиг
Хранится в домашней директории пользователя: `~/.ESH-Media.json`.
```json
{
"apps": [...],
"proxy": { "host": "127.0.0.1", "port": "7890" },
"movieSites": [...],
"tmdbApiKey": "...",
"bookmarks": [...]
}
```
## Структура
```
main.js — main process
preload.js — preload / IPC bridge
index.html — точка входа основного UI
loader.html — экран загрузки
dialog-error.html — диалог ошибки
dialog-confirm.html — диалог подтверждения
src/
entries/ — entry points для Vite (loader, dialogs)
components/ — React компоненты
pages/ — страницы
styles/ — стили
```

12
dialog-confirm.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Confirm</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/entries/dialog-confirm.tsx"></script>
</body>
</html>

12
dialog-error.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Error</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/entries/dialog-error.tsx"></script>
</body>
</html>

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESH-Media</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

12
loader.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loading</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/entries/loader.tsx"></script>
</body>
</html>

1462
main.js Normal file

File diff suppressed because it is too large Load Diff

6522
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

75
package.json Normal file
View File

@@ -0,0 +1,75 @@
{
"name": "ESH-Media",
"version": "1.0.11",
"private": true,
"main": "main.js",
"scripts": {
"dev:renderer": "vite",
"dev:electron": "node scripts/start-electron.js",
"dev": "concurrently \"npm:dev:renderer\" \"npm:dev:electron\"",
"build:renderer": "vite build",
"build:win": "npm run build:renderer && electron-builder --win",
"build:linux": "npm run build:renderer && electron-builder --linux",
"build": "npm run build:renderer && electron-builder --win --linux",
"dist": "electron-builder"
},
"dependencies": {
"@cliqz/adblocker-electron": "^1.34.0",
"cheerio": "^1.2.0",
"electron-updater": "^6.8.3",
"node-fetch": "^2.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"concurrently": "^8.2.2",
"electron": "^32.0.0",
"electron-builder": "^24.13.3",
"typescript": "^5.5.4",
"vite": "^5.4.2",
"wait-on": "^8.0.0"
},
"build": {
"appId": "com.ESH-Media",
"productName": "ESH-Media",
"asar": true,
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"main.js",
"preload.js",
"package.json",
"extensions/**/*"
],
"win": {
"target": [
"nsis",
"zip"
],
"icon": "public/favicon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
},
"linux": {
"target": [
"AppImage",
"deb"
],
"icon": "public/logo.png",
"category": "Utility"
},
"publish": [
{
"provider": "generic",
"url": "https://gitea.esh-service.ru/public/ESH-Media/releases/download/latest/"
}
]
}
}

35
preload.js Normal file
View File

@@ -0,0 +1,35 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electron', {
createView: (name, url, imageUrl, zoom, useProxy) =>
ipcRenderer.send('create-view', name, url, imageUrl, zoom, useProxy),
confirm: (text, funcName) => ipcRenderer.send('confirm', text, funcName),
removeView: (name) => ipcRenderer.send('remove-view', name),
hideView: () => ipcRenderer.send('hide-view'),
showView: (name) => ipcRenderer.send('show-view', name),
adjustView: (expanded) => ipcRenderer.send('adjust-view', expanded),
on: (channel, func) => {
const listener = (_event, ...args) => func(...args);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
},
handleAction: (action) => ipcRenderer.send('action', action),
setProxy: (host, port) => ipcRenderer.send('set-proxy', host, port),
expandWithHeader: () => ipcRenderer.send('expandWithHeader'),
collapseWithHeader: () => ipcRenderer.send('collapseWithHeader'),
backwardPage: () => ipcRenderer.send('backwardPage'),
forwardPage: () => ipcRenderer.send('forwardPage'),
refreshPage: () => ipcRenderer.send('refreshPage'),
getCurrentPage: () => ipcRenderer.invoke('get-current-page'),
getPageMeta: () => ipcRenderer.invoke('get-page-meta'),
installUpdate: () => ipcRenderer.invoke('install-update'),
checkUpdateNow: () => ipcRenderer.invoke('check-update-now'),
readConfig: () => ipcRenderer.invoke('read-config'),
writeConfig: (data) => ipcRenderer.send('write-config', data),
searchMovies: (query, sites) => ipcRenderer.invoke('search-movies', query, sites),
searchTmdb: (query, apiKey) => ipcRenderer.invoke('search-tmdb', query, apiKey),
discoverTmdb: (params) => ipcRenderer.invoke('discover-tmdb', params),
toggleKiosk: () => ipcRenderer.invoke('toggle-kiosk'),
isKiosk: () => ipcRenderer.invoke('is-kiosk'),
});

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
public/images/RuTube.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
public/images/VPN.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 217.499 217.499" xml:space="preserve">
<g>
<path d="M123.264,108.749l45.597-44.488c1.736-1.693,2.715-4.016,2.715-6.441s-0.979-4.748-2.715-6.441l-50.038-48.82
c-2.591-2.528-6.444-3.255-9.78-1.853c-3.336,1.406-5.505,4.674-5.505,8.294v80.504l-42.331-41.3
c-3.558-3.471-9.255-3.402-12.727,0.156c-3.471,3.558-3.401,9.256,0.157,12.727l48.851,47.663l-48.851,47.663
c-3.558,3.471-3.628,9.169-0.157,12.727s9.17,3.628,12.727,0.156l42.331-41.3v80.504c0,3.62,2.169,6.888,5.505,8.294
c1.128,0.476,2.315,0.706,3.493,0.706c2.305,0,4.572-0.886,6.287-2.559l50.038-48.82c1.736-1.693,2.715-4.016,2.715-6.441
s-0.979-4.748-2.715-6.441L123.264,108.749z M121.539,30.354l28.15,27.465l-28.15,27.465V30.354z M121.539,187.143v-54.93
l28.15,27.465L121.539,187.143z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 217.499 217.499" xml:space="preserve">
<g>
<path d="M123.264,108.749l45.597-44.488c1.736-1.693,2.715-4.016,2.715-6.441s-0.979-4.748-2.715-6.441l-50.038-48.82
c-2.591-2.528-6.444-3.255-9.78-1.853c-3.336,1.406-5.505,4.674-5.505,8.294v80.504l-42.331-41.3
c-3.558-3.471-9.255-3.402-12.727,0.156c-3.471,3.558-3.401,9.256,0.157,12.727l48.851,47.663l-48.851,47.663
c-3.558,3.471-3.628,9.169-0.157,12.727s9.17,3.628,12.727,0.156l42.331-41.3v80.504c0,3.62,2.169,6.888,5.505,8.294
c1.128,0.476,2.315,0.706,3.493,0.706c2.305,0,4.572-0.886,6.287-2.559l50.038-48.82c1.736-1.693,2.715-4.016,2.715-6.441
s-0.979-4.748-2.715-6.441L123.264,108.749z M121.539,30.354l28.15,27.465l-28.15,27.465V30.354z M121.539,187.143v-54.93
l28.15,27.465L121.539,187.143z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/images/church.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

BIN
public/images/collapse.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

BIN
public/images/expand.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

BIN
public/images/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

1
public/images/home.svg Normal file
View File

@@ -0,0 +1 @@
<svg id="Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 61.53 61.51"><defs><style>.cls-1{fill:#fff;}</style></defs><title>home</title><polygon class="cls-1" points="58.74 25.2 58.74 61.51 37.57 61.51 37.57 39.63 23.97 39.63 23.97 61.51 2.8 61.51 2.8 25.2 30.77 5.57 58.74 25.2"/><polyline class="cls-1" points="30.77 4.43 0 26.04 0 21.6 30.77 0"/><polyline class="cls-1" points="30.77 4.43 61.53 26.04 61.53 21.6 30.77 0"/></svg>

After

Width:  |  Height:  |  Size: 465 B

BIN
public/images/ivi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
public/images/kinogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
public/images/kinopoisk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 B

BIN
public/images/left.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/images/refresh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 B

BIN
public/images/right.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

BIN
public/images/tv.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg">
<title>ionicons-v5-g</title>
<path d="M232,416a23.88,23.88,0,0,1-14.2-4.68,8.27,8.27,0,0,1-.66-.51L125.76,336H56a24,24,0,0,1-24-24V200a24,24,0,0,1,24-24h69.75l91.37-74.81a8.27,8.27,0,0,1,.66-.51A24,24,0,0,1,256,120V392a24,24,0,0,1-24,24ZM125.82,336Zm-.27-159.86Z"/>
<path d="M320,336a16,16,0,0,1-14.29-23.19c9.49-18.87,14.3-38,14.3-56.81,0-19.38-4.66-37.94-14.25-56.73a16,16,0,0,1,28.5-14.54C346.19,208.12,352,231.44,352,256c0,23.86-6,47.81-17.7,71.19A16,16,0,0,1,320,336Z"/>
<path d="M368,384a16,16,0,0,1-13.86-24C373.05,327.09,384,299.51,384,256c0-44.17-10.93-71.56-29.82-103.94a16,16,0,0,1,27.64-16.12C402.92,172.11,416,204.81,416,256c0,50.43-13.06,83.29-34.13,120A16,16,0,0,1,368,384Z"/>
<path d="M416,432a16,16,0,0,1-13.39-24.74C429.85,365.47,448,323.76,448,256c0-66.5-18.18-108.62-45.49-151.39a16,16,0,1,1,27-17.22C459.81,134.89,480,181.74,480,256c0,64.75-14.66,113.63-50.6,168.74A16,16,0,0,1,416,432Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

9
public/images/volume.svg Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg">
<title>ionicons-v5-g</title>
<path d="M232,416a23.88,23.88,0,0,1-14.2-4.68,8.27,8.27,0,0,1-.66-.51L125.76,336H56a24,24,0,0,1-24-24V200a24,24,0,0,1,24-24h69.75l91.37-74.81a8.27,8.27,0,0,1,.66-.51A24,24,0,0,1,256,120V392a24,24,0,0,1-24,24ZM125.82,336Zm-.27-159.86Z"/>
<path d="M320,336a16,16,0,0,1-14.29-23.19c9.49-18.87,14.3-38,14.3-56.81,0-19.38-4.66-37.94-14.25-56.73a16,16,0,0,1,28.5-14.54C346.19,208.12,352,231.44,352,256c0,23.86-6,47.81-17.7,71.19A16,16,0,0,1,320,336Z"/>
<path d="M368,384a16,16,0,0,1-13.86-24C373.05,327.09,384,299.51,384,256c0-44.17-10.93-71.56-29.82-103.94a16,16,0,0,1,27.64-16.12C402.92,172.11,416,204.81,416,256c0,50.43-13.06,83.29-34.13,120A16,16,0,0,1,368,384Z"/>
<path d="M416,432a16,16,0,0,1-13.39-24.74C429.85,365.47,448,323.76,448,256c0-66.5-18.18-108.62-45.49-151.39a16,16,0,1,1,27-17.22C459.81,134.89,480,181.74,480,256c0,64.75-14.66,113.63-50.6,168.74A16,16,0,0,1,416,432Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 365.892 365.892" xml:space="preserve">
<g>
<circle cx="182.945" cy="286.681" r="41.494"/>
<path d="M182.946,176.029c-35.658,0-69.337,17.345-90.09,46.398c-5.921,8.288-4.001,19.806,4.286,25.726
c3.249,2.321,6.994,3.438,10.704,3.438c5.754,0,11.423-2.686,15.021-7.724c13.846-19.383,36.305-30.954,60.078-30.954
c23.775,0,46.233,11.571,60.077,30.953c5.919,8.286,17.437,10.209,25.726,4.288c8.288-5.92,10.208-17.438,4.288-25.726
C252.285,193.373,218.606,176.029,182.946,176.029z"/>
<path d="M182.946,106.873c-50.938,0-99.694,21.749-133.77,59.67c-6.807,7.576-6.185,19.236,1.392,26.044
c3.523,3.166,7.929,4.725,12.32,4.725c5.051-0.001,10.082-2.063,13.723-6.116c27.091-30.148,65.849-47.439,106.336-47.439
s79.246,17.291,106.338,47.438c6.808,7.576,18.468,8.198,26.043,1.391c7.576-6.808,8.198-18.468,1.391-26.043
C282.641,128.621,233.883,106.873,182.946,106.873z"/>
<path d="M360.611,112.293c-47.209-48.092-110.305-74.577-177.665-74.577c-67.357,0-130.453,26.485-177.664,74.579
c-7.135,7.269-7.027,18.944,0.241,26.079c3.59,3.524,8.255,5.282,12.918,5.281c4.776,0,9.551-1.845,13.161-5.522
c40.22-40.971,93.968-63.534,151.344-63.534c57.379,0,111.127,22.563,151.343,63.532c7.136,7.269,18.812,7.376,26.08,0.242
C367.637,131.238,367.745,119.562,360.611,112.293z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg id="Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.14 67.5"><defs><style>.cls-1{fill:#fff;}</style></defs><title>wifi-high</title><circle class="cls-1" cx="37.57" cy="60.64" r="6.87"/><path class="cls-1" d="M12.51,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M44.24,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M87.6,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M55.87,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M87.52,38.32c-37.49-37.49-75.09,0-75.09,0l.05-7.4s37.59-37.49,75.09,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M77.39,50.26c-27.37-27.37-54.82,0-54.82,0l0-7.4s27.45-27.37,54.82,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M68.76,64.29c-18.75-18.75-37.54,0-37.54,0l0-7.7s18.8-18.75,37.54,0Z" transform="translate(-12.43 -14.26)"/></svg>

After

Width:  |  Height:  |  Size: 927 B

View File

@@ -0,0 +1 @@
<svg id="Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.14 67.5"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:#666;}</style></defs><title>wifi-low</title><circle class="cls-1" cx="37.57" cy="60.64" r="6.87"/><path class="cls-1" d="M12.51,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M44.24,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M87.6,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M55.87,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-2" d="M87.52,38.32c-37.49-37.49-75.09,0-75.09,0l.05-7.4s37.59-37.49,75.09,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-2" d="M77.39,50.26c-27.37-27.37-54.82,0-54.82,0l0-7.4s27.45-27.37,54.82,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M68.76,64.29c-18.75-18.75-37.54,0-37.54,0l0-7.7s18.8-18.75,37.54,0Z" transform="translate(-12.43 -14.26)"/></svg>

After

Width:  |  Height:  |  Size: 944 B

View File

@@ -0,0 +1 @@
<svg id="Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.14 67.5"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:#666;}</style></defs><title>wifi-medium</title><circle class="cls-1" cx="37.57" cy="60.64" r="6.87"/><path class="cls-1" d="M12.51,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M44.24,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M87.6,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M55.87,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-2" d="M87.52,38.32c-37.49-37.49-75.09,0-75.09,0l.05-7.4s37.59-37.49,75.09,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M77.39,50.26c-27.37-27.37-54.82,0-54.82,0l0-7.4s27.45-27.37,54.82,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M68.76,64.29c-18.75-18.75-37.54,0-37.54,0l0-7.7s18.8-18.75,37.54,0Z" transform="translate(-12.43 -14.26)"/></svg>

After

Width:  |  Height:  |  Size: 947 B

View File

@@ -0,0 +1 @@
<svg id="Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.14 75.6"><defs><style>.cls-1{fill:#666;}.cls-2{fill:#fff;}</style></defs><title>wifi-off</title><circle class="cls-1" cx="37.57" cy="68.74" r="6.87"/><path class="cls-2" d="M12.51,25.76" transform="translate(-12.43 -6.16)"/><path class="cls-2" d="M44.24,78.62" transform="translate(-12.43 -6.16)"/><path class="cls-2" d="M87.6,25.76" transform="translate(-12.43 -6.16)"/><path class="cls-2" d="M55.87,78.62" transform="translate(-12.43 -6.16)"/><path class="cls-1" d="M87.52,38.32c-37.49-37.49-75.09,0-75.09,0l.05-7.4s37.59-37.49,75.09,0Z" transform="translate(-12.43 -6.16)"/><path class="cls-1" d="M77.39,50.26c-27.37-27.37-54.82,0-54.82,0l0-7.4s27.45-27.37,54.82,0Z" transform="translate(-12.43 -6.16)"/><path class="cls-1" d="M68.76,64.29c-18.75-18.75-37.54,0-37.54,0l0-7.7s18.8-18.75,37.54,0Z" transform="translate(-12.43 -6.16)"/><rect class="cls-2" x="4.79" y="38.18" width="90.41" height="4.58" transform="translate(32.39 -29.8) rotate(46.4)"/><rect class="cls-2" x="4.79" y="38.18" width="90.41" height="4.58" transform="translate(101.36 26.02) rotate(133.6)"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg id="Слой_1" data-name="Слой 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75.14 67.5"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:#666;}</style></defs><title>wifi-vary-low</title><circle class="cls-1" cx="37.57" cy="60.64" r="6.87"/><path class="cls-1" d="M12.51,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M44.24,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M87.6,25.76" transform="translate(-12.43 -14.26)"/><path class="cls-1" d="M55.87,78.62" transform="translate(-12.43 -14.26)"/><path class="cls-2" d="M87.52,38.32c-37.49-37.49-75.09,0-75.09,0l.05-7.4s37.59-37.49,75.09,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-2" d="M77.39,50.26c-27.37-27.37-54.82,0-54.82,0l0-7.4s27.45-27.37,54.82,0Z" transform="translate(-12.43 -14.26)"/><path class="cls-2" d="M68.76,64.29c-18.75-18.75-37.54,0-37.54,0l0-7.7s18.8-18.75,37.54,0Z" transform="translate(-12.43 -14.26)"/></svg>

After

Width:  |  Height:  |  Size: 949 B

20
public/images/wifi.svg Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 365.892 365.892" xml:space="preserve">
<g>
<circle cx="182.945" cy="286.681" r="41.494"/>
<path d="M182.946,176.029c-35.658,0-69.337,17.345-90.09,46.398c-5.921,8.288-4.001,19.806,4.286,25.726
c3.249,2.321,6.994,3.438,10.704,3.438c5.754,0,11.423-2.686,15.021-7.724c13.846-19.383,36.305-30.954,60.078-30.954
c23.775,0,46.233,11.571,60.077,30.953c5.919,8.286,17.437,10.209,25.726,4.288c8.288-5.92,10.208-17.438,4.288-25.726
C252.285,193.373,218.606,176.029,182.946,176.029z"/>
<path d="M182.946,106.873c-50.938,0-99.694,21.749-133.77,59.67c-6.807,7.576-6.185,19.236,1.392,26.044
c3.523,3.166,7.929,4.725,12.32,4.725c5.051-0.001,10.082-2.063,13.723-6.116c27.091-30.148,65.849-47.439,106.336-47.439
s79.246,17.291,106.338,47.438c6.808,7.576,18.468,8.198,26.043,1.391c7.576-6.808,8.198-18.468,1.391-26.043
C282.641,128.621,233.883,106.873,182.946,106.873z"/>
<path d="M360.611,112.293c-47.209-48.092-110.305-74.577-177.665-74.577c-67.357,0-130.453,26.485-177.664,74.579
c-7.135,7.269-7.027,18.944,0.241,26.079c3.59,3.524,8.255,5.282,12.918,5.281c4.776,0,9.551-1.845,13.161-5.522
c40.22-40.971,93.968-63.534,151.344-63.534c57.379,0,111.127,22.563,151.343,63.532c7.136,7.269,18.812,7.376,26.08,0.242
C367.637,131.238,367.745,119.562,360.611,112.293z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

43
public/index.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "logo.png",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

38
scripts/start-electron.js Normal file
View File

@@ -0,0 +1,38 @@
// Waits for Vite dev server, then launches Electron with ELECTRON_RUN_AS_NODE
// removed from environment (any value of that variable causes Electron to run
// as plain Node.js instead of a GUI app).
const { spawn } = require('child_process');
const http = require('http');
const electronPath = require('electron');
const VITE_URL = 'http://localhost:5173';
const POLL_INTERVAL = 500;
const MAX_WAIT_MS = 60000;
function waitForVite(url, timeout) {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeout;
const check = () => {
http.get(url, (res) => {
res.resume();
resolve();
}).on('error', () => {
if (Date.now() >= deadline) return reject(new Error('Timed out waiting for ' + url));
setTimeout(check, POLL_INTERVAL);
});
};
check();
});
}
console.log('Waiting for Vite at', VITE_URL, '...');
waitForVite(VITE_URL, MAX_WAIT_MS).then(() => {
console.log('Vite ready — starting Electron');
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
const child = spawn(electronPath, ['.'], { stdio: 'inherit', env });
child.on('close', code => process.exit(code ?? 0));
}).catch(err => {
console.error(err.message);
process.exit(1);
});

12
sidebar.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sidebar</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/entries/sidebar.tsx"></script>
</body>
</html>

6
src/App.tsx Normal file
View File

@@ -0,0 +1,6 @@
import React from 'react'
import HomePage from './pages/HomePage'
const App: React.FC = () => <HomePage />
export default App

View File

@@ -0,0 +1,27 @@
import React from 'react'
export interface AppCardProps {
name: string
imageUrl: string
url: string
useProxy?: boolean
}
const AppCard: React.FC<AppCardProps> = ({ name, imageUrl, url, useProxy }) => {
const openApp = () => {
window.electron?.createView(name, url, imageUrl, 1.0, useProxy ?? false)
}
return (
<div className="app-card" onClick={openApp}>
{imageUrl ? (
<img src={imageUrl} alt={name} />
) : (
<div className="app-card-icon-placeholder">{name.charAt(0).toUpperCase()}</div>
)}
<h3>{name}</h3>
</div>
)
}
export default AppCard

View File

@@ -0,0 +1,37 @@
import React from 'react'
import AppCard, { AppCardProps } from './AppCard'
import BookmarksBar from './BookmarksBar'
import { Bookmark } from './Settings'
interface AppListProps {
apps: AppCardProps[]
bookmarks: Bookmark[]
onBookmarkOpen: (b: Bookmark) => void
onBookmarkRemove: (index: number) => void
}
const AppList: React.FC<AppListProps> = ({ apps, bookmarks, onBookmarkOpen, onBookmarkRemove }) => {
return (
<div className="app-list">
<BookmarksBar bookmarks={bookmarks} onOpen={onBookmarkOpen} onRemove={onBookmarkRemove} />
{apps.length === 0 ? (
<div className="app-list-empty">
<p>Нет приложений.</p>
<p>Откройте настройки (шестерёнка) и добавьте сайты.</p>
</div>
) : (
apps.map((card, i) => (
<AppCard
key={i}
name={card.name}
imageUrl={card.imageUrl}
url={card.url}
useProxy={card.useProxy}
/>
))
)}
</div>
)
}
export default AppList

View File

@@ -0,0 +1,73 @@
import React, { useState } from 'react'
import { Bookmark } from './Settings'
interface BookmarksBarProps {
bookmarks: Bookmark[]
onOpen: (b: Bookmark) => void
onRemove: (index: number) => void
}
const BookmarksBar: React.FC<BookmarksBarProps> = ({ bookmarks, onOpen, onRemove }) => {
const [expanded, setExpanded] = useState(false)
if (!bookmarks.length) return null
return (
<div className="bookmarks-bar">
<div className="bookmarks-bar-header" onClick={() => setExpanded(e => !e)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" 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>
<span>Закладки ({bookmarks.length})</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
{expanded ? <polyline points="18 15 12 9 6 15" /> : <polyline points="6 9 12 15 18 9" />}
</svg>
</div>
<div className={`bookmarks-collapse${expanded ? ' open' : ''}`}>
<div className="bookmarks-collapse-inner">
<div className="bookmarks-list">
{bookmarks.map((b, i) => {
const hasMoviePoster = !!b.poster && b.poster !== b.siteIcon
return (
<div key={i} className="bookmark-card" onClick={() => onOpen(b)}>
<div className="bookmark-poster">
{b.poster
? <img src={b.poster} alt={b.title} onError={e => {
const t = e.currentTarget
t.style.display = 'none'
const ph = t.nextElementSibling as HTMLElement | null
if (ph) ph.style.display = 'flex'
}} />
: null}
<div className="bookmark-poster-placeholder" style={b.poster ? { display: 'none' } : undefined}>
{b.title.charAt(0).toUpperCase()}
</div>
</div>
<div className="bookmark-info">
<div className="bookmark-title" title={b.title}>{b.title}</div>
{b.source && (
<div className="bookmark-source-row">
{hasMoviePoster && b.siteIcon && (
<img className="bookmark-source-icon" src={b.siteIcon} alt="" onError={e => { e.currentTarget.style.display = 'none' }} />
)}
<span className="bookmark-source">{b.source}</span>
</div>
)}
</div>
<button
className="bookmark-remove"
onClick={e => { e.stopPropagation(); onRemove(i) }}
title="Удалить закладку"
></button>
</div>
)
})}
</div>
</div>
</div>
</div>
)
}
export default BookmarksBar

344
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,344 @@
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

View File

@@ -0,0 +1,629 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { MovieSite } from './Settings'
const Select: React.FC<{
value: string
onChange: (v: string) => void
options: { value: string; label: string }[]
placeholder?: string
}> = ({ value, onChange, options, placeholder }) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const selected = options.find(o => o.value === value)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
return (
<div ref={ref} className={`ms-select${open ? ' open' : ''}`} onClick={() => setOpen(o => !o)}>
<div className="ms-select-trigger">
<span className={selected && selected.value ? 'ms-select-active' : 'ms-select-placeholder'}>
{selected ? selected.label : (placeholder ?? '')}
</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
{open && (
<div className="ms-select-dropdown" onClick={e => e.stopPropagation()}>
{options.map(o => (
<div
key={o.value}
className={`ms-select-opt${o.value === value ? ' active' : ''}`}
onClick={() => { onChange(o.value); setOpen(false) }}
>{o.label}</div>
))}
</div>
)}
</div>
)
}
// Multi-select dropdown with checkboxes. Trigger shows N selected or placeholder.
const MultiSelect: React.FC<{
values: string[]
onChange: (v: string[]) => void
options: { value: string; label: string }[]
placeholder: string
maxHeight?: number
}> = ({ values, onChange, options, placeholder, maxHeight = 260 }) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const toggle = (v: string) => {
onChange(values.includes(v) ? values.filter(x => x !== v) : [...values, v])
}
const label = values.length === 0
? placeholder
: values.length === 1
? (options.find(o => o.value === values[0])?.label ?? values[0])
: `${placeholder}: ${values.length}`
return (
<div ref={ref} className={`ms-select${open ? ' open' : ''}`}>
<div className="ms-select-trigger" onClick={() => setOpen(o => !o)}>
<span className={values.length > 0 ? 'ms-select-active' : 'ms-select-placeholder'}>{label}</span>
{values.length > 0 && (
<button className="ms-multi-clear" onClick={e => { e.stopPropagation(); onChange([]) }} title="Сбросить">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
{open && (
<div className="ms-select-dropdown ms-multi-dropdown" style={{ maxHeight }} onClick={e => e.stopPropagation()}>
{options.map(o => {
const on = values.includes(o.value)
return (
<div key={o.value} className={`ms-select-opt ms-multi-opt${on ? ' active' : ''}`} onClick={() => toggle(o.value)}>
<span className={`ms-checkbox${on ? ' on' : ''}`}>{on && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}</span>
{o.label}
</div>
)
})}
</div>
)}
</div>
)
}
interface TmdbMovie {
id: number
mediaType: 'movie' | 'tv'
title: string
originalTitle: string
year: string
poster: string
overview: string
rating: string
}
interface SiteResult {
title: string
url: string
poster?: string
year?: string
source: string
}
interface MovieSearchProps {
onOpenUrl: (name: string, url: string) => void
onBookmark?: (title: string, url: string, poster: string, source: string) => void
initialQuery?: string
}
const MOVIE_GENRES = [
{ id: 28, name: 'Боевик' }, { id: 12, name: 'Приключения' }, { id: 16, name: 'Мультфильм' },
{ id: 35, name: 'Комедия' }, { id: 80, name: 'Криминал' }, { id: 99, name: 'Документальный' },
{ id: 18, name: 'Драма' }, { id: 10751, name: 'Семейный' }, { id: 14, name: 'Фэнтези' },
{ id: 36, name: 'История' }, { id: 27, name: 'Ужасы' }, { id: 9648, name: 'Детектив' },
{ id: 10749, name: 'Мелодрама' }, { id: 878, name: 'Фантастика' }, { id: 53, name: 'Триллер' },
{ id: 10752, name: 'Военный' }, { id: 37, name: 'Вестерн' },
]
const TV_GENRES = [
{ id: 10759, name: 'Боевик' }, { id: 16, name: 'Мультфильм' }, { id: 35, name: 'Комедия' },
{ id: 80, name: 'Криминал' }, { id: 99, name: 'Документальный' }, { id: 18, name: 'Драма' },
{ id: 10751, name: 'Семейный' }, { id: 10762, name: 'Детское' }, { id: 9648, name: 'Детектив' },
{ id: 10765, name: 'Фантастика' }, { id: 10768, name: 'Политика' }, { id: 37, name: 'Вестерн' },
]
const SORTS = [
{ value: 'popularity.desc', label: 'Популярные' },
{ value: 'vote_average.desc', label: 'По рейтингу' },
{ value: 'release_date.desc', label: 'Новые' },
{ value: 'revenue.desc', label: 'По сборам' },
]
const RATINGS = [
{ value: '', label: 'Любой' },
{ value: '5', label: '5+' },
{ value: '6', label: '6+' },
{ value: '7', label: '7+' },
{ value: '8', label: '8+' },
{ value: '9', label: '9+' },
]
const COUNTRIES = [
{ value: '', label: 'Страна' },
{ value: 'US', label: 'США' },
{ value: 'RU', label: 'Россия' },
{ value: 'GB', label: 'Великобритания' },
{ value: 'FR', label: 'Франция' },
{ value: 'DE', label: 'Германия' },
{ value: 'IT', label: 'Италия' },
{ value: 'ES', label: 'Испания' },
{ value: 'JP', label: 'Япония' },
{ value: 'KR', label: 'Южная Корея' },
{ value: 'CN', label: 'Китай' },
{ value: 'IN', label: 'Индия' },
{ value: 'SE', label: 'Швеция' },
{ value: 'DK', label: 'Дания' },
{ value: 'TR', label: 'Турция' },
]
const CURRENT_YEAR = new Date().getFullYear()
const YEARS = Array.from({ length: CURRENT_YEAR - 1899 }, (_, i) => CURRENT_YEAR + 1 - i)
const StarIcon = () => (
<svg width="11" height="11" viewBox="0 0 24 24" fill="#f5c518" stroke="none">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
)
const MovieCard: React.FC<{ movie: TmdbMovie; idx: number; baseIdx: number; onSelect: (m: TmdbMovie) => void }> = ({ movie, idx, baseIdx, onSelect }) => (
<div
className="movie-result-card"
style={{ animationDelay: `${Math.max(0, idx - baseIdx) * 30}ms` }}
onClick={() => onSelect(movie)}
>
<div className="movie-result-poster">
{movie.poster
? <img src={movie.poster} alt={movie.title} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style') }} />
: null
}
<div className="movie-result-poster-placeholder" style={movie.poster ? { display: 'none' } : undefined}>
{movie.title.charAt(0).toUpperCase()}
</div>
</div>
<div className="movie-result-info">
<span className="movie-result-title">{movie.title}</span>
<div className="ms-card-meta">
{movie.year && <span className="movie-result-year">{movie.year}</span>}
{movie.rating && <span className="ms-card-rating"><StarIcon /> {movie.rating}</span>}
</div>
{movie.mediaType === 'tv' && <span className="ms-card-type">Сериал</span>}
</div>
</div>
)
const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initialQuery = '' }) => {
const [query, setQuery] = useState(initialQuery)
const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie')
const [sortBy, setSortBy] = useState('popularity.desc')
const [genreIds, setGenreIds] = useState<number[]>([])
const [years, setYears] = useState<string[]>([])
const [minRating, setMinRating] = useState('')
const [countries, setCountries] = useState<string[]>([])
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [activeQuery, setActiveQuery] = useState(initialQuery) // committed query (on Enter)
const [tmdbResults, setTmdbResults] = useState<TmdbMovie[]>([])
const [selected, setSelected] = useState<TmdbMovie | null>(null)
const [siteResults, setSiteResults] = useState<SiteResult[]>([])
const [tmdbLoading, setTmdbLoading] = useState(false)
const [sitesLoading, setSitesLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [cardBase, setCardBase] = useState(0)
const [message, setMessage] = useState('')
const [apiKey, setApiKey] = useState('')
const [sites, setSites] = useState<MovieSite[]>([])
const [configLoaded, setConfigLoaded] = useState(false)
const isSearchMode = activeQuery.trim().length > 0
const genres = mediaType === 'tv' ? TV_GENRES : MOVIE_GENRES
useEffect(() => {
window.electron?.readConfig().then((cfg: any) => {
let enabled: MovieSite[] = (cfg?.movieSites ?? []).filter((s: MovieSite) => s.enabled !== false)
const NON_MOVIE = /youtube|rutube|vk\.com|ok\.ru|google|yandex|mail\.ru|twitch|tiktok|instagram|facebook|twitter|telegram/i
if (!enabled.length && cfg?.apps?.length) {
enabled = cfg.apps
.filter((app: any) => { try { return !NON_MOVIE.test(new URL(app.url).hostname) } catch { return false } })
.map((app: any) => {
let domain = app.url
try { domain = new URL(app.url).hostname } catch (_) {}
const type: MovieSite['type'] = /rezka/.test(domain) ? 'hdrezka' : /filmix/.test(domain) ? 'filmix' : 'dle'
return { domain, type, enabled: true }
})
}
const key: string = cfg?.tmdbApiKey ?? ''
setSites(enabled)
setApiKey(key)
setConfigLoaded(true)
if (initialQuery) {
if (key) doTmdbSearch(initialQuery, key)
else doSiteSearch(initialQuery, enabled, undefined, undefined)
}
})
}, [])
// Auto-load discover when config ready or filters change (discover mode only)
const discoverRef = useRef(0)
const doDiscover = useCallback(async (key: string, pg: number, append: boolean) => {
const token = ++discoverRef.current
if (append) setLoadingMore(true)
else { setTmdbLoading(true); setMessage('') }
try {
const res = await window.electron!.discoverTmdb({ apiKey: key, mediaType, sortBy, genreIds, years, minRating, countries, page: pg })
if (token !== discoverRef.current) return
if (res.error) { setMessage(`Ошибка: ${res.error}`); return }
setTmdbResults(prev => append ? [...prev, ...res.results] : res.results)
setTotalPages(res.totalPages)
if (!res.results.length && !append) setMessage('Ничего не найдено')
} catch {
if (token === discoverRef.current) setMessage('Ошибка загрузки')
} finally {
if (token === discoverRef.current) { setTmdbLoading(false); setLoadingMore(false) }
}
}, [mediaType, sortBy, genreIds, years, minRating, countries])
useEffect(() => {
if (!configLoaded || !apiKey || isSearchMode) return
setPage(1)
setTmdbResults([])
doDiscover(apiKey, 1, false)
}, [configLoaded, apiKey, mediaType, sortBy, genreIds, years, minRating, countries, isSearchMode])
const searchRef = useRef(0)
const doTmdbSearch = async (q: string, key: string) => {
const token = ++searchRef.current
setTmdbLoading(true)
setMessage('')
setTmdbResults([])
setSelected(null)
setSiteResults([])
try {
const res = await window.electron!.searchTmdb(q, key)
if (token !== searchRef.current) return
if (res.error) { setMessage(`Ошибка TMDB: ${res.error}`); return }
setTmdbResults(res.results)
setTotalPages(1)
if (!res.results.length) setMessage('Ничего не найдено')
} catch {
if (token === searchRef.current) setMessage('Ошибка TMDB')
} finally {
if (token === searchRef.current) setTmdbLoading(false)
}
}
const doSiteSearch = async (q: string, sitesToSearch: MovieSite[], yearHint?: string, mt?: string) => {
if (!sitesToSearch.length) { setMessage('Нет активных сайтов. Добавьте в Настройки → Поиск фильмов.'); return }
setSitesLoading(true)
setMessage('')
setSiteResults([])
try {
const data = await window.electron!.searchMovies(q, sitesToSearch)
let filtered = data
if (yearHint) {
const y = parseInt(yearHint)
const isTv = mt === 'tv'
const yearDist = (r: SiteResult) => r.year ? Math.abs(parseInt(r.year) - y) : 0.5
const normalizeTitle = (t: string) => t.toLowerCase().replace(/[^а-яёa-z0-9]/gi, ' ').replace(/\s+/g, ' ').trim()
const groups = new Map<string, SiteResult[]>()
for (const r of data) {
const key = normalizeTitle(r.title)
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(r)
}
const deduped: SiteResult[] = []
for (const group of groups.values()) {
const minDist = Math.min(...group.map(yearDist))
deduped.push(...group.filter(r => yearDist(r) === minDist))
}
filtered = isTv
? deduped.sort((a, b) => yearDist(a) - yearDist(b))
: deduped.filter(r => !r.year || yearDist(r) <= 1).sort((a, b) => yearDist(a) - yearDist(b))
}
setSiteResults(filtered)
if (!filtered.length) setMessage('Не найдено ни на одном сайте')
} catch {
setMessage('Ошибка поиска по сайтам')
} finally {
setSitesLoading(false)
}
}
const handleSearch = () => {
const q = query.trim()
if (!q) return
setActiveQuery(q)
if (apiKey) doTmdbSearch(q, apiKey)
else doSiteSearch(q, sites, undefined, undefined)
}
const handleSelectMovie = (movie: TmdbMovie) => {
setSelected(movie)
setSiteResults([])
setMessage('')
const searchTitle = movie.title || movie.originalTitle
doSiteSearch(searchTitle, sites, movie.year, movie.mediaType)
if (movie.originalTitle && movie.originalTitle !== movie.title) {
window.electron!.searchMovies(movie.originalTitle, sites).then(extra => {
setSiteResults(prev => {
const existing = new Set(prev.map(r => r.url))
return [...prev, ...extra.filter(r => !existing.has(r.url))]
})
}).catch(() => {})
}
}
const handleBack = () => { setSelected(null); setSiteResults([]); setMessage('') }
const handleLoadMore = () => {
const nextPage = page + 1
setPage(nextPage)
setCardBase(tmdbResults.length)
doDiscover(apiKey, nextPage, true)
}
const handleFilterChange = (fn: () => void) => {
setSelected(null)
setSiteResults([])
fn()
}
const clearQuery = () => {
setQuery('')
setActiveQuery('')
setTmdbResults([])
setMessage('')
}
const loading = tmdbLoading || sitesLoading
return (
<div className="movie-search">
{/* Search bar */}
<div className="movie-search-bar">
<input
className="header-search-input movie-search-input"
placeholder="Название фильма или сериала..."
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !loading && handleSearch()}
autoFocus
/>
{query && (
<button className="ms-clear-btn" onClick={clearQuery} title="Очистить">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
<button className="header-search-btn movie-search-btn" onClick={handleSearch} disabled={loading}>
<svg width="16" height="16" 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>
</div>
{/* Filters (only in discover mode with TMDB key) */}
{apiKey && !selected && (
<div className="ms-filters">
<div className="ms-filter-row">
{/* Type toggle */}
<div className="ms-type-toggle">
<button
className={`ms-type-btn${mediaType === 'movie' ? ' active' : ''}`}
onClick={() => handleFilterChange(() => { setMediaType('movie'); setGenreIds([]) })}
>Фильмы</button>
<button
className={`ms-type-btn${mediaType === 'tv' ? ' active' : ''}`}
onClick={() => handleFilterChange(() => { setMediaType('tv'); setGenreIds([]) })}
>Сериалы</button>
</div>
{/* Sort */}
{!isSearchMode && (
<Select value={sortBy} onChange={v => handleFilterChange(() => setSortBy(v))} options={SORTS} />
)}
{/* Min rating (single, threshold) */}
{!isSearchMode && (
<Select
value={minRating}
onChange={v => handleFilterChange(() => setMinRating(v))}
options={RATINGS.map(r => ({ value: r.value, label: r.label === 'Любой' ? 'Рейтинг' : r.label }))}
placeholder="Рейтинг"
/>
)}
{/* Years (multi, OR) */}
<MultiSelect
values={years}
onChange={v => handleFilterChange(() => setYears(v))}
options={YEARS.map(y => ({ value: String(y), label: String(y) }))}
placeholder="Год"
/>
{/* Countries (multi, OR) */}
{!isSearchMode && (
<MultiSelect
values={countries}
onChange={v => handleFilterChange(() => setCountries(v))}
options={COUNTRIES.filter(c => c.value).map(c => ({ value: c.value, label: c.label }))}
placeholder="Страна"
/>
)}
</div>
{/* Genres (multi, AND — фильм должен соответствовать ВСЕМ выбранным жанрам) */}
{!isSearchMode && (
<div className="ms-genres">
<button
className={`ms-genre-chip${genreIds.length === 0 ? ' active' : ''}`}
onClick={() => handleFilterChange(() => setGenreIds([]))}
title="Сбросить жанры"
>Все</button>
{genres.map(g => (
<button
key={g.id}
className={`ms-genre-chip${genreIds.includes(g.id) ? ' active' : ''}`}
onClick={() => handleFilterChange(() =>
setGenreIds(prev => prev.includes(g.id) ? prev.filter(x => x !== g.id) : [...prev, g.id])
)}
>{g.name}</button>
))}
{genreIds.length > 1 && (
<span className="ms-genres-hint">все выбранные одновременно</span>
)}
</div>
)}
</div>
)}
{message && <p className="movie-search-message">{message}</p>}
{/* Detail view */}
{selected ? (
<div className="ms-detail">
{selected.poster && (
<div className="ms-detail-bg">
<img className="ms-detail-bg-img" src={selected.poster} alt="" />
<div className="ms-detail-bg-gradient" />
</div>
)}
<div className="ms-detail-content">
<button className="ms-back-btn" onClick={handleBack}> Назад</button>
<div className="ms-detail-card">
{selected.poster
? <img className="ms-detail-poster" src={selected.poster} alt={selected.title} />
: <div className="ms-poster-placeholder">{selected.title.charAt(0)}</div>
}
<div className="ms-detail-info">
<h2 className="ms-detail-title">{selected.title}</h2>
{selected.originalTitle !== selected.title && (
<p className="ms-detail-orig">{selected.originalTitle}</p>
)}
<div className="ms-detail-meta">
{selected.year && <span>{selected.year}</span>}
{selected.mediaType === 'tv' && <span>Сериал</span>}
{selected.rating && <span><StarIcon /> {selected.rating}</span>}
</div>
{selected.overview && <p className="ms-detail-overview">{selected.overview}</p>}
</div>
</div>
<div className="ms-site-results">
{sitesLoading && <p className="movie-search-message">Ищем на {sites.length} сайтах...</p>}
{!sitesLoading && !siteResults.length && !message && <p className="movie-search-message">Поиск...</p>}
{!sitesLoading && siteResults.length > 0 && (
<div className="ms-sites-label">
Найдено на сайтах
<button className="ms-retry-btn" onClick={() => selected && doSiteSearch(selected.title || selected.originalTitle, sites, selected.year, selected.mediaType)} title="Повторить поиск">
<svg width="13" height="13" 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>
</div>
)}
{!sitesLoading && !siteResults.length && (
<button className="ms-retry-btn ms-retry-standalone" onClick={() => selected && doSiteSearch(selected.title || selected.originalTitle, sites, selected.year, selected.mediaType)}>
Повторить поиск
</button>
)}
{siteResults.map((r, i) => (
<div key={i} className="ms-site-row" onClick={() => onOpenUrl(r.title, r.url)}>
<span className="ms-site-source">{r.source}</span>
<span className="ms-site-title">{r.title}</span>
<div className="ms-site-actions">
{onBookmark && (
<button className="ms-bookmark-btn" title="В закладки" onClick={e => {
e.stopPropagation()
onBookmark(selected?.title || r.title, r.url, selected?.poster || '', r.source)
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" 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>
)}
<span className="ms-site-open">Открыть </span>
</div>
</div>
))}
</div>
</div>
</div>
) : (
<>
{tmdbLoading && <p className="movie-search-message">{isSearchMode ? 'Поиск...' : 'Загрузка...'}</p>}
{tmdbResults.length > 0 && (
<>
<div className="movie-results">
{tmdbResults.map((movie, i) => <MovieCard key={movie.id} movie={movie} idx={i} baseIdx={cardBase} onSelect={handleSelectMovie} />)}
</div>
{!isSearchMode && page < totalPages && (
<button className="ms-load-more" onClick={handleLoadMore} disabled={loadingMore}>
{loadingMore ? 'Загрузка...' : 'Загрузить ещё'}
</button>
)}
</>
)}
{/* Direct site results (no TMDB key) */}
{siteResults.length > 0 && (
<div className="movie-results">
{siteResults.map((r, i) => (
<div key={i} className="movie-result-card" onClick={() => onOpenUrl(r.title, r.url)}>
<div className="movie-result-poster">
{r.poster
? <img src={r.poster} alt={r.title} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style') }} />
: null
}
<div className="movie-result-poster-placeholder" style={r.poster ? { display: 'none' } : undefined}>{r.title.charAt(0).toUpperCase()}</div>
</div>
<div className="movie-result-info">
<span className="movie-result-title">{r.title}</span>
{r.year && <span className="movie-result-year">{r.year}</span>}
<span className="movie-result-source">{r.source}</span>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
)
}
export default MovieSearch

354
src/components/Settings.tsx Normal file
View File

@@ -0,0 +1,354 @@
import React, { useState, useEffect } from 'react'
export interface AppEntry {
name: string
imageUrl: string
url: string
useProxy: boolean
}
export interface Bookmark {
title: string
url: string
poster?: string // movie poster (og:image, or fallback site icon)
source?: string // domain shown under title
siteIcon?: string // small site icon shown alongside source
}
export interface MovieSite {
domain: string
type: 'dle' | 'hdrezka' | 'filmix'
enabled: boolean
}
interface ProxyConfig {
host: string
port: string
}
interface SettingsData {
apps: AppEntry[]
proxy: ProxyConfig
movieSites: MovieSite[]
tmdbApiKey: string
bookmarks: Bookmark[]
trustedDomains?: string[]
}
interface SettingsProps {
onClose: () => void
onAppsChange: (apps: AppEntry[]) => void
}
const DEFAULT_TRUSTED_DOMAINS = [
'google.com', 'accounts.google.com', 'googleapis.com', 'googleusercontent.com',
'gstatic.com', 'youtube.com', 'ytimg.com', 'googlevideo.com',
'yandex.ru', 'yandex.com', 'passport.yandex.ru', 'passport.yandex.com', 'yastatic.net',
'github.com', 'github.io', 'githubassets.com', 'githubusercontent.com',
'vk.com', 'vk.ru', 'vkuser.net', 'mail.ru', 'my.mail.ru',
'live.com', 'microsoft.com', 'microsoftonline.com', 'office.com',
'apple.com', 'icloud.com', 'facebook.com', 'fb.com',
]
const DEFAULT_SETTINGS: SettingsData = { apps: [], proxy: { host: '127.0.0.1', port: '7890' }, movieSites: [], tmdbApiKey: '', bookmarks: [], trustedDomains: DEFAULT_TRUSTED_DOMAINS }
function guessMovieSiteType(domain: string): MovieSite['type'] {
if (/rezka/.test(domain)) return 'hdrezka'
if (/filmix/.test(domain)) return 'filmix'
return 'dle'
}
function saveSettings(data: SettingsData) {
window.electron?.writeConfig(data)
}
const Settings: React.FC<SettingsProps> = ({ onClose, onAppsChange }) => {
const [settings, setSettings] = useState<SettingsData>(DEFAULT_SETTINGS)
const [newApp, setNewApp] = useState<AppEntry>({ name: '', imageUrl: '', url: '', useProxy: false })
useEffect(() => {
window.electron?.readConfig().then((cfg: SettingsData | null) => {
if (cfg?.apps) setSettings(cfg)
})
}, [])
const updateProxy = (field: keyof ProxyConfig, value: string) => {
const updated = { ...settings, proxy: { ...settings.proxy, [field]: value } }
setSettings(updated)
saveSettings(updated)
window.electron?.setProxy(updated.proxy.host, updated.proxy.port)
}
const toggleAppProxy = (index: number) => {
const apps = settings.apps.map((app, i) =>
i === index ? { ...app, useProxy: !app.useProxy } : app
)
const updated = { ...settings, apps }
setSettings(updated)
saveSettings(updated)
onAppsChange(apps)
}
const addApp = () => {
if (!newApp.name || !newApp.url) return
const apps = [...settings.apps, newApp]
const updated = { ...settings, apps }
setSettings(updated)
saveSettings(updated)
onAppsChange(apps)
setNewApp({ name: '', imageUrl: '', url: '', useProxy: false })
}
const removeApp = (index: number) => {
const apps = settings.apps.filter((_, i) => i !== index)
const updated = { ...settings, apps }
setSettings(updated)
saveSettings(updated)
onAppsChange(apps)
}
const [newSite, setNewSite] = useState<MovieSite>({ domain: '', type: 'dle', enabled: true })
const [newTrusted, setNewTrusted] = useState('')
const trustedDomains = settings.trustedDomains ?? DEFAULT_TRUSTED_DOMAINS
const addTrustedDomain = () => {
const d = newTrusted.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^\./, '')
if (!d || trustedDomains.includes(d)) { setNewTrusted(''); return }
const updated = { ...settings, trustedDomains: [...trustedDomains, d] }
setSettings(updated); saveSettings(updated); setNewTrusted('')
}
const removeTrustedDomain = (index: number) => {
const updated = { ...settings, trustedDomains: trustedDomains.filter((_, i) => i !== index) }
setSettings(updated); saveSettings(updated)
}
const resetTrustedDomains = () => {
const updated = { ...settings, trustedDomains: DEFAULT_TRUSTED_DOMAINS }
setSettings(updated); saveSettings(updated)
}
const addMovieSite = () => {
if (!newSite.domain.trim()) return
let domain = newSite.domain.trim()
if (domain.startsWith('https://')) domain = domain.slice(8)
else if (domain.startsWith('http://')) domain = domain.slice(7)
if (domain.endsWith('/')) domain = domain.slice(0, -1)
const movieSites = [...(settings.movieSites ?? []), { ...newSite, domain }]
const updated = { ...settings, movieSites }
setSettings(updated)
saveSettings(updated)
setNewSite({ domain: '', type: 'dle', enabled: true })
}
const removeMovieSite = (index: number) => {
const movieSites = (settings.movieSites ?? []).filter((_, i) => i !== index)
const updated = { ...settings, movieSites }
setSettings(updated)
saveSettings(updated)
}
const toggleMovieSite = (index: number) => {
const movieSites = (settings.movieSites ?? []).map((s, i) =>
i === index ? { ...s, enabled: !s.enabled } : s
)
const updated = { ...settings, movieSites }
setSettings(updated)
saveSettings(updated)
}
return (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-panel" onClick={e => e.stopPropagation()}>
<div className="settings-header">
<h2>Настройки</h2>
<button className="settings-close" onClick={onClose}></button>
</div>
<div className="settings-section">
<h3>Прокси</h3>
<div className="proxy-row">
<input
className="settings-input"
placeholder="Хост"
value={settings.proxy.host}
onChange={e => updateProxy('host', e.target.value)}
/>
<span className="proxy-colon">:</span>
<input
className="settings-input proxy-port"
placeholder="Порт"
value={settings.proxy.port}
onChange={e => updateProxy('port', e.target.value)}
/>
</div>
<div className="proxy-info">
<code>http_proxy=http://{settings.proxy.host}:{settings.proxy.port}</code>
<code>https_proxy=http://{settings.proxy.host}:{settings.proxy.port}</code>
<code>socks5://{settings.proxy.host}:{settings.proxy.port}</code>
</div>
<p className="proxy-hint">
Прокси применяется к каждому сайту индивидуально переключатель рядом с каждым приложением.
</p>
</div>
<div className="settings-section">
<div className="settings-section-head-row">
<h3>Доверенные домены</h3>
<button className="settings-reset-btn" onClick={resetTrustedDomains} title="Сбросить к стандартному списку">Сбросить</button>
</div>
<p className="proxy-hint">
Переходы и popup'ы на эти домены открываются без подтверждения — нужно для входа через Google, Яндекс, GitHub и т.п.
Совпадение по суффиксу: <code>google.com</code> разрешит и <code>accounts.google.com</code>, и <code>www.google.com</code>.
</p>
<div className="trusted-domains-list">
{trustedDomains.map((d, i) => (
<span key={i} className="trusted-domain-chip">
{d}
<button className="trusted-domain-remove" onClick={() => removeTrustedDomain(i)} title="Удалить">✕</button>
</span>
))}
{trustedDomains.length === 0 && <p className="settings-empty">Список пуст.</p>}
</div>
<div className="trusted-domain-add">
<input
className="settings-input"
placeholder="example.com"
value={newTrusted}
onChange={e => setNewTrusted(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTrustedDomain()}
/>
<button className="settings-add-btn" onClick={addTrustedDomain}>Добавить</button>
</div>
</div>
<div className="settings-section">
<h3>Приложения</h3>
<div className="settings-apps-list">
{settings.apps.map((app, i) => (
<div key={i} className="settings-app-row">
<div className="settings-app-info">
{app.imageUrl && (
<img src={app.imageUrl} alt={app.name} className="settings-app-icon" />
)}
<div className="settings-app-text">
<span className="settings-app-name">{app.name}</span>
<span className="settings-app-url">{app.url}</span>
</div>
</div>
<div className="settings-app-actions">
<label className="proxy-switch-label">
<span>Прокси</span>
<div
className={`proxy-switch ${app.useProxy ? 'on' : 'off'}`}
onClick={() => toggleAppProxy(i)}
/>
</label>
<button className="settings-remove-btn" onClick={() => removeApp(i)}>✕</button>
</div>
</div>
))}
{settings.apps.length === 0 && (
<p className="settings-empty">Нет приложений. Добавьте ниже.</p>
)}
</div>
<div className="add-app-form">
<h4>Добавить приложение</h4>
<input
className="settings-input"
placeholder="Название"
value={newApp.name}
onChange={e => setNewApp({ ...newApp, name: e.target.value })}
/>
<input
className="settings-input"
placeholder="URL сайта"
value={newApp.url}
onChange={e => setNewApp({ ...newApp, url: e.target.value })}
/>
<input
className="settings-input"
placeholder="URL иконки (необязательно)"
value={newApp.imageUrl}
onChange={e => setNewApp({ ...newApp, imageUrl: e.target.value })}
/>
<label className="proxy-switch-label add-proxy-label">
<span>Использовать прокси</span>
<div
className={`proxy-switch ${newApp.useProxy ? 'on' : 'off'}`}
onClick={() => setNewApp({ ...newApp, useProxy: !newApp.useProxy })}
/>
</label>
<button className="settings-add-btn" onClick={addApp}>Добавить</button>
</div>
</div>
<div className="settings-section">
<h3>Поиск фильмов</h3>
<h4>TMDB API ключ</h4>
<input
className="settings-input"
placeholder="Получить бесплатно на themoviedb.org"
value={settings.tmdbApiKey ?? ''}
onChange={e => {
const updated = { ...settings, tmdbApiKey: e.target.value }
setSettings(updated)
saveSettings(updated)
}}
/>
<p className="proxy-hint">Нужен для постеров и метаданных. Без ключа поиск работает напрямую по сайтам.</p>
<h4>Сайты</h4>
<p className="proxy-hint">Тип определяется автоматически. Поддерживаются: kinogo, lordfilm, gidonline, hdrezka, filmix и их зеркала. Домен без https://</p>
<div className="settings-apps-list">
{(settings.movieSites ?? []).map((site, i) => (
<div key={i} className="settings-app-row">
<div className="settings-app-info">
<div className="settings-app-text">
<span className="settings-app-name">{site.domain}</span>
<span className="settings-app-url">{site.type}</span>
</div>
</div>
<div className="settings-app-actions">
<label className="proxy-switch-label">
<span>Вкл</span>
<div className={`proxy-switch ${site.enabled ? 'on' : 'off'}`} onClick={() => toggleMovieSite(i)} />
</label>
<button className="settings-remove-btn" onClick={() => removeMovieSite(i)}>✕</button>
</div>
</div>
))}
{!(settings.movieSites ?? []).length && (
<p className="settings-empty">Нет сайтов. Добавьте ниже.</p>
)}
</div>
<div className="add-app-form">
<h4>Добавить сайт</h4>
<input
className="settings-input"
placeholder="Домен (например: kinogo.cc)"
value={newSite.domain}
onChange={e => {
const domain = e.target.value
setNewSite({ ...newSite, domain, type: guessMovieSiteType(domain) })
}}
/>
<select
className="settings-select"
value={newSite.type}
onChange={e => setNewSite({ ...newSite, type: e.target.value as MovieSite['type'] })}
>
<option value="dle">DLE (kinogo, lordfilm и зеркала)</option>
<option value="hdrezka">HDRezka</option>
<option value="filmix">Filmix</option>
</select>
<button className="settings-add-btn" onClick={addMovieSite}>Добавить</button>
</div>
</div>
</div>
</div>
)
}
export default Settings

View File

@@ -0,0 +1,72 @@
import React, { useState, useRef, useEffect } from 'react'
import SidebarElement, { SidebarElementProps } from './SidebarElement'
interface SidebarProps {
openedApps: SidebarElementProps[]
activeApp: string
setActiveApp: (name: string) => void
}
const Sidebar: React.FC<SidebarProps> = ({ openedApps, activeApp, setActiveApp }) => {
const [expanded, setExpanded] = useState(false)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const openedAppsCount = openedApps.length
useEffect(() => {
if (openedAppsCount === 0 && activeApp !== 'movie-search') setActiveApp('home')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openedAppsCount, setActiveApp])
const handleMouseEnter = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
setExpanded(true)
window.electron?.adjustView(true)
}, 150)
}
const handleMouseLeave = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
setExpanded(false)
window.electron?.adjustView(false)
}, 150)
}
const goHome = () => {
window.electron?.hideView()
setActiveApp('home')
}
return (
<div
className={`sidebar ${expanded ? 'expanded' : 'collapsed'}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div
className={`sidebar-item ${activeApp === 'home' ? 'active' : ''}`}
onClick={goHome}
>
<svg className="sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
<span>Домой</span>
</div>
{openedApps.map(app => (
<SidebarElement
key={app.name}
name={app.name}
imageUrl={app.imageUrl}
url={app.url}
isActive={app.isActive}
onClick={app.onClick}
/>
))}
</div>
)
}
export default Sidebar

View File

@@ -0,0 +1,26 @@
import React from 'react'
export interface SidebarElementProps {
name: string
imageUrl: string
url: string
isActive: boolean
onClick: () => void
}
const SidebarElement: React.FC<SidebarElementProps> = ({ name, imageUrl, isActive, onClick }) => {
return (
<div className={`sidebar-item ${isActive ? 'active' : ''}`} onClick={onClick}>
{imageUrl ? (
<img src={imageUrl} alt={name} className="sidebar-app-icon" />
) : (
<div className="sidebar-app-icon sidebar-app-icon-placeholder">
{name.charAt(0).toUpperCase()}
</div>
)}
<span>{name}</span>
</div>
)
}
export default SidebarElement

View File

@@ -0,0 +1,40 @@
import React, { useEffect, useState } from 'react'
import ReactDOM from 'react-dom/client'
import '../styles/dialogs.css'
declare global {
interface Window {
electron?: { handleAction: (action: string) => void }
__dialogData?: { text?: string }
}
}
const ConfirmDialog = () => {
const [visible, setVisible] = useState(false)
const params = new URLSearchParams(window.location.search)
const text = params.get('text') || window.__dialogData?.text || ''
useEffect(() => {
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)))
}, [])
useEffect(() => {
if (visible) document.body.classList.add('visible')
}, [visible])
return (
<div className="card">
{text && <div className="msg">{text}</div>}
<div className="btns">
<button className="btn-yes" onClick={() => window.electron?.handleAction('confirmYes')}>
Да
</button>
<button className="btn-no" onClick={() => window.electron?.handleAction('confirmNo')}>
Нет
</button>
</div>
</div>
)
}
ReactDOM.createRoot(document.getElementById('root')!).render(<ConfirmDialog />)

View File

@@ -0,0 +1,39 @@
import React, { useEffect, useState } from 'react'
import ReactDOM from 'react-dom/client'
import '../styles/dialogs.css'
declare global {
interface Window {
electron?: { handleAction: (action: string) => void }
__dialogData?: { title?: string; text?: string }
}
}
const ErrorDialog = () => {
const [visible, setVisible] = useState(false)
const params = new URLSearchParams(window.location.search)
const title = params.get('title') || window.__dialogData?.title || 'Ошибка'
const text = params.get('text') || window.__dialogData?.text || ''
useEffect(() => {
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)))
}, [])
useEffect(() => {
if (visible) document.body.classList.add('visible')
}, [visible])
return (
<div className="card">
{title && <div className="title">{title}</div>}
{text && <div className="msg">{text}</div>}
<div className="btns">
<button className="btn-ok" onClick={() => window.electron?.handleAction('error')}>
Закрыть
</button>
</div>
</div>
)
}
ReactDOM.createRoot(document.getElementById('root')!).render(<ErrorDialog />)

40
src/entries/loader.tsx Normal file
View File

@@ -0,0 +1,40 @@
import React, { useEffect } from 'react'
import ReactDOM from 'react-dom/client'
const css = `
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #111;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
transition: opacity 0.25s ease;
opacity: 0;
}
body.visible { opacity: 1; }
.spinner {
width: 36px;
height: 36px;
border: 3px solid rgba(255,255,255,0.1);
border-top-color: #E50914;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
`
const Loader = () => {
useEffect(() => {
const style = document.createElement('style')
style.textContent = css
document.head.appendChild(style)
requestAnimationFrame(() => requestAnimationFrame(() => {
document.body.classList.add('visible')
}))
}, [])
return <div className="spinner" />
}
ReactDOM.createRoot(document.getElementById('root')!).render(<Loader />)

99
src/entries/sidebar.tsx Normal file
View File

@@ -0,0 +1,99 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom/client'
import '../styles/main.css'
interface OpenedApp {
name: string
imageUrl: string
url: string
}
declare global {
interface Window {
electron?: {
on: (channel: string, fn: (...args: any[]) => void) => () => void
hideView: () => void
showView: (name: string) => void
adjustView: (expanded: boolean) => void
getSidebarState: () => Promise<{ openedApps: OpenedApp[]; activeApp: string }>
}
}
}
const SidebarApp = () => {
const [openedApps, setOpenedApps] = useState<OpenedApp[]>([])
const [activeApp, setActiveApp] = useState('home')
const [expanded, setExpanded] = useState(false)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
window.electron?.getSidebarState().then(data => {
if (!data) return
setOpenedApps(data.openedApps || [])
setActiveApp(data.activeApp || 'home')
})
const off = window.electron?.on('sidebar-update', (data: { openedApps: OpenedApp[]; activeApp: string }) => {
setOpenedApps(data.openedApps || [])
setActiveApp(data.activeApp || 'home')
})
return () => off?.()
}, [])
const handleMouseEnter = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
setExpanded(true)
window.electron?.adjustView(true)
}, 150)
}
const handleMouseLeave = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
setExpanded(false)
window.electron?.adjustView(false)
}, 150)
}
const goHome = () => {
window.electron?.hideView()
}
return (
<div
className={`sidebar ${expanded ? 'expanded' : 'collapsed'}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div
className={`sidebar-item ${activeApp === 'home' ? 'active' : ''}`}
onClick={goHome}
>
<svg className="sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
<span>Домой</span>
</div>
{openedApps.map(app => (
<div
key={app.name}
className={`sidebar-item ${activeApp === app.name ? 'active' : ''}`}
onClick={() => window.electron?.showView(app.name)}
>
{app.imageUrl ? (
<img src={app.imageUrl} alt={app.name} className="sidebar-app-icon" />
) : (
<div className="sidebar-app-icon sidebar-app-icon-placeholder">
{app.name.charAt(0).toUpperCase()}
</div>
)}
<span>{app.name}</span>
</div>
))}
</div>
)
}
ReactDOM.createRoot(document.getElementById('root')!).render(<SidebarApp />)

41
src/main.tsx Normal file
View File

@@ -0,0 +1,41 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
declare global {
interface Window {
electron?: {
createView: (name: string, url: string, imageUrl: string, zoom: number, useProxy: boolean) => void
confirm: (text: string, funcName: string) => void
removeView: (name?: string) => void
hideView: () => void
showView: (name: string) => void
adjustView: (expanded: boolean) => void
on: (channel: string, func: (...args: any[]) => void) => () => void
getCurrentPage: () => Promise<{ name: string; url: string; imageUrl: string } | null>
handleAction: (action: string) => void
setProxy: (host: string, port: string) => void
expandWithHeader: () => void
collapseWithHeader: () => void
backwardPage: () => void
forwardPage: () => void
refreshPage: () => void
readConfig: () => Promise<any>
writeConfig: (data: any) => void
searchMovies: (query: string, sites: any[]) => Promise<any[]>
searchTmdb: (query: string, apiKey: string) => Promise<{ results: any[]; error?: string }>
discoverTmdb: (params: any) => Promise<{ results: any[]; totalPages: number; error?: string }>
getPageMeta: () => Promise<{ poster: string; title: string; url: string } | null>
installUpdate: () => Promise<boolean>
checkUpdateNow: () => Promise<boolean>
toggleKiosk: () => Promise<boolean>
isKiosk: () => Promise<boolean>
}
}
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

124
src/pages/HomePage.tsx Normal file
View File

@@ -0,0 +1,124 @@
import React, { useState, useEffect, useRef } from 'react'
import Header from '../components/Header'
import Sidebar from '../components/Sidebar'
import AppList from '../components/AppList'
import MovieSearch from '../components/MovieSearch'
import '../styles/main.css'
import { AppEntry, Bookmark } from '../components/Settings'
interface OpenedApp {
name: string
imageUrl: string
url: string
}
const HomePage: React.FC = () => {
const [openedApps, setOpenedApps] = useState<OpenedApp[]>([])
const [activeApp, setActiveApp] = useState<string>('home')
const [appCardList, setAppCardList] = useState<AppEntry[]>([])
const [movieQuery, setMovieQuery] = useState<string | null>(null)
const [movieSearchKey, setMovieSearchKey] = useState(0)
const [openedFromSearch, setOpenedFromSearch] = useState(false)
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const configRef = useRef<any>({})
useEffect(() => {
if (!window.electron) return
window.electron.readConfig().then((cfg: any) => {
configRef.current = cfg ?? {}
if (cfg?.apps) setAppCardList(cfg.apps)
if (cfg?.bookmarks) setBookmarks(cfg.bookmarks)
if (cfg?.proxy?.host && cfg?.proxy?.port) {
window.electron!.setProxy(cfg.proxy.host, cfg.proxy.port)
}
})
const offOpenedApps = window.electron.on('update-opened-apps', (apps: OpenedApp[], activeName: string) => {
setOpenedApps(apps)
setActiveApp(activeName ?? 'home')
})
const offAlert = window.electron.on('alert', (text: string) => alert(text))
return () => { offOpenedApps(); offAlert() }
}, [])
const handleSidebarAppClick = (name: string) => {
setActiveApp(name)
window.electron?.showView(name)
}
const handleMovieSearch = (query: string) => {
window.electron?.hideView()
setMovieQuery(query)
setMovieSearchKey(k => k + 1)
setOpenedFromSearch(false)
setActiveApp('movie-search')
}
const handleMovieSearchOpen = (name: string, url: string) => {
window.electron?.createView(name, url, '', 1.0, resolveUseProxy(url))
setOpenedFromSearch(true)
setActiveApp(name)
}
const handleBackToSearch = () => {
window.electron?.hideView()
setActiveApp('movie-search')
}
const handleBookmarkAdd = (title: string, url: string, poster: string, source: string, siteIcon?: string) => {
// If caller didn't pass a site icon (e.g. movie search), look it up from the apps config by host.
let icon = siteIcon || ''
if (!icon) {
try {
const host = new URL(url).hostname
const match = appCardList.find(a => { try { return new URL(a.url).hostname === host } catch { return false } })
if (match?.imageUrl) icon = match.imageUrl
} catch {}
}
const sourceStr = source || (() => { try { return new URL(url).hostname.replace(/^www\./, '') } catch { return '' } })()
const updated = [...bookmarks, { title, url, poster, source: sourceStr, siteIcon: icon }]
setBookmarks(updated)
configRef.current = { ...configRef.current, bookmarks: updated }
window.electron?.writeConfig(configRef.current)
}
const handleBookmarkRemove = (index: number) => {
const updated = bookmarks.filter((_, i) => i !== index)
setBookmarks(updated)
configRef.current = { ...configRef.current, bookmarks: updated }
window.electron?.writeConfig(configRef.current)
}
const resolveUseProxy = (url: string) => {
try {
const host = new URL(url).hostname
const match = appCardList.find(a => { try { return new URL(a.url).hostname === host } catch { return false } })
return match ? match.useProxy : true
} catch { return true }
}
const handleBookmarkOpen = (b: Bookmark) => {
window.electron?.createView(b.title, b.url, b.poster || '', 1.0, resolveUseProxy(b.url))
setActiveApp(b.title)
}
const sidebarApps = openedApps.map(app => ({
...app,
isActive: activeApp === app.name,
onClick: () => handleSidebarAppClick(app.name),
}))
return (
<>
<Header activeApp={activeApp} setActiveApp={setActiveApp} onAppsChange={setAppCardList} onMovieSearch={handleMovieSearch} onBookmark={handleBookmarkAdd} onBookmarkRemove={handleBookmarkRemove} bookmarks={bookmarks} openedFromSearch={openedFromSearch} onBackToSearch={handleBackToSearch} />
<Sidebar openedApps={sidebarApps} activeApp={activeApp} setActiveApp={setActiveApp} />
<div style={{ display: activeApp === 'movie-search' ? undefined : 'none' }}>
<MovieSearch key={movieSearchKey} initialQuery={movieQuery ?? ''} onOpenUrl={handleMovieSearchOpen} onBookmark={handleBookmarkAdd} />
</div>
{activeApp !== 'movie-search' && (
<AppList apps={appCardList} bookmarks={bookmarks} onBookmarkOpen={handleBookmarkOpen} onBookmarkRemove={handleBookmarkRemove} />
)}
</>
)
}
export default HomePage

52
src/styles/dialogs.css Normal file
View File

@@ -0,0 +1,52 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: rgba(0, 0, 0, 0);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
transition: background 0.22s ease;
}
body.visible { background: rgba(0, 0, 0, 0.78); }
body.hiding { background: rgba(0, 0, 0, 0); }
.card {
background: #1c1c1c;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 32px 36px;
text-align: center;
min-width: 300px;
max-width: 420px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8);
opacity: 0;
transform: scale(0.92) translateY(10px);
transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
body.visible .card { opacity: 1; transform: scale(1) translateY(0); }
body.hiding .card { opacity: 0; transform: scale(0.95) translateY(6px); }
.title { font-size: 17px; font-weight: 700; color: #fff; margin-bottom: 10px; }
.msg { font-size: 13px; color: #999; line-height: 1.5; margin-bottom: 26px; }
.btns { display: flex; gap: 10px; justify-content: center; }
button {
padding: 10px 26px;
border: none;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
button:hover { opacity: 0.85; }
button:active { transform: scale(0.97); }
.btn-yes { background: #E50914; color: #fff; }
.btn-no,
.btn-ok { background: rgba(255, 255, 255, 0.1); color: #ccc; }

1534
src/styles/main.css Normal file

File diff suppressed because it is too large Load Diff

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

29
vite.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
plugins: [react()],
base: './',
build: {
outDir: 'dist',
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
loader: resolve(__dirname, 'loader.html'),
'dialog-error': resolve(__dirname, 'dialog-error.html'),
'dialog-confirm': resolve(__dirname, 'dialog-confirm.html'),
sidebar: resolve(__dirname, 'sidebar.html'),
},
},
},
server: {
port: 5173,
watch: {
ignored: ['**/extensions/**', '**/node_modules/**', '**/release/**', '**/dist/**'],
},
},
optimizeDeps: {
entries: ['index.html', 'loader.html', 'dialog-error.html', 'dialog-confirm.html', 'src/**/*.tsx', 'src/**/*.ts'],
},
})