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,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();
|
||||
Reference in new issue
Block a user