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