Merge pull request #4344 from FinnStutzenstein/countdowns

Countdown slide and controls
This commit is contained in:
Finn Stutzenstein 2019-02-15 12:37:23 +01:00 committed by GitHub
commit 9e794c4669
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 600 additions and 141 deletions

View File

@ -52,9 +52,9 @@ export class ProjectorService {
obj: Projectable | ProjectorElementBuildDeskriptor | IdentifiableProjectorElement
): IdentifiableProjectorElement {
if (isProjectable(obj)) {
return obj.getSlide().getBasicProjectorElement();
return obj.getSlide().getBasicProjectorElement({});
} else if (isProjectorElementBuildDeskriptor(obj)) {
return obj.getBasicProjectorElement();
return obj.getBasicProjectorElement({});
} else {
return obj;
}
@ -133,7 +133,9 @@ export class ProjectorService {
const element = this.getProjectorElement(obj);
if (element.stable) {
// Just add this stable element
// remove the same element, if it is currently projected
projector.removeElements(element);
// Add this stable element
projector.addElement(element);
await this.projectRequest(projector, projector.elements);
} else {

View File

@ -8,6 +8,7 @@ import { ViewCountdown } from 'app/site/projector/models/view-countdown';
import { Countdown } from 'app/shared/models/core/countdown';
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
import { TranslateService } from '@ngx-translate/core';
import { ServertimeService } from 'app/core/core-services/servertime.service';
@Injectable({
providedIn: 'root'
@ -18,7 +19,8 @@ export class CountdownRepositoryService extends BaseRepository<ViewCountdown, Co
mapperService: CollectionStringMapperService,
viewModelStoreService: ViewModelStoreService,
private dataSend: DataSendService,
private translate: TranslateService
private translate: TranslateService,
private servertimeService: ServertimeService
) {
super(DS, mapperService, viewModelStoreService, Countdown);
}
@ -41,7 +43,21 @@ export class CountdownRepositoryService extends BaseRepository<ViewCountdown, Co
await this.dataSend.updateModel(update);
}
public async delete(viewCountdown: ViewCountdown): Promise<void> {
await this.dataSend.deleteModel(viewCountdown.countdown);
public async delete(countdown: ViewCountdown): Promise<void> {
await this.dataSend.deleteModel(countdown.countdown);
}
public async start(countdown: ViewCountdown): Promise<void> {
const endTime = this.servertimeService.getServertime() / 1000 + countdown.countdown_time;
await this.update({ running: true, countdown_time: endTime }, countdown);
}
public async stop(countdown: ViewCountdown): Promise<void> {
await this.update({ running: false, countdown_time: countdown.default_time }, countdown);
}
public async pause(countdown: ViewCountdown): Promise<void> {
const endTime = countdown.countdown_time - this.servertimeService.getServertime() / 1000;
await this.update({ running: false, countdown_time: endTime }, countdown);
}
}

View File

@ -12,6 +12,9 @@ import { Injectable } from '@angular/core';
* // will also result in 70
* const b = this.durationService.stringToDuration('01:10');
*
* // will also result in 89 (interpret as seconds)
* const b = this.durationService.stringToDuration('01:20 m', 'm');
*
* // will result in 0
* const c = this.durationService.stringToDuration('01:10b');
* ```
@ -20,6 +23,9 @@ import { Injectable } from '@angular/core';
* ```ts
* // will result in 01:10 h
* const a = this.durationService.durationToString(70);
*
* // will result in 00:30 m (30 is interpreted as seconds)
* const a = this.durationService.durationToString(30);
* ```
*/
@Injectable({
@ -32,13 +38,16 @@ export class DurationService {
public constructor() {}
/**
* Transform a duration string to duration in minutes.
* Transform a duration string to duration in minutes or seconds. This depends on the
* provided suffix for the input.
*
* @param durationText the text to be transformed into a duration
* @returns time in minutes or 0 if values are below 0 or no parsable numbers
* @param suffix may be 'h' or 'm' for hour or minute. This character will be removed
* from the duration text.
* @returns time in minutes or seconds or 0 if values are below 0 or no parsable numbers
*/
public stringToDuration(durationText: string): number {
const splitDuration = durationText.replace('h', '').split(':');
public stringToDuration(durationText: string, suffix: 'h' | 'm' = 'h'): number {
const splitDuration = durationText.replace(suffix, '').split(':');
let time: number;
if (splitDuration.length > 1 && !isNaN(+splitDuration[0]) && !isNaN(+splitDuration[1])) {
time = +splitDuration[0] * 60 + +splitDuration[1];
@ -54,31 +63,18 @@ export class DurationService {
}
/**
* Converts a duration number (given in minutes)
* To a string in HH:MM format
* Converts a duration number (given in minutes or seconds)
*
* @param duration value in minutes
* @returns a more human readable time representation
*/
public durationToString(duration: number): string {
const hours = Math.floor(duration / 60);
const minutes = `0${Math.floor(duration - hours * 60)}`.slice(-2);
if (!isNaN(+hours) && !isNaN(+minutes)) {
return `${hours}:${minutes} h`;
public durationToString(duration: number, suffix: 'h' | 'm' = 'h'): string {
const major = Math.floor(duration / 60);
const minor = `0${duration % 60}`.slice(-2);
if (!isNaN(+major) && !isNaN(+minor)) {
return `${major}:${minor} ${suffix}`;
} else {
return '';
}
}
/**
* Converts a duration number (given in seconds)o a string in `MMM:SS` format
*
* @param time value in seconds
* @returns a more human readable time representation
*/
public secondDurationToString(time: number): string {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${`0${seconds}`.slice(-2)}`;
}
}

View File

@ -0,0 +1,5 @@
<div id="countdown" [ngClass]="{
'negative': seconds <= 0,
'warning_time': seconds <= warningTime && seconds > 0 }">
{{ time }}
</div>

