- 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.
13 KiB
Чёрная/пустая страница после завершения скачивания (краш при переключении плеера 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 никогда не пытается удалить отсоединённый узел.
Контекст
Почему это происходит (причина подтверждена по коду):
frontend/src/components/Player.tsx:292рендерит React-управляемый<div ref={ytHostRef} className="yt-player-host" />в YouTube-ветке.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.- Цепочка после завершения скачивания:
DownloadButton.tsx:55–64при переходе статуса вcompletedделаетinvalidateQueries(['video', id])→VideoPage.tsx:14перезапрашивает['video', youtubeVideoId]→video.local.availableстановитсяtrue→Player.tsx:100useLocalпереключается → ре-рендер уходит в локальную ветку. - В commit-фазе React удаляет поддерево YouTube-ветки:
removeChild(playerWrapper, hostDiv)бросаетNotFoundError(div уже не ребёнок — его вытеснил iframe). Неперехваченная ошибка в commit → React 19 (react/react-dom19.2.8) размонтирует весь root → чёрная страница. Error boundary в приложении нет (grep поfrontend/src:ErrorBoundary/componentDidCatchне найдены). - 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.
Критерии приёмки
- После завершения скачивания (статус
completed→ инвалидация →local.available=true) страница не крашится: контент остаётся, плеер переключается на локальную копию (<video controls>сmedia_url). - Переключение локальная → YouTube (удаление копии «Удалить копию»,
onErrorлокального видео) и обратно — без крашей, iframe YouTube-плеера создаётся и корректно отображается. - Размонтирование Player (уход на ленту) — без ошибок в консоли; таймаут-путь (сбой загрузки скрипта или отсутствие
onReadyза 6 с) — рендерится plain-iframe, ошибок нет. - StrictMode (dev, двойной вызов эффекта): нет дублирующихся iframe'ов и ошибок; cleanup уничтожает плеер и удаляет дочерний div.
npm run lint(oxlint) иnpm run build(tsc -b && vite build) вfrontend/— чистые.- Backend не тронут: изменения только в
frontend/src/components/Player.tsx,frontend/src/App.css,analytics/. - Деплой по правилу:
docker compose up -d --build,curl http://localhost:8080/api/health→ OK.
План
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) сохраняются.
- В начале эффекта завести
App.css: после строки 187 добавить.yt-player-host > div { width: 100%; height: 100%; }. Решение: CSS-правило, а не inline-стили — единообразно с существующей таблицей стилей; iframe YT.Player после замены остаётся внутри.yt-player-hostи продолжает попадать под.player-wrapper iframe(строка 185) — визуальная раскладка не меняется.- Проверки:
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 не затрагиваются.