myYouTube/analytics/2026-09-25-player-blank-page-after-download.md
vrubelroman 43adec5224 Fix download status handling and the blank page after download
- Never let a non-authoritative MeTube 'updated' event downgrade a
  terminal job (it refilled the progress bar after completion).
- Self-heal stale active jobs against MeTube history on status polls,
  so a missed event no longer leaves a job stuck in 'queued'.
- Build YT.Player on an imperatively created child div: React keeps
  owning the container, so switching to the local copy after a
  download no longer throws removeChild and blanks the page.
- Autoplay on open and seek controls; subtitle experiment reverted.
2026-09-27 23:36:09 +00:00

78 lines
13 KiB
Markdown
Raw Permalink 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.

# Чёрная/пустая страница после завершения скачивания (краш при переключении плеера 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 не затрагиваются.