Неделя 6 · Podcast Player
🧭 ← JS30 · Следующая → NFC ·
🎯 Что строим
Первый полноценный SPA в bootcamp — упрощённая версия Apple Podcasts / Spotify Podcasts на чистом vanilla JS (или TypeScript). 4 секции, 140 баллов:
- Landing + Search (40) — список свежих подкастов с Podcast Index API, поиск с debouncing
- Details page (25) — клик по подкасту → страница с эпизодами (парсинг XML-фида)
- Audio Player (45) — play/pause, прогресс-бар с seek, играет на всех страницах без остановки
- Memory + Playlist (30) —
localStorageдля плейлиста и позиции воспроизведения (восстанавливаемся за ~10 секунд до сохранённой)
🏷 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. Если нет — вернись и закрой.
- DOM поиск элементов · DOM создание и изменение элементов · DOM атрибуты и свойства
- DOM события клавиатуры и мыши
- JSON
- Fetch API — базовый GET без авторизации
1 · Промисы и асинхронность (фундамент)
Зачем: каждый запрос к API — это Promise. SHA-1 хеш через crypto.subtle.digest — тоже Promise. Без чёткого понимания цепочек .then / await ты быстро запутаешься в коде авторизации.
- Promise — что такое, состояния, цепочки
- Thenable · Deferred
- async-await — синтаксический сахар над промисами
- Promisify, callbackify, asyncify — для понимания
- Event Loop — почему
awaitне блокирует UI
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 API —
fetch(url, init),Response,.json(),.text() - URL и URLSearchParams · URL -- структура и компоненты — для query-параметров (
?q=...&max=10) - URLSearchParams — собрать query-строку красиво
- HTTP-методы -- GET, POST, PUT, DELETE, PATCH — нам нужен только GET
- HTTP-заголовки -- основные · HTTP-статусы -- 1xx, 2xx, 3xx, 4xx, 5xx
- Error handling — обработка
!response.ok, network errors
Подводный камень: 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 API —
crypto.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="..." который рефрешит страницу.
- History API —
pushState,replaceState,popstateevent - SPA -- что такое и чем отличается от MPA — общая концепция
- Что такое SPA — почему это вообще работает
- Роутинг в SPA — паттерн router как таблица "URL → render-функция"
- Клиентский роутинг -- как работает
- History API и hash-routing — два варианта (выбирай History API, hash — fallback)
- Динамические маршруты —
/podcast/:feedId
📝 Идея архитектуры (твоя):
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, замена потока при выборе нового эпизода.
- HTMLAudioElement —
audio.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'ом).
- DOMParser —
new 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 и sessionStorage —
setItem,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 строк.
- Архитектура фронтенд-приложения
- Компонентный подход · Компонент -- что это такое
- Module Pattern — паттерн "файл = модуль с публичным API"
- State Pattern / State -- внутреннее состояние — где хранить "что играет сейчас"
- Декларативный vs Императивный UI — vanilla обычно императивный
- MVC или MVP -- Model View Presenter — простейшие схемы разделения
- Загрузка данных и loading states — индикатор пока fetch в полёте (требование задания)
📝 Идея структуры (твоя — может быть другая):
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 подсказывает.
- Что такое TypeScript — зачем вообще
- TS базовые типы —
string,number,boolean,Array<T>,Record<K, V> - TS типы и формы объектов V8 — почему это вообще быстро
📝 Минимум типов под задачу:
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 (одинаковый для всех задач)
- Repo Workflow для bootcamp — публичный репо
podcast-player, веткаpodcast-player, PR вmain - gh-pages деплой — куда деплоить (помни про base-path и SPA-fallback)
- Git Commit Convention —
init:/feat:/fix:+ timestamp - PR Description Schema — список из 4 секций со статусами + ссылка на деплой
- Cross-Check процесс — 3 дня после дедлайна
Особо для этой задачи в 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, пока не сможешь ответить:
- Почему мой
Authorizationheader — это SHA-1, а не SHA-256? Где я это узнал? - Что произойдёт если время на моём ноуте уплыло на 10 минут — сможет ли API меня авторизовать?
- Открыл DevTools → Network → ввожу в search "joe" по букве — сколько запросов улетает? (Должен быть 1, не 3-4.)
- Когда я F5 на
/podcast-player/podcast/abc123— страница грузится или 404? Если 404 — что я сделал чтобы починить (404.html fallback / hash routing)? - Если я закрою таб посреди эпизода и через 2 часа вернусь — играет ли с того же места, и насколько раньше (~10 секунд назад)?
- API Key в моём коммите присутствует как plain text? (Если да — переписать историю, иначе -15 баллов.)
- Console errors при обычном использовании = 0? (Иначе -10.)
- На 1280px Chrome layout не плывёт? (Иначе -10.)
- У меня есть один инстанс
HTMLAudioElementили я случайно создаю новый на каждой странице? - Если попробую открыть на 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
📚 Внешние ресурсы
- 📄 Полное задание
- Podcast Index API docs
- Podcast Index — code examples (auth)
- Fetch API — MDN
- SubtleCrypto.digest — MDN
- Debouncing and Throttling — Telerik
- HTMLAudioElement — MDN
- Using HTML5 Audio API — MDN
- DOMParser — MDN
- History API — MDN
- Window.localStorage — MDN
- Building SPA without a framework — DEV
- TypeScript Handbook
- RSS 2.0 spec