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

+86 -5
View File
@@ -1,6 +1,10 @@
import NetInfo from '@react-native-community/netinfo';
import {
EventBuffer,
createActivityPublisher,
type ActivityPublisher,
ListeningCoordinator,
type ListeningRoom,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveInfo,
@@ -32,6 +36,7 @@ export { offlineWave };
export type { WaveInfo } from '@pipu/core';
export interface PlayerState {
listening?: import('@pipu/core').ListeningClientState;
ready: boolean;
track: Track | null;
queue: Track[];
@@ -72,6 +77,11 @@ async function quality(): Promise<Quality> {
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
*/
class Controller {
private activity: ActivityPublisher | null = null;
private listening: ListeningCoordinator | null = null;
private interrupted = false;
private remoteRoom: string | null = null;
private queueVersion = 0;
private session: AuthSession | null = null;
private server = '';
private events: EventBuffer | null = null;
@@ -104,12 +114,27 @@ class Controller {
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();
}
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
attach(session: AuthSession | null, server: string, userId: string | null) {
if (this.session === session) return;
this.activity?.dispose();
this.listening?.dispose();
this.listening = null;
this.activity = session && userId ? createActivityPublisher(session, eventId(Date.now())) : null;
if (session && this.activity) {
this.listening = new ListeningCoordinator(session, this.activity.deviceId, {
read: this.getState,
pause: () => { this.queueVersion++; this.leaveWave(); void TrackPlayer.pause().catch(() => undefined); },
apply: (room, position, current) => this.syncListening(room, position, current),
changed: listening => this.update({ listening }),
});
this.listening.start();
}
void this.events?.flush();
this.session = session;
this.server = server;
@@ -240,35 +265,49 @@ class Controller {
}
async playList(tracks: Track[], index: number, context: PlayContext) {
if (this.listening?.locked) return;
const version = ++this.queueVersion;
const current = () => version === this.queueVersion && !this.listening?.locked;
const list = tracks.filter(playable);
const target = tracks[index];
if (!target || !list.length) return;
await this.setup();
if (!current()) return;
const native = await this.toRntp(list);
if (!current()) return;
this.leaveWave();
this.finishAttempt(this.lastPositionMs / 1000);
const start = Math.max(list.indexOf(target), 0);
this.update({ queue: list, context, index: start, track: list[start] ?? null });
await TrackPlayer.reset();
await TrackPlayer.add(await this.toRntp(list));
if (!current()) return;
await TrackPlayer.add(native);
if (!current()) return;
await TrackPlayer.skip(start);
if (!current()) return;
await TrackPlayer.play();
}
toggle() {
if (this.listening?.locked) return;
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
}
next() {
if (this.listening?.locked) return;
void TrackPlayer.skipToNext().catch(() => undefined);
}
async prev() {
if (this.listening?.locked) return;
const { position } = await TrackPlayer.getProgress();
if (this.listening?.locked) return;
if (position > 3) await TrackPlayer.seekTo(0);
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
}
seek(ms: number) {
if (this.listening?.locked) return;
const t = this.state.track;
if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms });
this.attempt?.seek(ms);
@@ -306,6 +345,7 @@ class Controller {
// --- «Моя волна» ---
async startWave(activity: string | null, mood: string | null) {
if (this.listening?.locked) return;
if (!this.session) return;
await this.setup();
this.leaveWave();
@@ -335,6 +375,7 @@ class Controller {
}
async setWaveContext(activity: string | null, mood: string | null) {
if (this.listening?.locked) return;
const wave = this.state.wave;
if (!wave || !this.session) return this.startWave(activity, mood);
const version = this.wavePolicy.changeContext(wave.sessionId);
@@ -422,18 +463,28 @@ class Controller {
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */
async restore() {
if (!this.session || this.state.track) return;
if (!this.session || this.state.track || this.listening?.locked) return;
const session = this.session;
const initial = this.state;
const current = () => this.session === session && this.state.queue === initial.queue && !this.state.track && !this.listening?.locked;
await this.setup();
if (!current()) return;
try {
const st = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
const st = unwrap(await session.client.GET('/api/v1/me/playback-state'));
if (!current()) return;
if (!st.track_id || !st.queue.length) return;
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
if (!current()) return;
const tracks = res.items.filter(playable);
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
if (!tracks.length) return;
const native = await this.toRntp(tracks);
if (!current()) return;
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
await TrackPlayer.reset();
await TrackPlayer.add(await this.toRntp(tracks));
if (this.state.queue !== tracks || this.listening?.locked) return;
await TrackPlayer.add(native);
if (this.state.queue !== tracks || this.listening?.locked) return;
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
} catch {
// офлайн или нет сохранённого состояния
@@ -441,6 +492,7 @@ class Controller {
}
async reset() {
this.queueVersion++;
this.wavePolicy.activate(null);
this.attempt = null;
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
@@ -451,6 +503,35 @@ class Controller {
this.save();
void this.events?.flush();
}
play() { if (!this.listening?.locked) void TrackPlayer.play(); }
pause() { if (!this.listening?.locked) void TrackPlayer.pause(); }
setInterrupted(paused: boolean) { this.interrupted = paused; }
beginListeningAudio() { this.listening?.beginAudio(); }
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
if (!room.track_id) { await TrackPlayer.pause(); return; }
await this.setup();
if (!current()) 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 } } }));
const native = await this.toRntp([track]);
if (!current()) return;
this.remoteRoom = room.id;
this.leaveWave(); this.finishAttempt(this.lastPositionMs / 1000);
await TrackPlayer.pause();
if (!current()) return;
this.update({ queue: [track], track, index: 0, durationMs: track.duration_ms, positionMs: 0, context: { type: 'queue' }, wave: null });
await TrackPlayer.reset();
if (!current()) return;
await TrackPlayer.add(native);
}
if (!current()) return;
const target = Math.min(position, Math.max(0, this.state.durationMs - 100));
if (Math.abs(this.state.positionMs - target) > 1500) await TrackPlayer.seekTo(target / 1000);
if (!current()) { await TrackPlayer.pause(); return; }
if (room.playing && !this.interrupted) { if (!this.state.playing) await TrackPlayer.play(); }
else if (this.state.playing) await TrackPlayer.pause();
}
}
export const player = new Controller();