diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index debf8e34..257624ea 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -115,7 +115,7 @@ export class ViewModel extends EventEmitter<{change return result; } - emitChange(changedProps: any): void { + emitChange(changedProps?: any): void { if (this._options.emitChange) { this._options.emitChange(changedProps); } else { diff --git a/src/domain/session/room/CallViewModel.ts b/src/domain/session/room/CallViewModel.ts index cd974605..3e7fd951 100644 --- a/src/domain/session/room/CallViewModel.ts +++ b/src/domain/session/room/CallViewModel.ts @@ -46,12 +46,32 @@ export class CallViewModel extends ViewModel { .mapValues(member => new CallMemberViewModel(this.childOptions({member, mediaRepository: this.getOption("room").mediaRepository}))) .join(ownMemberViewModelMap) .sortValues((a, b) => a.compare(b)); + this.track(this.memberViewModels.subscribe({ + onRemove: () => { + this.emitChange(); // update memberCount + }, + onAdd: () => { + this.emitChange(); // update memberCount + }, + onUpdate: () => {}, + onReset: () => {}, + onMove: () => {} + })) } - private get call(): GroupCall { - return this.getOption("call"); + get isCameraMuted(): boolean { + return isLocalCameraMuted(this.call); } + get isMicrophoneMuted(): boolean { + return isLocalMicrophoneMuted(this.call); + } + + get memberCount(): number { + return this.memberViewModels.length; + } + + get name(): string { return this.call.name; } @@ -60,19 +80,27 @@ export class CallViewModel extends ViewModel { return this.call.id; } - get stream(): Stream | undefined { - return this.call.localMedia?.userMedia; + private get call(): GroupCall { + return this.getOption("call"); } - leave() { + hangup() { if (this.call.hasJoined) { this.call.leave(); } } - async toggleVideo() { + async toggleCamera() { if (this.call.muteSettings) { this.call.setMuted(this.call.muteSettings.toggleCamera()); + this.emitChange(); + } + } + + async toggleMicrophone() { + if (this.call.muteSettings) { + this.call.setMuted(this.call.muteSettings.toggleMicrophone()); + this.emitChange(); } } } @@ -102,11 +130,11 @@ class OwnMemberViewModel extends ViewModel implements IStreamViewModel } get isCameraMuted(): boolean { - return isMuted(this.call.muteSettings?.camera, !!getStreamVideoTrack(this.stream)); + return isLocalCameraMuted(this.call); } get isMicrophoneMuted(): boolean { - return isMuted(this.call.muteSettings?.microphone, !!getStreamAudioTrack(this.stream)); + return isLocalMicrophoneMuted(this.call); } get avatarLetter(): string { @@ -209,3 +237,11 @@ function isMuted(muted: boolean | undefined, hasTrack: boolean) { 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)); +} diff --git a/src/platform/web/ui/css/themes/element/call.css b/src/platform/web/ui/css/themes/element/call.css new file mode 100644 index 00000000..4398f9c6 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/call.css @@ -0,0 +1,206 @@ +/* +Copyright 2022 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. +*/ + +.CallView { + height: 40vh; + display: grid; +} + +.CallView > * { + grid-column: 1; + grid-row: 1; +} + +.CallView_members { + display: grid; + gap: 12px; + background: var(--background-color-secondary--darker-60); + padding: 12px; + margin: 0; + min-height: 0; + list-style: none; + align-self: stretch; +} + +.StreamView { + display: grid; + border-radius: 8px; + overflow: hidden; + background-color: black; +} + +.StreamView > * { + grid-column: 1; + grid-row: 1; +} + +.StreamView video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.StreamView_avatar { + align-self: center; + justify-self: center; +} + +.StreamView_muteStatus { + align-self: start; + justify-self: end; + width: 24px; + height: 24px; + background-position: center; + background-repeat: no-repeat; + background-size: 14px; + display: block; + background-color: var(--text-color); + border-radius: 4px; + margin: 4px; +} + +.StreamView_muteStatus.microphoneMuted { + background-image: url("./icons/mic-muted.svg?primary=text-color--lighter-80"); +} + +.StreamView_muteStatus.cameraMuted { + background-image: url("./icons/cam-muted.svg?primary=text-color--lighter-80"); +} + +.CallView_buttons { + align-self: end; + justify-self: center; + display: flex; + gap: 12px; + margin-bottom: 16px; +} + +.CallView_buttons button { + border-radius: 100%; + width: 48px; + height: 48px; + border: none; + background-color: var(--accent-color); + background-position: center; + background-repeat: no-repeat; +} + +.CallView_buttons .CallView_hangup { + background-color: var(--error-color); + background-image: url("./icons/hangup.svg?primary=background-color-primary"); +} + +.CallView_buttons .CallView_mutedMicrophone { + background-color: var(--background-color-primary); + background-image: url("./icons/mic-muted.svg?primary=text-color"); +} + +.CallView_buttons .CallView_unmutedMicrophone { + background-image: url("./icons/mic-unmuted.svg?primary=background-color-primary"); +} + +.CallView_buttons .CallView_mutedCamera { + background-color: var(--background-color-primary); + background-image: url("./icons/cam-muted.svg?primary=text-color"); +} + +.CallView_buttons .CallView_unmutedCamera { + background-image: url("./icons/cam-unmuted.svg?primary=background-color-primary"); +} + +.CallView_members.size1 { + grid-template-columns: 1fr; + grid-template-rows: 1fr; +} + +.CallView_members.size2 { + grid-template-columns: 1fr; + grid-template-rows: repeat(2, 1fr); +} + +/* square */ +.CallView_members.square.size3, +.CallView_members.square.size4 { + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(2, 1fr); +} +.CallView_members.square.size5, +.CallView_members.square.size6 { + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, 1fr); +} +.CallView_members.square.size7, +.CallView_members.square.size8, +.CallView_members.square.size9 { + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); +} +.CallView_members.square.size10 { + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(4, 1fr); +} +/** tall */ +.CallView_members.tall.size3 { + grid-template-columns: 1fr; + grid-template-rows: repeat(3, 1fr); +} +.CallView_members.tall.size4 { + grid-template-columns: 1fr; + grid-template-rows: repeat(4, 1fr); +} +.CallView_members.tall.size5, +.CallView_members.tall.size6 { + grid-template-rows: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); +} +.CallView_members.tall.size7, +.CallView_members.tall.size8 { + grid-template-rows: repeat(4, 1fr); + grid-template-columns: repeat(2, 1fr); +} +.CallView_members.tall.size9, +.CallView_members.tall.size10 { + grid-template-rows: repeat(5, 1fr); + grid-template-columns: repeat(2, 1fr); +} +/** wide */ +.CallView_members.wide.size2 { + grid-template-columns: repeat(2, 1fr); + grid-template-rows: 1fr; +} +.CallView_members.wide.size3 { + grid-template-rows: 1fr; + grid-template-columns: repeat(3, 1fr); +} +.CallView_members.wide.size4 { + grid-template-rows: 1fr; + grid-template-columns: repeat(4, 1fr); +} +.CallView_members.wide.size5, +.CallView_members.wide.size6 { + grid-template-rows: repeat(2, 1fr); + grid-template-columns: repeat(3, 1fr); +} +.CallView_members.wide.size7, +.CallView_members.wide.size8 { + grid-template-rows: repeat(2, 1fr); + grid-template-columns: repeat(4, 1fr); +} +.CallView_members.wide.size9, +.CallView_members.wide.size10 { + grid-template-rows: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); +} diff --git a/src/platform/web/ui/css/themes/element/icons/cam-muted.svg b/src/platform/web/ui/css/themes/element/icons/cam-muted.svg new file mode 100644 index 00000000..6a739ae2 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/cam-muted.svg @@ -0,0 +1 @@ + diff --git a/src/platform/web/ui/css/themes/element/icons/cam-unmuted.svg b/src/platform/web/ui/css/themes/element/icons/cam-unmuted.svg new file mode 100644 index 00000000..9497e075 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/cam-unmuted.svg @@ -0,0 +1 @@ + diff --git a/src/platform/web/ui/css/themes/element/icons/hangup.svg b/src/platform/web/ui/css/themes/element/icons/hangup.svg new file mode 100644 index 00000000..c56fe7a4 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/hangup.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/icons/mic-muted.svg b/src/platform/web/ui/css/themes/element/icons/mic-muted.svg new file mode 100644 index 00000000..35669ee0 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/mic-muted.svg @@ -0,0 +1,19 @@ + + + + + + diff --git a/src/platform/web/ui/css/themes/element/icons/mic-unmuted.svg b/src/platform/web/ui/css/themes/element/icons/mic-unmuted.svg new file mode 100644 index 00000000..94b81510 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/mic-unmuted.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/src/platform/web/ui/css/themes/element/icons/video-call.svg b/src/platform/web/ui/css/themes/element/icons/video-call.svg new file mode 100644 index 00000000..bc3688b5 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/video-call.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/platform/web/ui/css/themes/element/icons/voice-call.svg b/src/platform/web/ui/css/themes/element/icons/voice-call.svg new file mode 100644 index 00000000..02a79969 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/voice-call.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index db0ad66b..6403bb60 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -18,6 +18,7 @@ limitations under the License. @import url('../../main.css'); @import url('inter.css'); @import url('timeline.css'); +@import url('call.css'); :root { font-size: 10px; @@ -1155,56 +1156,3 @@ 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; - color: var(--text-color--lighter-80); -} - -.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 ac31973c..5ccdaa84 100644 --- a/src/platform/web/ui/session/room/CallView.ts +++ b/src/platform/web/ui/session/room/CallView.ts @@ -17,26 +17,81 @@ limitations under the License. import {TemplateView, Builder} from "../../general/TemplateView"; import {AvatarView} from "../../AvatarView"; import {ListView} from "../../general/ListView"; +import {classNames} from "../../general/html"; import {Stream} from "../../../../types/MediaDevices"; import type {CallViewModel, CallMemberViewModel, IStreamViewModel} from "../../../../../domain/session/room/CallViewModel"; export class CallView extends TemplateView { + private resizeObserver?: ResizeObserver; + render(t: Builder, vm: CallViewModel): Element { + const members = t.view(new ListView({ + className: "CallView_members", + list: vm.memberViewModels + }, vm => new StreamView(vm))) as HTMLElement; + this.bindMembersCssClasses(t, members); return t.div({class: "CallView"}, [ - t.p(vm => `Call ${vm.name} (${vm.id})`), - 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"), + members, + //t.p(vm => `Call ${vm.name}`), + t.div({class: "CallView_buttons"}, [ + t.button({className: { + "CallView_mutedMicrophone": vm => vm.isMicrophoneMuted, + "CallView_unmutedMicrophone": vm => !vm.isMicrophoneMuted, + }, onClick: () => vm.toggleMicrophone()}), + t.button({className: { + "CallView_mutedCamera": vm => vm.isCameraMuted, + "CallView_unmutedCamera": vm => !vm.isCameraMuted, + }, onClick: () => vm.toggleCamera()}), + t.button({className: "CallView_hangup", onClick: () => vm.hangup()}), ]) ]); } + + private bindMembersCssClasses(t, members) { + t.mapSideEffect(vm => vm.memberCount, count => { + members.classList.forEach((c, _, list) => { + if (c.startsWith("size")) { + list.remove(c); + } + }); + members.classList.add(`size${count}`); + }); + // update classes describing aspect ratio categories + if (typeof ResizeObserver === "function") { + const set = (c, flag) => { + if (flag) { + members.classList.add(c); + } else { + members.classList.remove(c); + } + }; + this.resizeObserver = new ResizeObserver(() => { + const ar = members.clientWidth / members.clientHeight; + const isTall = ar < 0.5; + const isSquare = !isTall && ar < 1.8 + const isWide = !isTall && !isSquare; + set("tall", isTall); + set("square", isSquare); + set("wide", isWide); + }); + this.resizeObserver!.observe(members); + } + } + + public unmount() { + if (this.resizeObserver) { + this.resizeObserver.unobserve((this.root()! as Element).querySelector(".CallView_members")); + this.resizeObserver = undefined; + } + super.unmount(); + } } class StreamView extends TemplateView { render(t: Builder, vm: IStreamViewModel): Element { const video = t.video({ autoplay: true, + disablePictureInPicture: true, className: { hidden: vm => vm.isCameraMuted }