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
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:
1 parent
0a05bfe2cd
commit
11a16cd023
59 files changed
+5022
-542
No files matched your search
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user