feat: finish the upload, playback, and test gaps from the docs
ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s

Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mrleo1nidandCursor committed 2026-10-21 09:37:53 +03:00
1 parent 2b76060ea3
commit 066ba24834
61 files changed
+2818 -463

No files matched your search

+114 -3
View File
@@ -1,5 +1,6 @@
import {
EventBuffer,
normalizeGain,
PlayAttempt,
PlayQueue,
unwrap,
@@ -80,6 +81,9 @@ function deviceId(): string {
*/
export class Player {
private readonly audio: HTMLAudioElement;
private readonly overlap: HTMLAudioElement;
private fading = false;
private ignoreEnded = false;
private readonly queue = new PlayQueue();
private readonly events: EventBuffer;
private attempt: PlayAttempt | null = null;
@@ -102,6 +106,8 @@ export class Player {
) {
this.audio = audio ?? new Audio();
this.audio.preload = 'auto';
this.overlap = new Audio();
this.overlap.preload = 'auto';
this.events = new EventBuffer({
send: async (events) => {
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
@@ -324,15 +330,17 @@ export class Player {
}
setVolume(v: number) {
this.audio.volume = Math.min(Math.max(v, 0), 1);
const volume = Math.min(Math.max(v, 0), 1);
this.audio.muted = false;
this.update({ volume: this.audio.volume, muted: false });
this.update({ volume, muted: false });
this.applyLoudness();
this.saveLocal();
}
toggleMute() {
this.audio.muted = !this.audio.muted;
this.update({ muted: this.audio.muted });
this.applyLoudness();
}
toggleShuffle() {
@@ -382,6 +390,7 @@ export class Player {
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
this.audio.src = track.stream_url;
if (startMs > 0) this.audio.currentTime = startMs / 1000;
this.applyLoudness();
this.attempt = new PlayAttempt(track, startMs);
this.preloaded = null;
this.events.push({
@@ -429,6 +438,107 @@ export class Player {
}
}
private prefs() {
const user = this.session.state.status === 'signed_in' ? this.session.state.user : null;
return user?.ui_prefs;
}
private normalizeOn() {
return this.prefs()?.normalize !== false;
}
private crossfadeSeconds() {
return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0));
}
/** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */
private gain: GainNode | null = null;
private wired = false;
private applyLoudness(mix = 1) {
const track = this.state.track;
const user = this.state.muted ? 0 : this.state.volume;
const level = user * normalizeGain(track?.features?.loudness_lufs, this.normalizeOn()) * mix;
if (!this.wired && typeof AudioContext !== 'undefined') {
try {
const ctx = new AudioContext();
const src = ctx.createMediaElementSource(this.audio);
this.gain = ctx.createGain();
src.connect(this.gain);
this.gain.connect(ctx.destination);
this.audio.volume = 1;
this.wired = true;
} catch {
this.wired = true;
}
}
if (this.gain) this.gain.gain.value = level;
else this.audio.volume = Math.min(1, level);
}
/** В конце трека накладывает следующий на crossfade_s секунд. */
private maybeCrossfade(positionMs: number) {
const cf = this.crossfadeSeconds();
if (this.fading || cf <= 0 || this.state.repeat === 'one') return;
const dur = this.audio.duration;
if (!Number.isFinite(dur)) return;
const left = dur * 1000 - positionMs;
if (left <= 0 || left > cf * 1000) return;
const next = this.state.queue[this.state.index + 1];
if (!next?.stream_url || !next.available) return;
this.fading = true;
this.overlap.src = next.stream_url;
this.overlap.volume = 0;
void this.overlap.play().catch(() => {
this.fading = false;
});
const started = performance.now();
const step = () => {
if (!this.fading) return;
const p = Math.min(1, (performance.now() - started) / (cf * 1000));
this.applyLoudness(1 - p);
this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p);
if (p < 1) {
requestAnimationFrame(step);
return;
}
this.finishCrossfade(next);
};
requestAnimationFrame(step);
}
private finishCrossfade(next: Track) {
const at = this.overlap.currentTime;
this.ignoreEnded = true;
this.audio.pause();
this.overlap.pause();
this.finishAttempt('play_end');
if (!this.queue.next(true)) {
this.fading = false;
this.ignoreEnded = false;
this.update({ playing: false, positionMs: 0 });
return;
}
this.syncQueue();
const src = next.stream_url;
if (!src) {
this.fading = false;
this.ignoreEnded = false;
this.update({ playing: false });
return;
}
this.audio.src = src;
if (at > 0) this.audio.currentTime = at;
this.applyLoudness();
this.attempt = new PlayAttempt(next, Math.round(at * 1000));
this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms });
this.updateMediaMetadata(next);
void this.audio.play().finally(() => {
this.fading = false;
this.ignoreEnded = false;
});
}
private bindAudio() {
const a = this.audio;
a.addEventListener('playing', () => {
@@ -451,10 +561,12 @@ export class Player {
this.update({ positionMs: ms });
this.updatePositionState();
this.maybePreload(ms);
this.maybeCrossfade(ms);
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
this.saveLocal();
});
a.addEventListener('ended', () => {
if (this.ignoreEnded || this.fading) return;
this.finishAttempt('play_end');
if (this.queue.next(true)) {
this.syncQueue();
@@ -465,7 +577,6 @@ export class Player {
}
});
a.addEventListener('error', () => void this.onError());
a.addEventListener('volumechange', () => this.update({ volume: a.volume, muted: a.muted }));
}
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */