Неделя 6 · Podcast Player

🧭 ← JS30 · Следующая → NFC ·

🎯 Что строим

Первый полноценный SPA в bootcamp — упрощённая версия Apple Podcasts / Spotify Podcasts на чистом vanilla JS (или TypeScript). 4 секции, 140 баллов:

  1. Landing + Search (40) — список свежих подкастов с Podcast Index API, поиск с debouncing
  2. Details page (25) — клик по подкасту → страница с эпизодами (парсинг XML-фида)
  3. Audio Player (45) — play/pause, прогресс-бар с seek, играет на всех страницах без остановки
  4. Memory + Playlist (30) — localStorage для плейлиста и позиции воспроизведения (восстанавливаемся за ~10 секунд до сохранённой)

📄 Полное задание на GitHub →

🏷 Required Skills (как заявлено в задании)

vanilla JavaScript · TypeScript · Fetch API · Web Crypto API · async/await · debouncing · routing · HTML5 Audio API · LocalStorage · XML parsing · responsive design

🚫 Запреты + штрафы

Что нельзя Штраф
🔴 UI-фреймворк (React, Vue, Angular, Svelte и т.п.) -140 (обнуление)
🔴 НЕ-SPA — переходы с полной перезагрузкой страницы -20
🔴 API Key / API Secret захардкожен в коммите plain-text'ом -15
🔴 Console errors при обычном использовании -10
🔴 Сломанный layout на Chrome 1280px -10

💡 Разрешено: vanilla JS, TypeScript, любые build-инструменты (Vite/Webpack/esbuild), CSS-фреймворки/препроцессоры. Ключевое: нет UI-фреймворка и есть SPA-роутинг.

⛰ Что унаследовано (compound из P3 + JS30)

Из Shelter P3 ты уже умеешь:

  • DOM-манипуляции, делегирование событий, fetch JSON
  • Модульная организация vanilla-кода без фреймворка
  • Парсинг JSON, рендер карточек, popup-окна

Из JS30:

  • HTMLAudioElement (если делал challenge с drum kit / video player)
  • requestAnimationFrame для плавной прогресс-бар анимации
  • Работа с диапазонами времени (currentTime, duration)

Новое в этой задаче:

  • SHA-1 хеш через Web Crypto API для авторизации Podcast Index
  • Debouncing input-событий
  • SPA-роутинг через History API + popstate
  • DOMParser для XML-фидов
  • localStorage для playlist + playback position
  • TypeScript (опционально, но настойчиво рекомендуется)

📚 Что изучить (по порядку)

⚠️ Иди по блокам. Web Crypto (блок 3) — самое нетривиальное место всего задания, без него ни один запрос к API не уйдёт. Не пропускай.

📥 Что должен знать ДО старта

Должен быть закрыт compound из Shelter P3 + JS30. Если нет — вернись и закрой.


1 · Промисы и асинхронность (фундамент)

Зачем: каждый запрос к API — это Promise. SHA-1 хеш через crypto.subtle.digest — тоже Promise. Без чёткого понимания цепочек .then / await ты быстро запутаешься в коде авторизации.

Self-check: в чём разница между Promise.all и Promise.race? Что произойдёт если await бросит — где ловить? Можешь ли переписать пример из задания с .then на async/await (метод fetchRecent)?


2 · Fetch API + HTTP-запросы

Зачем: все 3 эндпоинта Podcast Index (/recent/feeds, /search/byterm, /podcasts/byfeedid) — это fetch с кастомными headers. Плюс отдельный fetch за XML-фидом эпизодов.

Подводный камень: fetch НЕ бросает на 4xx/5xx — только на network failure. Проверяй response.ok руками.

Self-check: какой заголовок надо проставить чтобы получить JSON ответ от Podcast Index? Что вернёт fetch если сервер вернул 404 — это reject или resolve? Как передать query-параметр ?max=10 через URLSearchParams?


3 · Web Crypto API + SHA-1 ⭐⭐⭐ (специфика задания)

