feat(social): add listening activity, shared rooms and modern chat
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s

This commit is contained in:
mrleo1nid committed 2026-10-23 10:45:40 +03:00
1 parent 0a05bfe2cd
commit 11a16cd023
59 files changed
+5022 -542

No files matched your search

+67 -2
View File
@@ -1,5 +1,9 @@
import {
EventBuffer,
createActivityPublisher,
type ActivityPublisher,
ListeningCoordinator,
type ListeningRoom,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveOutcome,
@@ -35,6 +39,10 @@ const PRELOAD_BEFORE_MS = 20_000;
* Media Session и сохранение «трек и момент» (локально и на сервере).
*/
export class Player {
private activity: ActivityPublisher | null = null;
private listening: ListeningCoordinator | null = null;
private applyingSync = false;
private remoteRoom: string | null = null;
readonly offline: DesktopOffline | null;
private retryEvents: (() => Promise<void>) | null = null;
private readonly engine: AudioEngine;
@@ -93,6 +101,17 @@ export class Player {
/** Запуск фоновой отправки событий (парный вызов — dispose). */
start() {
this.activity ??= createActivityPublisher(this.session);
this.activity.update(this.state);
if (!this.listening) {
this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, {
read: this.getState,
pause: () => { this.restoreVersion++; this.leaveWave(); this.audio.pause(); },
apply: (room, position, current) => this.syncListening(room, position, current),
changed: listening => this.update({ listening }),
});
this.listening.start();
}
this.offline?.activate();
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
if (this.retryEvents) {
@@ -116,6 +135,8 @@ export class Player {
private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch };
this.activity?.update(this.state);
this.listening?.update();
for (const fn of this.listeners) fn();
this.media.syncDesktopMedia();
}
@@ -135,6 +156,7 @@ export class Player {
/** Запустить список с позиции index в контексте (альбом, поиск, коллекция...). */
playList(tracks: Track[], index: number, context: PlayContext) {
if (this.listening?.locked && !this.applyingSync) return;
const playable = tracks.filter((t) => t.available && t.stream_url);
const target = tracks[index];
if (!target || !playable.length) return;
@@ -151,6 +173,7 @@ export class Player {
/** Запустить волну (или перезапустить с новым контекстом). */
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
if (this.listening?.locked) return;
this.restoreVersion++;
this.finishAttempt('skip');
this.leaveWave();
@@ -190,6 +213,7 @@ export class Player {
* Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с.
*/
async setWaveContext(activity: string | null, mood: string | null) {
if (this.listening?.locked) return;
const wave = this.state.wave;
if (!wave) return this.startWave(activity, mood);
this.update({ wave: { ...wave, activity, mood } });
@@ -215,6 +239,7 @@ export class Player {
}
stopWave() {
if (this.listening?.locked) return;
this.leaveWave();
this.pause();
}
@@ -247,11 +272,13 @@ export class Player {
}
toggle() {
if (this.listening?.locked && !this.applyingSync) return;
if (this.state.playing) this.pause();
else this.play();
}
play() {
if (this.listening?.locked && !this.applyingSync) return;
if (!this.queue.current) return;
if (!this.audio.src || !this.attempt) {
void this.load(true, this.state.positionMs);
@@ -263,10 +290,12 @@ export class Player {
}
pause() {
if (this.listening?.locked && !this.applyingSync) return;
this.audio.pause();
}
next() {
if (this.listening?.locked) return;
this.finishAttempt('skip');
if (this.queue.next()) {
this.syncQueue();
@@ -278,6 +307,7 @@ export class Player {
/** «Назад»: первые 3 с — к предыдущему треку, дальше — в начало текущего. */
prev() {
if (this.listening?.locked) return;
if (this.audio.currentTime > 3) {
this.seek(0);
return;
@@ -290,6 +320,7 @@ export class Player {
}
jump(index: number) {
if (this.listening?.locked) return;
this.finishAttempt('skip');
this.queue.jump(index);
this.syncQueue();
@@ -297,6 +328,7 @@ export class Player {
}
seek(ms: number) {
if (this.listening?.locked && !this.applyingSync) return;
const track = this.queue.current;
if (!track) return;
const to = Math.min(Math.max(ms, 0), track.duration_ms);
@@ -323,12 +355,14 @@ export class Player {
}
toggleShuffle() {
if (this.listening?.locked) return;
this.queue.setShuffle(!this.queue.shuffle);
this.syncQueue();
this.saveLocal();
}
cycleRepeat() {
if (this.listening?.locked) return;
const order: RepeatMode[] = ['off', 'all', 'one'];
this.queue.repeat = order[(order.indexOf(this.queue.repeat) + 1) % order.length]!;
this.syncQueue();
@@ -363,13 +397,13 @@ export class Player {
// --- загрузка трека ---
private async load(autoplay: boolean, startMs = 0) {
private async load(autoplay: boolean, startMs = 0, current = () => true) {
const track = this.queue.current;
if (!track?.stream_url) return;
const version = this.restoreVersion;
if (this.offline) {
await this.offline.ready.catch(() => undefined);
if (this.queue.current !== track || version !== this.restoreVersion) return;
if (!current() || this.queue.current !== track || version !== this.restoreVersion) return;
}
this.offline?.keep([track.id]);
const source = this.offline?.local(track).stream_url ?? track.stream_url;
@@ -396,6 +430,7 @@ export class Player {
try {
const resumed = this.engine.resume();
if (resumed) await resumed;
if (!current() || version !== this.restoreVersion) return;
await this.audio.play();
} catch {
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
@@ -649,6 +684,10 @@ export class Player {
}
dispose() {
this.listening?.dispose();
this.listening = null;
this.activity?.dispose();
this.activity = null;
this.restoreVersion++;
this.wavePolicy.activate(null);
this.fading = false;
@@ -661,4 +700,30 @@ export class Player {
this.media.dispose();
void this.events.flush();
}
beginListeningAudio() {
if (this.state.listening?.room?.playing) {
this.applyingSync = true; try { this.play(); } finally { this.applyingSync = false; }
}
this.listening?.beginAudio();
}
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
if (!room.track_id) { this.audio.pause(); return; }
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
if (!current()) return;
this.remoteRoom = room.id;
this.restoreVersion++; this.finishAttempt('skip'); this.leaveWave(); this.context = { type: 'queue' };
this.queue.set([track], 0); this.queue.repeat = 'off'; this.queue.shuffle = false; this.syncQueue();
await this.load(room.playing, Math.min(position, Math.max(0, track.duration_ms - 100)), current);
return;
}
if (!current()) return;
this.applyingSync = true;
try {
if (Math.abs(this.state.positionMs - position) > 1500) this.seek(Math.min(position, Math.max(0, this.state.durationMs - 100)));
if (room.playing && !this.state.playing) this.play();
if (!room.playing && this.state.playing) this.pause();
} finally { this.applyingSync = false; }
}
}