Compare commits

..

11 commits

27 changed files with 622 additions and 179 deletions

View file

@ -0,0 +1,9 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class ChannelService {
constructor() { }
}

View file

@ -1,5 +1,6 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
import { map } from 'rxjs/operators';
import { Story } from './story'; import { Story } from './story';
import { SupaService } from './supa.service'; import { SupaService } from './supa.service';
@ -27,25 +28,20 @@ export class StoryService {
if (!this.subscribedStandUpIds.includes(standup_id)) { if (!this.subscribedStandUpIds.includes(standup_id)) {
this.subscribeToStories(standup_id); this.subscribeToStories(standup_id);
console.log('getStories - REFRESH', standup_id) console.log('getStories - REFRESH', standup_id)
const subject: Subject<Story[]> = new Subject();
this.supa.client.from<Story>('story').select() this.supa.client.from<Story>('story').select()
.filter(<never>'standup_id', 'eq', standup_id) .filter(<never>'standup_id', 'eq', standup_id)
.then(data => { .then(data => {
this.updateStore(data.body); this.updateStore(data.body);
subject.next(data.body);
subject.complete();
}) })
.catch(error => { .catch(error => {
subject.error(error); console.error(error);
subject.complete();
}); });
return subject.asObservable(); } else {
} else {
console.log('getStories - LOCAL', standup_id) console.log('getStories - LOCAL', standup_id)
const stories = Object.values<Story>(this.storyMap).filter(e => e.standup_id === standup_id)
console.log('filteredStories', stories)
return of(stories);
} }
return this.stories.asObservable().pipe(
map(stories => stories.filter(e => e.standup_id === standup_id))
);
} }
/** /**
@ -89,13 +85,17 @@ export class StoryService {
// this.storyMap.set(e.id, e); // this.storyMap.set(e.id, e);
this.storyMap[e.id] = e; this.storyMap[e.id] = e;
}); });
console.log('update stories', this.storyMap)
this.next(); this.next();
} }
/** /**
* Emits local store. * Emits local store.
*/ */
next = () => this.stories.next(Object.values(this.storyMap)); next = () => {
console.log('next', Object.values(this.storyMap))
this.stories.next(Object.values(this.storyMap))
};
/** /**
* Retrieve story from local store. * Retrieve story from local store.

View file

@ -1,13 +1,16 @@
import { User } from './user';
export class Story { export class Story {
id: number; id: string;
standup_id: number; standup_id: number;
user_id: string user_id: string;
base64: string; user?: User;
src: string;
created_at: string | Date; created_at: string | Date;
constructor(user_id: string, standup_id: number, base64: string) { constructor(user_id: string, standup_id: number, src: string) {
this.user_id = user_id; this.user_id = user_id;
this.standup_id = standup_id; this.standup_id = standup_id;
this.base64 = base64; this.src = src;
} }
} }

View file

@ -1,7 +1,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { createClient, SupabaseAuthUser, SupabaseClient } from '@supabase/supabase-js' import { createClient, SupabaseAuthUser, SupabaseClient } from '@supabase/supabase-js'
import { BehaviorSubject } from 'rxjs'; import { Subject } from 'rxjs';
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
import { User } from './user';
@Injectable({ @Injectable({
@ -9,7 +10,9 @@ import { environment } from '../../../environments/environment'
}) })
export class SupaService { export class SupaService {
client: SupabaseClient; client: SupabaseClient;
user: BehaviorSubject<SupabaseAuthUser> = new BehaviorSubject(null); user: Subject<SupabaseAuthUser> = new Subject();
supabaseUser: SupabaseAuthUser;
userProfile: User;
constructor() { constructor() {
// Create a single supabase client for interacting with your database // Create a single supabase client for interacting with your database
@ -20,9 +23,39 @@ export class SupaService {
async getUser() { async getUser() {
const user = await this.client.auth.user(); const user = await this.client.auth.user();
console.log('user', user); console.log('user', user);
this.supabaseUser = user;
this.getUserProfile();
this.user.next(user); this.user.next(user);
} }
getUserProfile(user_id: string = this.supabaseUser.id) {
const subject: Subject<User> = new Subject();
if (!this.userProfile) {
this.client.from<User>('user').select().match({id: <never>user_id})
.then(data => {
console.log('getUserProfile', data)
if (data.body.length === 0) {
// create default user profile
this.client.from<User>('user').insert(new User(user_id, this.supabaseUser.email.split('@')[0]))
.then(data => {
console.log('created UserProfile', data.body[0]);
this.userProfile = data.body[0];
subject.next(this.userProfile);
})
.catch(error => console.error('Error creating UserProfile', error))
} else {
console.log('loaded UserProfile', data.body[0]);
this.userProfile = data.body[0];
subject.next(this.userProfile);
}
})
.catch(error => console.error('getUserProfile', error))
} else {
setTimeout(() =>subject.next(this.userProfile), 100);
}
return subject.asObservable();
}
async login(email: string, password: string): Promise<SupabaseAuthUser> { async login(email: string, password: string): Promise<SupabaseAuthUser> {
try { try {
const res = await this.client.auth.login( const res = await this.client.auth.login(
@ -30,6 +63,7 @@ export class SupaService {
password password
); );
this.user.next(res.body.user); this.user.next(res.body.user);
this.supabaseUser = res.body.user;
return res.body.user; return res.body.user;
} catch (e) { } catch (e) {
console.error('Login', e); console.error('Login', e);
@ -49,6 +83,7 @@ export class SupaService {
password password
); );
this.user.next(res.body.user); this.user.next(res.body.user);
this.supabaseUser = res.body.user;
return res.body.user; return res.body.user;
} catch (e) { } catch (e) {
console.error('Signup', e); console.error('Signup', e);

View file

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UserService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View file

@ -0,0 +1,161 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
import { map } from 'rxjs/internal/operators/map';
import { SupaService } from './supa.service';
import { User } from './user';
@Injectable({
providedIn: 'root'
})
export class UserService {
userMap = {};
users: BehaviorSubject<User[]> = new BehaviorSubject(Object.values(this.userMap));
isListening: boolean = true;
constructor(
private supa: SupaService,
) { }
/**
* Get Users from local store.
* Requests data if store is emtpy.
* @returns Observable<User[]>
*/
getStories(): Observable<User[]> {
if (Object.values(this.userMap).length === 0) {
this.subscribeToUsers();
console.log('getUsers- REFRESH')
this.supa.client.from<User>('user').select()
.then(data => {
this.updateStore(data.body);
})
.catch(error => {
console.error(error);
});
}
return this.users.asObservable();
}
/**
* Listen to realtime events from users db.
*/
subscribeToUsers() {
if (!this.isListening) {
this.isListening = true;
this.supa.client.from<User>('user').on('*', payload => {
console.log('subscribeToStories - REALTIME EVENT', payload)
if ((payload.eventType === 'INSERT') || (payload.eventType === 'UPDATE')) {
this.userMap[payload.new.id] = payload.new;
} else {
delete this.userMap[payload.old.id];
}
this.next();
}).subscribe();
}
}
/**
* Update the local store with provided users
* @param users
*/
updateStore(users: User[]) {
users.forEach(e => {
// this.userMap.set(e.id, e);
this.userMap[e.id] = e;
});
console.log('update users', this.userMap)
this.next();
}
/**
* Emits local store.
*/
next = () => {
console.log('next', Object.values(this.userMap))
this.users.next(Object.values(this.userMap))
};
/**
* Retrieve users from local store.
* @param id
*/
getOne(id: string) {
if (this.userMap[id]) {
return of(this.userMap[id]);
} else {
const subject: Subject<User> = new Subject();
this.supa.client.from<User>('user').select()
.filter(<never>'id', 'eq', id)
.then(data => {
this.updateStore([data.body[0]]);
subject.next(data.body[0]);
subject.complete();
})
.catch(error => {
subject.error(error);
subject.complete();
});
return subject.asObservable();
}
}
/**
* Update a users data.
* @param users
*/
updateOne(user: User): Observable<User> {
const subject: Subject<User> = new Subject();
this.supa.client.from<User>('user').update(user)
.match({ id: user.id })
.then(data => {
subject.next(data.body[0]);
this.updateStore([data.body[0]]);
subject.complete();
})
.catch(error => {
subject.error(error);
subject.complete();
});
return subject.asObservable();
}
/**
* Removes one user from db.
* @param user
*/
deleteOne(user: User): Observable<User> {
const subject: Subject<User> = new Subject();
this.supa.client.from<User>('user').delete()
.match({ id: user.id })
.then(data => {
subject.next(user);
subject.complete();
})
.catch(error => {
subject.error(error);
subject.complete();
});
return subject.asObservable();
}
/**
* Creates a user on the db.
* @param users
*/
addOne(user: User): Observable<User> {
const subject: Subject<User> = new Subject();
this.supa.client.from<User>('user').insert(user)
.then(data => {
subject.next(data.body[0]);
this.updateStore([data.body[0]]);
subject.complete();
})
.catch(error => {
subject.error(error);
subject.complete();
});
return subject.asObservable();
}
}