View File

@ -0,0 +1,15 @@
#countdown {
font-weight: bold;
padding: 10px;
display: inline-block;
min-width: 75px;
text-align: right;
&.warning_time {
color: #ed940d;
}
&.negative {
color: #cc0000;
}
}

View File

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CountdownTimeComponent } from './countdown-time.component';
import { E2EImportsModule } from '../../../../e2e-imports.module';
describe('CountdownTimeComponent', () => {
let component: CountdownTimeComponent;
let fixture: ComponentFixture<CountdownTimeComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CountdownTimeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,99 @@
import { Component, OnDestroy, Input } from '@angular/core';
import { ServertimeService } from 'app/core/core-services/servertime.service';
export interface CountdownData {
running: boolean;
countdown_time: number;
}
/**
* Displays the countdown time.
*/
@Component({
selector: 'os-countdown-time',
templateUrl: './countdown-time.component.html',
styleUrls: ['./countdown-time.component.scss']
})
export class CountdownTimeComponent implements OnDestroy {
/**
* The time in seconds to make the countdown orange, is the countdown is below this value.
*/
@Input()
public warningTime: number;
/**
* The amount of seconds to display
*/
public seconds: number;
/**
* String formattet seconds.
*/
public time: string;
/**
* The updateinterval.
*/
private countdownInterval: any;
private _countdown: CountdownData;
/**
* The needed data for the countdown.
*/
@Input()
public set countdown(data: CountdownData) {
this._countdown = data;
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
}
if (data) {
this.updateCountdownTime();
this.countdownInterval = setInterval(() => this.updateCountdownTime(), 500);
}
}
public get countdown(): CountdownData {
return this._countdown;
}
public constructor(private servertimeService: ServertimeService) {}
/**
* Updates the countdown time and string format it.
*/
private updateCountdownTime(): void {
if (this.countdown.running) {
this.seconds = Math.floor(this.countdown.countdown_time - this.servertimeService.getServertime() / 1000);
} else {
this.seconds = this.countdown.countdown_time;
}
const negative = this.seconds < 0;
let seconds = this.seconds;
if (negative) {
seconds = -seconds;
}
const time = new Date(seconds * 1000);
const m = '0' + time.getMinutes();
const s = '0' + time.getSeconds();
this.time = m.slice(-2) + ':' + s.slice(-2);
if (negative) {
this.time = '-' + this.time;
}
}
/**
* Clear all pending intervals.
*/
public ngOnDestroy(): void {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
}
}
}

View File

@ -17,15 +17,15 @@
<div *ngFor="let option of options">
<div *ngIf="isDecisionOption(option)">
<mat-checkbox
[checked]="projectorElement[option.key]"
(change)="projectorElement[option.key] = !projectorElement[option.key]"
[checked]="optionValues[option.key]"
(change)="optionValues[option.key] = !optionValues[option.key]"
>
{{ option.displayName | translate }}
</mat-checkbox>
</div>
<div *ngIf="isChoiceOption(option)">
<h3>{{ option.displayName | translate }}</h3>
<mat-radio-group [name]="option.key" [(ngModel)]="projectorElement[option.key]">
<mat-radio-group [name]="option.key" [(ngModel)]="optionValues[option.key]">
<mat-radio-button *ngFor="let choice of option.choices" [value]="choice.value">
{{ choice.displayName | translate }}
</mat-radio-button>

View File

