WIP
This commit is contained in:
parent
2d4301fe5a
commit
bc118b5c0b
13 changed files with 154 additions and 128 deletions
|
@ -18,7 +18,7 @@ import {ViewModel, Options as BaseOptions} from "../../ViewModel";
|
||||||
import type {GroupCall} from "../../../matrix/calls/group/GroupCall";
|
import type {GroupCall} from "../../../matrix/calls/group/GroupCall";
|
||||||
import type {Member} from "../../../matrix/calls/group/Member";
|
import type {Member} from "../../../matrix/calls/group/Member";
|
||||||
import type {BaseObservableList} from "../../../observable/list/BaseObservableList";
|
import type {BaseObservableList} from "../../../observable/list/BaseObservableList";
|
||||||
import type {Track} from "../../../platform/types/MediaDevices";
|
import type {Stream} from "../../../platform/types/MediaDevices";
|
||||||
|
|
||||||
type Options = BaseOptions & {call: GroupCall};
|
type Options = BaseOptions & {call: GroupCall};
|
||||||
|
|
||||||
|
@ -46,8 +46,8 @@ export class CallViewModel extends ViewModel<Options> {
|
||||||
return this.call.id;
|
return this.call.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
get localTracks(): Track[] {
|
get localStream(): Stream | undefined {
|
||||||
return this.call.localMedia?.tracks ?? [];
|
return this.call.localMedia?.userMedia;
|
||||||
}
|
}
|
||||||
|
|
||||||
leave() {
|
leave() {
|
||||||
|
@ -60,8 +60,8 @@ export class CallViewModel extends ViewModel<Options> {
|
||||||
type MemberOptions = BaseOptions & {member: Member};
|
type MemberOptions = BaseOptions & {member: Member};
|
||||||
|
|
||||||
export class CallMemberViewModel extends ViewModel<MemberOptions> {
|
export class CallMemberViewModel extends ViewModel<MemberOptions> {
|
||||||
get tracks(): Track[] {
|
get stream(): Stream | undefined {
|
||||||
return this.member.remoteTracks;
|
return this.member.remoteMedia?.userMedia;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get member(): Member {
|
private get member(): Member {
|
||||||
|
|
|
@ -365,8 +365,9 @@ export class RoomViewModel extends ViewModel {
|
||||||
async startCall() {
|
async startCall() {
|
||||||
try {
|
try {
|
||||||
const session = this.getOption("session");
|
const session = this.getOption("session");
|
||||||
const mediaTracks = await this.platform.mediaDevices.getMediaTracks(false, true);
|
const stream = await this.platform.mediaDevices.getMediaTracks(false, true);
|
||||||
const localMedia = new LocalMedia().withTracks(mediaTracks);
|
const localMedia = new LocalMedia().withUserMedia(stream);
|
||||||
|
await this._call.join(localMedia);
|
||||||
// this will set the callViewModel above as a call will be added to callHandler.calls
|
// this will set the callViewModel above as a call will be added to callHandler.calls
|
||||||
const call = await session.callHandler.createCall(this._room.id, localMedia, "A call " + Math.round(this.platform.random() * 100));
|
const call = await session.callHandler.createCall(this._room.id, localMedia, "A call " + Math.round(this.platform.random() * 100));
|
||||||
await call.join(localMedia);
|
await call.join(localMedia);
|
||||||
|
|
|
@ -74,9 +74,8 @@ export class CallTile extends SimpleTile {
|
||||||
|
|
||||||
async join() {
|
async join() {
|
||||||
if (this.canJoin) {
|
if (this.canJoin) {
|
||||||
const mediaTracks = await this.platform.mediaDevices.getMediaTracks(false, true);
|
const stream = await this.platform.mediaDevices.getMediaTracks(false, true);
|
||||||
// const screenShareTrack = await this.platform.mediaDevices.getScreenShareTrack();
|
const localMedia = new LocalMedia().withUserMedia(stream);
|
||||||
const localMedia = new LocalMedia().withTracks(mediaTracks);
|
|
||||||
await this._call.join(localMedia);
|
await this._call.join(localMedia);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
||||||
|
|
||||||
import {ObservableMap} from "../../observable/map/ObservableMap";
|
import {ObservableMap} from "../../observable/map/ObservableMap";
|
||||||
import {WebRTC, PeerConnection, PeerConnectionHandler} from "../../platform/types/WebRTC";
|
import {WebRTC, PeerConnection, PeerConnectionHandler} from "../../platform/types/WebRTC";
|
||||||
import {MediaDevices, Track, AudioTrack, TrackType} from "../../platform/types/MediaDevices";
|
import {MediaDevices, Track, AudioTrack} from "../../platform/types/MediaDevices";
|
||||||
import {handlesEventType} from "./PeerCall";
|
import {handlesEventType} from "./PeerCall";
|
||||||
import {EventType, CallIntent} from "./callEventTypes";
|
import {EventType, CallIntent} from "./callEventTypes";
|
||||||
import {GroupCall} from "./group/GroupCall";
|
import {GroupCall} from "./group/GroupCall";
|
||||||
|
|
|
@ -37,24 +37,6 @@ export class LocalMedia {
|
||||||
return new LocalMedia(this.userMedia, this.screenShare, options);
|
return new LocalMedia(this.userMedia, this.screenShare, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSDPMetadata(): SDPStreamMetadata {
|
|
||||||
const metadata = {};
|
|
||||||
const userMediaTrack = this.microphoneTrack ?? this.cameraTrack;
|
|
||||||
if (userMediaTrack) {
|
|
||||||
metadata[userMediaTrack.streamId] = {
|
|
||||||
purpose: SDPStreamMetadataPurpose.Usermedia,
|
|
||||||
audio_muted: this.microphoneTrack?.muted ?? true,
|
|
||||||
video_muted: this.cameraTrack?.muted ?? true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (this.screenShareTrack) {
|
|
||||||
metadata[this.screenShareTrack.streamId] = {
|
|
||||||
purpose: SDPStreamMetadataPurpose.Screenshare
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
clone() {
|
clone() {
|
||||||
return new LocalMedia(this.userMedia?.clone(), this.screenShare?.clone(), this.dataChannelOptions);
|
return new LocalMedia(this.userMedia?.clone(), this.screenShare?.clone(), this.dataChannelOptions);
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,8 +23,8 @@ import type {StateEvent} from "../storage/types";
|
||||||
import type {ILogItem} from "../../logging/types";
|
import type {ILogItem} from "../../logging/types";
|
||||||
|
|
||||||
import type {TimeoutCreator, Timeout} from "../../platform/types/types";
|
import type {TimeoutCreator, Timeout} from "../../platform/types/types";
|
||||||
import {WebRTC, PeerConnection, PeerConnectionHandler} from "../../platform/types/WebRTC";
|
import {WebRTC, PeerConnection, PeerConnectionHandler, TrackSender, TrackReceiver} from "../../platform/types/WebRTC";
|
||||||
import {MediaDevices, Track, AudioTrack, TrackType} from "../../platform/types/MediaDevices";
|
import {MediaDevices, Track, AudioTrack, Stream} from "../../platform/types/MediaDevices";
|
||||||
import type {LocalMedia} from "./LocalMedia";
|
import type {LocalMedia} from "./LocalMedia";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -52,6 +52,10 @@ export type Options = {
|
||||||
sendSignallingMessage: (message: SignallingMessage<MCallBase>, log: ILogItem) => Promise<void>;
|
sendSignallingMessage: (message: SignallingMessage<MCallBase>, log: ILogItem) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export class RemoteMedia {
|
||||||
|
constructor(public userMedia?: Stream | undefined, public screenShare?: Stream | undefined) {}
|
||||||
|
}
|
||||||
|
|
||||||
// when sending, we need to encrypt message with olm. I think the flow of room => roomEncryption => olmEncryption as we already
|
// when sending, we need to encrypt message with olm. I think the flow of room => roomEncryption => olmEncryption as we already
|
||||||
// do for sharing keys will be best as that already deals with room tracking.
|
// do for sharing keys will be best as that already deals with room tracking.
|
||||||
/**
|
/**
|
||||||
|
@ -89,6 +93,7 @@ export class PeerCall implements IDisposable {
|
||||||
|
|
||||||
private _dataChannel?: any;
|
private _dataChannel?: any;
|
||||||
private _hangupReason?: CallErrorCode;
|
private _hangupReason?: CallErrorCode;
|
||||||
|
private _remoteMedia: RemoteMedia;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private callId: string,
|
private callId: string,
|
||||||
|
@ -96,6 +101,7 @@ export class PeerCall implements IDisposable {
|
||||||
private readonly logItem: ILogItem,
|
private readonly logItem: ILogItem,
|
||||||
) {
|
) {
|
||||||
const outer = this;
|
const outer = this;
|
||||||
|
this._remoteMedia = new RemoteMedia();
|
||||||
this.peerConnection = options.webRTC.createPeerConnection({
|
this.peerConnection = options.webRTC.createPeerConnection({
|
||||||
onIceConnectionStateChange(state: RTCIceConnectionState) {
|
onIceConnectionStateChange(state: RTCIceConnectionState) {
|
||||||
outer.logItem.wrap({l: "onIceConnectionStateChange", status: state}, log => {
|
outer.logItem.wrap({l: "onIceConnectionStateChange", status: state}, log => {
|
||||||
|
@ -112,9 +118,14 @@ export class PeerCall implements IDisposable {
|
||||||
outer.handleIceGatheringState(state, log);
|
outer.handleIceGatheringState(state, log);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onRemoteTracksChanged(tracks: Track[]) {
|
onRemoteStreamRemoved(stream: Stream) {
|
||||||
outer.logItem.wrap({l: "onRemoteTracksChanged", length: tracks.length}, log => {
|
outer.logItem.wrap("onRemoteStreamRemoved", log => {
|
||||||
outer.options.emitUpdate(outer, undefined);
|
outer.updateRemoteMedia(log);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onRemoteTracksAdded(trackReceiver: TrackReceiver) {
|
||||||
|
outer.logItem.wrap("onRemoteTracksAdded", log => {
|
||||||
|
outer.updateRemoteMedia(log);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onRemoteDataChannel(dataChannel: any | undefined) {
|
onRemoteDataChannel(dataChannel: any | undefined) {
|
||||||
|
@ -130,9 +141,6 @@ export class PeerCall implements IDisposable {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
outer.responsePromiseChain = outer.responsePromiseChain?.then(promiseCreator) ?? promiseCreator();
|
outer.responsePromiseChain = outer.responsePromiseChain?.then(promiseCreator) ?? promiseCreator();
|
||||||
},
|
|
||||||
getPurposeForStreamId(streamId: string): SDPStreamMetadataPurpose {
|
|
||||||
return outer.remoteSDPStreamMetadata?.[streamId]?.purpose ?? SDPStreamMetadataPurpose.Usermedia;
|
|
||||||
}
|
}
|
||||||
}, this.options.forceTURN, this.options.turnServers, 0);
|
}, this.options.forceTURN, this.options.turnServers, 0);
|
||||||
}
|
}
|
||||||
|
@ -143,8 +151,9 @@ export class PeerCall implements IDisposable {
|
||||||
|
|
||||||
get hangupReason(): CallErrorCode | undefined { return this._hangupReason; }
|
get hangupReason(): CallErrorCode | undefined { return this._hangupReason; }
|
||||||
|
|
||||||
get remoteTracks(): Track[] {
|
// we should keep an object with streams by purpose ... e.g. RemoteMedia?
|
||||||
return this.peerConnection.remoteTracks;
|
get remoteMedia(): Readonly<RemoteMedia> {
|
||||||
|
return this._remoteMedia;
|
||||||
}
|
}
|
||||||
|
|
||||||
call(localMedia: LocalMedia): Promise<void> {
|
call(localMedia: LocalMedia): Promise<void> {
|
||||||
|
@ -152,13 +161,10 @@ export class PeerCall implements IDisposable {
|
||||||
if (this._state !== CallState.Fledgling) {
|
if (this._state !== CallState.Fledgling) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.localMedia = localMedia;
|
|
||||||
this.direction = CallDirection.Outbound;
|
this.direction = CallDirection.Outbound;
|
||||||
this.setState(CallState.CreateOffer, log);
|
this.setState(CallState.CreateOffer, log);
|
||||||
for (const t of this.localMedia.tracks) {
|
this.setMedia(localMedia);
|
||||||
this.peerConnection.addTrack(t);
|
if (this.localMedia?.dataChannelOptions) {
|
||||||
}
|
|
||||||
if (this.localMedia.dataChannelOptions) {
|
|
||||||
this._dataChannel = this.peerConnection.createDataChannel(this.localMedia.dataChannelOptions);
|
this._dataChannel = this.peerConnection.createDataChannel(this.localMedia.dataChannelOptions);
|
||||||
}
|
}
|
||||||
// after adding the local tracks, and wait for handleNegotiation to be called,
|
// after adding the local tracks, and wait for handleNegotiation to be called,
|
||||||
|
@ -172,11 +178,8 @@ export class PeerCall implements IDisposable {
|
||||||
if (this._state !== CallState.Ringing) {
|
if (this._state !== CallState.Ringing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.localMedia = localMedia;
|
|
||||||
this.setState(CallState.CreateAnswer, log);
|
this.setState(CallState.CreateAnswer, log);
|
||||||
for (const t of this.localMedia.tracks) {
|
this.setMedia(localMedia, log);
|
||||||
this.peerConnection.addTrack(t);
|
|
||||||
}
|
|
||||||
let myAnswer: RTCSessionDescriptionInit;
|
let myAnswer: RTCSessionDescriptionInit;
|
||||||
try {
|
try {
|
||||||
myAnswer = await this.peerConnection.createAnswer();
|
myAnswer = await this.peerConnection.createAnswer();
|
||||||
|
@ -205,27 +208,40 @@ export class PeerCall implements IDisposable {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setMedia(localMediaPromise: Promise<LocalMedia>): Promise<void> {
|
setMedia(localMedia: LocalMedia, logItem: ILogItem = this.logItem): Promise<void> {
|
||||||
return this.logItem.wrap("setMedia", async log => {
|
return logItem.wrap("setMedia", async log => {
|
||||||
const oldMedia = this.localMedia;
|
const oldMedia = this.localMedia;
|
||||||
this.localMedia = await localMediaPromise;
|
this.localMedia = localMedia;
|
||||||
|
const applyStream = (oldStream: Stream | undefined, stream: Stream | undefined, logLabel: string) => {
|
||||||
|
const streamSender = oldMedia ? this.peerConnection.localStreams.get(oldStream!.id) : undefined;
|
||||||
|
|
||||||
const applyTrack = (selectTrack: (media: LocalMedia | undefined) => Track | undefined) => {
|
const applyTrack = (oldTrack: Track | undefined, sender: TrackSender | undefined, track: Track | undefined) => {
|
||||||
const oldTrack = selectTrack(oldMedia);
|
if (track) {
|
||||||
const newTrack = selectTrack(this.localMedia);
|
if (oldTrack && sender) {
|
||||||
if (oldTrack && newTrack) {
|
log.wrap(`replacing ${logLabel} ${track.kind} track`, log => {
|
||||||
this.peerConnection.replaceTrack(oldTrack, newTrack);
|
sender.replaceTrack(track);
|
||||||
} else if (oldTrack) {
|
});
|
||||||
this.peerConnection.removeTrack(oldTrack);
|
} else {
|
||||||
} else if (newTrack) {
|
log.wrap(`adding ${logLabel} ${track.kind} track`, log => {
|
||||||
this.peerConnection.addTrack(newTrack);
|
this.peerConnection.addTrack(track);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (sender) {
|
||||||
|
log.wrap(`replacing ${logLabel} ${sender.track.kind} track`, log => {
|
||||||
|
this.peerConnection.removeTrack(sender);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// add the local tracks, and wait for onNegotiationNeeded and handleNegotiation to be called
|
applyTrack(oldStream?.audioTrack, streamSender?.audioSender, stream?.audioTrack);
|
||||||
applyTrack(m => m?.microphoneTrack);
|
applyTrack(oldStream?.videoTrack, streamSender?.videoSender, stream?.videoTrack);
|
||||||
applyTrack(m => m?.cameraTrack);
|
}
|
||||||
applyTrack(m => m?.screenShareTrack);
|
|
||||||
|
applyStream(oldMedia?.userMedia, localMedia?.userMedia, "userMedia");
|
||||||
|
applyStream(oldMedia?.screenShare, localMedia?.screenShare, "screenShare");
|
||||||
|
// TODO: datachannel, but don't do it here as we don't want to do it from answer, rather in different method
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -321,7 +337,7 @@ export class PeerCall implements IDisposable {
|
||||||
const content = {
|
const content = {
|
||||||
call_id: this.callId,
|
call_id: this.callId,
|
||||||
offer,
|
offer,
|
||||||
[SDPStreamMetadataKey]: this.localMedia!.getSDPMetadata(),
|
[SDPStreamMetadataKey]: this.getSDPMetadata(),
|
||||||
version: 1,
|
version: 1,
|
||||||
seq: this.seq++,
|
seq: this.seq++,
|
||||||
lifetime: CALL_TIMEOUT_MS
|
lifetime: CALL_TIMEOUT_MS
|
||||||
|
@ -408,7 +424,7 @@ export class PeerCall implements IDisposable {
|
||||||
|
|
||||||
const sdpStreamMetadata = content[SDPStreamMetadataKey];
|
const sdpStreamMetadata = content[SDPStreamMetadataKey];
|
||||||
if (sdpStreamMetadata) {
|
if (sdpStreamMetadata) {
|
||||||
this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
|
this.updateRemoteSDPStreamMetadata(sdpStreamMetadata, log);
|
||||||
} else {
|
} else {
|
||||||
log.log(`Call did not get any SDPStreamMetadata! Can not send/receive multiple streams`);
|
log.log(`Call did not get any SDPStreamMetadata! Can not send/receive multiple streams`);
|
||||||
}
|
}
|
||||||
|
@ -470,7 +486,7 @@ export class PeerCall implements IDisposable {
|
||||||
|
|
||||||
const sdpStreamMetadata = content[SDPStreamMetadataKey];
|
const sdpStreamMetadata = content[SDPStreamMetadataKey];
|
||||||
if (sdpStreamMetadata) {
|
if (sdpStreamMetadata) {
|
||||||
this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
|
this.updateRemoteSDPStreamMetadata(sdpStreamMetadata, log);
|
||||||
} else {
|
} else {
|
||||||
log.log(`Did not get any SDPStreamMetadata! Can not send/receive multiple streams`);
|
log.log(`Did not get any SDPStreamMetadata! Can not send/receive multiple streams`);
|
||||||
}
|
}
|
||||||
|
@ -596,7 +612,7 @@ export class PeerCall implements IDisposable {
|
||||||
// type: EventType.CallNegotiate,
|
// type: EventType.CallNegotiate,
|
||||||
// content: {
|
// content: {
|
||||||
// description: this.peerConnection.localDescription!,
|
// description: this.peerConnection.localDescription!,
|
||||||
// [SDPStreamMetadataKey]: this.localMedia.getSDPMetadata(),
|
// [SDPStreamMetadataKey]: this.getSDPMetadata(),
|
||||||
// }
|
// }
|
||||||
// });
|
// });
|
||||||
// }
|
// }
|
||||||
|
@ -615,7 +631,7 @@ export class PeerCall implements IDisposable {
|
||||||
sdp: localDescription.sdp,
|
sdp: localDescription.sdp,
|
||||||
type: localDescription.type,
|
type: localDescription.type,
|
||||||
},
|
},
|
||||||
[SDPStreamMetadataKey]: this.localMedia!.getSDPMetadata(),
|
[SDPStreamMetadataKey]: this.getSDPMetadata(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// We have just taken the local description from the peerConn which will
|
// We have just taken the local description from the peerConn which will
|
||||||
|
@ -699,18 +715,11 @@ export class PeerCall implements IDisposable {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateRemoteSDPStreamMetadata(metadata: SDPStreamMetadata): void {
|
private updateRemoteSDPStreamMetadata(metadata: SDPStreamMetadata, log: ILogItem): void {
|
||||||
|
// this will accumulate all updates into one object, so we still have the old stream info when we change stream id
|
||||||
this.remoteSDPStreamMetadata = recursivelyAssign(this.remoteSDPStreamMetadata || {}, metadata, true);
|
this.remoteSDPStreamMetadata = recursivelyAssign(this.remoteSDPStreamMetadata || {}, metadata, true);
|
||||||
for (const track of this.peerConnection.remoteTracks) {
|
this.updateRemoteMedia(log);
|
||||||
const streamMetaData = this.remoteSDPStreamMetadata?.[track.streamId];
|
// TODO: apply muting
|
||||||
if (streamMetaData) {
|
|
||||||
if (track.type === TrackType.Microphone) {
|
|
||||||
track.setMuted(streamMetaData.audio_muted);
|
|
||||||
} else { // Camera or ScreenShare
|
|
||||||
track.setMuted(streamMetaData.video_muted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async addBufferedIceCandidates(log: ILogItem): Promise<void> {
|
private async addBufferedIceCandidates(log: ILogItem): Promise<void> {
|
||||||
|
@ -755,8 +764,6 @@ export class PeerCall implements IDisposable {
|
||||||
this.iceDisconnectedTimeout?.abort();
|
this.iceDisconnectedTimeout?.abort();
|
||||||
this.iceDisconnectedTimeout = undefined;
|
this.iceDisconnectedTimeout = undefined;
|
||||||
this.setState(CallState.Connected, log);
|
this.setState(CallState.Connected, log);
|
||||||
const transceivers = this.peerConnection.peerConnection.getTransceivers();
|
|
||||||
console.log(transceivers);
|
|
||||||
} else if (state == 'failed') {
|
} else if (state == 'failed') {
|
||||||
this.iceDisconnectedTimeout?.abort();
|
this.iceDisconnectedTimeout?.abort();
|
||||||
this.iceDisconnectedTimeout = undefined;
|
this.iceDisconnectedTimeout = undefined;
|
||||||
|
@ -807,14 +814,53 @@ export class PeerCall implements IDisposable {
|
||||||
this.hangupParty = hangupParty;
|
this.hangupParty = hangupParty;
|
||||||
this._hangupReason = hangupReason;
|
this._hangupReason = hangupReason;
|
||||||
this.setState(CallState.Ended, log);
|
this.setState(CallState.Ended, log);
|
||||||
//this.localMedia?.dispose();
|
this.localMedia?.dispose();
|
||||||
//this.localMedia = undefined;
|
this.localMedia = undefined;
|
||||||
|
|
||||||
if (this.peerConnection && this.peerConnection.signalingState !== 'closed') {
|
if (this.peerConnection && this.peerConnection.signalingState !== 'closed') {
|
||||||
this.peerConnection.close();
|
this.peerConnection.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSDPMetadata(): SDPStreamMetadata {
|
||||||
|
const metadata = {};
|
||||||
|
if (this.localMedia?.userMedia) {
|
||||||
|
const streamId = this.localMedia.userMedia.id;
|
||||||
|
const streamSender = this.peerConnection.localStreams.get(streamId);
|
||||||
|
metadata[streamId] = {
|
||||||
|
purpose: SDPStreamMetadataPurpose.Usermedia,
|
||||||
|
audio_muted: !(streamSender?.audioSender?.enabled),
|
||||||
|
video_muted: !(streamSender?.videoSender?.enabled),
|
||||||
|
};
|
||||||
|
console.log("video_muted", streamSender?.videoSender?.enabled, streamSender?.videoSender?.transceiver?.direction, streamSender?.videoSender?.transceiver?.currentDirection, JSON.stringify(metadata));
|
||||||
|
}
|
||||||
|
if (this.localMedia?.screenShare) {
|
||||||
|
const streamId = this.localMedia.screenShare.id;
|
||||||
|
metadata[streamId] = {
|
||||||
|
purpose: SDPStreamMetadataPurpose.Screenshare
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateRemoteMedia(log: ILogItem) {
|
||||||
|
this._remoteMedia.userMedia = undefined;
|
||||||
|
this._remoteMedia.screenShare = undefined;
|
||||||
|
if (this.remoteSDPStreamMetadata) {
|
||||||
|
for (const [streamId, streamReceiver] of this.peerConnection.remoteStreams.entries()) {
|
||||||
|
const metaData = this.remoteSDPStreamMetadata[streamId];
|
||||||
|
if (metaData) {
|
||||||
|
if (metaData.purpose === SDPStreamMetadataPurpose.Usermedia) {
|
||||||
|
this._remoteMedia.userMedia = streamReceiver.stream;
|
||||||
|
} else if (metaData.purpose === SDPStreamMetadataPurpose.Screenshare) {
|
||||||
|
this._remoteMedia.screenShare = streamReceiver.stream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.options.emitUpdate(this, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
private async delay(timeoutMs: number): Promise<void> {
|
private async delay(timeoutMs: number): Promise<void> {
|
||||||
// Allow a short time for initial candidates to be gathered
|
// Allow a short time for initial candidates to be gathered
|
||||||
const timeout = this.disposables.track(this.options.createTimeout(timeoutMs));
|
const timeout = this.disposables.track(this.options.createTimeout(timeoutMs));
|
||||||
|
|
|
@ -165,7 +165,7 @@ export class GroupCall extends EventEmitter<{change: never}> {
|
||||||
this._state = GroupCallState.Creating;
|
this._state = GroupCallState.Creating;
|
||||||
this.emitChange();
|
this.emitChange();
|
||||||
this.callContent = Object.assign({
|
this.callContent = Object.assign({
|
||||||
"m.type": localMedia.cameraTrack ? "m.video" : "m.voice",
|
"m.type": localMedia.userMedia?.videoTrack ? "m.video" : "m.voice",
|
||||||
}, this.callContent);
|
}, this.callContent);
|
||||||
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCall, this.id, this.callContent!, {log});
|
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCall, this.id, this.callContent!, {log});
|
||||||
await request.response();
|
await request.response();
|
||||||
|
|
|
@ -19,10 +19,9 @@ import {makeTxnId, makeId} from "../../common";
|
||||||
import {EventType, CallErrorCode} from "../callEventTypes";
|
import {EventType, CallErrorCode} from "../callEventTypes";
|
||||||
import {formatToDeviceMessagesPayload} from "../../common";
|
import {formatToDeviceMessagesPayload} from "../../common";
|
||||||
|
|
||||||
import type {Options as PeerCallOptions} from "../PeerCall";
|
import type {Options as PeerCallOptions, RemoteMedia} from "../PeerCall";
|
||||||
import type {LocalMedia} from "../LocalMedia";
|
import type {LocalMedia} from "../LocalMedia";
|
||||||
import type {HomeServerApi} from "../../net/HomeServerApi";
|
import type {HomeServerApi} from "../../net/HomeServerApi";
|
||||||
import type {Track} from "../../../platform/types/MediaDevices";
|
|
||||||
import type {MCallBase, MGroupCallBase, SignallingMessage, CallDeviceMembership} from "../callEventTypes";
|
import type {MCallBase, MGroupCallBase, SignallingMessage, CallDeviceMembership} from "../callEventTypes";
|
||||||
import type {GroupCall} from "./GroupCall";
|
import type {GroupCall} from "./GroupCall";
|
||||||
import type {RoomMember} from "../../room/members/RoomMember";
|
import type {RoomMember} from "../../room/members/RoomMember";
|
||||||
|
@ -60,8 +59,8 @@ export class Member {
|
||||||
private readonly logItem: ILogItem,
|
private readonly logItem: ILogItem,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get remoteTracks(): Track[] {
|
get remoteMedia(): RemoteMedia | undefined {
|
||||||
return this.peerCall?.remoteTracks ?? [];
|
return this.peerCall?.remoteMedia;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isConnected(): boolean {
|
get isConnected(): boolean {
|
||||||
|
|
|
@ -18,7 +18,7 @@ import {Track, Stream} from "./MediaDevices";
|
||||||
import {SDPStreamMetadataPurpose} from "../../matrix/calls/callEventTypes";
|
import {SDPStreamMetadataPurpose} from "../../matrix/calls/callEventTypes";
|
||||||
|
|
||||||
export interface WebRTC {
|
export interface WebRTC {
|
||||||
createPeerConnection(handler: PeerConnectionHandler, forceTURN: boolean, turnServers: RTCIceServer[], iceCandidatePoolSize): PeerConnection;
|
createPeerConnection(handler: PeerConnectionHandler, forceTURN: boolean, turnServers: RTCIceServer[], iceCandidatePoolSize: number): PeerConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamSender {
|
export interface StreamSender {
|
||||||
|
@ -41,7 +41,7 @@ export interface TrackReceiver {
|
||||||
|
|
||||||
export interface TrackSender extends TrackReceiver {
|
export interface TrackSender extends TrackReceiver {
|
||||||
/** replaces the track if possible without renegotiation. Can throw. */
|
/** replaces the track if possible without renegotiation. Can throw. */
|
||||||
replaceTrack(track: Track): Promise<void>;
|
replaceTrack(track: Track | undefined): Promise<void>;
|
||||||
/** make any needed adjustments to the sender or transceiver settings
|
/** make any needed adjustments to the sender or transceiver settings
|
||||||
* depending on the purpose, after adding the track to the connection */
|
* depending on the purpose, after adding the track to the connection */
|
||||||
prepareForPurpose(purpose: SDPStreamMetadataPurpose): void;
|
prepareForPurpose(purpose: SDPStreamMetadataPurpose): void;
|
||||||
|
@ -61,8 +61,8 @@ export interface PeerConnection {
|
||||||
get iceGatheringState(): RTCIceGatheringState;
|
get iceGatheringState(): RTCIceGatheringState;
|
||||||
get signalingState(): RTCSignalingState;
|
get signalingState(): RTCSignalingState;
|
||||||
get localDescription(): RTCSessionDescription | undefined;
|
get localDescription(): RTCSessionDescription | undefined;
|
||||||
get localStreams(): ReadonlyArray<StreamSender>;
|
get localStreams(): ReadonlyMap<string, StreamSender>;
|
||||||
get remoteStreams(): ReadonlyArray<StreamReceiver>;
|
get remoteStreams(): ReadonlyMap<string, StreamReceiver>;
|
||||||
createOffer(): Promise<RTCSessionDescriptionInit>;
|
createOffer(): Promise<RTCSessionDescriptionInit>;
|
||||||
createAnswer(): Promise<RTCSessionDescriptionInit>;
|
createAnswer(): Promise<RTCSessionDescriptionInit>;
|
||||||
setLocalDescription(description?: RTCSessionDescriptionInit): Promise<void>;
|
setLocalDescription(description?: RTCSessionDescriptionInit): Promise<void>;
|
||||||
|
|
|
@ -72,8 +72,8 @@ export class MediaDevicesWrapper implements IMediaDevices {
|
||||||
|
|
||||||
export class StreamWrapper implements Stream {
|
export class StreamWrapper implements Stream {
|
||||||
|
|
||||||
public audioTrack: AudioTrackWrapper | undefined;
|
public audioTrack: AudioTrackWrapper | undefined = undefined;
|
||||||
public videoTrack: TrackWrapper | undefined;
|
public videoTrack: TrackWrapper | undefined = undefined;
|
||||||
|
|
||||||
constructor(public readonly stream: MediaStream) {
|
constructor(public readonly stream: MediaStream) {
|
||||||
for (const track of stream.getTracks()) {
|
for (const track of stream.getTracks()) {
|
||||||
|
@ -91,13 +91,13 @@ export class StreamWrapper implements Stream {
|
||||||
if (track.kind === "video") {
|
if (track.kind === "video") {
|
||||||
if (!this.videoTrack || track.id !== this.videoTrack.track.id) {
|
if (!this.videoTrack || track.id !== this.videoTrack.track.id) {
|
||||||
this.videoTrack = new TrackWrapper(track, this.stream);
|
this.videoTrack = new TrackWrapper(track, this.stream);
|
||||||
return this.videoTrack;
|
|
||||||
}
|
}
|
||||||
|
return this.videoTrack;
|
||||||
} else if (track.kind === "audio") {
|
} else if (track.kind === "audio") {
|
||||||
if (!this.audioTrack || track.id !== this.audioTrack.track.id) {
|
if (!this.audioTrack || track.id !== this.audioTrack.track.id) {
|
||||||
this.audioTrack = new AudioTrackWrapper(track, this.stream);
|
this.audioTrack = new AudioTrackWrapper(track, this.stream);
|
||||||
return this.audioTrack;
|
|
||||||
}
|
}
|
||||||
|
return this.audioTrack;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,10 +62,10 @@ export class DOMStreamSender implements StreamSender {
|
||||||
if (transceiver && sender.track) {
|
if (transceiver && sender.track) {
|
||||||
const trackWrapper = this.stream.update(sender.track);
|
const trackWrapper = this.stream.update(sender.track);
|
||||||
if (trackWrapper) {
|
if (trackWrapper) {
|
||||||
if (trackWrapper.kind === TrackKind.Video) {
|
if (trackWrapper.kind === TrackKind.Video && (!this.videoSender || this.videoSender.track.id !== trackWrapper.id)) {
|
||||||
this.videoSender = new DOMTrackSender(trackWrapper, transceiver);
|
this.videoSender = new DOMTrackSender(trackWrapper, transceiver);
|
||||||
return this.videoSender;
|
return this.videoSender;
|
||||||
} else {
|
} else if (trackWrapper.kind === TrackKind.Audio && (!this.audioSender || this.audioSender.track.id !== trackWrapper.id)) {
|
||||||
this.audioSender = new DOMTrackSender(trackWrapper, transceiver);
|
this.audioSender = new DOMTrackSender(trackWrapper, transceiver);
|
||||||
return this.audioSender;
|
return this.audioSender;
|
||||||
}
|
}
|
||||||
|
@ -105,20 +105,20 @@ export class DOMTrackSenderOrReceiver implements TrackReceiver {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get enabled(): boolean {
|
get enabled(): boolean {
|
||||||
return this.transceiver.currentDirection === "sendrecv" ||
|
return this.transceiver.direction === "sendrecv" ||
|
||||||
this.transceiver.currentDirection === this.exclusiveValue;
|
this.transceiver.direction === this.exclusiveValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
enable(enabled: boolean) {
|
enable(enabled: boolean) {
|
||||||
if (enabled !== this.enabled) {
|
if (enabled !== this.enabled) {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
if (this.transceiver.currentDirection === "inactive") {
|
if (this.transceiver.direction === "inactive") {
|
||||||
this.transceiver.direction = this.exclusiveValue;
|
this.transceiver.direction = this.exclusiveValue;
|
||||||
} else {
|
} else {
|
||||||
this.transceiver.direction = "sendrecv";
|
this.transceiver.direction = "sendrecv";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (this.transceiver.currentDirection === "sendrecv") {
|
if (this.transceiver.direction === "sendrecv") {
|
||||||
this.transceiver.direction = this.excludedValue;
|
this.transceiver.direction = this.excludedValue;
|
||||||
} else {
|
} else {
|
||||||
this.transceiver.direction = "inactive";
|
this.transceiver.direction = "inactive";
|
||||||
|
@ -145,7 +145,7 @@ export class DOMTrackSender extends DOMTrackSenderOrReceiver {
|
||||||
super(track, transceiver, "sendonly", "recvonly");
|
super(track, transceiver, "sendonly", "recvonly");
|
||||||
}
|
}
|
||||||
/** replaces the track if possible without renegotiation. Can throw. */
|
/** replaces the track if possible without renegotiation. Can throw. */
|
||||||
replaceTrack(track: Track): Promise<void> {
|
replaceTrack(track: Track | undefined): Promise<void> {
|
||||||
return this.transceiver.sender.replaceTrack(track ? (track as TrackWrapper).track : null);
|
return this.transceiver.sender.replaceTrack(track ? (track as TrackWrapper).track : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -192,8 +192,8 @@ export class DOMTrackSender extends DOMTrackSenderOrReceiver {
|
||||||
class DOMPeerConnection implements PeerConnection {
|
class DOMPeerConnection implements PeerConnection {
|
||||||
private readonly peerConnection: RTCPeerConnection;
|
private readonly peerConnection: RTCPeerConnection;
|
||||||
private readonly handler: PeerConnectionHandler;
|
private readonly handler: PeerConnectionHandler;
|
||||||
public readonly localStreams: DOMStreamSender[];
|
public readonly localStreams: Map<string, DOMStreamSender> = new Map();
|
||||||
public readonly remoteStreams: DOMStreamReceiver[];
|
public readonly remoteStreams: Map<string, DOMStreamReceiver> = new Map();
|
||||||
|
|
||||||
constructor(handler: PeerConnectionHandler, forceTURN: boolean, turnServers: RTCIceServer[], iceCandidatePoolSize) {
|
constructor(handler: PeerConnectionHandler, forceTURN: boolean, turnServers: RTCIceServer[], iceCandidatePoolSize) {
|
||||||
this.handler = handler;
|
this.handler = handler;
|
||||||
|
@ -238,10 +238,11 @@ class DOMPeerConnection implements PeerConnection {
|
||||||
throw new Error("Not a TrackWrapper");
|
throw new Error("Not a TrackWrapper");
|
||||||
}
|
}
|
||||||
const sender = this.peerConnection.addTrack(track.track, track.stream);
|
const sender = this.peerConnection.addTrack(track.track, track.stream);
|
||||||
let streamSender: DOMStreamSender | undefined = this.localStreams.find(s => s.stream.id === track.stream.id);
|
let streamSender = this.localStreams.get(track.stream.id);
|
||||||
if (!streamSender) {
|
if (!streamSender) {
|
||||||
|
// TODO: reuse existing stream wrapper here?
|
||||||
streamSender = new DOMStreamSender(new StreamWrapper(track.stream));
|
streamSender = new DOMStreamSender(new StreamWrapper(track.stream));
|
||||||
this.localStreams.push(streamSender);
|
this.localStreams.set(track.stream.id, streamSender);
|
||||||
}
|
}
|
||||||
const trackSender = streamSender.update(this.peerConnection.getTransceivers(), sender);
|
const trackSender = streamSender.update(this.peerConnection.getTransceivers(), sender);
|
||||||
return trackSender;
|
return trackSender;
|
||||||
|
@ -307,7 +308,7 @@ class DOMPeerConnection implements PeerConnection {
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.deregisterHandler();
|
this.deregisterHandler();
|
||||||
for (const r of this.remoteStreams) {
|
for (const r of this.remoteStreams.values()) {
|
||||||
r.stream.dispose();
|
r.stream.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -328,23 +329,21 @@ class DOMPeerConnection implements PeerConnection {
|
||||||
}
|
}
|
||||||
|
|
||||||
onRemoteStreamEmpty = (stream: RemoteStreamWrapper): void => {
|
onRemoteStreamEmpty = (stream: RemoteStreamWrapper): void => {
|
||||||
const idx = this.remoteStreams.findIndex(r => r.stream === stream);
|
if (this.remoteStreams.delete(stream.id)) {
|
||||||
if (idx !== -1) {
|
|
||||||
this.remoteStreams.splice(idx, 1);
|
|
||||||
this.handler.onRemoteStreamRemoved(stream);
|
this.handler.onRemoteStreamRemoved(stream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleRemoteTrack(evt: RTCTrackEvent) {
|
private handleRemoteTrack(evt: RTCTrackEvent) {
|
||||||
if (evt.streams.length !== 0) {
|
if (evt.streams.length !== 1) {
|
||||||
throw new Error("track in multiple streams is not supported");
|
throw new Error("track in multiple streams is not supported");
|
||||||
}
|
}
|
||||||
const stream = evt.streams[0];
|
const stream = evt.streams[0];
|
||||||
const transceivers = this.peerConnection.getTransceivers();
|
const transceivers = this.peerConnection.getTransceivers();
|
||||||
let streamReceiver: DOMStreamReceiver | undefined = this.remoteStreams.find(r => r.stream.id === stream.id);
|
let streamReceiver: DOMStreamReceiver | undefined = this.remoteStreams.get(stream.id);
|
||||||
if (!streamReceiver) {
|
if (!streamReceiver) {
|
||||||
streamReceiver = new DOMStreamReceiver(new RemoteStreamWrapper(stream, this.onRemoteStreamEmpty));
|
streamReceiver = new DOMStreamReceiver(new RemoteStreamWrapper(stream, this.onRemoteStreamEmpty));
|
||||||
this.remoteStreams.push(streamReceiver);
|
this.remoteStreams.set(stream.id, streamReceiver);
|
||||||
}
|
}
|
||||||
const trackReceiver = streamReceiver.update(evt);
|
const trackReceiver = streamReceiver.update(evt);
|
||||||
if (trackReceiver) {
|
if (trackReceiver) {
|
||||||
|
|
|
@ -16,14 +16,14 @@ limitations under the License.
|
||||||
|
|
||||||
import {TemplateView, TemplateBuilder} from "../../general/TemplateView";
|
import {TemplateView, TemplateBuilder} from "../../general/TemplateView";
|
||||||
import {ListView} from "../../general/ListView";
|
import {ListView} from "../../general/ListView";
|
||||||
import {Track, TrackType} from "../../../../types/MediaDevices";
|
import {Stream} from "../../../../types/MediaDevices";
|
||||||
import type {TrackWrapper} from "../../../dom/MediaDevices";
|
import type {StreamWrapper} from "../../../dom/MediaDevices";
|
||||||
import type {CallViewModel, CallMemberViewModel} from "../../../../../domain/session/room/CallViewModel";
|
import type {CallViewModel, CallMemberViewModel} from "../../../../../domain/session/room/CallViewModel";
|
||||||
|
|
||||||
function bindVideoTracks<T>(t: TemplateBuilder<T>, video: HTMLVideoElement, propSelector: (vm: T) => Track[]) {
|
function bindVideoTracks<T>(t: TemplateBuilder<T>, video: HTMLVideoElement, propSelector: (vm: T) => Stream | undefined) {
|
||||||
t.mapSideEffect(propSelector, tracks => {
|
t.mapSideEffect(propSelector, stream => {
|
||||||
if (tracks.length) {
|
if (stream) {
|
||||||
video.srcObject = (tracks[0] as TrackWrapper).stream;
|
video.srcObject = (stream as StreamWrapper).stream;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return video;
|
return video;
|
||||||
|
@ -33,7 +33,7 @@ export class CallView extends TemplateView<CallViewModel> {
|
||||||
render(t: TemplateBuilder<CallViewModel>, vm: CallViewModel): HTMLElement {
|
render(t: TemplateBuilder<CallViewModel>, vm: CallViewModel): HTMLElement {
|
||||||
return t.div({class: "CallView"}, [
|
return t.div({class: "CallView"}, [
|
||||||
t.p(vm => `Call ${vm.name} (${vm.id})`),
|
t.p(vm => `Call ${vm.name} (${vm.id})`),
|
||||||
t.div({class: "CallView_me"}, bindVideoTracks(t, t.video({autoplay: true, width: 240}), vm => vm.localTracks)),
|
t.div({class: "CallView_me"}, bindVideoTracks(t, t.video({autoplay: true, width: 240}), vm => vm.localStream)),
|
||||||
t.view(new ListView({list: vm.memberViewModels}, vm => new MemberView(vm))),
|
t.view(new ListView({list: vm.memberViewModels}, vm => new MemberView(vm))),
|
||||||
t.div({class: "buttons"}, [
|
t.div({class: "buttons"}, [
|
||||||
t.button({onClick: () => vm.leave()}, "Leave")
|
t.button({onClick: () => vm.leave()}, "Leave")
|
||||||
|
@ -44,6 +44,6 @@ export class CallView extends TemplateView<CallViewModel> {
|
||||||
|
|
||||||
class MemberView extends TemplateView<CallMemberViewModel> {
|
class MemberView extends TemplateView<CallMemberViewModel> {
|
||||||
render(t: TemplateBuilder<CallMemberViewModel>, vm: CallMemberViewModel) {
|
render(t: TemplateBuilder<CallMemberViewModel>, vm: CallMemberViewModel) {
|
||||||
return bindVideoTracks(t, t.video({autoplay: true, width: 360}), vm => vm.tracks);
|
return bindVideoTracks(t, t.video({autoplay: true, width: 360}), vm => vm.stream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1485,10 +1485,10 @@ type-fest@^0.20.2:
|
||||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
|
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
|
||||||
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
|
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
|
||||||
|
|
||||||
typescript@^4.3.5:
|
typescript@^4.4:
|
||||||
version "4.6.2"
|
version "4.6.3"
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.2.tgz#fe12d2727b708f4eef40f51598b3398baa9611d4"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.3.tgz#eefeafa6afdd31d725584c67a0eaba80f6fc6c6c"
|
||||||
integrity sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==
|
integrity sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==
|
||||||
|
|
||||||
typeson-registry@^1.0.0-alpha.20:
|
typeson-registry@^1.0.0-alpha.20:
|
||||||
version "1.0.0-alpha.39"
|
version "1.0.0-alpha.39"
|
||||||
|
|
Reference in a new issue