diff --git a/src/domain/AvatarSource.ts b/src/domain/AvatarSource.ts new file mode 100644 index 00000000..8673b6e6 --- /dev/null +++ b/src/domain/AvatarSource.ts @@ -0,0 +1,23 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export interface AvatarSource { + get avatarLetter(): string; + get avatarColorNumber(): number; + avatarUrl(size: number): string | undefined; + get avatarTitle(): string; +} diff --git a/src/domain/session/room/CallViewModel.ts b/src/domain/session/room/CallViewModel.ts index df61d334..0287e10e 100644 --- a/src/domain/session/room/CallViewModel.ts +++ b/src/domain/session/room/CallViewModel.ts @@ -14,23 +14,34 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {AvatarSource} from "../../AvatarSource"; import {ViewModel, Options as BaseOptions} from "../../ViewModel"; +import {getStreamVideoTrack, getStreamAudioTrack} from "../../../matrix/calls/common"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; import type {GroupCall} from "../../../matrix/calls/group/GroupCall"; import type {Member} from "../../../matrix/calls/group/Member"; import type {BaseObservableList} from "../../../observable/list/BaseObservableList"; +import {EventObservableValue} from "../../../observable/value/EventObservableValue"; +import {ObservableValueMap} from "../../../observable/map/ObservableValueMap"; import type {Stream} from "../../../platform/types/MediaDevices"; +import type {MediaRepository} from "../../../matrix/net/MediaRepository"; -type Options = BaseOptions & {call: GroupCall}; +type Options = BaseOptions & { + call: GroupCall, + mediaRepository: MediaRepository +}; export class CallViewModel extends ViewModel { - public readonly memberViewModels: BaseObservableList; constructor(options: Options) { super(options); - this.memberViewModels = this.getOption("call").members + const ownMemberViewModelMap = new ObservableValueMap("self", new EventObservableValue(this.call, "change")) + .mapValues(call => new OwnMemberViewModel(this.childOptions({call: this.call, mediaRepository: this.getOption("mediaRepository")})), () => {}); + this.memberViewModels = this.call.members .filterValues(member => member.isConnected) - .mapValues(member => new CallMemberViewModel(this.childOptions({member}))) + .mapValues(member => new CallMemberViewModel(this.childOptions({member, mediaRepository: this.getOption("mediaRepository")}))) + .join(ownMemberViewModelMap) .sortValues((a, b) => a.compare(b)); } @@ -46,7 +57,7 @@ export class CallViewModel extends ViewModel { return this.call.id; } - get localStream(): Stream | undefined { + get stream(): Stream | undefined { return this.call.localMedia?.userMedia; } @@ -56,14 +67,65 @@ export class CallViewModel extends ViewModel { } } + get isCameraMuted(): boolean { + return this.call.muteSettings.camera; + } + + get isMicrophoneMuted(): boolean { + return this.call.muteSettings.microphone; + } + async toggleVideo() { this.call.setMuted(this.call.muteSettings.toggleCamera()); } } -type MemberOptions = BaseOptions & {member: Member}; +type OwnMemberOptions = BaseOptions & { + call: GroupCall, + mediaRepository: MediaRepository +} -export class CallMemberViewModel extends ViewModel { +class OwnMemberViewModel extends ViewModel implements IStreamViewModel { + get stream(): Stream | undefined { + return this.call.localMedia?.userMedia; + } + + private get call(): GroupCall { + return this.getOption("call"); + } + + get isCameraMuted(): boolean { + return this.call.muteSettings.camera ?? !!getStreamVideoTrack(this.stream); + } + + get isMicrophoneMuted(): boolean { + return this.call.muteSettings.microphone ?? !!getStreamAudioTrack(this.stream); + } + + get avatarLetter(): string { + return "I"; + } + + get avatarColorNumber(): number { + return 3; + } + + avatarUrl(size: number): string | undefined { + return undefined; + } + + get avatarTitle(): string { + return "ikke"; + } + + compare(other: OwnMemberViewModel | CallMemberViewModel): number { + return -1; + } +} + +type MemberOptions = BaseOptions & {member: Member, mediaRepository: MediaRepository}; + +export class CallMemberViewModel extends ViewModel implements IStreamViewModel { get stream(): Stream | undefined { return this.member.remoteMedia?.userMedia; } @@ -72,7 +134,36 @@ export class CallMemberViewModel extends ViewModel { return this.getOption("member"); } - compare(other: CallMemberViewModel): number { + get isCameraMuted(): boolean { + return this.member.remoteMuteSettings?.camera ?? !getStreamVideoTrack(this.stream); + } + + get isMicrophoneMuted(): boolean { + return this.member.remoteMuteSettings?.microphone ?? !getStreamAudioTrack(this.stream); + } + + get avatarLetter(): string { + return avatarInitials(this.member.member.name); + } + + get avatarColorNumber(): number { + return getIdentifierColorNumber(this.member.userId); + } + + avatarUrl(size: number): string | undefined { + const {avatarUrl} = this.member.member; + const mediaRepository = this.getOption("mediaRepository"); + return getAvatarHttpUrl(avatarUrl, size, this.platform, mediaRepository); + } + + get avatarTitle(): string { + return this.member.member.name; + } + + compare(other: OwnMemberViewModel | CallMemberViewModel): number { + if (other instanceof OwnMemberViewModel) { + return -other.compare(this); + } const myUserId = this.member.member.userId; const otherUserId = other.member.member.userId; if(myUserId === otherUserId) { @@ -81,3 +172,9 @@ export class CallMemberViewModel extends ViewModel { return myUserId < otherUserId ? -1 : 1; } } + +export interface IStreamViewModel extends AvatarSource, ViewModel { + get stream(): Stream | undefined; + get isCameraMuted(): boolean; + get isMicrophoneMuted(): boolean; +} diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index b88b17f0..c60ce649 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -62,7 +62,7 @@ export class RoomViewModel extends ViewModel { } this._callViewModel = this.disposeTracked(this._callViewModel); if (call) { - this._callViewModel = this.track(new CallViewModel(this.childOptions({call}))); + this._callViewModel = this.track(new CallViewModel(this.childOptions({call, mediaRepository: this._room.mediaRepository}))); } this.emitChange("callViewModel"); })); diff --git a/src/matrix/calls/common.ts b/src/matrix/calls/common.ts index 6770aec1..6e3dc7ab 100644 --- a/src/matrix/calls/common.ts +++ b/src/matrix/calls/common.ts @@ -25,7 +25,7 @@ export function getStreamVideoTrack(stream: Stream | undefined): Track | undefin } export class MuteSettings { - constructor (public readonly microphone: boolean, public readonly camera: boolean) {} + constructor (public readonly microphone: boolean = false, public readonly camera: boolean = false) {} toggleCamera(): MuteSettings { return new MuteSettings(this.microphone, !this.camera); diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 113ea254..a0ad1ef0 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -1155,3 +1155,55 @@ button.RoomDetailsView_row::after { background-position: center; background-size: 36px; } + +.CallView { + max-height: 50vh; + overflow-y: auto; +} + +.CallView ul { + display: flex; + margin: 0; + gap: 12px; + padding: 0; + flex-wrap: wrap; + justify-content: center; +} + +.StreamView { + width: 360px; + min-height: 200px; + border: 2px var(--accent-color) solid; + display: grid; + border-radius: 8px; + overflow: hidden; + background-color: black; +} + +.StreamView > * { + grid-column: 1; + grid-row: 1; +} + +.StreamView video { + width: 100%; +} + +.StreamView_avatar { + align-self: center; + justify-self: center; +} + +.StreamView_muteStatus { + align-self: end; + justify-self: start; +} + +.StreamView_muteStatus.microphoneMuted::before { + content: "mic muted"; +} + +.StreamView_muteStatus.cameraMuted::before { + content: "cam muted"; +} + diff --git a/src/platform/web/ui/session/room/CallView.ts b/src/platform/web/ui/session/room/CallView.ts index 95d9c027..92dfe6b9 100644 --- a/src/platform/web/ui/session/room/CallView.ts +++ b/src/platform/web/ui/session/room/CallView.ts @@ -15,34 +15,16 @@ limitations under the License. */ import {TemplateView, Builder} from "../../general/TemplateView"; +import {AvatarView} from "../../AvatarView"; import {ListView} from "../../general/ListView"; import {Stream} from "../../../../types/MediaDevices"; -import {getStreamVideoTrack, getStreamAudioTrack} from "../../../../../matrix/calls/common"; -import type {CallViewModel, CallMemberViewModel} from "../../../../../domain/session/room/CallViewModel"; - -function bindStream(t: TemplateBuilder, video: HTMLVideoElement, propSelector: (vm: T) => Stream | undefined) { - t.mapSideEffect(vm => getStreamVideoTrack(propSelector(vm))?.enabled, (_,__, vm) => { - const stream = propSelector(vm); - if (stream) { - video.srcObject = stream as MediaStream; - if (getStreamVideoTrack(stream)?.enabled) { - video.classList.remove("hidden"); - } else { - video.classList.add("hidden"); - } - } else { - video.classList.add("hidden"); - } - }); - return video; -} +import type {CallViewModel, CallMemberViewModel, IStreamViewModel} from "../../../../../domain/session/room/CallViewModel"; export class CallView extends TemplateView { render(t: Builder, vm: CallViewModel): Element { return t.div({class: "CallView"}, [ t.p(vm => `Call ${vm.name} (${vm.id})`), - t.div({class: "CallView_me"}, bindStream(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 StreamView(vm))), t.div({class: "buttons"}, [ t.button({onClick: () => vm.leave()}, "Leave"), t.button({onClick: () => vm.toggleVideo()}, "Toggle video"), @@ -51,12 +33,31 @@ export class CallView extends TemplateView { } } -class MemberView extends TemplateView { - render(t: TemplateBuilder, vm: CallMemberViewModel) { - return bindStream(t, t.video({autoplay: true, width: 360}), vm => vm.stream); +class StreamView extends TemplateView { + render(t: Builder, vm: IStreamViewModel): Element { + const video = t.video({ + autoplay: true, + className: { + hidden: vm => vm.isCameraMuted + } + }) as HTMLVideoElement; + t.mapSideEffect(vm => vm.stream, stream => { + video.srcObject = stream as MediaStream; + }); + return t.div({className: "StreamView"}, [ + video, + t.div({className: { + StreamView_avatar: true, + hidden: vm => !vm.isCameraMuted + }}, t.view(new AvatarView(vm, 64), {parentProvidesUpdates: true})), + t.div({ + className: { + StreamView_muteStatus: true, + hidden: vm => !vm.isCameraMuted && !vm.isMicrophoneMuted, + microphoneMuted: vm => vm.isMicrophoneMuted && !vm.isCameraMuted, + cameraMuted: vm => vm.isCameraMuted, + } + }) + ]); } } - -// class StreamView extends TemplateView { -// render(t: TemplateBuilder