79 lines
13 KiB
Markdown
79 lines
13 KiB
Markdown
|
|
# Чёрная/пустая страница после завершения скачивания (краш при переключении плеера YouTube → локальная копия)
|
|||
|
|
|
|||
|
|
## Задача
|
|||
|
|
|
|||
|
|
Устранить краш приложения: после завершения скачивания видео страница видео становится полностью чёрной/пустой. Причина — создание `YT.Player` прямо на React-управляемом div; YT.Player заменяет его iframe'ом, и React при переключении ветки падает в commit-фазе (`NotFoundError`), размонтируя весь root.
|
|||
|
|
|
|||
|
|
Фикс — канонический паттерн интеграции YT.Player с React: React рендерит только контейнер `<div ref={ytHostRef} className="yt-player-host" />`, а сам плеер создаётся на императивно созданном дочернем div, который YT.Player и «съедает». Тогда React никогда не пытается удалить отсоединённый узел.
|
|||
|
|
|
|||
|
|
## Контекст
|
|||
|
|
|
|||
|
|
**Почему это происходит (причина подтверждена по коду):**
|
|||
|
|
|
|||
|
|
1. `frontend/src/components/Player.tsx:292` рендерит React-управляемый `<div ref={ytHostRef} className="yt-player-host" />` в YouTube-ветке.
|
|||
|
|
2. `Player.tsx:176–178`: эффект берёт `const host = ytHostRef.current` и вызывает `new window.YT.Player(host, {...})`. **YT.Player заменяет переданный элемент на `<iframe>`** (документированное поведение IFrame Player API): исходный div отсоединяется от DOM, на его месте стоит iframe. React при этом считает div по-прежнему дочерним элементом `.player-wrapper`.
|
|||
|
|
3. Цепочка после завершения скачивания: `DownloadButton.tsx:55–64` при переходе статуса в `completed` делает `invalidateQueries(['video', id])` → `VideoPage.tsx:14` перезапрашивает `['video', youtubeVideoId]` → `video.local.available` становится `true` → `Player.tsx:100` `useLocal` переключается → ре-рендер уходит в локальную ветку.
|
|||
|
|
4. В commit-фазе React удаляет поддерево YouTube-ветки: `removeChild(playerWrapper, hostDiv)` бросает `NotFoundError` (div уже не ребёнок — его вытеснил iframe). Неперехваченная ошибка в commit → React 19 (`react`/`react-dom` 19.2.8) размонтирует весь root → чёрная страница. Error boundary в приложении нет (grep по `frontend/src`: `ErrorBoundary`/`componentDidCatch` не найдены).
|
|||
|
|
5. Cleanup-эффект с `destroy()` (`Player.tsx:204–209`) не спасает: passive-очистка выполняется **после** commit'а; а при переходе в локальную ветку новый запуск эффекта сразу выходит по `if (useLocal) return` (строка 158), так что и `destroy()` в начале эффекта (строки 162–163) не выполняется. В любом случае — слишком поздно.
|
|||
|
|
|
|||
|
|
**Тот же дефект в других сценариях (фикс покрывает все):**
|
|||
|
|
|
|||
|
|
- **Размонтирование Player** (уход со страницы видео): React удаляет host-div, отсоединённый YT.Player → тот же `NotFoundError`.
|
|||
|
|
- **Таймаут-путь**: если `YT.Player` уже создан (div заменён), но `onReady` не сработал за 6 с, ветка таймаута (`Player.tsx:165–171`) вызывает `destroy()` и `setYtApiStatus('timeout')` → React меняет host-div на plain-iframe → удаление отсоединённого div → краш.
|
|||
|
|
|
|||
|
|
**Обратное переключение (локальная → YouTube) не крашит:** host-div при этом монтируется заново, YT.Player заменяет его уже после монтирования — удаления отсоединённого узла нет.
|
|||
|
|
|
|||
|
|
**Факты по смежным файлам:**
|
|||
|
|
|
|||
|
|
- `VideoPage.tsx:24`: `<Player key={video.youtube_video_id} … />` — key по video id, при переключении доступности локальной копии key не меняется → ремоунта нет, переключение ветки происходит «на месте» (реконсиляцией внутри того же инстанса). Key на сценарий не влияет.
|
|||
|
|
- `App.css:184–187`: `.player-wrapper` — `position: relative; aspect-ratio: 16/9`; `.player-wrapper iframe` — `position: absolute; inset: 0; width/height 100%` (это правило стилизует и iframe, созданный YT.Player — потому плеер сейчас и выглядит корректно); `.yt-player-host { position: absolute; inset: 0; }`.
|
|||
|
|
- **Внимание:** в worktree уже есть незакоммиченные правки `Player.tsx` от задачи «субтитры выключены по умолчанию» (`cc_load_policy: 0` в `playerVars` и в `src` фолбэк-iframe, отключение `textTracks` в локальной ветке). Фикс накладывается поверх текущего состояния worktree и эти правки сохраняет.
|
|||
|
|
- `main.tsx`: приложение в `StrictMode` — в dev эффект выполняется дважды, фикс должен переживать двойной вызов без дублей и ошибок.
|
|||
|
|
|
|||
|
|
## Затронутые подсистемы и файлы
|
|||
|
|
|
|||
|
|
Только frontend, два файла:
|
|||
|
|
|
|||
|
|
- `frontend/src/components/Player.tsx` — YouTube-эффект (строки 157–210): создание плеера на дочернем div, удаление дочернего div в cleanup и в ветке таймаута.
|
|||
|
|
- `frontend/src/App.css` — новое правило `.yt-player-host > div { width: 100%; height: 100%; }` рядом со строкой 187.
|
|||
|
|
|
|||
|
|
Для справки (без изменений): `frontend/src/pages/VideoPage.tsx` (key по `youtube_video_id`, строка 24), `frontend/src/components/DownloadButton.tsx` (инвалидации на `completed`, строки 55–64), backend, миграции, тесты, README.
|
|||
|
|
|
|||
|
|
## Критерии приёмки
|
|||
|
|
|
|||
|
|
1. После завершения скачивания (статус `completed` → инвалидация → `local.available=true`) страница **не крашится**: контент остаётся, плеер переключается на локальную копию (`<video controls>` с `media_url`).
|
|||
|
|
2. Переключение локальная → YouTube (удаление копии «Удалить копию», `onError` локального видео) и обратно — без крашей, iframe YouTube-плеера создаётся и корректно отображается.
|
|||
|
|
3. Размонтирование Player (уход на ленту) — без ошибок в консоли; таймаут-путь (сбой загрузки скрипта или отсутствие `onReady` за 6 с) — рендерится plain-iframe, ошибок нет.
|
|||
|
|
4. StrictMode (dev, двойной вызов эффекта): нет дублирующихся iframe'ов и ошибок; cleanup уничтожает плеер и удаляет дочерний div.
|
|||
|
|
5. `npm run lint` (oxlint) и `npm run build` (`tsc -b && vite build`) в `frontend/` — чистые.
|
|||
|
|
6. Backend не тронут: изменения только в `frontend/src/components/Player.tsx`, `frontend/src/App.css`, `analytics/`.
|
|||
|
|
7. Деплой по правилу: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
|
|||
|
|
|
|||
|
|
## План
|
|||
|
|
|
|||
|
|
1. `Player.tsx`, YouTube-эффект (строки 157–210):
|
|||
|
|
- В начале эффекта завести `let child: HTMLDivElement | null = null`.
|
|||
|
|
- В `.then()` после существующих гардов (`cancelled || timedOut || !window.YT?.Player`) и проверки `const host = ytHostRef.current; if (!host) return`:
|
|||
|
|
```ts
|
|||
|
|
child = document.createElement('div')
|
|||
|
|
host.appendChild(child)
|
|||
|
|
const player = new window.YT.Player(child, { …существующие опции без изменений… })
|
|||
|
|
```
|
|||
|
|
- В ветке таймаута (строки 165–171) после `ytPlayerRef.current?.destroy()` добавить `child?.remove(); child = null;`.
|
|||
|
|
- В cleanup (строки 204–209) после `ytPlayerRef.current?.destroy()` добавить `child?.remove(); child = null;`.
|
|||
|
|
- Гарды `cancelled`/`timedOut` и вся существующая логика (`onReady`, сброс таймаута, `setYtApiStatus`) сохраняются.
|
|||
|
|
2. `App.css`: после строки 187 добавить `.yt-player-host > div { width: 100%; height: 100%; }`. **Решение: CSS-правило, а не inline-стили** — единообразно с существующей таблицей стилей; iframe YT.Player после замены остаётся внутри `.yt-player-host` и продолжает попадать под `.player-wrapper iframe` (строка 185) — визуальная раскладка не меняется.
|
|||
|
|
3. Проверки: `npm run lint`, `npm run build`; ручная — скачать видео на странице YouTube-видео и дождаться переключения на локальную копию; «Удалить копию» → обратное переключение; уход со страницы видео; таймаут (блокировка `www.youtube.com` в DevTools); dev-режим со StrictMode; деплой + health.
|
|||
|
|
|
|||
|
|
## Риски и ограничения
|
|||
|
|
|
|||
|
|
- **Незакоммиченные правки Player.tsx** (задача про субтитры: `cc_load_policy`, `textTracks`): фикс применяется поверх них, их не откатывать и не «причёсывать» попутно.
|
|||
|
|
- **Механика DOM у YT.Player:** при создании плеера widgetapi отсоединяет дочерний div (заменяя его iframe'ом) — поэтому `child.remove()` в cleanup в этом случае no-op (по спецификации `Element.remove()` без родителя ничего не делает, ошибку не бросает) и обязателен для сценария, когда плеер так и не был создан (сбой загрузки скрипта).
|
|||
|
|
- **Позиционирование iframe:** YT-iframe остаётся внутри `.yt-player-host`, который перекрывает всю `.player-wrapper` (`inset: 0`), правило `.player-wrapper iframe` продолжает работать — раскладка не меняется; проверить визуально при ручном тесте.
|
|||
|
|
- **Отсутствие error boundary:** React 19 без границы ошибок продолжит «выносить» root при любой другой неперехваченной commit-ошибке; фикс устраняет именно эту ошибку. Добавление общего error boundary — отдельная задача, в объём не входит.
|
|||
|
|
- **StrictMode:** двойной запуск эффекта в dev создаёт плеер дважды последовательно (cleanup между вызовами уничтожает первый) — дублей iframe быть не должно; проверить в ручном тесте.
|
|||
|
|
|
|||
|
|
## Журнал изменений
|
|||
|
|
|
|||
|
|
- 2026-09-25: документ создан до реализации. Гипотеза **подтверждена** по коду: `new YT.Player(host, …)` на React-управляемом div (`Player.tsx:178` + `:292`); YT.Player заменяет host-элемент iframe'ом; при переключении `useLocal` (инвалидация из `DownloadButton.tsx:61` после `completed`) React в commit-фазе удаляет отсоединённый div → `NotFoundError` → размонтирование root (React 19.2.8, error boundary нет); cleanup с `destroy()` выполняется после commit'а и не спасает. Тот же дефект на unmount и в таймаут-пути. Зафиксирован план: плеер на императивном дочернем div, `child.remove()` в cleanup и таймаут-ветке, CSS-правило `.yt-player-host > div` (выбор: CSS, не inline). README и backend не затрагиваются.
|