Files
lampac/mods/Music/docs/client-architecture.md
T

159 lines
17 KiB
Markdown
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.
# Архитектура клиента (plugin.js)
Этот документ — карта для разработчика, открывающего [plugin.js](../plugin.js) впервые. Серверная сторона описана в [README.md](../README.md);
## Почему монолит
Весь клиент — один файл, который сервер отдаёт как `/music.js` (с подстановкой `{localhost}`, `{client_debug_enabled}`, `{stats_clear_enabled}` и `{daily_reset_enabled}` при отдаче). Это осознанное решение: файл монтируется в контейнер живьём, правка не требует пересборки, а Lampa загружает плагин одним запросом. Плата — размер (~17 тыс. строк), поэтому навигация построена на двух уровнях маркеров:
- `// ===== ИМЯ =====` — 14 крупных секций (список в шапке файла);
- `// --- имя ---` — подсекции внутри больших секций.
Шапка файла содержит карту секций, точки входа, легенду именований и typedef основных DTO — начинать чтение оттуда.
## Поток данных
### Главный экран
```
/music/home ──> MUSIC_HOME_CACHE (снапшот секций)
│
├── локальные полки из Lampa.Storage: недавние
│ поиски/альбомы/артисты, закладки, «Мои плейлисты»
└── discovery-полки сервера (Apple Music, VK Top, SoundCloud)
карточка = entry (mapTrackCard / mapAlbumCard / mapArtistCard / mapPlaylistCard)
«Ещё» ──> /music/section?id=... (полный список, пагинация)
```
Recent-полки живут в `Lampa.Storage` и синхронизируются между экранами событием `MUSIC_RECENT_EVENT` — компоненты подписываются через `bindRecentListener` и обновляют только затронутую секцию, не перерисовывая home целиком.
После рестарта сервера discovery-провайдеры могут не успеть в 2-секундный бюджет `/music/home` — сервер тогда ставит `browse_sections_warming: true`, а клиент тихо дозапрашивает home (3s → 5s → 8s) и **доклеивает** недостающие полки в конец без перерисовки и потери фокуса (`homeLoadToken` гейтит устаревшие таймеры).
### Запуск трека
```
тап по треку
└─> warmupStandaloneIosGesture() — синхронно В ЖЕСТЕ (только player=ios)
└─> /music/play?... — резолв источника на сервере
└─> { track, selected_match, sources[] }
└─> pickPlaybackSource(sources) — m4a-first для ios-пути
└─> buildResolvedPlayback() — playback-объект
├─ player=ios → startStandaloneIosAudioPlayback (свой <audio>)
└─ player=inner → Lampa.Player.play (штатный плеер)
```
Все stream-URL ведут на серверный relay `/music/stream` — клиент не ходит на CDN напрямую.
### Прифетч-слой
Холодный резолв `/music/play` стоит секунды (YouTube-поиск + матчинг на сервере), поэтому кэш (`PLAY_PREFETCH_CACHE`, TTL 10 минут) греется заранее **тремя** независимыми механизмами. Все ходят через `requestPlay` (общий кэш + single-flight: повторный запрос присоединяется к летящему), только греют кэш и ничего не запускают:
| Прифетч | Триггер | Таймер |
| --- | --- | --- |
| `schedulePlayPrefetch` | `hover:focus` по карточке (ТВ-паттерн) | общий 180ms, «грей последнее под фокусом» |
| `scheduleNextTrackPrefetch` | ~3s после реального старта звука | свой; гейт поколением очереди; зеркалит арифметику embedded-next (`index+1` по `queueTracks`) |
| `scheduleFirstTrackPrefetch` | открытие экрана альбома/плейлиста | свой; 1 трек, на standalone iOS — стартовое окно `getStandaloneIosInitialResolveCount` треков (запуск там ждёт жадный резолв этого окна) |
Не переиспользовать таймеры между прифетчами — hover-семантика (сброс на каждом фокусе) конфликтует с отложенными.
Два инварианта тайминга вокруг кэша: cache-hit в `requestPlay` отдаёт ответ **строго асинхронно** (standalone-запуск рассчитан на резолв вне жеста), а `startStandaloneIosAudioPlayback` держит **session-settle** ~700ms между жестовым silent-warmup и прицеплением реального src — iOS фиксирует capabilities Now Playing в первые сотни ms сессии, мгновенная подмена src строила сессию на emptied-элементе.
### Мёртвый трек в очереди
Провал резолва — не тупик: на **честный** отказ сервера (`available: false`) очередь автоматически шагает дальше (`maybeAutoSkipUnavailableTrack`), сетевой сбой (`null`/битый ответ) — стоп, как раньше (iOS душит сеть скрытой страницы — авто-скип прощёлкал бы живые треки под локскрином). Кап 3 скипа подряд, счётчик сбрасывается только на реальном старте звука; repeat-one не скипается.
## Два режима воспроизведения
| | `player=ios` (standalone) | `player=inner` (embedded) |
| --- | --- | --- |
| Media owner | собственный `<audio>` модуля | видеоэлемент `Lampa.PlayerVideo` |
| UI | мини-бар + фулл-плеер (свайпы, шиты) | штатный интерфейс Lampa |
| Lock screen | полный: play/pause/prev/next/скраббер | play/pause/prev/next, без скраббера |
| Состояние | `MUSIC_IOS_AUDIO` | `MUSIC_EMBEDDED_IOS` |
| Код | секции QUEUE / IOS PLAYBACK, IOS FULL PLAYER | подсекция embedded в IOS FULL PLAYER + LAMPA PLAYER BRIDGE |
Режимы нельзя смешивать: у них разные media owner и разная логика Media Session. Почему в embedded-режиме нет скраббера — разобрано в [ios-embedded-lockscreen-seek.md](./ios-embedded-lockscreen-seek.md).
## Жизненный цикл standalone iOS-плеера
Это самая хрупкая зона модуля. Каждый шаг ниже существует из-за конкретного поведения iOS/WebKit; подробности — в «почему»-комментариях у функций.
```
тап «играть» (жестовый контекст!)
├─ warmupStandaloneIosGesture() тихий play() выдаёт элементу user-gesture
│ кредит — иначе iOS игнорирует lock-screen команды
├─ armStandaloneIosMediaSessionHandlers()
│ все обработчики ДО первого play(): iOS фиксирует
│ возможности Now Playing при создании сессии
└─ startStandaloneIosKeepAlive() Web Audio-контекст держит аудио-сессию,
чтобы resume работал на заблокированном экране
играет трек
├─ timeupdate → обновление бара/фулл-плеера (UI-ключи против лишних перерисовок)
├─ maybeSyntheticStandaloneIosEnded() ручной автопереход: WebKit парсит
│ YouTube fMP4 с удвоенной длительностью,
│ штатный 'ended' не стреляет
└─ scheduleStandaloneIosLockKick() pause()+play() через 400ms после
блокировки — включает скраббер
команда с lock screen (play/pause/seekto)
├─ pause-as-play: повторный pause на стоящем треке при живом keep-alive = play
└─ watchStandaloneIosPlayProgress() watchdog: резолв play() не доказывает
├─ attempt 0: pause()+play() звук — проверяем прогресс currentTime
└─ attempt 1: recoverStandaloneIosPlayback (переприцепка src, страховка)
stop / конец плейлиста
└─ stopStandaloneIosKeepAlive(), снапшот очереди очищается
```
## Снапшот очереди (v2)
Очередь переживает перезапуск клиента через два ключа `Lampa.Storage` — разнесены, чтобы частый тик не пересобирал всю очередь (это грело телефон):
- `queue_blob_v2` — окно ≤500 треков + режимы (repeat/shuffle/провайдер) + `snapshotId`; пишется **по событиям** (смена трека/состава, toggles, приближение к краю окна);
- `queue_position_v2` — крошечная запись `snapshotId`/`trackId`/`currentTime`; пишется каждые 4 секунды.
Связка через `snapshotId`: позиция от чужого поколения блоба игнорируется. TTL 14 дней считается по свежести позиции. Restore читает v2 с fallback на legacy-ключ v1. `snapshotId` также служит маркером поколения очереди для радио (один запрос автоподборки на поколение).
## Радио на клиенте
Два входа, одна настройка (`radio_autoplay_enabled`): тумблер в фильтре и чип «Подборка» в фулл-плеере. Автоподборка триггерится, когда играет предпоследний трек **по порядку воспроизведения** (при shuffle — по `standaloneIosOrder`, не по физическому индексу); guard — один POST `/music/radio` на поколение очереди + `pending`-флаг. Клиент шлёт seed-треки и `exclude` всей очереди (сервер её не видит). Добавленные треки несут флаг `auto_radio` (едет в снапшот), в шите очереди перед первым из них — разделитель «Дальше автоподборка». «Радио от трека» (`startRadioFromTrack`) строит **новую** очередь: сид первым, волна следом; при неудаче текущая очередь не трогается. `repeat=all`, restored-очередь до play и внешние m3u-плееры автоподборку отключают.
## Анти-гонки
Асинхронные колбэки защищены токенами (`prepareToken`, `playWatchToken`, `switchToken`, `MUSIC_PLAY_LAUNCH_TOKEN`, `homeLoadToken`): перед применением результата колбэк сверяет токен с текущим и молча выходит, если операция устарела (пользователь переключил трек, начался новый watch). `MUSIC_PLAY_LAUNCH_TOKEN` — last-tap-wins для `playTrack`: каждый вызов инвалидирует незавершённые флоу предыдущего, стейловый запуск не доезжает до плеера (раньше двойной тап по медленно резолвящемуся треку давал два одновременных плеера). Новых async-путей это правило тоже касается.
## Отладка
- `client_debug_enabled: true` в конфиге модуля → `traceStandaloneIosAudio` и heat-метрики шлют события на `/music/clientlog`, сервер пишет их в лог. Это единственный способ отлаживать lock screen на реальном iPhone. После отладки — выключить.
- Имена трейсов стабильные (`play-nudge-ok`, `keepalive-start`, `media-session-pause-as-play`…) — по ним можно грепать историю в логах контейнера.
## Глоссарий
| Термин | Значение |
| --- | --- |
| entry | карточка полки: `{ type, id, title, subtitle, badge, image, raw }` |
| section | полка home или полный экран списка (`/music/section`) |
| match | кандидат audio-источника для трека (`MusicAudioMatch`) |
| pinned | ручной выбор источника; авторитетнее любых эвристик, живёт в SQLite |
| warmup | тихий `play()` синхронно в жесте — выдаёт user-gesture кредит |
| keep-alive | беззвучный Web Audio-контекст, держит аудио-сессию во время паузы |
| nudge | цикл `pause()+play()` — единственное, что размораживает clock элемента |
| lock-kick | rate-переход после блокировки, включающий скраббер на lock screen (на бете iOS 27 не действует — сломано платформой) |
| session-settle | выдержка ~700ms между warmup и реальным src: iOS фиксирует capabilities сессии в первые сотни ms |
| auto-skip | автопропуск трека с честным `available: false`; кап 3 подряд, сеть не скипается |
| warming | `browse_sections_warming` в `/music/home`: полки ещё греются, клиент дозапросит и доклеит |
| synthetic-ended | ручной автопереход, когда WebKit завысил длительность и `'ended'` не стреляет |
| sane-duration | выбор между длительностью стрима и метаданных при диком расхождении |
| snapshot / restore | сохранение очереди в Storage (blob + position, v2) и восстановление после перезапуска |
| snapshotId | поколение очереди: связывает blob с позицией, гейтит радио-запросы |
| auto_radio | флаг трека, добавленного автоподборкой; рисует разделитель в очереди |
| heat-метрики | счётчики горячих путей, уходят на `/music/clientlog` при отладке |
## Правила изменений
1. После правки `plugin.js` поднять revision в `lampac-docker/plugins/override/lampainit-invc.js`, иначе клиенты держат кэшированный JS.
2. Всё, что касается standalone iOS-плеера, тестировать на реальном iPhone с заблокированным экраном — симулятор и десктоп-браузер не воспроизводят поведение аудио-сессий iOS.
3. Не удалять «бессмысленные» на вид pause/play-циклы и таймауты — почти каждый из них компенсирует конкретное поведение iOS (см. комментарии). История экспериментов: [ios-embedded-lockscreen-seek.md](./ios-embedded-lockscreen-seek.md).