Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules

This commit is contained in:
2026-10-02 12:11:49 +03:00
parent 8dcbc6bc52
commit 7b8e28b3bf
151 changed files with 68625 additions and 44 deletions
+158
View File
@@ -0,0 +1,158 @@
# Архитектура клиента (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).