forked from mystiq/hydrogen-web
WIP: work on group call state transitions
This commit is contained in:
parent
b2ac4bc291
commit
b213a45c5c
5 changed files with 119 additions and 50 deletions
|
@ -21,6 +21,7 @@ import {handlesEventType} from "./PeerCall";
|
|||
import {EventType} from "./callEventTypes";
|
||||
import {GroupCall} from "./group/GroupCall";
|
||||
|
||||
import type {LocalMedia} from "./LocalMedia";
|
||||
import type {Room} from "../room/Room";
|
||||
import type {MemberChange} from "../room/members/RoomMember";
|
||||
import type {StateEvent} from "../storage/types";
|
||||
|
@ -49,6 +50,22 @@ export class GroupCallHandler {
|
|||
});
|
||||
}
|
||||
|
||||
async createCall(roomId: string, localMedia: LocalMedia, name: string): Promise<GroupCall> {
|
||||
const call = new GroupCall(undefined, undefined, roomId, this.groupCallOptions);
|
||||
this._calls.set(call.id, call);
|
||||
try {
|
||||
await call.create(localMedia, name);
|
||||
} catch (err) {
|
||||
if (err.name === "ConnectionError") {
|
||||
// if we're offline, give up and remove the call again
|
||||
this._calls.remove(call.id);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await call.join(localMedia);
|
||||
return call;
|
||||
}
|
||||
|
||||
get calls(): BaseObservableMap<string, GroupCall> { return this._calls; }
|
||||
|
||||
// TODO: check and poll turn server credentials here
|
||||
|
@ -58,7 +75,7 @@ export class GroupCallHandler {
|
|||
// first update call events
|
||||
for (const event of events) {
|
||||
if (event.type === EventType.GroupCall) {
|
||||
this.handleCallEvent(event, room);
|
||||
this.handleCallEvent(event, room.id);
|
||||
}
|
||||
}
|
||||
// then update members
|
||||
|
@ -71,7 +88,8 @@ export class GroupCallHandler {
|
|||
|
||||
/** @internal */
|
||||
updateRoomMembers(room: Room, memberChanges: Map<string, MemberChange>) {
|
||||
|
||||
// TODO: also have map for roomId to calls, so we can easily update members
|
||||
// we will also need this to get the call for a room
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
|
@ -86,7 +104,7 @@ export class GroupCallHandler {
|
|||
call?.handleDeviceMessage(message, userId, deviceId, log);
|
||||
}
|
||||
|
||||
private handleCallEvent(event: StateEvent, room: Room) {
|
||||
private handleCallEvent(event: StateEvent, roomId: string) {
|
||||
const callId = event.state_key;
|
||||
let call = this._calls.get(callId);
|
||||
if (call) {
|
||||
|
@ -95,7 +113,7 @@ export class GroupCallHandler {
|
|||
this._calls.remove(call.id);
|
||||
}
|
||||
} else {
|
||||
call = new GroupCall(event, room, this.groupCallOptions);
|
||||
call = new GroupCall(event.state_key, event.content, roomId, this.groupCallOptions);
|
||||
this._calls.set(call.id, call);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -127,12 +127,11 @@ export class PeerCall implements IDisposable {
|
|||
return;
|
||||
}
|
||||
this.setState(CallState.CreateOffer);
|
||||
// add the local tracks, and wait for onNegotiationNeeded and handleNegotiation to be called
|
||||
for (const t of this.localMedia.tracks) {
|
||||
this.peerConnection.addTrack(t);
|
||||
}
|
||||
// TODO: in case of glare, we would not go to InviteSent if we haven't started sending yet
|
||||
// but we would go straight to CreateAnswer, so also need to wait for that state
|
||||
// after adding the local tracks, and wait for handleNegotiation to be called,
|
||||
// or invite glare where we give up our invite and answer instead
|
||||
await this.waitForState([CallState.InviteSent, CallState.CreateAnswer]);
|
||||
}
|
||||
|
||||
|
|
|
@ -97,12 +97,12 @@ party identification
|
|||
|
||||
Build basic version of PeerCall
|
||||
- add candidates code
|
||||
Build basic version of GroupCall
|
||||
- add state, block invalid actions
|
||||
DONE: Build basic version of GroupCall
|
||||
- DONE: add state, block invalid actions
|
||||
DONE: Make it possible to olm encrypt the messages
|
||||
Do work needed for state events
|
||||
- receiving (almost done?)
|
||||
- sending
|
||||
- DONEish: receiving (almost done?)
|
||||
- DONEish: sending
|
||||
Expose call objects
|
||||
expose volume events from audiotrack to group call
|
||||
Write view model
|
||||
|
|
|
@ -18,6 +18,8 @@ import {ObservableMap} from "../../../observable/map/ObservableMap";
|
|||
import {Member} from "./Member";
|
||||
import {LocalMedia} from "../LocalMedia";
|
||||
import {RoomMember} from "../../room/members/RoomMember";
|
||||
import {makeId} from "../../common";
|
||||
|
||||
import type {Options as MemberOptions} from "./Member";
|
||||
import type {BaseObservableMap} from "../../../observable/map/BaseObservableMap";
|
||||
import type {Track} from "../../../platform/types/MediaDevices";
|
||||
|
@ -30,12 +32,11 @@ import type {ILogItem} from "../../../logging/types";
|
|||
import type {Storage} from "../../storage/idb/Storage";
|
||||
|
||||
export enum GroupCallState {
|
||||
LocalCallFeedUninitialized = "local_call_feed_uninitialized",
|
||||
InitializingLocalCallFeed = "initializing_local_call_feed",
|
||||
LocalCallFeedInitialized = "local_call_feed_initialized",
|
||||
Joining = "entering",
|
||||
Joined = "entered",
|
||||
Ended = "ended",
|
||||
Fledgling = "fledgling",
|
||||
Creating = "creating",
|
||||
Created = "created",
|
||||
Joining = "joining",
|
||||
Joined = "joined",
|
||||
}
|
||||
|
||||
export type Options = Omit<MemberOptions, "emitUpdate" | "confId" | "encryptDeviceMessage"> & {
|
||||
|
@ -46,70 +47,112 @@ export type Options = Omit<MemberOptions, "emitUpdate" | "confId" | "encryptDevi
|
|||
};
|
||||
|
||||
export class GroupCall {
|
||||
public readonly id: string;
|
||||
private readonly _members: ObservableMap<string, Member> = new ObservableMap();
|
||||
private localMedia?: Promise<LocalMedia>;
|
||||
private _localMedia?: LocalMedia;
|
||||
private _memberOptions: MemberOptions;
|
||||
private _state: GroupCallState = GroupCallState.LocalCallFeedInitialized;
|
||||
|
||||
// TODO: keep connected state and deal
|
||||
private _state: GroupCallState;
|
||||
|
||||
constructor(
|
||||
private callEvent: StateEvent,
|
||||
private readonly room: Room,
|
||||
id: string | undefined,
|
||||
private callContent: Record<string, any> | undefined,
|
||||
private readonly roomId: string,
|
||||
private readonly options: Options
|
||||
) {
|
||||
this.id = id ?? makeId("conf-");
|
||||
this._state = id ? GroupCallState.Created : GroupCallState.Fledgling;
|
||||
this._memberOptions = Object.assign({
|
||||
confId: this.id,
|
||||
emitUpdate: member => this._members.update(member.member.userId, member),
|
||||
encryptDeviceMessage: (message: SignallingMessage<MGroupCallBase>, log) => {
|
||||
return this.options.encryptDeviceMessage(this.room.id, message, log);
|
||||
return this.options.encryptDeviceMessage(this.roomId, message, log);
|
||||
}
|
||||
}, options);
|
||||
}
|
||||
|
||||
static async create(roomId: string, options: Options): Promise<GroupCall> {
|
||||
|
||||
}
|
||||
|
||||
get localMedia(): LocalMedia | undefined { return this._localMedia; }
|
||||
get members(): BaseObservableMap<string, Member> { return this._members; }
|
||||
|
||||
get id(): string { return this.callEvent.state_key; }
|
||||
|
||||
get isTerminated(): boolean {
|
||||
return this.callEvent.content["m.terminated"] === true;
|
||||
return this.callContent?.["m.terminated"] === true;
|
||||
}
|
||||
|
||||
async join(localMedia: Promise<LocalMedia>) {
|
||||
this.localMedia = localMedia;
|
||||
const memberContent = await this._createOrUpdateOwnMemberStateContent();
|
||||
async join(localMedia: LocalMedia) {
|
||||
if (this._state !== GroupCallState.Created) {
|
||||
return;
|
||||
}
|
||||
this._state = GroupCallState.Joining;
|
||||
this._localMedia = localMedia;
|
||||
const memberContent = await this._joinCallMemberContent();
|
||||
// send m.call.member state event
|
||||
const request = this.options.hsApi.sendState(this.room.id, "m.call.member", this.options.ownUserId, memberContent);
|
||||
const request = this.options.hsApi.sendState(this.roomId, "m.call.member", this.options.ownUserId, memberContent);
|
||||
await request.response();
|
||||
// send invite to all members that are < my userId
|
||||
for (const [,member] of this._members) {
|
||||
member.connect(this.localMedia);
|
||||
member.connect(this._localMedia);
|
||||
}
|
||||
}
|
||||
|
||||
async leave() {
|
||||
const memberContent = await this._leaveCallMemberContent();
|
||||
// send m.call.member state event
|
||||
if (memberContent) {
|
||||
const request = this.options.hsApi.sendState(this.roomId, "m.call.member", this.options.ownUserId, memberContent);
|
||||
await request.response();
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
updateCallEvent(callEvent: StateEvent) {
|
||||
this.callEvent = callEvent;
|
||||
// TODO: emit update
|
||||
async create(localMedia: LocalMedia, name: string) {
|
||||
if (this._state !== GroupCallState.Fledgling) {
|
||||
return;
|
||||
}
|
||||
this._state = GroupCallState.Creating;
|
||||
this.callContent = {
|
||||
"m.type": localMedia.cameraTrack ? "m.video" : "m.voice",
|
||||
"m.name": name,
|
||||
"m.intent": "m.ring"
|
||||
};
|
||||
const request = this.options.hsApi.sendState(this.roomId, "m.call", this.id, this.callContent);
|
||||
await request.response();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
updateCallEvent(callContent: Record<string, any>) {
|
||||
this.callContent = callContent;
|
||||
if (this._state === GroupCallState.Creating) {
|
||||
this._state = GroupCallState.Created;
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
addMember(userId, memberCallInfo) {
|
||||
if (userId === this.options.ownUserId) {
|
||||
if (this._state === GroupCallState.Joining) {
|
||||
this._state = GroupCallState.Joined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let member = this._members.get(userId);
|
||||
if (member) {
|
||||
member.updateCallInfo(memberCallInfo);
|
||||
} else {
|
||||
member = new Member(RoomMember.fromUserId(this.room.id, userId, "join"), this._memberOptions);
|
||||
member.updateCallInfo(memberCallInfo);
|
||||
member = new Member(RoomMember.fromUserId(this.roomId, userId, "join"), memberCallInfo, this._memberOptions);
|
||||
this._members.add(userId, member);
|
||||
if (this._state === GroupCallState.Joining || this._state === GroupCallState.Joined) {
|
||||
member.connect(this._localMedia!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
removeMember(userId) {
|
||||
if (userId === this.options.ownUserId) {
|
||||
if (this._state === GroupCallState.Joined) {
|
||||
this._state = GroupCallState.Created;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._members.remove(userId);
|
||||
}
|
||||
|
||||
|
@ -124,10 +167,10 @@ export class GroupCall {
|
|||
}
|
||||
}
|
||||
|
||||
private async _createOrUpdateOwnMemberStateContent() {
|
||||
private async _joinCallMemberContent() {
|
||||
const {storage} = this.options;
|
||||
const txn = await storage.readTxn([storage.storeNames.roomState]);
|
||||
const stateEvent = await txn.roomState.get(this.room.id, "m.call.member", this.options.ownUserId);
|
||||
const stateEvent = await txn.roomState.get(this.roomId, "m.call.member", this.options.ownUserId);
|
||||
const stateContent = stateEvent?.event?.content ?? {
|
||||
["m.calls"]: []
|
||||
};
|
||||
|
@ -150,4 +193,13 @@ export class GroupCall {
|
|||
}
|
||||
return stateContent;
|
||||
}
|
||||
|
||||
private async _leaveCallMemberContent(): Promise<Record<string, any> | undefined> {
|
||||
const {storage} = this.options;
|
||||
const txn = await storage.readTxn([storage.storeNames.roomState]);
|
||||
const stateEvent = await txn.roomState.get(this.roomId, "m.call.member", this.options.ownUserId);
|
||||
const callsInfo = stateEvent?.event?.content?.["m.calls"];
|
||||
callsInfo?.filter(c => c["m.call_id"] === this.id);
|
||||
return stateEvent?.event.content;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,10 +38,11 @@ export type Options = Omit<PeerCallOptions, "emitUpdate" | "sendSignallingMessag
|
|||
|
||||
export class Member {
|
||||
private peerCall?: PeerCall;
|
||||
private localMedia?: Promise<LocalMedia>;
|
||||
private localMedia?: LocalMedia;
|
||||
|
||||
constructor(
|
||||
public readonly member: RoomMember,
|
||||
private memberCallInfo: Record<string, any>,
|
||||
private readonly options: Options
|
||||
) {}
|
||||
|
||||
|
@ -53,13 +54,13 @@ export class Member {
|
|||
return this.peerCall?.state === CallState.Connected;
|
||||
}
|
||||
|
||||
/* @internal */
|
||||
connect(localMedia: Promise<LocalMedia>) {
|
||||
/** @internal */
|
||||
connect(localMedia: LocalMedia) {
|
||||
this.localMedia = localMedia;
|
||||
// otherwise wait for it to connect
|
||||
if (this.member.userId < this.options.ownUserId) {
|
||||
this.peerCall = this._createPeerCall(makeId("c"));
|
||||
this.peerCall.call(localMedia);
|
||||
this.peerCall.call(Promise.resolve(localMedia.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -71,13 +72,12 @@ export class Member {
|
|||
/** @internal */
|
||||
emitUpdate = (peerCall: PeerCall, params: any) => {
|
||||
if (peerCall.state === CallState.Ringing) {
|
||||
peerCall.answer(this.localMedia!);
|
||||
peerCall.answer(Promise.resolve(this.localMedia!));
|
||||
}
|
||||
this.options.emitUpdate(this, params);
|
||||
}
|
||||
|
||||
/** From PeerCallHandler
|
||||
* @internal */
|
||||
/** @internal */
|
||||
sendSignallingMessage = async (message: SignallingMessage<MCallBase>, log: ILogItem) => {
|
||||
const groupMessage = message as SignallingMessage<MGroupCallBase>;
|
||||
groupMessage.content.conf_id = this.options.confId;
|
||||
|
|
Loading…
Reference in a new issue