Зачем: Podcast Index API требует заголовок Authorization: <SHA-1(apiKey + apiSecret + apiHeaderTime)> в hex-формате. Без правильного хеша API возвращает 401 — задание не работает.

  • Web Crypto APIcrypto.subtle.digest(algorithm, data)
  • Возвращает Promise<ArrayBuffer> — нужно сконвертировать в hex-строку
  • new TextEncoder.encode(string)Uint8Array → передаём в digest
  • Из ArrayBuffer собираем hex через [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('')

📝 Алгоритм (повтори своими руками минимум 1 раз):

1. apiHeaderTime = Math.round(Date.now() / 1000).toString()   // секунды
2. payload = apiKey + apiSecret + apiHeaderTime               // конкатенация
3. bytes = new TextEncoder.encode(payload)                  // в Uint8Array
4. digest = await crypto.subtle.digest('SHA-1', bytes)        // ArrayBuffer
5. hex = [...new Uint8Array(digest)]
          .map(b => b.toString(16).padStart(2, '0'))
          .join('')                                           // hex-строка
6. headers.Authorization = hex

Подводные камни:

  • Math.round(Date.now() / 1000)в секундах, не миллисекундах. API отклонит запрос если время отличается от серверного больше чем на ~5 минут.
  • padStart(2, '0') обязательно — без него байты вроде 0x0a дадут "a" вместо "0a", хеш не совпадёт.
  • crypto.subtle доступен только по HTTPS (и на localhost). Деплой на gh-pages работает, потому что HTTPS.

Self-check: почему именно SHA-1 а не SHA-256 (загляни в доки Podcast Index)? Что произойдёт если забыть padStart? Куда подставляется apiHeaderTime — в hash и в заголовок X-Auth-Date одновременно или только в hash?


4 · Debouncing для поискового input ⭐

Зачем: по требованию задания — +5 баллов именно за debouncing/throttling. Без него на каждый keystroke полетит запрос → 1) лимит API быстро исчерпаешь, 2) гонки race conditions, 3) тормоза.

  • Debounce и Throttle для DOM-событий — пиши свою реализацию, не тащи lodash
  • Типичный паттерн: ~300-500ms задержка после последнего keystroke
  • AbortController — отменять предыдущий запрос когда стартует новый (защита от race)

📝 Идея реализации (твоя):

function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout( => fn(...args), ms);
  };
}

Подводный камень: debounce не отменяет уже улетевший fetch. Если успел улететь старый запрос и пришёл медленнее нового — UI покажет старые результаты. Лечится AbortController + сравнением "это ещё актуальный запрос?".

Self-check: чем debounce отличается от throttle — какой выбрать для поиска? Зачем нужен AbortController если уже есть debounce? Как ты убедишься через DevTools Network что debounce работает?


5 · SPA-роутинг через History API ⭐⭐

Зачем: требование SPA. Переходы Landing → Details → Player без перезагрузки страницы. Минус 20 баллов если есть <a href="..." который рефрешит страницу.

📝 Идея архитектуры (твоя):

1. router.add('/', renderLanding)
2. router.add('/podcast/:id', renderDetails)
3. router.add('/playlist', renderPlaylist)
4. На клик по ссылке: e.preventDefault() + history.pushState + render
5. На window.popstate: матчим location.pathname → render

