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

+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { ActivityPublisher } from './activity';
const track = { id: '00000000-0000-4000-8000-000000000001' };
const device = '00000000-0000-4000-8000-000000000002';
describe('listening activity leases', () => {
it('publishes actual playback, throttles progress, and clears on pause', async () => {
let now = 1000; const sent: unknown[] = [];
const publisher = new ActivityPublisher(async value => { sent.push(value); }, device, () => now);
publisher.update({ track, playing: false, context: null }); await publisher.idle(); expect(sent).toEqual([]);
publisher.update({ track, playing: true, context: { type: 'wave' }, positionMs: 123 }); await publisher.idle();
expect(sent[0]).toMatchObject({ context: 'wave', track_id: track.id, sequence: 1, position_ms: 123 });
publisher.update({ track, playing: true, context: { type: 'wave' }, positionMs: 900 }); await publisher.idle(); expect(sent).toHaveLength(1);
now += 20_001; publisher.update({ track, playing: true, context: { type: 'wave' }, positionMs: 20123 }); await publisher.idle(); expect(sent).toHaveLength(2);
publisher.update({ track, playing: false, context: null }); await publisher.idle(); expect(sent.at(-1)).toMatchObject({ playing: false, sequence: 3 });
publisher.dispose(); await publisher.idle();
});
it('serializes an in-flight play and a final stop without replaying intermediate tracks', async () => {
let release!: () => void; const waiting = new Promise<void>(r => { release = r; }); const sent: { playing: boolean; sequence: number }[] = [];
const publisher = new ActivityPublisher(async value => { sent.push(value); if (sent.length === 1) await waiting; }, device);
publisher.update({ track, playing: true, context: null });
publisher.update({ track: { id: device }, playing: true, context: null });
publisher.dispose(); release(); await publisher.idle();
expect(sent.map(v => [v.playing, v.sequence])).toEqual([[true, 1], [false, 2]]);
});
it('only treats UUID collection contexts as playlists', async () => {
const sent: { context: string }[] = []; const publisher = new ActivityPublisher(async v => { sent.push(v); }, device);
publisher.update({ track, playing: true, context: { type: 'collection', id: 'likes' } }); await publisher.idle(); expect(sent[0]?.context).toBe('other');
publisher.update({ track, playing: true, context: { type: 'collection', id: device } }); await publisher.idle(); expect(sent[1]?.context).toBe('playlist');
publisher.dispose(); await publisher.idle();
});
});
+73
View File
@@ -0,0 +1,73 @@
import type { components } from '@pipu/api-client';
import { type AuthSession, unwrap } from './auth';
import { uuidv7, type PlayContext } from './player';
export type ActivityVisibility = components['schemas']['ActivityVisibility'];
export type ListeningActivity = components['schemas']['ListeningActivity'];
type Input = components['schemas']['ListeningActivityInput'];
export interface ActivityPlaybackState {
positionMs?: number;
playing: boolean;
track: { id: string } | null;
context: PlayContext | null;
wave?: unknown;
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const publishers = new WeakMap<AuthSession, Set<ActivityPublisher>>();
/** Ephemeral, serialized heartbeats. Never persists or replays offline activity. */
export class ActivityPublisher {
private current: ActivityPlaybackState = { playing: false, track: null, context: null };
private pending: Omit<Input, 'device_id' | 'sequence'> | null = null;
private sequence = 0;
private lastKey = '';
private lastAttempt = 0;
private active = false;
private closed = false;
private flight: Promise<void> | null = null;
constructor(private readonly send: (body: Input) => Promise<void>, readonly deviceId: string, private readonly now = Date.now, private readonly onDispose = () => {}) {}
update(state: ActivityPlaybackState, force = false) {
if (this.closed) return;
this.current = state;
const playing = state.playing && !!state.track;
if (!playing && !this.active) return;
const playlist = playing && !state.wave && state.context?.type === 'collection' && UUID.test(state.context.id ?? '') ? state.context.id : undefined;
const body: Omit<Input, 'device_id' | 'sequence'> = playing
? { position_ms: 0, playing: true, track_id: state.track!.id, context: state.wave || state.context?.type === 'wave' ? 'wave' : playlist ? 'playlist' : 'other', ...(playlist ? { playlist_id: playlist } : {}) }
: { position_ms: 0, playing: false, context: 'other' };
const key = JSON.stringify(body);
if (!force && key === this.lastKey && (!playing || this.now() - this.lastAttempt < 20_000)) return;
this.active ||= playing;
this.lastKey = key;
this.lastAttempt = this.now();
this.pending = { ...body, ...(playing ? { position_ms: Math.max(0, Math.round(state.positionMs ?? 0)) } : {}) };
if (!this.flight) this.flight = this.drain();
}
refresh() { this.update(this.current, true); }
idle() { return this.flight ?? Promise.resolve(); }
private async drain() {
while (this.pending) {
const body = this.pending;
this.pending = null;
try { await this.send({ ...body, device_id: this.deviceId, sequence: ++this.sequence }); }
catch { /* A failed heartbeat expires; it must never interrupt playback. */ }
}
this.flight = null;
}
dispose() {
if (this.closed) return;
this.update({ playing: false, track: null, context: null }, true);
this.closed = true;
this.onDispose();
}
}
export function createActivityPublisher(session: AuthSession, deviceId = uuidv7()) {
let set = publishers.get(session);
if (!set) { set = new Set(); publishers.set(session, set); }
const publisher = new ActivityPublisher(async body => { unwrap(await session.client.PUT('/api/v1/me/social/activity', { body })); }, deviceId, Date.now, () => set!.delete(publisher));
set.add(publisher);
return publisher;
}
export function refreshListeningActivity(session: AuthSession) { publishers.get(session)?.forEach(p => p.refresh()); }
+3
View File
@@ -24,6 +24,9 @@ export {
export { normalizeGain, TARGET_LUFS } from './gain';
export { playlistShareUrl, trackShareUrl } from './share';
export { socialApi, attachmentPath, type SocialPerson, type SocialInbox, type SocialAction, type DirectMessage, type SendDirectMessage, type MusicAttachment } from './social';
export { CHAT_REACTIONS, chatDraftKey, type ChatMessageUpdate } from './social';
export { ActivityPublisher, createActivityPublisher, type ActivityVisibility, type ListeningActivity } from './activity';
export { ListeningCoordinator, refreshListening, type ListeningClientState, type ListeningRoom, type ListeningState, type ListeningPreferences, type ListeningAction } from './listening';
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveScale, type Rgb, type WaveState } from './wave-anim';
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import type { AuthSession } from './auth';
import { ListeningCoordinator, type ListeningRoom, type ListeningState } from './listening';
const host='00000000-0000-4000-8000-000000000001';const guest='00000000-0000-4000-8000-000000000002';const device='00000000-0000-4000-8000-000000000003';
const room: ListeningRoom={id:'room',owner_id:host,owner_name:'Host',device_id:device,track_id:'track',playing:true,position_ms:1000,updated_at:'2026-10-23T00:00:00Z',participants:[{id:host,name:'Host'},{id:guest,name:'Guest'}]};
function setup(user=guest,sourceDevice='other'){
let now=1000;let state:ListeningState={preferences:{requests:'friends',auto_accept:'nobody',autoplay:true},room:{...room},requests:[],server_time:'2026-10-23T00:00:02Z'};
const put=vi.fn(async()=>({response:new Response(null,{status:204})}));const post=vi.fn(async()=>({response:new Response(null,{status:204})}));
const get=vi.fn(async()=>({data:state,response:new Response()}));
const session={state:{status:'signed_in',user:{id:user}},client:{GET:get,PUT:put,POST:post}} as unknown as AuthSession;
const adapter={read:()=>({track:{id:'track'},playing:true,positionMs:4000,context:null}),apply:vi.fn(async(_r:ListeningRoom,_p:number,_current:()=>boolean)=>{}),pause:vi.fn(),changed:vi.fn()};
const coordinator=new ListeningCoordinator(session,sourceDevice,adapter,()=>now);
return {coordinator,adapter,get,put,post,setState:(v:ListeningState)=>{state=v;},state,advance:(ms:number)=>{now+=ms;}};
}
describe('shared listening',()=>{
it('publishes only from the source device and leaves source playback running',async()=>{
const e=setup(host,device);await e.coordinator.poll(true);expect(e.put).toHaveBeenCalledWith('/api/v1/me/social/listening/state',expect.objectContaining({body:expect.objectContaining({device_id:device,position_ms:4000})}));expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.coordinator.locked).toBe(false);e.coordinator.dispose();expect(e.adapter.pause).not.toHaveBeenCalled();
});
it('locks follower controls and extrapolates the host position from server time',async()=>{
const e=setup();await e.coordinator.poll(true);expect(e.coordinator.locked).toBe(true);expect(e.adapter.apply).toHaveBeenCalledWith(expect.objectContaining({track_id:'track'}),3000,expect.any(Function));expect(e.put).not.toHaveBeenCalled();e.coordinator.dispose();
});
it('prepares silent audio when autoplay is disabled',async()=>{
const e=setup();e.state.preferences.autoplay=false;await e.coordinator.poll(true);expect(e.adapter.apply).toHaveBeenCalledWith(expect.objectContaining({playing:false}),3000,expect.any(Function));expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({locked:true,needsStart:true}));e.coordinator.beginAudio();await vi.waitFor(()=>expect(e.adapter.apply).toHaveBeenLastCalledWith(expect.objectContaining({playing:true}),3000,expect.any(Function)));e.coordinator.dispose();
});
it('invalidates late synchronization and allows local exit even without a connection',async()=>{
const e=setup();let current!:()=>boolean;let release!:()=>void;
e.adapter.apply.mockImplementation(async(_r,_p,live)=>{current=live;await new Promise<void>(r=>{release=r;});});
const pending=e.coordinator.poll(true);await vi.waitFor(()=>expect(current).toBeDefined());expect(current()).toBe(true);e.coordinator.leaveLocally();expect(current()).toBe(false);expect(e.coordinator.locked).toBe(false);release();await pending;e.coordinator.dispose();
});
it('pauses on connection loss and on room termination',async()=>{
const e=setup();await e.coordinator.poll(true);e.get.mockRejectedValueOnce(new TypeError('offline'));e.advance(11000);await e.coordinator.poll(true);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({disconnected:true}));e.setState({...e.state,room:null});await e.coordinator.poll(true);expect(e.coordinator.locked).toBe(false);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null}));e.coordinator.dispose();
});
});
+119
View File
@@ -0,0 +1,119 @@
import type { components } from '@pipu/api-client';
import { type AuthSession, unwrap } from './auth';
import type { ActivityPlaybackState } from './activity';
export type ListeningRoom = components['schemas']['ListeningRoom'];
export type ListeningState = components['schemas']['ListeningState'];
export type ListeningPreferences = components['schemas']['ListeningPreferences'];
export type ListeningAction = components['schemas']['ListeningAction'];
export interface ListeningClientState { room: ListeningRoom | null; locked: boolean; needsStart: boolean; disconnected: boolean }
export interface ListeningPlaybackState extends ActivityPlaybackState { positionMs: number }
interface Adapter {
read(): ListeningPlaybackState;
apply(room: ListeningRoom, position: number, current: () => boolean): Promise<void>;
pause(): void;
changed(state: ListeningClientState): void;
}
const clients = new WeakMap<AuthSession, Set<ListeningCoordinator>>();
/** Source keeps its own wave/queue. Followers play only the source's current track. */
export class ListeningCoordinator {
private snapshot: ListeningState | null = null;
private generation = 0;
private sequence = 0;
private busy = false;
private closed = false;
private lastPoll = 0;
private lastSuccess = 0;
private key = '';
private enabled = new Set<string>();
private ignored = new Set<string>();
private timer: ReturnType<typeof setInterval> | null = null;
locked = false;
constructor(private readonly session: AuthSession, readonly deviceId: string, private readonly adapter: Adapter, private readonly now = Date.now) {}
start() {
let set = clients.get(this.session); if (!set) { set = new Set(); clients.set(this.session, set); } set.add(this);
this.timer ??= setInterval(() => void this.poll(), 2000);
void this.poll(true);
}
private source(room: ListeningRoom) { return this.session.state.status === 'signed_in' && room.owner_id === this.session.state.user.id && room.device_id === this.deviceId; }
private emit(room: ListeningRoom | null, needsStart = false, disconnected = false) {
this.locked = !!room && !this.source(room);
this.adapter.changed({ room, locked: this.locked, needsStart, disconnected });
}
update() {
const room = this.snapshot?.room;
if (!room || !this.source(room)) return;
const state = this.adapter.read();
const key = `${state.track?.id}:${state.playing}`;
if (key !== this.key) { this.key = key; void this.poll(true); }
}
refresh() { void this.poll(true); }
allowJoining() { this.ignored.clear(); }
beginAudio() { if (this.snapshot?.room) this.enabled.add(this.snapshot.room.id); this.refresh(); }
leaveLocally() {
if (this.snapshot?.room) this.ignored.add(this.snapshot.room.id);
this.generation++;
if (this.locked) this.adapter.pause();
this.emit(null);
}
prepare(action: ListeningAction): ListeningAction {
if (action.action !== 'invite') return action;
const state = this.adapter.read();
return { ...action, device_id: this.deviceId, track_id: state.track?.id, position_ms: Math.max(0, Math.round(state.positionMs)), playing: state.playing };
}
async poll(force = false) {
if (this.closed || this.busy || (!force && !this.snapshot?.room && this.now() - this.lastPoll < 10_000)) return;
if (this.session.state?.status !== 'signed_in') return;
this.busy = true; this.lastPoll = this.now();
try {
const state = unwrap(await this.session.client.GET('/api/v1/me/social/listening'));
if (this.closed) return;
this.lastSuccess = this.now();
const previous = this.snapshot?.room;
this.snapshot = state;
const room = state.room;
if (previous?.id !== room?.id) this.generation++;
if (!room) { if (this.locked) this.adapter.pause(); this.emit(null); return; }
if (this.ignored.has(room.id)) {
this.emit(null);
await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'leave', target_id: room.id } });
return;
}
if (this.source(room)) {
this.emit(room);
const playback = this.adapter.read();
unwrap(await this.session.client.PUT('/api/v1/me/social/listening/state', { body: { room_id: room.id, device_id: this.deviceId, sequence: ++this.sequence, track_id: playback.track?.id, playing: playback.playing && !!playback.track, position_ms: Math.max(0, Math.round(playback.positionMs)) } }));
return;
}
if (!this.locked) this.adapter.pause();
const needsStart = !state.preferences.autoplay && !this.enabled.has(room.id);
this.emit(room, needsStart);
const version = this.generation;
const elapsed = room.playing ? Math.max(0, Date.parse(state.server_time) - Date.parse(room.updated_at)) : 0;
await this.adapter.apply(needsStart ? { ...room, playing: false } : room, room.position_ms + elapsed, () => !this.closed && this.locked && version === this.generation && !this.ignored.has(room.id));
} catch {
if (this.closed) return;
if (this.locked && this.now() - this.lastSuccess > 10_000) { this.adapter.pause(); this.emit(this.snapshot?.room ?? null, false, true); }
} finally { this.busy = false; }
}
dispose() {
if (this.closed) return;
this.closed = true; this.generation++;
if (this.timer) clearInterval(this.timer); this.timer = null;
clients.get(this.session)?.delete(this);
const room = this.snapshot?.room;
if (this.locked) this.adapter.pause();
if (room) void this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: this.source(room) ? 'end' : 'leave', target_id: room.id } }).catch(() => undefined);
}
}
export async function sendListeningAction(session: AuthSession, action: ListeningAction) {
const set = clients.get(session);
if (action.action === 'leave' || action.action === 'end') set?.forEach(c => c.leaveLocally());
const client = set?.values().next().value as ListeningCoordinator | undefined;
unwrap(await session.client.POST('/api/v1/me/social/listening/actions', { body: client?.prepare(action) ?? action }));
if (action.action !== 'leave' && action.action !== 'end') set?.forEach(c => c.allowJoining());
set?.forEach(c => c.refresh());
}
export function refreshListening(session: AuthSession) { clients.get(session)?.forEach(c => c.refresh()); }
+18 -1
View File
@@ -1,5 +1,7 @@
import type { components } from '@pipu/api-client';
import { type AuthSession, unwrap } from './auth';
import { refreshListeningActivity, type ActivityVisibility } from './activity';
import { sendListeningAction, refreshListening, type ListeningAction, type ListeningPreferences } from './listening';
export type SocialPerson = components['schemas']['SocialPerson'];
export type SocialInbox = components['schemas']['SocialInbox'];
@@ -7,14 +9,29 @@ export type SocialAction = components['schemas']['SocialAction'];
export type DirectMessage = components['schemas']['DirectMessage'];
export type SendDirectMessage = components['schemas']['SendDirectMessage'];
export type MusicAttachment = { kind: 'track' | 'playlist'; id: string; title: string };
export type ChatMessageUpdate = { action: 'edit' | 'delete' | 'react'; text?: string; emoji?: '❤️' | '👍' | '🔥' | '😂' | '🎵' | '👏' };
export const CHAT_REACTIONS = ['❤️', '👍', '🔥', '😂', '🎵', '👏'] as const;
export function chatDraftKey(server: string, user: string, peer: string) { return `pipu.chat-draft.${encodeURIComponent(server)}.${user}.${peer}`; }
/** All clients use the authenticated transport, including refresh and account isolation. */
export function socialApi(session: AuthSession) {
return {
inbox: async () => unwrap(await session.client.GET('/api/v1/me/social')),
setFriendsRecommendations: async (enabled: boolean) => unwrap(await session.client.PUT('/api/v1/me/social/recommendations', { body: { enabled } })),
listening: async () => unwrap(await session.client.GET('/api/v1/me/social/listening')),
listeningAction: (action: ListeningAction) => sendListeningAction(session, action),
setListeningPreferences: async (body: ListeningPreferences) => { unwrap(await session.client.PUT('/api/v1/me/social/listening/preferences', { body })); refreshListening(session); },
setVisibility: async (visibility: ActivityVisibility) => {
unwrap(await session.client.PUT('/api/v1/me/social/activity/visibility', { body: { visibility } }));
refreshListeningActivity(session);
},
activityPlaylist: async (userId: string) => unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId } } })),
search: async (q: string) => unwrap(await session.client.GET('/api/v1/me/social/people', { params: { query: { q } } })),
action: async (body: SocialAction) => unwrap(await session.client.POST('/api/v1/me/social/actions', { body })),
messages: async (peerId: string, before?: number) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId }, query: { before } } })),
messages: async (peerId: string, before?: number, q?: string) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId }, query: { before, q } } })),
chatMeta: async (peerId: string) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}/meta', { params: { path: { peerId } } })),
typing: async (peerId: string, typing: boolean) => unwrap(await session.client.PUT('/api/v1/me/social/chats/{peerId}/meta', { params: { path: { peerId } }, body: { typing } })),
updateMessage: async (peerId: string, messageId: string, body: ChatMessageUpdate) => unwrap(await session.client.PATCH('/api/v1/me/social/chats/{peerId}/messages/{messageId}', { params: { path: { peerId, messageId } }, body })),
send: async (peerId: string, body: SendDirectMessage) => unwrap(await session.client.POST('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId } }, body })),
};
}