nicer UI for calls, show avatar when muted, muted status
This commit is contained in:
parent
433dc957ee
commit
f61064c462
6 changed files with 211 additions and 38 deletions
23
src/domain/AvatarSource.ts
Normal file
23
src/domain/AvatarSource.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||||
|
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;
|
||||||
|
}
|
|
@ -14,23 +14,34 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {AvatarSource} from "../../AvatarSource";
|
||||||
import {ViewModel, Options as BaseOptions} from "../../ViewModel";
|
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 {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 {EventObservableValue} from "../../../observable/value/EventObservableValue";
|
||||||
|
import {ObservableValueMap} from "../../../observable/map/ObservableValueMap";
|
||||||
import type {Stream} from "../../../platform/types/MediaDevices";
|
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<Options> {
|
export class CallViewModel extends ViewModel<Options> {
|
||||||
|
|
||||||
public readonly memberViewModels: BaseObservableList<CallMemberViewModel>;
|
public readonly memberViewModels: BaseObservableList<CallMemberViewModel>;
|
||||||
|
|
||||||
constructor(options: Options) {
|
constructor(options: Options) {
|
||||||
super(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)
|
.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));
|
.sortValues((a, b) => a.compare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,7 +57,7 @@ export class CallViewModel extends ViewModel<Options> {
|
||||||
return this.call.id;
|
return this.call.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
get localStream(): Stream | undefined {
|
get stream(): Stream | undefined {
|
||||||
return this.call.localMedia?.userMedia;
|
return this.call.localMedia?.userMedia;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,14 +67,65 @@ export class CallViewModel extends ViewModel<Options> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isCameraMuted(): boolean {
|
||||||
|
return this.call.muteSettings.camera;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMicrophoneMuted(): boolean {
|
||||||
|
return this.call.muteSettings.microphone;
|
||||||
|
}
|
||||||
|
|
||||||
async toggleVideo() {
|
async toggleVideo() {
|
||||||
this.call.setMuted(this.call.muteSettings.toggleCamera());
|
this.call.setMuted(this.call.muteSettings.toggleCamera());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type MemberOptions = BaseOptions & {member: Member};
|
type OwnMemberOptions = BaseOptions & {
|
||||||
|
call: GroupCall,
|
||||||
|
mediaRepository: MediaRepository
|
||||||
|
}
|
||||||
|
|
||||||
export class CallMemberViewModel extends ViewModel<MemberOptions> {
|
class OwnMemberViewModel extends ViewModel<OwnMemberOptions> 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<MemberOptions> implements IStreamViewModel {
|
||||||
get stream(): Stream | undefined {
|
get stream(): Stream | undefined {
|
||||||
return this.member.remoteMedia?.userMedia;
|
return this.member.remoteMedia?.userMedia;
|
||||||
}
|
}
|
||||||
|
@ -72,7 +134,36 @@ export class CallMemberViewModel extends ViewModel<MemberOptions> {
|
||||||
return this.getOption("member");
|
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 myUserId = this.member.member.userId;
|
||||||
const otherUserId = other.member.member.userId;
|
const otherUserId = other.member.member.userId;
|
||||||
if(myUserId === otherUserId) {
|
if(myUserId === otherUserId) {
|
||||||
|
@ -81,3 +172,9 @@ export class CallMemberViewModel extends ViewModel<MemberOptions> {
|
||||||
return myUserId < otherUserId ? -1 : 1;
|
return myUserId < otherUserId ? -1 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IStreamViewModel extends AvatarSource, ViewModel {
|
||||||
|
get stream(): Stream | undefined;
|
||||||
|
get isCameraMuted(): boolean;
|
||||||
|
get isMicrophoneMuted(): boolean;
|
||||||
|
}
|
||||||
|
|
|
@ -62,7 +62,7 @@ export class RoomViewModel extends ViewModel {
|
||||||
}
|
}
|
||||||
this._callViewModel = this.disposeTracked(this._callViewModel);
|
this._callViewModel = this.disposeTracked(this._callViewModel);
|
||||||
if (call) {
|
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");
|
this.emitChange("callViewModel");
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -25,7 +25,7 @@ export function getStreamVideoTrack(stream: Stream | undefined): Track | undefin
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MuteSettings {
|
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 {
|
toggleCamera(): MuteSettings {
|
||||||
return new MuteSettings(this.microphone, !this.camera);
|
return new MuteSettings(this.microphone, !this.camera);
|
||||||
|
|
|
@ -1155,3 +1155,55 @@ button.RoomDetailsView_row::after {
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-size: 36px;
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,34 +15,16 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {TemplateView, Builder} from "../../general/TemplateView";
|
import {TemplateView, Builder} from "../../general/TemplateView";
|
||||||
|
import {AvatarView} from "../../AvatarView";
|
||||||
import {ListView} from "../../general/ListView";
|
import {ListView} from "../../general/ListView";
|
||||||
import {Stream} from "../../../../types/MediaDevices";
|
import {Stream} from "../../../../types/MediaDevices";
|
||||||
import {getStreamVideoTrack, getStreamAudioTrack} from "../../../../../matrix/calls/common";
|
import type {CallViewModel, CallMemberViewModel, IStreamViewModel} from "../../../../../domain/session/room/CallViewModel";
|
||||||
import type {CallViewModel, CallMemberViewModel} from "../../../../../domain/session/room/CallViewModel";
|
|
||||||
|
|
||||||
function bindStream<T>(t: TemplateBuilder<T>, 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CallView extends TemplateView<CallViewModel> {
|
export class CallView extends TemplateView<CallViewModel> {
|
||||||
render(t: Builder<CallViewModel>, vm: CallViewModel): Element {
|
render(t: Builder<CallViewModel>, vm: CallViewModel): Element {
|
||||||
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"}, bindStream(t, t.video({autoplay: true, width: 240}), vm => vm.localStream)),
|
t.view(new ListView({list: vm.memberViewModels}, vm => new StreamView(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"),
|
||||||
t.button({onClick: () => vm.toggleVideo()}, "Toggle video"),
|
t.button({onClick: () => vm.toggleVideo()}, "Toggle video"),
|
||||||
|
@ -51,12 +33,31 @@ export class CallView extends TemplateView<CallViewModel> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MemberView extends TemplateView<CallMemberViewModel> {
|
class StreamView extends TemplateView<IStreamViewModel> {
|
||||||
render(t: TemplateBuilder<CallMemberViewModel>, vm: CallMemberViewModel) {
|
render(t: Builder<IStreamViewModel>, vm: IStreamViewModel): Element {
|
||||||
return bindStream(t, t.video({autoplay: true, width: 360}), vm => vm.stream);
|
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<Stream> {
|
|
||||||
// render(t: TemplateBuilder<Stream)
|
|
||||||
// }
|
|
||||||
|
|
Reference in a new issue