Files
ESH-Media/src/components/MovieSearch.tsx
eshmeshek 747b0f4c18 feat(1.0.14): тематики searchable, фикс лага confirm-диалога
- Confirm dialog: предзагрузка WebContentsView при старте приложения.
  Раньше каждое нажатие "Закрыть" создавало новый view с холодной
  загрузкой HTML+React → ~2с лаг и дубликаты от повторных кликов.
  Теперь view кэшируется, текст обновляется через IPC, повторные
  клики игнорируются пока диалог открыт.
- Темы: 14 → 71 (Война, Холодная война, Вьетнам, Призраки, Драконы,
  Шахматы, Самолёты, Поезда, Сёрфинг, Япония, ...). Все ID
  провалидированы probe-скриптом (≥50 фильмов на тематику).
- Chip-row заменён на SearchableSelect с поиском по подстроке —
  длинный список не помещается в чипы, а dropdown с фильтром
  гораздо удобнее. Заодно ушёл фиолетовый цвет чипа, плохо
  сочетавшийся с темой сайта.
2026-05-17 11:29:39 +03:00

821 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import React, { useState, useEffect, useRef, 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>
)
}
// Searchable single-select dropdown — для длинных списков (тематик).
const SearchableSelect: React.FC<{
value: string
onChange: (v: string) => void
options: { value: string; label: string }[]
placeholder: string
maxHeight?: number
}> = ({ value, onChange, options, placeholder, maxHeight = 320 }) => {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(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); setSearch('') }
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
useEffect(() => { if (open) requestAnimationFrame(() => inputRef.current?.focus()) }, [open])
const q = search.trim().toLowerCase()
const filtered = q ? options.filter(o => o.label.toLowerCase().includes(q)) : options
return (
<div ref={ref} className={`ms-select ms-searchable${open ? ' open' : ''}`}>
<div className="ms-select-trigger" onClick={() => setOpen(o => !o)}>
<span className={selected ? 'ms-select-active' : 'ms-select-placeholder'}>
{selected ? selected.label : placeholder}
</span>
{selected && (
<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-searchable-dropdown" onClick={e => e.stopPropagation()}>
<div className="ms-searchable-input-wrap">
<svg className="ms-searchable-icon" width="12" height="12" 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>
<input
ref={inputRef}
className="ms-searchable-input"
placeholder="Найти тему..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="ms-searchable-list" style={{ maxHeight }}>
{filtered.map(o => (
<div
key={o.value}
className={`ms-select-opt${o.value === value ? ' active' : ''}`}
onClick={() => { onChange(o.value); setOpen(false); setSearch('') }}
>{o.label}</div>
))}
{!filtered.length && <div className="ms-searchable-empty">Ничего не найдено</div>}
</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: 'Вестерн' },
]
// Кураторский список тематик. Каждая — pipe-OR по keyword ID'ам из TMDB.
// extraGenre добавляется AND-связкой к выбранным жанрам (только в movie-режиме),
// если тематика требует фильтра по жанру для чистоты выдачи (например, "Музыка"
// без жанра 10402 даёт хорроры со скрипачами в кадре).
// IDs провалидированы probe-скриптом (/search/keyword + /discover/movie):
// каждая тема даёт ≥50 результатов на первой странице популярных.
const THEMES: { id: string; label: string; keywordIds: number[]; extraGenre?: number }[] = [
{ id: 'war', label: 'Война', keywordIds: [1956, 273967] },
{ id: 'coldwar', label: 'Холодная война', keywordIds: [2106] },
{ id: 'vietnam', label: 'Вьетнамская война', keywordIds: [2957] },
{ id: 'zombie', label: 'Зомби', keywordIds: [12377] },
{ id: 'vampire', label: 'Вампиры', keywordIds: [3133] },
{ id: 'ghost', label: 'Призраки', keywordIds: [162846] },
{ id: 'witch', label: 'Ведьмы', keywordIds: [616] },
{ id: 'magic', label: 'Маги и волшебство', keywordIds: [2343, 177912] },
{ id: 'dragon', label: 'Драконы', keywordIds: [12554] },
{ id: 'fairytale', label: 'Сказки', keywordIds: [3205] },
{ id: 'cannibal', label: 'Каннибалы', keywordIds: [278235, 14895] },
{ id: 'cult', label: 'Секты', keywordIds: [6158] },
{ id: 'serialkiller', label: 'Серийные убийцы', keywordIds: [10714] },
{ id: 'space', label: 'Космос', keywordIds: [252634, 3801, 4344] },
{ id: 'alien', label: 'Инопланетяне', keywordIds: [9951] },
{ id: 'timetravel', label: 'Путешествия во времени', keywordIds: [4379] },
{ id: 'postapoc', label: 'Постапокалипсис', keywordIds: [4458, 359337] },
{ id: 'dystopia', label: 'Антиутопия', keywordIds: [4565, 350338] },
{ id: 'epidemic', label: 'Эпидемия и вирус', keywordIds: [188973, 17995, 188957] },
{ id: 'disaster', label: 'Стихийные бедствия', keywordIds: [5096, 10617] },
{ id: 'dinosaur', label: 'Динозавры', keywordIds: [12616] },
{ id: 'robot', label: 'Роботы и ИИ', keywordIds: [14544, 803, 371846] },
{ id: 'superhero', label: 'Супергерои', keywordIds: [9715], extraGenre: 28 },
{ id: 'spy', label: 'Шпионы', keywordIds: [470, 4289] },
{ id: 'mafia', label: 'Мафия', keywordIds: [10391, 10291] },
{ id: 'gangster', label: 'Гангстеры', keywordIds: [3149] },
{ id: 'heist', label: 'Ограбления', keywordIds: [10051, 642] },
{ id: 'prison', label: 'Тюрьма', keywordIds: [378] },
{ id: 'escape', label: 'Побег из тюрьмы', keywordIds: [9777] },
{ id: 'police', label: 'Полиция и детективы', keywordIds: [703, 6149] },
{ id: 'court', label: 'Суд и адвокаты', keywordIds: [33519, 10909] },
{ id: 'journalism', label: 'Журналисты', keywordIds: [736, 917] },
{ id: 'doctor', label: 'Врачи и медицина', keywordIds: [11612, 13005] },
{ id: 'psych', label: 'Психбольница', keywordIds: [11857, 10323] },
{ id: 'school', label: 'Школа', keywordIds: [6270] },
{ id: 'college', label: 'Колледж и студенты', keywordIds: [3616] },
{ id: 'martial', label: 'Боевые искусства', keywordIds: [779, 780], extraGenre: 28 },
{ id: 'samurai', label: 'Самураи', keywordIds: [1462] },
{ id: 'ninja', label: 'Ниндзя', keywordIds: [10278] },
{ id: 'boxing', label: 'Бокс', keywordIds: [209476] },
{ id: 'football', label: 'Футбол', keywordIds: [13042, 352822] },
{ id: 'sport', label: 'Спорт (общий)', keywordIds: [333328] },
{ id: 'olympics', label: 'Олимпиада', keywordIds: [315138] },
{ id: 'racing', label: 'Гонки', keywordIds: [191279, 9666] },
{ id: 'skiing', label: 'Лыжи и сноуборд', keywordIds: [248915, 3522] },
{ id: 'mountains', label: 'Горы и альпинизм', keywordIds: [159212, 160177] },
{ id: 'surfing', label: 'Сёрфинг', keywordIds: [5349] },
{ id: 'chess', label: 'Шахматы', keywordIds: [316] },
{ id: 'submarine', label: 'Подводные лодки', keywordIds: [339] },
{ id: 'ship', label: 'Корабли и море', keywordIds: [3799, 191585] },
{ id: 'airplane', label: 'Самолёты и пилоты', keywordIds: [3800, 3203] },
{ id: 'train', label: 'Поезда', keywordIds: [13008] },
{ id: 'rome', label: 'Древний Рим', keywordIds: [5049, 1405] },
{ id: 'medieval', label: 'Средневековье', keywordIds: [355987, 161257] },
{ id: 'revolution', label: 'Революция', keywordIds: [2020] },
{ id: 'biography', label: 'Биография и реальные события', keywordIds: [5565, 9672] },
{ id: 'artist', label: 'Художники и писатели', keywordIds: [2679, 13028] },
{ id: 'music', label: 'Музыка и группы', keywordIds: [18001, 4048], extraGenre: 10402 },
{ id: 'circus', label: 'Цирк', keywordIds: [291] },
{ id: 'cooking', label: 'Кулинария', keywordIds: [1918, 18293] },
{ id: 'wallstreet', label: 'Уолл-стрит и финансы', keywordIds: [5636, 177493] },
{ id: 'religion', label: 'Религия', keywordIds: [11001] },
{ id: 'afterlife', label: 'Жизнь после смерти', keywordIds: [6155, 5484] },
{ id: 'dream', label: 'Сны', keywordIds: [346773] },
{ id: 'love', label: 'О любви', keywordIds: [9673] },
{ id: 'wedding', label: 'Свадьба', keywordIds: [13027] },
{ id: 'friendship', label: 'Дружба', keywordIds: [6054] },
{ id: 'family', label: 'Семья', keywordIds: [10235] },
{ id: 'twins', label: 'Близнецы', keywordIds: [15016] },
{ id: 'africa', label: 'Африка', keywordIds: [409] },
{ id: 'japan', label: 'Япония', keywordIds: [233] },
]
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 [themeId, setThemeId] = useState<string>('') // single-select
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 theme = THEMES.find(t => t.id === themeId)
const themeKeywords = theme?.keywordIds ?? []
// extraGenre — только для фильмов (TV-каталог TMDB имеет другие genre ID)
const effectiveGenres = (theme?.extraGenre && mediaType === 'movie' && !genreIds.includes(theme.extraGenre))
? [...genreIds, theme.extraGenre]
: genreIds
const res = await window.electron!.discoverTmdb({ apiKey: key, mediaType, sortBy, genreIds: effectiveGenres, years, minRating, countries, page: pg, themeKeywords })
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, themeId, years, minRating, countries])
useEffect(() => {
if (!configLoaded || !apiKey || isSearchMode) return
setPage(1)
setTmdbResults([])
doDiscover(apiKey, 1, false)
}, [configLoaded, apiKey, mediaType, sortBy, genreIds, themeId, 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 siteSearchCancelRef = useRef<(() => void) | null>(null)
const doSiteSearch = (q: string, sitesToSearch: MovieSite[], yearHint?: string, mt?: string, append = false): (() => void) | null => {
if (!sitesToSearch.length) {
if (!append) setMessage('Нет активных сайтов. Добавьте в Настройки → Поиск фильмов.')
return null
}
// Отменить предыдущий стрим (если был)
if (!append && siteSearchCancelRef.current) { siteSearchCancelRef.current(); siteSearchCancelRef.current = null }
if (!append) {
setSitesLoading(true)
setMessage('')
setSiteResults([])
}
const y = yearHint ? parseInt(yearHint) : NaN
const isTv = mt === 'tv'
const yearDist = (r: SiteResult) => r.year ? Math.abs(parseInt(r.year) - y) : 0.5
const filterBatch = (batch: SiteResult[]): SiteResult[] => {
if (!yearHint) return batch
// Для фильмов выкидываем результаты с явно неверным годом (>1 год отличия).
// Для сериалов год часто не совпадает (разные сезоны) — оставляем все.
const filtered = isTv ? batch : batch.filter(r => !r.year || yearDist(r) <= 1)
return filtered.sort((a, b) => yearDist(a) - yearDist(b))
}
const cancel = window.electron!.searchMoviesStream(
q,
sitesToSearch,
({ source: _src, results }: { source: string; results: SiteResult[] }) => {
const batch = filterBatch(results)
if (!batch.length) return
setSiteResults(prev => {
// Дедупим по URL (если параллельный поиск по originalTitle принесёт те же ссылки).
const seen = new Set(prev.map(r => r.url))
const fresh = batch.filter(r => !seen.has(r.url))
if (!fresh.length) return prev
const merged = [...prev, ...fresh]
return yearHint ? merged.sort((a, b) => yearDist(a) - yearDist(b)) : merged
})
},
() => {
setSitesLoading(false)
setSiteResults(prev => {
if (!prev.length && !append) setMessage('Не найдено ни на одном сайте')
return prev
})
},
)
if (!append) siteSearchCancelRef.current = cancel
return cancel
}
// Чистка слушателей при размонтировании
useEffect(() => () => { if (siteSearchCancelRef.current) siteSearchCancelRef.current() }, [])
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)
// Параллельный поиск по оригинальному названию — append=true, чтобы не сбросить первичный поиск.
// Дедуп по URL уже в doSiteSearch.
if (movie.originalTitle && movie.originalTitle !== movie.title) {
doSiteSearch(movie.originalTitle, sites, movie.year, movie.mediaType, true)
}
}
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([]); setThemeId('') })}
>Фильмы</button>
<button
className={`ms-type-btn${mediaType === 'tv' ? ' active' : ''}`}
onClick={() => handleFilterChange(() => { setMediaType('tv'); setGenreIds([]); setThemeId('') })}
>Сериалы</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>
{/* Themes — searchable dropdown (71 тематика, длинный список) */}
{!isSearchMode && (
<div className="ms-theme-row">
<SearchableSelect
value={themeId}
onChange={v => handleFilterChange(() => setThemeId(v))}
options={THEMES.map(t => ({ value: t.id, label: t.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