allow unmuting when we don't yet have a mic/cam track

This commit is contained in:
Bruno Windels 2022-06-10 17:10:23 +02:00
parent 10caba6872
commit 41288683fc
7 changed files with 105 additions and 46 deletions

View file

@ -26,7 +26,13 @@ const idToPrepend = "icon-url";
function findAndReplaceUrl(decl) { function findAndReplaceUrl(decl) {
const value = decl.value; const value = decl.value;
const parsed = valueParser(value); let parsed;
try {
parsed = valueParser(value);
} catch (err) {
console.log(`Error trying to parse ${decl}`);
throw err;
}
parsed.walk(node => { parsed.walk(node => {
if (node.type !== "function" || node.value !== "url") { if (node.type !== "function" || node.value !== "url") {
return; return;

View file

@ -60,18 +60,17 @@ export class CallViewModel extends ViewModel<Options> {
} }
get isCameraMuted(): boolean { get isCameraMuted(): boolean {
return isLocalCameraMuted(this.call); return this.call.muteSettings?.camera ?? true;
} }
get isMicrophoneMuted(): boolean { get isMicrophoneMuted(): boolean {
return isLocalMicrophoneMuted(this.call); return this.call.muteSettings?.microphone ?? true;
} }
get memberCount(): number { get memberCount(): number {
return this.memberViewModels.length; return this.memberViewModels.length;
} }
get name(): string { get name(): string {
return this.call.name; return this.call.name;
} }
@ -84,22 +83,36 @@ export class CallViewModel extends ViewModel<Options> {
return this.getOption("call"); return this.getOption("call");
} }
hangup() { async hangup() {
if (this.call.hasJoined) { if (this.call.hasJoined) {
this.call.leave(); await this.call.leave();
} }
} }
async toggleCamera() { async toggleCamera() {
if (this.call.muteSettings) { const {localMedia, muteSettings} = this.call;
this.call.setMuted(this.call.muteSettings.toggleCamera()); if (muteSettings && localMedia) {
// unmute but no track?
if (muteSettings.camera && !getStreamVideoTrack(localMedia.userMedia)) {
const stream = await this.platform.mediaDevices.getMediaTracks(!muteSettings.microphone, true);
await this.call.setMedia(localMedia.withUserMedia(stream));
} else {
await this.call.setMuted(muteSettings.toggleCamera());
}
this.emitChange(); this.emitChange();
} }
} }
async toggleMicrophone() { async toggleMicrophone() {
if (this.call.muteSettings) { const {localMedia, muteSettings} = this.call;
this.call.setMuted(this.call.muteSettings.toggleMicrophone()); if (muteSettings && localMedia) {
// unmute but no track?
if (muteSettings.microphone && !getStreamAudioTrack(localMedia.userMedia)) {
const stream = await this.platform.mediaDevices.getMediaTracks(true, !muteSettings.camera);
await this.call.setMedia(localMedia.withUserMedia(stream));
} else {
await this.call.setMuted(muteSettings.toggleMicrophone());
}
this.emitChange(); this.emitChange();
} }
} }
@ -130,11 +143,11 @@ class OwnMemberViewModel extends ViewModel<Options> implements IStreamViewModel
} }
get isCameraMuted(): boolean { get isCameraMuted(): boolean {
return isLocalCameraMuted(this.call); return this.call.muteSettings?.camera ?? true;
} }
get isMicrophoneMuted(): boolean { get isMicrophoneMuted(): boolean {
return isLocalMicrophoneMuted(this.call); return this.call.muteSettings?.microphone ?? true;
} }
get avatarLetter(): string { get avatarLetter(): string {
@ -186,11 +199,11 @@ export class CallMemberViewModel extends ViewModel<MemberOptions> implements ISt
} }
get isCameraMuted(): boolean { get isCameraMuted(): boolean {
return isMuted(this.member.remoteMuteSettings?.camera, !!getStreamVideoTrack(this.stream)); return this.member.remoteMuteSettings?.camera ?? true;
} }
get isMicrophoneMuted(): boolean { get isMicrophoneMuted(): boolean {
return isMuted(this.member.remoteMuteSettings?.microphone, !!getStreamAudioTrack(this.stream)); return this.member.remoteMuteSettings?.microphone ?? true;
} }
get avatarLetter(): string { get avatarLetter(): string {
@ -229,19 +242,3 @@ export interface IStreamViewModel extends AvatarSource, ViewModel {
get isCameraMuted(): boolean; get isCameraMuted(): boolean;
get isMicrophoneMuted(): boolean; get isMicrophoneMuted(): boolean;
} }
function isMuted(muted: boolean | undefined, hasTrack: boolean) {
if (muted) {
return true;
} else {
return !hasTrack;
}
}
function isLocalCameraMuted(call: GroupCall): boolean {
return isMuted(call.muteSettings?.camera, !!getStreamVideoTrack(call.localMedia?.userMedia));
}
function isLocalMicrophoneMuted(call: GroupCall): boolean {
return isMuted(call.muteSettings?.microphone, !!getStreamAudioTrack(call.localMedia?.userMedia));
}

View file

@ -887,8 +887,8 @@ export class PeerCall implements IDisposable {
const streamId = this.localMedia.userMedia.id; const streamId = this.localMedia.userMedia.id;
metadata[streamId] = { metadata[streamId] = {
purpose: SDPStreamMetadataPurpose.Usermedia, purpose: SDPStreamMetadataPurpose.Usermedia,
audio_muted: this.localMuteSettings?.microphone || !getStreamAudioTrack(this.localMedia.userMedia), audio_muted: this.localMuteSettings?.microphone ?? false,
video_muted: this.localMuteSettings?.camera || !getStreamVideoTrack(this.localMedia.userMedia), video_muted: this.localMuteSettings?.camera ?? false,
}; };
} }
if (this.localMedia?.screenShare) { if (this.localMedia?.screenShare) {
@ -936,7 +936,7 @@ export class PeerCall implements IDisposable {
this.updateRemoteMedia(log); this.updateRemoteMedia(log);
} }
} }
}) });
}; };
stream.addEventListener("removetrack", listener); stream.addEventListener("removetrack", listener);
const disposeListener = () => { const disposeListener = () => {
@ -971,8 +971,10 @@ export class PeerCall implements IDisposable {
videoReceiver.track.enabled = !metaData.video_muted; videoReceiver.track.enabled = !metaData.video_muted;
} }
this._remoteMuteSettings = new MuteSettings( this._remoteMuteSettings = new MuteSettings(
metaData.audio_muted || !audioReceiver?.track, metaData.audio_muted ?? false,
metaData.video_muted || !videoReceiver?.track metaData.video_muted ?? false,
!!audioReceiver?.track ?? false,
!!videoReceiver?.track ?? false
); );
log.log({ log.log({
l: "setting userMedia", l: "setting userMedia",

View file

@ -25,14 +25,36 @@ export function getStreamVideoTrack(stream: Stream | undefined): Track | undefin
} }
export class MuteSettings { export class MuteSettings {
constructor (public readonly microphone: boolean = false, public readonly camera: boolean = false) {} constructor (
private readonly isMicrophoneMuted: boolean = false,
private readonly isCameraMuted: boolean = false,
private hasMicrophoneTrack: boolean = false,
private hasCameraTrack: boolean = false,
) {}
updateTrackInfo(userMedia: Stream | undefined) {
this.hasMicrophoneTrack = !!getStreamAudioTrack(userMedia);
this.hasCameraTrack = !!getStreamVideoTrack(userMedia);
}
get microphone(): boolean {
return !this.hasMicrophoneTrack || this.isMicrophoneMuted;
}
get camera(): boolean {
return !this.hasCameraTrack || this.isCameraMuted;
}
toggleCamera(): MuteSettings { toggleCamera(): MuteSettings {
return new MuteSettings(this.microphone, !this.camera); return new MuteSettings(this.microphone, !this.camera, this.hasMicrophoneTrack, this.hasCameraTrack);
} }
toggleMicrophone(): MuteSettings { toggleMicrophone(): MuteSettings {
return new MuteSettings(!this.microphone, this.camera); return new MuteSettings(!this.microphone, this.camera, this.hasMicrophoneTrack, this.hasCameraTrack);
}
equals(other: MuteSettings) {
return this.microphone === other.microphone && this.camera === other.camera;
} }
} }

View file

@ -137,11 +137,13 @@ export class GroupCall extends EventEmitter<{change: never}> {
ownSessionId: this.options.sessionId ownSessionId: this.options.sessionId
}); });
const membersLogItem = logItem.child("member connections"); const membersLogItem = logItem.child("member connections");
const localMuteSettings = new MuteSettings();
localMuteSettings.updateTrackInfo(localMedia.userMedia);
const joinedData = new JoinedData( const joinedData = new JoinedData(
logItem, logItem,
membersLogItem, membersLogItem,
localMedia, localMedia,
new MuteSettings() localMuteSettings
); );
this.joinedData = joinedData; this.joinedData = joinedData;
await joinedData.logItem.wrap("join", async log => { await joinedData.logItem.wrap("join", async log => {
@ -163,9 +165,14 @@ export class GroupCall extends EventEmitter<{change: never}> {
if ((this._state === GroupCallState.Joining || this._state === GroupCallState.Joined) && this.joinedData) { if ((this._state === GroupCallState.Joining || this._state === GroupCallState.Joined) && this.joinedData) {
const oldMedia = this.joinedData.localMedia; const oldMedia = this.joinedData.localMedia;
this.joinedData.localMedia = localMedia; this.joinedData.localMedia = localMedia;
// reflect the fact we gained or lost local tracks in the local mute settings
// and update the track info so PeerCall can use it to send up to date metadata,
this.joinedData.localMuteSettings.updateTrackInfo(localMedia.userMedia);
this.emitChange(); //allow listeners to see new media/mute settings
await Promise.all(Array.from(this._members.values()).map(m => { await Promise.all(Array.from(this._members.values()).map(m => {
return m.setMedia(localMedia, oldMedia); return m.setMedia(localMedia, oldMedia);
})); }));
oldMedia?.stopExcept(localMedia); oldMedia?.stopExcept(localMedia);
} }
} }
@ -175,11 +182,19 @@ export class GroupCall extends EventEmitter<{change: never}> {
if (!joinedData) { if (!joinedData) {
return; return;
} }
const prevMuteSettings = joinedData.localMuteSettings;
// we still update the mute settings if nothing changed because
// you might be muted because you don't have a track or because
// you actively chosen to mute
// (which we want to respect in the future when you add a track)
joinedData.localMuteSettings = muteSettings; joinedData.localMuteSettings = muteSettings;
await Promise.all(Array.from(this._members.values()).map(m => { joinedData.localMuteSettings.updateTrackInfo(joinedData.localMedia.userMedia);
return m.setMuted(joinedData.localMuteSettings); if (!prevMuteSettings.equals(muteSettings)) {
})); await Promise.all(Array.from(this._members.values()).map(m => {
this.emitChange(); return m.setMuted(joinedData.localMuteSettings);
}));
this.emitChange();
}
} }
get muteSettings(): MuteSettings | undefined { get muteSettings(): MuteSettings | undefined {

View file

@ -40,6 +40,7 @@ limitations under the License.
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
background-color: black; background-color: black;
display: block;
} }
.StreamView > * { .StreamView > * {
@ -98,11 +99,19 @@ limitations under the License.
background-repeat: no-repeat; background-repeat: no-repeat;
} }
.CallView_buttons button:disabled {
background-color: var(--accent-color--lighter-10);
}
.CallView_buttons .CallView_hangup { .CallView_buttons .CallView_hangup {
background-color: var(--error-color); background-color: var(--error-color);
background-image: url("./icons/hangup.svg?primary=background-color-primary"); background-image: url("./icons/hangup.svg?primary=background-color-primary");
} }
.CallView_buttons .CallView_hangup:disabled {
background-color: var(--error-color--lighter-10);
}
.CallView_buttons .CallView_mutedMicrophone { .CallView_buttons .CallView_mutedMicrophone {
background-color: var(--background-color-primary); background-color: var(--background-color-primary);
background-image: url("./icons/mic-muted.svg?primary=text-color"); background-image: url("./icons/mic-muted.svg?primary=text-color");

View file

@ -37,12 +37,12 @@ export class CallView extends TemplateView<CallViewModel> {
t.button({className: { t.button({className: {
"CallView_mutedMicrophone": vm => vm.isMicrophoneMuted, "CallView_mutedMicrophone": vm => vm.isMicrophoneMuted,
"CallView_unmutedMicrophone": vm => !vm.isMicrophoneMuted, "CallView_unmutedMicrophone": vm => !vm.isMicrophoneMuted,
}, onClick: () => vm.toggleMicrophone()}), }, onClick: disableTargetCallback(() => vm.toggleMicrophone())}),
t.button({className: { t.button({className: {
"CallView_mutedCamera": vm => vm.isCameraMuted, "CallView_mutedCamera": vm => vm.isCameraMuted,
"CallView_unmutedCamera": vm => !vm.isCameraMuted, "CallView_unmutedCamera": vm => !vm.isCameraMuted,
}, onClick: () => vm.toggleCamera()}), }, onClick: disableTargetCallback(() => vm.toggleCamera())}),
t.button({className: "CallView_hangup", onClick: () => vm.hangup()}), t.button({className: "CallView_hangup", onClick: disableTargetCallback(() => vm.hangup())}),
]) ])
]); ]);
} }
@ -122,3 +122,11 @@ class StreamView extends TemplateView<IStreamViewModel> {
this.updateSubViews(value, props); this.updateSubViews(value, props);
} }
} }
function disableTargetCallback(callback: (evt: Event) => Promise<void>): (evt: Event) => Promise<void> {
return async (evt: Event) => {
(evt.target as HTMLElement)?.setAttribute("disabled", "disabled");
await callback(evt);
(evt.target as HTMLElement)?.removeAttribute("disabled");
}
}