View file

@ -0,0 +1,11 @@
export class User {
id: string;
status: 'OFFLINE' |'ONLINE';
username: string;
constructor(id: string, username: string, status: 'OFFLINE' | 'ONLINE' = 'ONLINE') {
this.id = id;
this.username = username;
this.status = status;
}
}

View file

@ -5,7 +5,7 @@
<div class="container-fluid p-0"> <div class="container-fluid p-0">
<div class="myContainer d-flex"> <div class="myContainer d-flex">
<div *ngIf="supa.user | async" class="menu"> <div *ngIf="supa.user | async" class="menu">
<app-channel-list></app-channel-list> <app-sidebar></app-sidebar>
</div> </div>
<div class="container content"> <div class="container content">
<router-outlet></router-outlet> <router-outlet></router-outlet>

View file

@ -8,6 +8,7 @@ import { FormsModule } from '@angular/forms';
import { SupaService } from './api/supabase/supa.service'; import { SupaService } from './api/supabase/supa.service';
import { StandupService } from './api/supabase/standup.service'; import { StandupService } from './api/supabase/standup.service';
import { StoryService } from './api/supabase/story.service'; import { StoryService } from './api/supabase/story.service';
import { UserService } from './api/supabase/user.service';
import { AuthGuard } from './authguard.service'; import { AuthGuard } from './authguard.service';
import { AppComponent } from "./app.component"; import { AppComponent } from "./app.component";
@ -17,8 +18,9 @@ import { SignupComponent } from './signup/signup.component';
import { HuddleComponent } from './huddle/huddle.component'; import { HuddleComponent } from './huddle/huddle.component';
import { ProfileComponent } from './profile/profile.component'; import { ProfileComponent } from './profile/profile.component';
import { ChannelComponent } from './channel/channel.component'; import { ChannelComponent } from './channel/channel.component';
import { ChannelListComponent } from './channel-list/channel-list.component'; import { SidebarComponent } from './sidebar/sidebar.component';
import { DashboardComponent } from './dashboard/dashboard.component'; import { DashboardComponent } from './dashboard/dashboard.component';
import { InputManagerComponent } from './input-manager/input-manager.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -29,8 +31,9 @@ import { DashboardComponent } from './dashboard/dashboard.component';
SignupComponent, SignupComponent,
ProfileComponent, ProfileComponent,
ChannelComponent, ChannelComponent,
ChannelListComponent, SidebarComponent,
DashboardComponent DashboardComponent,
InputManagerComponent
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
@ -42,6 +45,7 @@ import { DashboardComponent } from './dashboard/dashboard.component';
SupaService, SupaService,
StandupService, StandupService,
StoryService, StoryService,
UserService,
AuthGuard, AuthGuard,
], ],
bootstrap: [ bootstrap: [

View file

@ -1,23 +1,22 @@
<div *ngIf="standup" class="row"> <div *ngIf="standup" class="row">
<div class="col-12 col-sm-9"> <div class="col-12 col-sm-9 order-1 order-sm-0">
<legend #name contenteditable="true" (click)="dataUpdated=true">{{ standup.name }}</legend> <legend #name contenteditable="true" (click)="dataUpdated=true">{{ standup.name }}</legend>
<p #desc contenteditable="true" (click)="dataUpdated=true">{{ standup.description }}</p> <p #desc contenteditable="true" (click)="dataUpdated=true">{{ standup.description }}</p>
</div> </div>
<div class="col-12 col-sm-3"> <div class="col-12 col-sm-3 order-0 oder-sm-1">
<div class="float-right"> <div class="float-right">
<button class="btn btn-primary ml-1 mr-1" [disabled]="!dataUpdated" (click)="updateStandUp(name.innerText, desc.innerText)">Save</button> <button class="btn btn-primary ml-1 mr-1" [disabled]="!dataUpdated" (click)="updateStandUp(name.innerText, desc.innerText)">Save</button>
<button class="btn btn-danger ml-1 mr-1" (click)="deleteStandUp()">Delete</button> <button class="btn btn-danger ml-1 mr-1" (click)="deleteStandUp()">Delete</button>
</div> </div>
</div> </div>
<div class="col-12"> <div class="col-12 order-2">
<ah-recorder (recordingEnded)="uploadStory($event)"></ah-recorder> <ah-recorder (recordingEnded)="uploadStory($event)"></ah-recorder>
</div> </div>
<div *ngFor="let user of users" class="col p-3"> <div *ngFor="let story of stories" class="col p-3 order-3">
<div class="text-center" (click)="playStory(user)"> <div class="text-center" (click)="playStory(story)">
<div class="storyImage" style="background-image: url({{user.image}})" <div class="storyImage" style="background-image: url({{'https://dummyimage.com/256x256/000/fff&text='+(story.user ? story.user.username: '...')}})" [ngClass]="{'hasStory': story.id && story.src}"></div>
[ngClass]="{'hasStory': user.story_link && user.submit_time}"></div> <p class="mt-2 mb-0"><strong>{{story.user ? story.user.username : '...'}}</strong></p>
<p class="mt-2 mb-0"><strong>{{user.name}}</strong></p> <p><small>{{story.created_at | date :'dd.MM. HH:mm' }}</small></p>
<p><small>{{user.submit_time | date :'dd.MM. HH:mm' }}</small></p>
</div> </div>
</div> </div>
</div> </div>
@ -46,9 +45,9 @@
</button> </button>
</div> </div>
<p class="text-center m-0"> <p class="text-center m-0">
<strong>{{selectedUser.name}} - <strong>{{selectedStory.user ? selectedStory.user.username : '...'}} - <small>{{selectedStory.created_at | date :'dd.MM. HH:mm' }}</small></strong>
<small>{{selectedUser.submit_time | date :'dd.MM. HH:mm' }}</small></strong>
</p> </p>
<video [src]="selectedUser.story_link" (ended)="nextUser(modal)" autoplay></video> <video [src]="base64ToSafeURL(selectedStory.src)" (ended)="nextUser(modal)" autoplay>
</video>
</div> </div>
</ng-template> </ng-template>

View file

@ -28,7 +28,7 @@
} }
.modal-body { .modal-body {
height: 90vh; max-height: 90vh;
} }
.modal-body video { .modal-body video {

View file

@ -1,46 +1,28 @@
import { Component, OnInit, ViewChild } from "@angular/core"; import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { NgbModal, NgbModalRef } from "@ng-bootstrap/ng-bootstrap"; import { NgbModal, NgbModalRef } from "@ng-bootstrap/ng-bootstrap";
import { SupabaseAuthUser } from '@supabase/supabase-js'; import { SupabaseAuthUser } from '@supabase/supabase-js';
import { Subject } from 'rxjs';
import { take, takeUntil, map } from 'rxjs/operators';
import { StandUp } from '../api/supabase/standup'; import { StandUp } from '../api/supabase/standup';
import { StandupService } from '../api/supabase/standup.service'; import { StandupService } from '../api/supabase/standup.service';
import { Story } from '../api/supabase/story'; import { Story } from '../api/supabase/story';
import { StoryService } from '../api/supabase/story.service'; import { StoryService } from '../api/supabase/story.service';
import { SupaService } from '../api/supabase/supa.service'; import { SupaService } from '../api/supabase/supa.service';
import { UserService } from '../api/supabase/user.service';
@Component({ @Component({
selector: 'app-huddle', selector: 'app-huddle',
templateUrl: './huddle.component.html', templateUrl: './huddle.component.html',
styleUrls: ['./huddle.component.scss'] styleUrls: ['./huddle.component.scss']
}) })
export class HuddleComponent implements OnInit { export class HuddleComponent implements OnInit, OnDestroy {
@ViewChild("content") content; @ViewChild("content") content;
@ViewChild("activeStory") video; @ViewChild("activeStory") video;
users = [
{
name: "Jan",
image: "https://www.supercardating.com/doc/image.rhtm/profile-pic2.jpg",
submit_time: 1601655386668,
story_link: "https://erjb.s3.nl-ams.scw.cloud/ttk_beagle.mp4"
},
{
name: "Bob",
image:
"https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Ftse1.mm.bing.net%2Fth%3Fid%3DOIP.4oYqJqInuQd2TAlPPdggLgHaHa%26pid%3DApi&f=1",
submit_time: null,
story_link: "https://erjb.s3.nl-ams.scw.cloud/ttk_golden.mp4"
},
{
name: "Angela",
image:
"https://writestylesonline.com/wp-content/uploads/2019/01/What-To-Wear-For-Your-Professional-Profile-Picture-or-Headshot.jpg",
submit_time: 1601655386668,
story_link: "https://erjb.s3.nl-ams.scw.cloud/ttk_frenchie.mp4"
}
];
selectedUser;
standup: StandUp; standup: StandUp;
selectedStory: Story;
stories: Story[] = []; stories: Story[] = [];
unsubscribe: Subject<boolean> = new Subject();
constructor( constructor(
private modalService: NgbModal, private modalService: NgbModal,
@ -48,17 +30,22 @@ export class HuddleComponent implements OnInit {
private storyService: StoryService, private storyService: StoryService,
private supaService: SupaService, private supaService: SupaService,
private route: ActivatedRoute, private route: ActivatedRoute,
private sanitizer: DomSanitizer,
private userService: UserService,
) {} ) {}
ngOnInit() { ngOnInit() {
this.route.params.subscribe( this.route.params.subscribe(
params => { params => {
this.reset();
if (params.id) { if (params.id) {
this.standupService.getOne(params.id).subscribe( this.standupService.getOne(params.id).subscribe(
data => { data => {
console.log(data); console.log(data);
this.standup = data; this.standup = data;
this.loadStories(data.id).subscribe(stories => this.stories = stories); this.loadStories(data.id).subscribe(stories => {
this.stories = stories;
});
}, },
error => { error => {
console.error(error); console.error(error);
@ -69,60 +56,66 @@ export class HuddleComponent implements OnInit {
) )
} }
playStory(user) { ngOnDestroy() {
if (!user.submit_time || !user.story_link) { this.unsubscribe.next(true);
this.unsubscribe.complete();
}
reset() {
this.stories = [];
delete this.standup;
delete this.selectedStory;
this.unsubscribe.next(true);
}
playStory(story) {
if (!story.id || !story.src) {
return; return;
} }
this.modalService.open(this.content, { centered: true }); this.modalService.open(this.content, { centered: true });
this.selectedUser = user; this.selectedStory = story;
} }
prevUser(modal: NgbModalRef) { prevUser(modal: NgbModalRef) {
let index: number = this.users.findIndex( let index: number = this.stories.findIndex(
(u) => u.name === this.selectedUser.name (u) => u.id === this.selectedStory.id
); );
if (index < 1) { if (index < 1) {
modal.close(); modal.close();
return; return;
} }
while ( while (!this.stories[index - 1].id) {
!this.users[index - 1].story_link ||
!this.users[index - 1].submit_time
) {
index--; index--;
if (index === 0) { if (index === 0) {
modal.close(); modal.close();
return; return;
} }
} }
this.selectedUser = this.users[index - 1]; this.selectedStory = this.stories[index - 1];
} }
nextUser(modal: NgbModalRef) { nextUser(modal: NgbModalRef) {
let index: number = this.users.findIndex( let index: number = this.stories.findIndex(
(u) => u.name === this.selectedUser.name (u) => u.id === this.selectedStory.id
); );
if (index === -1 || index === this.users.length - 1) { if (index === -1 || index === this.stories.length - 1) {
modal.close(); modal.close();
return; return;
} }
while ( while (!this.stories[index + 1].id) {
!this.users[index + 1].story_link || if (index === -1 || index === this.stories.length - 1) {
!this.users[index + 1].submit_time
) {
if (index === -1 || index === this.users.length - 1) {
modal.close(); modal.close();
return; return;
} }
index++; index++;
} }
this.selectedUser = this.users[index + 1]; this.selectedStory = this.stories[index + 1];
} }
updateStandUp(name:string, desc:string) { updateStandUp(name:string, desc:string) {
this.standup.name = name; this.standup.name = name;
this.standup.description = desc; this.standup.description = desc;
this.standupService.updateOne(this.standup).subscribe( this.standupService.updateOne(this.standup).pipe(take(1)).subscribe(
data => { data => {
console.log('Success', data); console.log('Success', data);
}, },
@ -133,7 +126,7 @@ export class HuddleComponent implements OnInit {
} }
deleteStandUp() { deleteStandUp() {
this.standupService.deleteOne(this.standup).subscribe( this.standupService.deleteOne(this.standup).pipe(take(1)).subscribe(
data => { data => {
console.log('Success', data); console.log('Success', data);
}, },
@ -143,12 +136,11 @@ export class HuddleComponent implements OnInit {
); );
} }
async uploadStory(base64: string) { async uploadStory(src: string) {
console.log('uploadStory', event); console.log('uploadStory', event);
const user: SupabaseAuthUser = await this.supaService.client.auth.user(); const user: SupabaseAuthUser = await this.supaService.client.auth.user();
// TODO ID muss in DB noch autoincremented werden. const story = new Story(user.id, this.standup.id, src);
const story = new Story(user.id, this.standup.id, base64); this.storyService.addOne(story).pipe(take(1)).subscribe(
this.storyService.addOne(story).subscribe(
data => { data => {
console.log('Success', data); console.log('Success', data);
}, },
@ -159,7 +151,36 @@ export class HuddleComponent implements OnInit {
} }
loadStories(id: number) { loadStories(id: number) {
return this.storyService.getStories(id); return this.storyService.getStories(id).pipe(
takeUntil(this.unsubscribe.asObservable()),
map(stories => {
stories.forEach(story => {
this.userService.getOne(story.user_id).subscribe(user => story.user = user)
})
return stories;
})
);
}
base64ToSafeURL(b64Data: string, contentType: string ='video/webm', sliceSize: number=512): SafeUrl {
// https://stackoverflow.com/questions/16245767/creating-a-blob-from-a-base64-string-in-javascript#16245768
const byteCharacters = atob(b64Data.split('base64,')[1]);
const byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
const slice = byteCharacters.slice(offset, offset + sliceSize);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
const blob = new Blob(byteArrays, {type: contentType});
return this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
} }
} }

View file

@ -0,0 +1,21 @@
<video id="localVideo" autoplay muted></video>
<details [open]="detailsOpen">
<summary>
Settings:
</summary>
<div class="form-group">
<label for="audioSource">Audio input source: </label>
<select class="form-control" id="audioSource" (change)="start()"></select>
</div>
<!-- <div class="select">
<label for="audioOutput">Audio output destination: </label><select id="audioOutput" (change)="changeAudioDestination()"></select>
</div> -->
<div class="form-group">
<label for="videoSource">Video source: </label>
<select class="form-control" id="videoSource" (change)="start()"></select>
</div>
</details>

View file

@ -0,0 +1,4 @@
video {
max-width: 100%;
max-height: 100%;
}

View file

@ -1,20 +1,20 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ChannelListComponent } from './channel-list.component'; import { InputManagerComponent } from './input-manager.component';
describe('ChannelListComponent', () => { describe('InputManagerComponent', () => {
let component: ChannelListComponent; let component: InputManagerComponent;
let fixture: ComponentFixture<ChannelListComponent>; let fixture: ComponentFixture<InputManagerComponent>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
declarations: [ ChannelListComponent ] declarations: [ InputManagerComponent ]
}) })
.compileComponents(); .compileComponents();
}); });
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(ChannelListComponent); fixture = TestBed.createComponent(InputManagerComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); });

View file

@ -0,0 +1,143 @@
import { AfterViewInit, Component, EventEmitter, Input, Output } from '@angular/core';
@Component({
selector: 'app-input-manager',
templateUrl: './input-manager.component.html',
styleUrls: ['./input-manager.component.scss']
})
export class InputManagerComponent implements AfterViewInit {
@Input() detailsOpen: boolean = true;
@Output() streamOutput: EventEmitter<MediaStream> = new EventEmitter();
stream: MediaStream;
videoElement:any = document.querySelector('#localVideo');
audioInputSelect:any = document.querySelector('select#audioSource');
// audioOutputSelect:any = document.querySelector('select#audioOutput');
videoSelect:any = document.querySelector('select#videoSource');
// selectors = [this.audioInputSelect, this.audioOutputSelect, this.videoSelect];
selectors = [this.audioInputSelect, this.videoSelect];
constructor(
) { }
ngAfterViewInit(): void {
this.videoElement = document.querySelector('#localVideo');
this.videoElement.muted = true;
this.audioInputSelect = document.querySelector('select#audioSource');
// this.audioOutputSelect = document.querySelector('select#audioOutput');
this.videoSelect = document.querySelector('select#videoSource');
// this.selectors = [this.audioInputSelect, this.audioOutputSelect, this.videoSelect];
this.selectors = [this.audioInputSelect, this.videoSelect];
// console.log(this.videoSelect, this.selectors)
// this.audioOutputSelect.disabled = !('sinkId' in HTMLMediaElement.prototype);
navigator.mediaDevices.enumerateDevices().then(devices => this.gotDevices(devices)).catch(error => this.handleError(error));
this.start();
}
// Attach audio output device to video element using device/sink ID.
attachSinkId(element, sinkId) {
if (typeof element.sinkId !== 'undefined') {
element.setSinkId(sinkId)
.then(() => {
// console.log(`Success, audio output device attached: ${sinkId}`);
})
.catch(error => {
let errorMessage = error;
if (error.name === 'SecurityError') {
errorMessage = `You need to use HTTPS for selecting audio output device: ${error}`;
}
// console.error(errorMessage);
// Jump back to first output device in the list as it's the default.
// this.audioOutputSelect.selectedIndex = 0;
});
} else {
// console.warn('Browser does not support output device selection.');
}
}
// changeAudioDestination() {
// const audioDestination = this.audioOutputSelect.value;
// this.attachSinkId(this.videoElement, audioDestination);
// }
gotStream(stream: MediaStream) {
this.stream = stream; // make stream available to // console
this.videoElement.srcObject = stream;
this.videoElement.muted = true;
this.streamOutput.next(stream);
// Refresh button list in case labels have become available
return navigator.mediaDevices.enumerateDevices();
}
handleError(error) {
console.error('navigator.MediaDevices.getUserMedia error: ', error.message, error.name);
}
gotDevices(deviceInfos) {
// Handles being called several times to update labels. Preserve values.
const values = this.selectors.map(select => select.value);
this.selectors.forEach(select => {
while (select.firstChild) {
select.removeChild(select.firstChild);
}
});
for (let i = 0; i !== deviceInfos.length; ++i) {
const deviceInfo = deviceInfos[i];
const option = document.createElement('option');
option.value = deviceInfo.deviceId;
if (deviceInfo.kind === 'audioinput') {
option.text = deviceInfo.label || `microphone ${this.audioInputSelect.length + 1}`;
this.audioInputSelect.appendChild(option);
// } else if (deviceInfo.kind === 'audiooutput') {
// option.text = deviceInfo.label || `speaker ${this.audioOutputSelect.length + 1}`;
// this.audioOutputSelect.appendChild(option);
} else if (deviceInfo.kind === 'videoinput') {
option.text = deviceInfo.label || `camera ${this.videoSelect.length + 1}`;
this.videoSelect.appendChild(option);
} else {
// console.log('Some other kind of source/device: ', deviceInfo);
}
}
this.selectors.forEach((select, selectorIndex) => {
if (Array.prototype.slice.call(select.childNodes).some(n => n.value === values[selectorIndex])) {
select.value = values[selectorIndex];
}
});
}
start() {
if (this.stream) {
this.stream.getTracks().forEach(track => {
track.stop();
});
}
let audioSource = this.audioInputSelect.value;
const savedAudioSource = localStorage.getItem('async-huddle-selected-mic');
if (audioSource) {
localStorage.setItem('async-huddle-selected-mic', audioSource);
} else if (savedAudioSource) {
audioSource = savedAudioSource;
setTimeout(() => {
const e:any = document.querySelector('[value="'+savedAudioSource+'"]')
if (e) e.selected = true;
}, 200);
}
console.warn('Selecting audio:', audioSource);
let videoSource = this.videoSelect.value;
const savedVideoSource = localStorage.getItem('async-huddle-selected-video-device');
if (videoSource) {
localStorage.setItem('async-huddle-selected-video-device', videoSource);
} else if (savedVideoSource) {
videoSource = savedVideoSource;
setTimeout(() => {
const e:any = document.querySelector('[value="'+savedVideoSource+'"]')
if (e) e.selected = true;
}, 200);
}
console.warn('Selecting video:', videoSource);
const constraints = {
audio: {deviceId: audioSource ? {exact: audioSource} : undefined},
video: {deviceId: videoSource ? {exact: videoSource} : undefined}
};
navigator.mediaDevices.getUserMedia(constraints).then((stream) => this.gotStream(stream)).then(devices => this.gotDevices(devices)).catch(error => this.handleError(error));
}
}

View file

@ -1,22 +1,24 @@
<div class="row mt-3 justify-content-md-center"> <form>
<div class="form-group col-12 col-sm-4"> <div class="row mt-3 justify-content-md-center">
<label for="email">Email</label> <div class="form-group col-12 col-sm-4">
<input #email class="form-control" type="email"> <label for="email">Email</label>
<input #email class="form-control" type="email">
</div>
</div> </div>
</div> <div class="row justify-content-md-center">
<div class="row justify-content-md-center"> <div class="form-group col-12 col-sm-4">
<div class="form-group col-12 col-sm-4"> <label for="password">Password</label>
<label for="password">Password</label> <input #password class="form-control" type="password">
<input #password class="form-control" type="password"> </div>
</div> </div>
</div> <div class="row justify-content-md-center">
<div class="row justify-content-md-center"> <div class="col-12 col-sm-4">
<div class="col-12 col-sm-4"> <button class="btn btn-primary text-uppercase" type="submit" (click)="login(email.value, password.value)">
<button class="btn btn-primary text-uppercase" type="button" (click)="login(email.value, password.value)"> Log in
Log in </button> or
</button> or <a class="btn btn-secondary text-uppercase" (click)="router.navigate(['/signup'])">
<a class="btn btn-secondary text-uppercase" [routerLink]="'/signup'"> signup
signup </a>
</a> </div>
</div> </div>
</div> </form>

View file

@ -10,7 +10,7 @@ import { SupaService } from '../api/supabase/supa.service';
export class LoginComponent implements OnInit { export class LoginComponent implements OnInit {
constructor( constructor(
private router: Router, public router: Router,
private supa: SupaService, private supa: SupaService,
) { } ) { }

View file

@ -1,6 +1,16 @@
<legend>Profile</legend> <div class="row">
<pre> <div class="col-12 col-sm-3">
<code *ngIf="user"> <legend>Profile</legend>
{{ user | json }} <form *ngIf="user">
</code> <div class="form-group">
</pre> <label for="username">Username</label>
<input type="text" class="form-control" name="username" aria-describedby="Input for username." [(ngModel)]="user.username">
</div>
<button type="submit" class="btn btn-primary" (submit)="updateUser()" (click)="updateUser()">Save</button>
</form>
</div>
<div class="col-12 col-sm-6">
<legend>Media Settings</legend>
<app-input-manager></app-input-manager>
</div>
</div>

View file

@ -1,6 +1,8 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { SupabaseAuthUser } from '@supabase/supabase-js'; import { SupabaseAuthUser } from '@supabase/supabase-js';
import { SupaService } from '../api/supabase/supa.service'; import { SupaService } from '../api/supabase/supa.service';
import { User } from '../api/supabase/user';
import { UserService } from '../api/supabase/user.service';
@Component({ @Component({
selector: 'app-profile', selector: 'app-profile',
@ -8,17 +10,25 @@ import { SupaService } from '../api/supabase/supa.service';
styleUrls: ['./profile.component.scss'] styleUrls: ['./profile.component.scss']
}) })
export class ProfileComponent implements OnInit { export class ProfileComponent implements OnInit {
user: SupabaseAuthUser; user: User;
constructor( constructor(
public supa: SupaService, private supaService: SupaService,
private userService: UserService,
) { } ) { }
ngOnInit(): void { ngOnInit(): void {
this.supa.client.auth.user().then(user => { this.supaService.getUserProfile().subscribe((user:User) => {
console.log('user', user) console.log('user', user);
this.user = user; this.user = user;
}); });
} }
updateUser() {
this.userService.updateOne(this.user).subscribe(
data => console.log('Success', data),
error => console.error(error)
)
}
} }

View file

@ -23,8 +23,8 @@
</span> </span>
</div> </div>
<div class="modal-body d-flex flex-column"> <div class="modal-body d-flex flex-column">
<video *ngIf="stream" [id]="id" [srcObject]="stream" class="video-recorder" autoplay muted volume="0"></video> <app-input-manager (streamOutput)="handleStreamChange($event)" [detailsOpen]="false"></app-input-manager>
<button *ngIf="!isRecording" class="btn btn-success" (click)="startRecording()"> <button *ngIf="!isRecording" class="btn btn-success mt-2" (click)="startRecording()">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path
d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z" d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"
@ -35,6 +35,6 @@
</svg> </svg>
Start recording Start recording
</button> </button>
<button *ngIf="isRecording" class="btn btn-danger" (click)="stopRecording()">Stop</button> <button *ngIf="isRecording" class="btn btn-danger mt-2" (click)="stopRecording()">Stop</button>
</div> </div>
</ng-template> </ng-template>

View file

@ -27,21 +27,20 @@ export class RecorderComponent implements OnInit {
this.modal = this.modalService.open(this.content, { this.modal = this.modalService.open(this.content, {
centered: true centered: true
}); });
this.stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
setTimeout(() => {
const video:any = document.getElementById(this.id);
video.volume = 0;
video.muted = true;
}, 200);
} }
startRecording() { handleStreamChange(stream: MediaStream) {
this.stream = stream;
}
startRecording(stream: MediaStream = this.stream) {
if (!stream) {
return;
}
this.isRecording = true; this.isRecording = true;
this.recorder = new rrtc.RecordRTCPromisesHandler(this.stream, { this.recorder = new rrtc.RecordRTCPromisesHandler(stream, {
type: 'video', type: 'video',
mimeType: 'video/webm;codecs=vp8',
}); });
this.recorder.startRecording(); this.recorder.startRecording();
} }
@ -50,21 +49,10 @@ export class RecorderComponent implements OnInit {
const that = this; const that = this;
this.isRecording = false; this.isRecording = false;
await this.recorder.stopRecording(); await this.recorder.stopRecording();
let blob = await this.recorder.getBlob(); let blob = await this.recorder.getDataURL();
// read as b64 that.recordingEnded.emit(blob);
const reader = new FileReader(); that.modal.close();
reader.readAsDataURL(blob); return;
reader.onloadend = function() {
const base64data:string = String(reader.result);
console.log(base64data);
that.recordingEnded.emit(base64data);
that.modal.close();
}
const handleError = (error) => {
console.error(error);
}
reader.onerror = handleError;
reader.onabort = handleError;
} }
} }

View file

@ -14,4 +14,5 @@
<ul class="list-unstyled"> <ul class="list-unstyled">
<li [routerLink]="'/profile'">Profile</li> <li [routerLink]="'/profile'">Profile</li>
</ul> </ul>
<p style="position: fixed; bottom: 5px; text-align: center;" class="text-muted"><small>v{{environment.version}}</small></p>
</div> </div>

View file

@ -2,14 +2,16 @@ import { Component, OnInit } from '@angular/core';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { StandUp } from '../api/supabase/standup'; import { StandUp } from '../api/supabase/standup';
import { StandupService } from '../api/supabase/standup.service'; import { StandupService } from '../api/supabase/standup.service';
import { environment } from '../../environments/environment';
@Component({ @Component({
selector: 'app-channel-list', selector: 'app-sidebar',
templateUrl: './channel-list.component.html', templateUrl: './sidebar.component.html',
styleUrls: ['./channel-list.component.scss'] styleUrls: ['./sidebar.component.scss']
}) })
export class ChannelListComponent implements OnInit { export class SidebarComponent implements OnInit {
standups: StandUp[] = []; standups: StandUp[] = [];
environment = environment;
constructor( constructor(
private standupSevice: StandupService, private standupSevice: StandupService,

View file

@ -1,25 +1,27 @@
<div class="row mt-3 justify-content-md-center"> <form>
<div class="form-group col-12 col-sm-4"> <div class="row mt-3 justify-content-md-center">
<label for="email">Email</label> <div class="form-group col-12 col-sm-4">
<input [(ngModel)]="email" class="form-control" type="email"> <label for="email">Email</label>
<input [(ngModel)]="email" name="email" class="form-control" type="email">
</div>
</div> </div>
</div> <div class="row justify-content-md-center">
<div class="row justify-content-md-center"> <div class="form-group col-12 col-sm-4">
<div class="form-group col-12 col-sm-4"> <label for="password">Password</label>
<label for="password">Password</label> <input [(ngModel)]="password" name="password" class="form-control" type="password">
<input [(ngModel)]="password" class="form-control" type="password"> </div>
</div> </div>
</div> <div class="row justify-content-md-center">
<div class="row justify-content-md-center"> <div class="form-group col-12 col-sm-4">
<div class="form-group col-12 col-sm-4"> <label for="password">Repeat password</label>
<label for="password">Repeat password</label> <input [(ngModel)]="repeat" name="repeat" class="form-control" type="password">
<input [(ngModel)]="repeat" class="form-control" type="password"> </div>
</div> </div>
</div> <div class="row justify-content-md-center">
<div class="row justify-content-md-center"> <div class="col-12 col-sm-4">
<div class="col-12 col-sm-4"> <button type="button" class="btn btn-secondary text-uppercase" (click)="signup(email, password, repeat)">
<button type="button" class="btn btn-secondary text-uppercase" (click)="signup(email, password, repeat)"> signup
signup </button>
</button> </div>
</div> </div>
</div> </form>

View file

@ -6,5 +6,6 @@
export const environment = { export const environment = {
production: false, production: false,
supa_url: 'https://wdklwjgycvhxpbbnrpto.supabase.co', supa_url: 'https://wdklwjgycvhxpbbnrpto.supabase.co',
supa_key: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYwMzQ2OTMyOCwiZXhwIjoxOTE5MDQ1MzI4fQ.KunzEloX8UoOAK8fGbiZtAi9bMxB_Ap8GVcJFWyPx0Q' supa_key: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYwMzQ2OTMyOCwiZXhwIjoxOTE5MDQ1MzI4fQ.KunzEloX8UoOAK8fGbiZtAi9bMxB_Ap8GVcJFWyPx0Q',
version: '0.0.1'
}; };