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

13 KiB
Raw Permalink Blame History

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