Свій клієнт за 15 хвилин
Ця сторінка проводить від порожнього файлу до звуку в колонках. Наприкінці у вас буде клієнт, який підключається до сервера, показує поточний трек і програє його синхронно з усіма іншими слухачами.
Припускається, що сервер уже запущено — якщо ні, спершу зробіть Запуск власного сервера.
Що треба знати наперед
Три речі, які визначають форму клієнта. Якщо їх пропустити, ви згаєте години.
Стан радіо приходить через Socket.io, а не через REST. HTTP-ендпоінти — це допоміжні ресурси: бібліотека, тексти, аудіо. Поточний трек і позиція програвання приходять подією sync кожні дві секунди.
Аудіо захищене двома токенами. Спершу треба отримати artToken через сокет, потім обміняти його на audioToken. Без обох аудіо не віддається.
Сервер має два режими програвання. У sync-режимі клієнт сам качає файл і тримає позицію; у stream-режимі сервер віддає спільний MP3-потік. Дізнатися режим можна з /api/public/config — клієнт мусить підтримати щонайменше один.
CORS
Сервер віддає ресурси лише тим походженням, що перелічені в CLIENT_ORIGIN у server/.env. Поки ви не додасте туди адресу свого клієнта, браузер блокуватиме і запити, і сокет. Кілька значень пишуться через кому:
CLIENT_ORIGIN=http://localhost:3000,http://localhost:5173Після зміни сервер треба перезапустити.
Крок 1. Дізнатися конфігурацію сервера
Єдиний ендпоінт, який працює без будь-якої авторизації:
GET /api/public/config{
"dataProvider": "json",
"musicSource": "local",
"nightMode": true,
"streamMode": false,
"radioHostsMode": false,
"allPrivileges": ["queue_manage", "artist_arts", "..."],
"timeZone": "Europe/Kyiv",
"capabilities": { "uploadTracks": true, "editSettings": true, "...": true }
}capabilities цікавить лише адмінський клієнт — повний перелік полів у «Решті адмінських сценаріїв». Слухацькому клієнту з нього нічого не потрібно. Вас цікавить streamMode. Далі описано шлях для streamMode: false — він складніший, і саме його варто реалізувати першим. Для streamMode: true див. Режими програвання.
Крок 2. Підключитися до сокета
import { io } from 'socket.io-client';
const SERVER = 'http://localhost:3001';
const socket = io(SERVER, { withCredentials: true });Одразу після підключення, без жодного запиту з вашого боку, сервер надішле:
| Подія | Payload |
|---|---|
sync | повний стан радіо (див. крок 4) |
radio_hosts_mode | true / false — чи ввімкнено живих ведучих |
usersUpdate | масив активних слухачів |
Крок 3. Отримати artToken
Це той крок, який неможливо вгадати. Клієнт має сам надіслати listener_init:
let artToken = null;
let listenerUid = null;
socket.on('connect', () => socket.emit('listener_init'));
socket.on('listener_uid', (data) => {
listenerUid = data.uid;
artToken = data.artToken;
// data.artTokenExpiresIn — секунди до кінця дії (за замовчуванням 3600)
// data.cooldownSecsLeft — скільки чекати до наступного замовлення пісні
});Ідентичність виводиться з IP-адреси
uid визначає сервер за IP-адресою з'єднання — клієнт не може його обрати. Наслідок: усі слухачі за одним NAT ділять один uid, а отже спільний токен і спільний кулдаун на замовлення пісні. Це не помилка у вашому клієнті.
artToken живе одну годину. Клієнт зобов'язаний перепідключитись або повторно надіслати listener_init, щоб отримати свіжий.
Крок 4. Читати стан радіо
Подія sync приходить кожні дві секунди й описує стан повністю:
socket.on('sync', (state) => {
console.log(state.title, '—', state.artist, '@', state.seek);
});{
"track": "day/artist - title.mp3",
"title": "Title",
"artist": "Artist",
"album": "Album",
"year": 2020,
"duration": 214.5,
"seek": 87.2,
"isPlaying": true,
"playlist": [{ "id": "…", "title": "…", "artist": "…", "orderType": "…" }],
"mode": "day",
"serverTimeMs": 1754899200000,
"uiSettings": { "…": "…" }
}Ключові поля:
track— ідентифікатор для запиту аудіо. Це шлях видуday/…абоnight/….seek— позиція в секундах на моментserverTimeMs. Саме різниця між вашою локальною позицією і цим значенням дає дрейф, який треба компенсувати.playlist— до 10 наступних треків.isPreparing: true— сервер перемикає режим;trackбудеnull.
Коли ведучі ставлять чергу на паузу, title стає "Just chatting", а artist — порожнім рядком. Клієнт має показати щось замість метаданих треку.
Крок 5. Обміняти artToken на audioToken
GET /api/audio-key
X-Art-Token: <artToken>
X-Listener-Uid: <uid>{ "token": "<audioToken>", "expiresIn": 900 }Обидва заголовки обов'язкові — без X-Listener-Uid сервер відповість 400.
Токен короткоживучий, тож оновлюйте його завчасно. Референсний клієнт робить це за 30 секунд до закінчення.
Крок 6. Програти аудіо
const url = new URL(`${SERVER}/api/audio/stream`);
url.searchParams.set('track', state.track);
url.searchParams.set('artToken', artToken);
url.searchParams.set('audioToken', audioToken);
const audio = new Audio(url.toString());
audio.currentTime = state.seek;
await audio.play();Токени можна передавати або заголовками (X-Art-Token, X-Audio-Token), або query-параметрами (artToken, audioToken). Для <audio src> доступний лише другий варіант, бо заголовки там задати неможливо.
Аудіо не зашифроване
Поширена помилка при читанні коду: audioToken виглядає як ключ шифрування, але ним не є — це перепустка. Байти аудіо віддаються як є, і <audio> програє їх напряму.
XOR-обгортка в цьому проєкті застосовується не до аудіо, а до артів, аватарів і текстів пісень — і ключем там служить artToken. Див. Токени і доступ.
Поведінка залежить від musicSource з кроку 1, і клієнта це стосується:
musicSource | Що робить /api/audio/stream |
|---|---|
local | Віддає байти з підтримкою HTTP Range |
cloud | Відповідає 302 на тимчасове посилання Cloudflare R2 |
Обидва випадки прозорі для <audio>, але якщо ви качаєте через fetch, редирект треба дозволити.
Альтернатива — GET /api/audio/url, що повертає { url, ttl } і не віддає самих байтів. Зручно, коли програвач хоче отримати посилання наперед.
Крок 7. Тримати синхронність
Мінімальна корекція дрейфу: на кожному sync порівняйте свою позицію з серверною й підтягніться, якщо розбіжність завелика.
const DRIFT_TOLERANCE = 0.35; // секунди
socket.on('sync', (state) => {
if (!state.isPlaying || state.track !== currentTrackId) return;
if (Math.abs(audio.currentTime - state.seek) > DRIFT_TOLERANCE) {
audio.currentTime = state.seek;
}
});Поріг у 0,35 с узятий з референсного клієнта: менші значення дають чутні стрибки на кожній похибці мережі. Для точнішої корекції враховуйте serverTimeMs і час доставки події.
Що далі
Мінімальний клієнт готовий. Наступні шари, кожен зі своїм ендпоінтом:
| Можливість | Куди дивитися |
|---|---|
| Бібліотека треків | GET /api/library — публічний, без токенів |
| Тексти пісень | GET /api/lyrics — art-токен, XOR-обгортка |
| Арти виконавців | GET /api/artist-art/:artist — art-токен, XOR-обгортка |
| Замовлення пісні | подія suggest_song, кулдаун 5 хвилин |
| Список слухачів | подія usersUpdate |
| Живі ведучі й гості | radio_hosts_mode, див. довідник подій |
Локалізовані повідомлення
Сервер повертає помилки й повідомлення об'єктом з усіма мовами одразу, а не рядком. Клієнт, який чекає error: "текст", отримає об'єкт і зламається. Перед тим як обробляти помилки, прочитайте Формат повідомлень та i18n.