allow unmuting when we don't yet have a mic/cam track
This commit is contained in:
parent
10caba6872
commit
41288683fc
7 changed files with 105 additions and 46 deletions
|
@ -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;
|
||||||
|
|
|
@ -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));
|
|
||||||
}
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Reference in a new issue