@ -26,7 +26,7 @@ export type ProjectionDialogReturnType = [Projector[], IdentifiableProjectorElem
export class ProjectionDialogComponent {
public projectors: Projector[];
private selectedProjectors: Projector[] = [];
public projectorElement: IdentifiableProjectorElement;
public optionValues: object = {};
public options: SlideOptions;
public constructor(
@ -52,11 +52,9 @@ export class ProjectionDialogComponent {
}
}
this.projectorElement = this.projectorElementBuildDescriptor.getBasicProjectorElement();
// Set option defaults
this.projectorElementBuildDescriptor.slideOptions.forEach(option => {
this.projectorElement[option.key] = option.default;
this.optionValues[option.key] = option.default;
});
this.options = this.projectorElementBuildDescriptor.slideOptions;
@ -88,7 +86,9 @@ export class ProjectionDialogComponent {
}
public onOk(): void {
this.dialogRef.close([this.selectedProjectors, this.projectorElement]);
let element = this.projectorElementBuildDescriptor.getBasicProjectorElement(this.optionValues);
element = { ...element, ...this.optionValues };
this.dialogRef.close([this.selectedProjectors, element]);
}
public onCancel(): void {

View File

@ -4,8 +4,9 @@
&.content {
width: calc(100% - 100px);
margin-left: 50px;
margin-right: 50px;
position: absolute;
left: 50px;
top: 0;
}
}

View File

@ -5,10 +5,13 @@ import { TranslateService } from '@ngx-translate/core';
import { BaseComponent } from 'app/base.component';
import { SlideManager } from 'app/slides/services/slide-manager.service';
import { BaseSlideComponent } from 'app/slides/base-slide-component';
import { SlideOptions } from 'app/slides/slide-manifest';
import { ConfigService } from 'app/core/ui-services/config.service';
import { SlideData } from 'app/site/projector/services/projector-data.service';
import { ProjectorElement } from 'app/shared/models/core/projector';
function hasError(obj: object): obj is { error: string } {
return (<{ error: string }>obj).error !== undefined;
}
/**
* Container for one slide. Cares about the position (scale, scroll) in the projector,
* and loading of slides.
@ -31,24 +34,31 @@ export class SlideContainerComponent extends BaseComponent {
private _slideData: SlideData<object>;
@Input()
public set slideData(data: SlideData<object>) {
public set slideData(slideData: SlideData<object>) {
// If there is no ata or an error, clear and exit.
if (!data || data.error) {
if (!slideData || hasError(slideData) || (slideData.data && hasError(slideData.data))) {
// clear slide container:
if (this.slide) {
this.slide.clear();
}
if (data.error) {
console.error(data.error);
let error;
if (hasError(slideData)) {
error = slideData.error;
} else if (slideData.data && hasError(slideData.data)) {
error = slideData.data.error;
}
if (error) {
console.log(error);
}
return;
}
this._slideData = data;
if (this.previousSlideName !== data.element.name) {
this.slideChanged(data.element.name);
this.previousSlideName = data.element.name;
this._slideData = slideData;
if (this.previousSlideName !== slideData.element.name) {
this.slideChanged(slideData.element);
this.previousSlideName = slideData.element.name;
}
this.setDataForComponent();
}
@ -88,7 +98,7 @@ export class SlideContainerComponent extends BaseComponent {
/**
* The current slideoptions.
*/
public slideOptions: SlideOptions = { scaleable: false, scrollable: false };
public slideOptions: { scaleable: boolean; scrollable: boolean } = { scaleable: false, scrollable: false };
/**
* Styles for scaling and scrolling.
@ -137,9 +147,19 @@ export class SlideContainerComponent extends BaseComponent {
*
* @param slideName The slide to load.
*/
private slideChanged(slideName: string): void {
this.slideOptions = this.slideManager.getSlideOptions(slideName);
this.slideManager.getSlideFactory(slideName).then(slideFactory => {
private slideChanged(element: ProjectorElement): void {
const options = this.slideManager.getSlideConfiguration(element.name);
if (typeof options.scaleable === 'boolean') {
this.slideOptions.scaleable = options.scaleable;
} else {
this.slideOptions.scaleable = options.scaleable(element);
}
if (typeof options.scrollable === 'boolean') {
this.slideOptions.scrollable = options.scrollable;
} else {
this.slideOptions.scrollable = options.scrollable(element);
}
this.slideManager.getSlideFactory(element.name).then(slideFactory => {
this.slide.clear();
this.slideRef = this.slide.createComponent(slideFactory);
this.setDataForComponent();

View File

@ -1,11 +1,18 @@
import { BaseModel } from '../base/base-model';
export interface ProjectorElementOptions {
/**
* Additional data.
*/
[key: string]: any;
}
/**
* A projectorelement must have a name and optional attributes.
* error is listed here, because this might be set by the server, if
* something is wrong and I want you to be sensible about this.
*/
export interface ProjectorElement {
export interface ProjectorElement extends ProjectorElementOptions {
/**
* The name of the element.
*/
@ -16,11 +23,6 @@ export interface ProjectorElement {
* DO NOT read additional data (name is save).
*/
error?: string;
/**
* Additional data.
*/
[key: string]: any;
}
export interface IdentifiableProjectorElement extends ProjectorElement {

View File

@ -82,6 +82,7 @@ import { MetaTextBlockComponent } from './components/meta-text-block/meta-text-b
import { OpenSlidesTranslateModule } from '../core/translate/openslides-translate-module';
import { ProjectorComponent } from './components/projector/projector.component';
import { SlideContainerComponent } from './components/slide-container/slide-container.component';
import { CountdownTimeComponent } from './components/contdown-time/countdown-time.component';
/**
* Share Module for all "dumb" components and pipes.
@ -201,7 +202,8 @@ import { SlideContainerComponent } from './components/slide-container/slide-cont
ProjectorComponent,
SlideContainerComponent,
OwlDateTimeModule,
OwlNativeDateTimeModule
OwlNativeDateTimeModule,
CountdownTimeComponent
],
declarations: [
PermsDirective,
@ -227,7 +229,8 @@ import { SlideContainerComponent } from './components/slide-container/slide-cont
ResizedDirective,
MetaTextBlockComponent,
ProjectorComponent,
SlideContainerComponent
SlideContainerComponent,
CountdownTimeComponent
],
providers: [
{ provide: DateAdapter, useClass: OpenSlidesDateAdapter },

View File

@ -342,6 +342,6 @@ export class SpeakerListComponent extends BaseViewComponent implements OnInit {
const duration = Math.floor(
(new Date(speaker.end_time).valueOf() - new Date(speaker.begin_time).valueOf()) / 1000
);
return `${this.durationService.secondDurationToString(duration)} ${this.translate.instant('minutes')}`;
return `${this.durationService.durationToString(duration, 'm')} ${this.translate.instant('minutes')}`;
}
}

View File

@ -97,7 +97,7 @@ export class ViewTopic extends BaseAgendaViewModel {
public getSlide(): ProjectorElementBuildDeskriptor {
return {
getBasicProjectorElement: () => ({
getBasicProjectorElement: options => ({
name: Topic.COLLECTIONSTRING,
id: this.id,
getIdentifiers: () => ['name', 'id']

View File

@ -81,7 +81,7 @@ export class ViewAssignment extends BaseAgendaViewModel {
public getSlide(): ProjectorElementBuildDeskriptor {
return {
getBasicProjectorElement: () => ({
getBasicProjectorElement: options => ({
name: Assignment.COLLECTIONSTRING,
id: this.id,
getIdentifiers: () => ['name', 'id']

View File

@ -1,5 +1,5 @@
import { Displayable } from 'app/site/base/displayable';
import { IdentifiableProjectorElement } from 'app/shared/models/core/projector';
import { IdentifiableProjectorElement, ProjectorElementOptions } from 'app/shared/models/core/projector';
import { SlideOptions } from './slide-options';
export function isProjectorElementBuildDeskriptor(obj: any): obj is ProjectorElementBuildDeskriptor {
@ -15,7 +15,7 @@ export function isProjectorElementBuildDeskriptor(obj: any): obj is ProjectorEle
export interface ProjectorElementBuildDeskriptor {
slideOptions: SlideOptions;
projectionDefaultName?: string;
getBasicProjectorElement(): IdentifiableProjectorElement;
getBasicProjectorElement(options: ProjectorElementOptions): IdentifiableProjectorElement;
/**
* The title to show in the projection dialog

View File

@ -1,10 +1,14 @@
export interface SlideDecisionOption {
interface BaseSlideOption {
key: string;
displayName: string;
default: string;
}
export interface SlideChoiceOption extends SlideDecisionOption {
export interface SlideDecisionOption extends BaseSlideOption {
default: boolean;
}
export interface SlideChoiceOption extends BaseSlideOption {
default: string;
choices: { value: string; displayName: string }[];
}

View File

@ -561,7 +561,7 @@ export class ViewMotion extends BaseAgendaViewModel implements Searchable {
public getSlide(): ProjectorElementBuildDeskriptor {
return {
getBasicProjectorElement: () => ({
getBasicProjectorElement: options => ({
name: Motion.COLLECTIONSTRING,
id: this.id,
getIdentifiers: () => ['name', 'id']

View File

@ -0,0 +1,12 @@
<div *ngIf="countdown">
<os-countdown-time [countdown]="countdown" [warningTime]="warningTime"></os-countdown-time>
<button type="button" [disabled]="!canStop()" mat-icon-button (click)="stop($event)">
<mat-icon>stop</mat-icon>
</button>
<button *ngIf="!countdown.running" type="button" mat-icon-button (click)="start($event)">
<mat-icon>play_arrow</mat-icon>
</button>
<button *ngIf="countdown.running" type="button" mat-icon-button (click)="pause($event)">
<mat-icon>pause</mat-icon>
</button>
</div>

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CountdownControlsComponent } from './countdown-controls.component';
import { E2EImportsModule } from 'e2e-imports.module';
describe('CountdownControlsComponent', () => {
let component: CountdownControlsComponent;
let fixture: ComponentFixture<CountdownControlsComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [CountdownControlsComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CountdownControlsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,67 @@
import { Component, Input } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
import { BaseViewComponent } from '../../../base/base-view';
import { ViewCountdown } from '../../models/view-countdown';
import { CountdownRepositoryService } from 'app/core/repositories/projector/countdown-repository.service';
import { ConfigService } from 'app/core/ui-services/config.service';
@Component({
selector: 'os-countdown-controls',
templateUrl: './countdown-controls.component.html'
})
export class CountdownControlsComponent extends BaseViewComponent {
@Input()
public countdown: ViewCountdown;
/**
* The time in seconds to make the countdown orange, is the countdown is below this value.
*/
public warningTime: number;
public constructor(
titleService: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
private repo: CountdownRepositoryService,
private configService: ConfigService
) {
super(titleService, translate, matSnackBar);
this.configService.get<number>('agenda_countdown_warning_time').subscribe(time => (this.warningTime = time));
}
/**
* Start the countdown
*/
public start(event: Event): void {
event.stopPropagation();
this.repo.start(this.countdown).catch(this.raiseError);
}
/**
* Pause the countdown
*/
public pause(event: Event): void {
event.stopPropagation();
this.repo.pause(this.countdown).catch(this.raiseError);
}
/**
* Stop the countdown
*/
public stop(event: Event): void {
event.stopPropagation();
this.repo.stop(this.countdown).catch(this.raiseError);
}
/**
* One can stop the countdown, if it is running or not resetted.
*/
public canStop(): boolean {
return this.countdown.running || this.countdown.countdown_time !== this.countdown.default_time;
}
}

View File

@ -18,6 +18,13 @@
<span translate>Required</span>
</mat-hint>
</mat-form-field>
</p><p>
<mat-form-field>
<input formControlName="default_time" matInput placeholder="{{ 'Time' | translate}}" required>
<mat-hint *ngIf="!createForm.controls.default_time.valid">
<span translate>Required</span>
</mat-hint>
</mat-form-field>
</p>
</form>
</mat-card-content>
@ -45,8 +52,10 @@
<div class="header-name">
{{ countdown.description }}
</div>
<div class="header-controls">
<os-countdown-controls [countdown]="countdown"></os-countdown-controls>
</div>
</div>
</mat-panel-title>
</mat-expansion-panel-header>
<form [formGroup]="updateForm"
@ -60,11 +69,15 @@
<span translate>Required</span>
</mat-hint>
</mat-form-field>
</p><p>
<mat-form-field>
<input formControlName="default_time" matInput placeholder="{{ 'Time' | translate}}" required>
<mat-hint *ngIf="!updateForm.controls.default_time.valid">
<span translate>Required</span>
</mat-hint>
</mat-form-field>
</p>
</form>
<ng-container *ngIf="editId !== countdown.id">
TODO: Show countdown time etc.
</ng-container>
<mat-action-row>
<button *ngIf="editId !== countdown.id" mat-button class="on-transition-fade" (click)="onEditButton(countdown)"
mat-icon-button>

View File

@ -14,7 +14,7 @@ mat-card {
.header-container {
display: grid;
grid-template-rows: auto;
grid-template-columns: 40px 1fr;
grid-template-columns: 40px 1fr 2fr;
width: 100%;
> div {
@ -25,10 +25,17 @@ mat-card {
.header-projector-button {
grid-column-start: 1;
grid-column-end: 2;
}
.header-name {
grid-column-start: 2;
grid-column-end: 3;
padding: 10px;
}
.header-controls {
grid-column-start: 3;
grid-column-end: 4;
}
}

View File

@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CountdownListComponent } from './countdown-list.component';
import { E2EImportsModule } from 'e2e-imports.module';
import { CountdownControlsComponent } from '../countdown-controls/countdown-controls.component';
describe('CountdownListComponent', () => {
let component: CountdownListComponent;
@ -10,7 +11,7 @@ describe('CountdownListComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [CountdownListComponent]
declarations: [CountdownListComponent, CountdownControlsComponent]
}).compileComponents();
}));

View File

@ -10,9 +10,10 @@ import { BaseViewComponent } from '../../../base/base-view';
import { ViewCountdown } from '../../models/view-countdown';
import { CountdownRepositoryService } from 'app/core/repositories/projector/countdown-repository.service';
import { Countdown } from 'app/shared/models/core/countdown';
import { DurationService } from 'app/core/ui-services/duration.service';
/**
* List view for the statute paragraphs.
* List view for countdowns.
*/
@Component({
selector: 'os-countdown-list',
@ -37,20 +38,20 @@ export class CountdownListComponent extends BaseViewComponent implements OnInit
public openId: number | null;
public editId: number | null;
/**
*/
public constructor(
titleService: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
private repo: CountdownRepositoryService,
private formBuilder: FormBuilder,
private promptService: PromptService
private promptService: PromptService,
private durationService: DurationService
) {
super(titleService, translate, matSnackBar);
const form = {
description: ['', Validators.required]
description: ['', Validators.required],
default_time: ['', Validators.required]
};
this.createForm = this.formBuilder.group(form);
this.updateForm = this.formBuilder.group(form);
@ -75,7 +76,8 @@ export class CountdownListComponent extends BaseViewComponent implements OnInit
if (!this.countdownToCreate) {
this.createForm.reset();
this.createForm.setValue({
description: ''
description: '',
default_time: '1:00 m'
});
this.countdownToCreate = new Countdown();
}
@ -86,7 +88,17 @@ export class CountdownListComponent extends BaseViewComponent implements OnInit
*/
public create(): void {
if (this.createForm.valid) {
this.countdownToCreate.patchValues(this.createForm.value as Countdown);
let default_time = this.durationService.stringToDuration(this.createForm.value.default_time, 'm');
if (default_time === 0) {
default_time = 60;
}
const newValues: Partial<Countdown> = {
description: this.createForm.value.description,
default_time: default_time
};
newValues.countdown_time = default_time;
this.countdownToCreate.patchValues(newValues);
this.repo.create(this.countdownToCreate).then(() => {
this.countdownToCreate = null;
}, this.raiseError);
@ -101,7 +113,8 @@ export class CountdownListComponent extends BaseViewComponent implements OnInit
this.editId = countdown.id;
this.updateForm.setValue({
description: countdown.description
description: countdown.description,
default_time: this.durationService.durationToString(countdown.default_time, 'm')
});
}
@ -111,7 +124,18 @@ export class CountdownListComponent extends BaseViewComponent implements OnInit
*/
public onSaveButton(countdown: ViewCountdown): void {
if (this.updateForm.valid) {
this.repo.update(this.updateForm.value as Partial<Countdown>, countdown).then(() => {
let default_time = this.durationService.stringToDuration(this.updateForm.value.default_time, 'm');
if (default_time === 0) {
default_time = 60;
}
const newValues: Partial<Countdown> = {
description: this.updateForm.value.description,
default_time: default_time
};
if (!countdown.running) {
newValues.countdown_time = default_time;
}
this.repo.update(newValues, countdown).then(() => {
this.openId = this.editId = null;
}, this.raiseError);
}
@ -123,7 +147,7 @@ export class CountdownListComponent extends BaseViewComponent implements OnInit
* @param countdown The countdown to delete
*/
public async onDeleteButton(countdown: ViewCountdown): Promise<void> {
const content = this.translate.instant('Delete') + ` ${countdown.description}?`;
const content = this.translate.instant('Delete countdown') + ` ${countdown.description}?`;
if (await this.promptService.open('Are you sure?', content)) {
this.repo.delete(countdown).then(() => (this.openId = this.editId = null), this.raiseError);
}

View File

@ -3,7 +3,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { ProjectorMessageListComponent } from './projector-message-list.component';
describe('CountdownListComponent', () => {
describe('ProjectorMessageListComponent', () => {
let component: ProjectorMessageListComponent;
let fixture: ComponentFixture<ProjectorMessageListComponent>;

View File

@ -16,6 +16,18 @@ export class ViewCountdown extends BaseProjectableViewModel {
return this.countdown.id;
}
public get running(): boolean {
return this.countdown.running;
}
public get default_time(): number {
return this.countdown.default_time;
}
public get countdown_time(): number {
return this.countdown.countdown_time;
}
public get description(): string {
return this.countdown.description;
}
@ -38,13 +50,19 @@ export class ViewCountdown extends BaseProjectableViewModel {
public getSlide(): ProjectorElementBuildDeskriptor {
return {
getBasicProjectorElement: () => ({
getBasicProjectorElement: options => ({
stable: true,
name: Countdown.COLLECTIONSTRING,
id: this.id,
getIdentifiers: () => ['name', 'id']
}),
slideOptions: [],
slideOptions: [
{
key: 'fullscreen',
displayName: 'Fullscreen',
default: false
}
],
projectionDefaultName: 'countdowns',
getTitle: () => this.getTitle()
};

View File

@ -39,7 +39,7 @@ export class ViewProjectorMessage extends BaseProjectableViewModel {
public getSlide(): ProjectorElementBuildDeskriptor {
return {
getBasicProjectorElement: () => ({
getBasicProjectorElement: options => ({
stable: true,
name: ProjectorMessage.COLLECTIONSTRING,
id: this.id,

View File

@ -10,6 +10,7 @@ import { ProjectorDataService } from './services/projector-data.service';
import { CurrentListOfSpeakersSlideService } from './services/current-list-of-of-speakers-slide.service';
import { CountdownListComponent } from './components/countdown-list/countdown-list.component';
import { ProjectorMessageListComponent } from './components/projector-message-list/projector-message-list.component';
import { CountdownControlsComponent } from './components/countdown-controls/countdown-controls.component';
@NgModule({
providers: [ClockSlideService, ProjectorDataService, CurrentListOfSpeakersSlideService],
@ -18,7 +19,8 @@ import { ProjectorMessageListComponent } from './components/projector-message-li
ProjectorListComponent,
ProjectorDetailComponent,
CountdownListComponent,
ProjectorMessageListComponent
ProjectorMessageListComponent,
CountdownControlsComponent
]
})
export class ProjectorModule {}

View File

@ -22,11 +22,9 @@ export class CurrentListOfSpeakersSlideService {
private slideManager: SlideManager
) {
this.projectorRepo.getGeneralViewModelObservable().subscribe(projector => {
if (projector) {
if (projector && this.currentItemIds[projector.id]) {
const item = this.getCurrentAgendaItemIdForProjector(projector);
if (this.currentItemIds[projector.id]) {
this.currentItemIds[projector.id].next(item);
}
this.currentItemIds[projector.id].next(item);
}
});
}

View File

@ -188,7 +188,7 @@ export class ViewUser extends BaseProjectableViewModel implements Searchable {
public getSlide(): ProjectorElementBuildDeskriptor {
return {
getBasicProjectorElement: () => ({
getBasicProjectorElement: options => ({
name: User.COLLECTIONSTRING,
id: this.id,
getIdentifiers: () => ['name', 'id']

View File

@ -0,0 +1,49 @@
import { SlideDynamicConfiguration, Slide } from './slide-manifest';
export const allSlidesDynamicConfiguration: (SlideDynamicConfiguration & Slide)[] = [
{
slide: 'topics/topic',
scaleable: true,
scrollable: true
},
{
slide: 'motions/motion',
scaleable: true,
scrollable: true
},
{
slide: 'users/user',
scaleable: true,
scrollable: true
},
{
slide: 'core/clock',
scaleable: false,
scrollable: false
},
{
slide: 'core/countdown',
scaleable: false,
scrollable: false
},
{
slide: 'core/projector-message',
scaleable: false,
scrollable: false
},
{
slide: 'agenda/current-list-of-speakers',
scaleable: true,
scrollable: true
},
{
slide: 'agenda/current-list-of-speakers-overlay',
scaleable: false,
scrollable: false
},
{
slide: 'assignments/assignment',
scaleable: true,
scrollable: true
}
];

View File

@ -12,8 +12,6 @@ export const allSlides: SlideManifest[] = [
slide: 'topics/topic',
path: 'topics/topic',
loadChildren: './slides/agenda/topic/topics-topic-slide.module#TopicsTopicSlideModule',
scaleable: true,
scrollable: true,
verboseName: 'Topic',
elementIdentifiers: ['name', 'id'],
canBeMappedToModel: true
@ -22,8 +20,6 @@ export const allSlides: SlideManifest[] = [
slide: 'motions/motion',
path: 'motions/motion',
loadChildren: './slides/motions/motion/motions-motion-slide.module#MotionsMotionSlideModule',
scaleable: true,
scrollable: true,
verboseName: 'Motion',
elementIdentifiers: ['name', 'id'],
canBeMappedToModel: true
@ -32,8 +28,6 @@ export const allSlides: SlideManifest[] = [
slide: 'users/user',
path: 'users/user',
loadChildren: './slides/users/user/users-user-slide.module#UsersUserSlideModule',
scaleable: true,
scrollable: true,
verboseName: 'Participant',
elementIdentifiers: ['name', 'id'],
canBeMappedToModel: true
@ -42,8 +36,6 @@ export const allSlides: SlideManifest[] = [
slide: 'core/clock',
path: 'core/clock',
loadChildren: './slides/core/clock/clock-slide.module#ClockSlideModule',
scaleable: false,
scrollable: false,
verboseName: 'Clock',
elementIdentifiers: ['name'],
canBeMappedToModel: false
@ -52,8 +44,6 @@ export const allSlides: SlideManifest[] = [
slide: 'core/countdown',
path: 'core/countdown',
loadChildren: './slides/core/countdown/countdown-slide.module#CountdownSlideModule',
scaleable: false,
scrollable: false,
verboseName: 'Countdown',
elementIdentifiers: ['name', 'id'],
canBeMappedToModel: true
@ -62,8 +52,6 @@ export const allSlides: SlideManifest[] = [
slide: 'core/projector-message',
path: 'core/projector-message',
loadChildren: './slides/core/projector-message/projector-message-slide.module#ProjectorMessageSlideModule',
scaleable: false,
scrollable: false,
verboseName: 'Message',
elementIdentifiers: ['name', 'id'],
canBeMappedToModel: true
@ -73,8 +61,6 @@ export const allSlides: SlideManifest[] = [
path: 'agenda/current-list-of-speakers',
loadChildren:
'./slides/agenda/current-list-of-speakers/agenda-current-list-of-speakers-slide.module#AgendaCurrentListOfSpeakersSlideModule',
scaleable: true,
scrollable: true,
verboseName: 'Current list of speakers',
elementIdentifiers: ['name', 'id'],
canBeMappedToModel: false
@ -84,8 +70,6 @@ export const allSlides: SlideManifest[] = [
path: 'agenda/current-list-of-speakers-overlay',
loadChildren:
'./slides/agenda/current-list-of-speakers-overlay/agenda-current-list-of-speakers-overlay-slide.module#AgendaCurrentListOfSpeakersOverlaySlideModule',
scaleable: false,
scrollable: false,
verboseName: 'Current list of speakers overlay',
elementIdentifiers: ['name', 'id'],
canBeMappedToModel: false
@ -94,8 +78,6 @@ export const allSlides: SlideManifest[] = [
slide: 'assignments/assignment',
path: 'assignments/assignment',
loadChildren: './slides/assignments/assignment/assignment-slide.module#AssignmentSlideModule',
scaleable: true,
scrollable: true,
verboseName: 'Election',
elementIdentifiers: ['name', 'id'],
canBeMappedToModel: true

View File

@ -15,6 +15,8 @@ export class ClockSlideComponent extends BaseSlideComponent<{}> implements OnIni
private servertimeSubscription: Subscription | null = null;
private clockInterval: any;
public constructor(private servertimeService: ServertimeService) {
super();
}
@ -26,7 +28,7 @@ export class ClockSlideComponent extends BaseSlideComponent<{}> implements OnIni
.subscribe(() => this.updateClock());
// Update clock every 10 seconds.
setInterval(() => this.updateClock(), 10 * 1000);
this.clockInterval = setInterval(() => this.updateClock(), 10 * 1000);
}
private updateClock(): void {
@ -42,5 +44,8 @@ export class ClockSlideComponent extends BaseSlideComponent<{}> implements OnIni
if (this.servertimeSubscription) {
this.servertimeSubscription.unsubscribe();
}
if (this.clockInterval) {
clearInterval(this.clockInterval);
}
}
}

View File

@ -1,3 +1,6 @@
export interface CountdownSlideData {
error: string;
description: string;
countdown_time: number;
running: boolean;
warning_time: number;
}

View File

@ -1,3 +1,11 @@
<div id="outer">
COUNTDOWN
<div class="countdown overlay" *ngIf="data && !data.element.fullscreen">
<os-countdown-time [countdown]="data.data" [warningTime]="data.data.warning_time"></os-countdown-time>
<div class="description">
{{ data.data.description }}
</div>
</div>
<div class="countdown fullscreen" *ngIf="data && data.element.fullscreen">
<div>
<os-countdown-time [countdown]="data.data" [warningTime]="data.data.warning_time"></os-countdown-time>
</div>
</div>

View File

@ -1,10 +1,42 @@
#outer {
position: absolute;
right: 0;
top: 0;
background-color: green;
height: 30px;
margin: 10px;
margin-top: 100px;
z-index: 2;
.countdown {
z-index: 8;
&.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
display: grid;
& > div {
justify-content: center;
align-content: center;
font-size: 10vw;
display: inline-grid;
}
}
&.overlay {
position: relative;
float: right;
margin: 100px 10px 10px 10px;
padding: 10px 40px 7px 10px;
min-height: 72px;
min-width: 180px;
font-size: 3.7em;
font-weight: bold;
text-align: right;
border-radius: 7px 7px 7px 7px;
background-color: #f5f5f5;
border: 1px solid #e3e3e3;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);
.description {
font-weight: normal;
font-size: 18px;
padding-right: 6px;
}
}
}

View File

@ -1,4 +1,5 @@
import { Component } from '@angular/core';
import { BaseSlideComponent } from 'app/slides/base-slide-component';
import { CountdownSlideData } from './countdown-slide-data';

View File

@ -1,10 +1,11 @@
import { Injectable, Inject, Injector, NgModuleFactoryLoader, ComponentFactory, Type } from '@angular/core';
import { SlideManifest, SlideOptions } from '../slide-manifest';
import { SlideManifest, SlideDynamicConfiguration, Slide } from '../slide-manifest';
import { SLIDE } from '../slide-token';
import { SLIDE_MANIFESTS } from '../slide-manifest';
import { BaseSlideComponent } from '../base-slide-component';
import { ProjectorElement, IdentifiableProjectorElement } from 'app/shared/models/core/projector';
import { allSlidesDynamicConfiguration } from '../all-slide-configurations';
/**
* Cares about loading slides dynamically.
@ -12,6 +13,7 @@ import { ProjectorElement, IdentifiableProjectorElement } from 'app/shared/model
@Injectable()
export class SlideManager {
private loadedSlides: { [name: string]: SlideManifest } = {};
private loadedSlideConfigurations: { [name: string]: SlideDynamicConfiguration & Slide } = {};
public constructor(
@Inject(SLIDE_MANIFESTS) private manifests: SlideManifest[],
@ -21,6 +23,9 @@ export class SlideManager {
this.manifests.forEach(slideManifest => {
this.loadedSlides[slideManifest.slide] = slideManifest;
});
allSlidesDynamicConfiguration.forEach(config => {
this.loadedSlideConfigurations[config.slide] = config;
});
}
/**
@ -42,12 +47,13 @@ export class SlideManager {
* @param slideName The slide
* @returns SlideOptions for the requested slide.
*/
public getSlideOptions(slideName: string): SlideOptions {
return this.getManifest(slideName);
public getSlideConfiguration(slideName: string): SlideDynamicConfiguration {
if (!this.loadedSlideConfigurations[slideName]) {
throw new Error(`Could not find slide for "${slideName}"`);
}
return this.loadedSlideConfigurations[slideName];
}
/**
*/
public getIdentifialbeProjectorElement(element: ProjectorElement): IdentifiableProjectorElement {
const identifiableElement: IdentifiableProjectorElement = element as IdentifiableProjectorElement;
const identifiers = this.getManifest(element.name).elementIdentifiers.map(x => x); // map to copy.

View File

@ -1,27 +1,32 @@
import { InjectionToken } from '@angular/core';
import { IdentifiableProjectorElement } from 'app/shared/models/core/projector';
import { IdentifiableProjectorElement, ProjectorElement } from 'app/shared/models/core/projector';
type BooleanOrFunction = boolean | ((element: ProjectorElement) => boolean);
export interface Slide {
slide: string;
}
/**
* Slides can have these options.
*/
export interface SlideOptions {
export interface SlideDynamicConfiguration {
/**
* Should this slide be scrollable?
*/
scrollable: boolean;
scrollable: BooleanOrFunction;
/**
* Should this slide be scaleable?
*/
scaleable: boolean;
scaleable: BooleanOrFunction;
}
/**
* Is similar to router entries, so we can trick the router. Keep slideName and
* path in sync.
*/
export interface SlideManifest extends SlideOptions {
slide: string;
export interface SlideManifest extends Slide {
path: string;
loadChildren: string;
verboseName: string;

View File

@ -1,6 +1,11 @@
from typing import Any, Dict
from ..utils.projector import AllData, register_projector_slide
from ..utils.projector import (
AllData,
ProjectorElementException,
get_config,
register_projector_slide,
)
# Important: All functions have to be prune. This means, that thay can only
@ -23,9 +28,16 @@ def countdown_slide(all_data: AllData, element: Dict[str, Any]) -> Dict[str, Any
countdown_id = element.get("id") or 1
try:
return all_data["core/countdown"][countdown_id]
countdown = all_data["core/countdown"][countdown_id]
except KeyError:
return {"error": f"Countdown {countdown_id} does not exist"}
raise ProjectorElementException(f"Countdown {countdown_id} does not exist")
return {
"description": countdown["description"],
"running": countdown["running"],
"countdown_time": countdown["countdown_time"],
"warning_time": get_config(all_data, "agenda_countdown_warning_time"),
}
def message_slide(all_data: AllData, element: Dict[str, Any]) -> Dict[str, Any]:
@ -44,7 +56,7 @@ def message_slide(all_data: AllData, element: Dict[str, Any]) -> Dict[str, Any]:
try:
return all_data["core/projector-message"][message_id]
except KeyError:
return {"error": f"Message {message_id} does not exist"}
raise ProjectorElementException(f"Message {message_id} does not exist")
def clock_slide(all_data: AllData, element: Dict[str, Any]) -> Dict[str, Any]: