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

17 KiB
Raw Blame History

Архитектура клиента (plugin.js)

Этот документ — карта для разработчика, открывающего plugin.js впервые. Серверная сторона описана в 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.

Жизненный цикл 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.