Подводный камень на gh-pages: деплой не в корень домена (/podcast-player/...), и при F5 на /podcast-player/podcast/123 сервер вернёт 404. Лечится либо hash-routing (#/podcast/123), либо файлом 404.html который редиректит на index.

Self-check: в чём разница pushState и replaceState? Когда срабатывает popstate — на любой pushState или только на back/forward? Почему на gh-pages F5 на deep-link ломается?


6 · HTMLAudioElement (плеер) ⭐⭐

Зачем: сердце Section 3 — 45 баллов. Play/pause, прогресс-бар который обновляется в реальном времени, кликабельный seek, замена потока при выборе нового эпизода.

  • HTMLAudioElementaudio.play(), audio.pause(), audio.currentTime, audio.duration
  • Создавать через new Audio (НЕ обязательно тегом в DOM)
  • События: loadedmetadata (узнали duration), timeupdate (тикает каждые ~250ms), ended, play, pause, error

📝 Идея архитектуры плеера:

1. Один singleton-инстанс Audio на всё приложение
2. При смене эпизода: audio.src = newUrl; audio.load(); audio.play()
3. На timeupdate: обновить прогресс-бар + сохранить currentTime в localStorage
4. Прогресс-бар — div с background-color и width: %; клик → seekTo(clickX / barWidth * duration)
5. Плеер монтируется один раз в shell-разметку, НЕ в "роутом" контейнере

Подводные камни:

  • audio.duration может быть NaN или Infinity пока не сработал loadedmetadata — не пытайся форматировать 00:NaN.
  • Mobile-браузеры требуют жест юзера для первого play (autoplay блокируется).
  • При audio.src = newUrl старый поток автоматически останавливается — но не забудь сбросить currentTime если нужно с начала.

Self-check: как отформатировать audio.currentTime (секунды-флоат) в MM:SS? Что произойдёт если вызвать play до loadedmetadata? Как сделать seek по клику на прогресс-бар — какая формула?


7 · DOMParser для XML-фидов ⭐

Зачем: Section 2 — список эпизодов берётся не из JSON API, а из RSS-фида (XML) по URL который Podcast Index возвращает в поле url. Парсить надо нативным DOMParser (НЕ regex'ом).

  • DOMParsernew DOMParser.parseFromString(xmlString, 'application/xml')
  • Возвращает Document — навигация через те же querySelector / getElementsByTagName что и для HTML
  • DOM поиск элементов — пригодится для XML-документа тоже
  • Структура RSS: <rss><channel><item>...</item><item>...</item></channel></rss>
  • В <item> ищем: <title>, <pubDate>, <itunes:duration> (namespace!), <enclosure url="..."> (это аудио-файл)

📝 Подводные камни:

  • Namespaces (itunes:duration) — querySelector НЕ поддерживает : в селекторе. Использовать getElementsByTagName('itunes:duration') или getElementsByTagNameNS(NS, 'duration').
  • Парсер не бросает на невалидном XML — он молча создаёт документ с <parsererror>. Проверяй: doc.querySelector('parsererror').
  • CORS: RSS-фид с произвольного домена может не отдавать Access-Control-Allow-Origin. Это известная проблема — иногда придётся проксировать или использовать cors-anywhere для разработки.

Self-check: как достать <itunes:duration> если querySelector(':') не работает? Как обработать <enclosure url="..." length="..." type="audio/mpeg" /> — где URL аудиофайла? Что делать если фид вернул 404 или CORS-ошибку?


8 · localStorage — память приложения ⭐

Зачем: Section 4 — 30 баллов. Плейлист и playback-позиция должны переживать F5.

  • Web Storage -- localStorage и sessionStoragesetItem, getItem, removeItem, clear
  • Хранится только строки — всё что не string гонишь через JSON.stringify / JSON.parse
  • Лимит ~5MB на origin
  • Синхронный API — для большого payload может тормозить

📝 Что хранить:

key 'podcast-player:playlist'  → JSON массив [{episodeId, podcastId, title, audioUrl, ...}]
key 'podcast-player:position:<episodeId>'  → число (currentTime в секундах)
key 'podcast-player:last-episode'  → episodeId последнего проигранного

Требование задания: при возврате к ранее слушанному эпизоду — стартуем Math.max(0, savedPosition - 10) (~10 секунд назад).

Альтернатива на будущее: IndexedDB — если объёмов больше 5MB или нужны индексы.

Self-check: что произойдёт если попытаться сохранить объект напрямую без JSON.stringify? Как очистить только свои ключи (podcast-player:*) не задев чужие? Где именно вызывать setItem('position:X', currentTime) — на каждый timeupdate или throttle'ить?


9 · Архитектура SPA без фреймворка ⭐⭐

Зачем: это первый проект где у тебя 4 страницы, общее состояние (плеер играет на любой странице), и навигация. Без архитектуры — спагетти-код через 200 строк.

📝 Идея структуры (твоя — может быть другая):

src/
  api/        podcast-index.ts   (fetchRecent, search, getEpisodesByFeedId)
              auth.ts            (SHA-1 + headers)
              rss-parser.ts      (DOMParser обёртка)
  player/     audio-player.ts    (singleton HTMLAudioElement, события)
              player-ui.ts       (кнопки, прогресс-бар)
  pages/      landing.ts
              details.ts
              playlist.ts
  router/     router.ts          (pushState + popstate + map)
  store/      playlist-store.ts  (localStorage обёртка)
              position-store.ts
  utils/      debounce.ts
              format-time.ts
  main.ts     (точка входа, init router + player shell)

Тест на запах: если в landing.ts есть импорт из details.ts — что-то не так, листы должны быть листами.


10 · TypeScript (опционально, но рекомендую) ⭐

Зачем: Podcast Index возвращает JSON с десятком полей вложенных объектов. Без типов будешь промахиваться по именам полей и ловить undefined.title в рантайме. С типами IDE подсказывает.

📝 Минимум типов под задачу:

interface PodcastFeed {
  id: number;
  title: string;
  url: string;       // ссылка на RSS
  image: string;
  author: string;
}

interface Episode {
  id: string;
  title: string;
  pubDate: string;
  duration: number;  // секунды
  audioUrl: string;
}

Не нужно прямо сейчас: дженерики, decorators, advanced types. Базовых типов хватит за глаза.

Если идёшь без TS: JSDoc-комментарии над функциями (@param {PodcastFeed} feed) дадут IDE половину пользы от типов.


11 · Workflow (одинаковый для всех задач)

Особо для этой задачи в PR-описании:

  • Какой API Key вы используете (НЕ показывать сам ключ, но указать что он реально подключён)
  • Какие фичи реализованы из 4 секций — галочками
  • Известные баги если есть (особенно CORS на RSS если бьёт)

✅ Чек-лист критериев (140 баллов)

Section 1 — Landing + Search · 40

  • Landing рендерит recent feeds из API (+5)
  • Карточка показывает image + title + author (+5)
  • Пагинация или infinite scroll (+5)
  • Search input присутствует (+5)
  • Пустой input → показ recent feeds (+5)
  • Непустой input → результаты из Search API (+5)
  • Запросы дебаунсятся (видно в DevTools Network) (+5)
  • Loading-индикатор пока запрос в полёте (+5)

Section 2 — Details page · 25

  • Клик по карточке → переход на details (+5)
  • Список эпизодов спарсен из XML-фида (+10)
  • Эпизод показывает title + pubDate + duration (+5)
  • In-app кнопка "назад" (НЕ браузерная) (+5)

Section 3 — Audio Player · 45

  • Выбор эпизода стартует плеер (+5)
  • Работающий Play/Pause toggle (+5)
  • Показ currentTime + duration (+5)
  • Прогресс-бар обновляется в реальном времени (+5)
  • Клик по прогресс-бару = seek (+10)
  • Плеер видим на всех страницах (+5)
  • Можно искать/листать пока играет — без перерывов (+5)
  • Выбор другого эпизода = замена потока (нет двойного воспроизведения) (+5)

Section 4 — Memory + Playlist · 30

  • Страница плейлиста доступна из навигации (+5)
  • Можно добавить эпизод в плейлист (+5)
  • Можно удалить эпизод из плейлиста (+5)
  • Плейлист переживает F5 (+5)
  • Playback position сохраняется в localStorage (+5)
  • При возврате к эпизоду — стартует за ~10 сек до saved position (+5)

🧠 Self-check перед коммитом

Не нажимай git push, пока не сможешь ответить:

  1. Почему мой Authorization header — это SHA-1, а не SHA-256? Где я это узнал?
  2. Что произойдёт если время на моём ноуте уплыло на 10 минут — сможет ли API меня авторизовать?
  3. Открыл DevTools → Network → ввожу в search "joe" по букве — сколько запросов улетает? (Должен быть 1, не 3-4.)
  4. Когда я F5 на /podcast-player/podcast/abc123 — страница грузится или 404? Если 404 — что я сделал чтобы починить (404.html fallback / hash routing)?
  5. Если я закрою таб посреди эпизода и через 2 часа вернусь — играет ли с того же места, и насколько раньше (~10 секунд назад)?
  6. API Key в моём коммите присутствует как plain text? (Если да — переписать историю, иначе -15 баллов.)
  7. Console errors при обычном использовании = 0? (Иначе -10.)
  8. На 1280px Chrome layout не плывёт? (Иначе -10.)
  9. У меня есть один инстанс HTMLAudioElement или я случайно создаю новый на каждой странице?
  10. Если попробую открыть на iPhone — play сработает на первый клик юзера? (Mobile autoplay policy.)

➡️ Что переходит в следующие задачи (compound forward)

После Podcast Player в NFC переиспользуешь:

  • Блоки 1, 2 (Promise + Fetch) — всё ещё база
  • Блок 9 (архитектура без фреймворка) — повторишь паттерн "router + pages + store"

В Async Race:

  • Блок 1 (Promise.all/race/allSettled) — массовый запуск анимаций машинок
  • Блок 8 (localStorage) — победители гонок, рекорды

В Migrations:

  • Блок 2 (Fetch + ошибки) — streaming с backpressure поверх fetch'a
  • Блок 1 (async/await) — async iterators

📚 Внешние ресурсы