Merge branch 'settings-section' into 'master'

Adding Font Scaling support to Stage and Chord View + Creating Settings Page

See merge request openlp/web-remote!54
This commit is contained in:
Raoul Snyman 2023-02-10 18:55:09 +00:00
commit 4eab9a080a
19 changed files with 449 additions and 47 deletions

View File

@ -48,6 +48,14 @@
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
}
},
@ -59,8 +67,12 @@
"configurations": {
"production": {
"browserTarget": "@openlp/web-remote:build:production"
},
"development": {
"browserTarget": "@openlp/web-remote:build:development"
}
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",

View File

@ -28,7 +28,7 @@
<a mat-list-item (click)="menu.close()" routerLink="/stage">Stage View</a>
<a mat-list-item (click)="menu.close()" routerLink="/chords">Chord View</a>
<mat-divider></mat-divider>
<mat-slide-toggle color="primary" [checked]="fastSwitching" (change)="sliderChanged($event)">Fast switching</mat-slide-toggle>
<a mat-list-item (click)="menu.close()" routerLink="/settings"><mat-icon>settings</mat-icon> Settings</a>
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content>

View File

@ -12,6 +12,7 @@ import { fromEvent } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { DisplayModeSelectorComponent } from './components/display-mode-selector/display-mode-selector.component';
import { HotKeysService } from './hotkeys.service';
import { SettingsService } from './settings.service';
// import { version } from '../../package.json';
@Component({
@ -23,16 +24,16 @@ export class AppComponent implements OnInit {
// Make DisplayMode enum visible to html code
DisplayMode = DisplayMode;
private _fastSwitching = false;
state = new State();
showLogin = false;
pageTitle = 'OpenLP Remote';
appVersion = '0.0';
webSocketOpen = false;
fastSwitching = false;
constructor(private pageTitleService: PageTitleService, private openlpService: OpenLPService,
private dialog: MatDialog, private bottomSheet: MatBottomSheet, private windowRef: WindowRef,
private hotKeysService: HotKeysService) {
private hotKeysService: HotKeysService, private settingsService: SettingsService) {
pageTitleService.pageTitleChanged$.subscribe(pageTitle => this.pageTitle = pageTitle);
openlpService.stateChanged$.subscribe(item => this.state = item);
openlpService.webSocketStateChanged$.subscribe(status => this.webSocketOpen = status === WebSocketStatus.Open);
@ -48,6 +49,8 @@ export class AppComponent implements OnInit {
ngOnInit(): void {
this.openlpService.retrieveSystemInformation().subscribe(res => this.showLogin = res.login_required);
this.addHotKeys();
this.fastSwitching = this.settingsService.get('fastSwitching');
this.settingsService.onPropertyChanged('fastSwitching').subscribe(value => this.fastSwitching = value);
}
addHotKeys(): void {
@ -87,18 +90,6 @@ export class AppComponent implements OnInit {
);
}
get fastSwitching(): boolean {
if (localStorage.getItem('OpenLP-fastSwitching')) {
this._fastSwitching = JSON.parse(localStorage.getItem('OpenLP-fastSwitching'));
}
return this._fastSwitching;
}
set fastSwitching(value: boolean) {
this._fastSwitching = value;
localStorage.setItem('OpenLP-fastSwitching', JSON.stringify(value));
}
openDisplayModeSelector(): void {
const selectorRef = this.bottomSheet.open(DisplayModeSelectorComponent, {data: this.state.displayMode});
selectorRef.afterDismissed().subscribe(result => {
@ -154,10 +145,6 @@ export class AppComponent implements OnInit {
this.openlpService.showDisplay().subscribe();
}
sliderChanged(event: MatSlideToggleChange) {
this.fastSwitching = event.checked;
}
forceWebSocketReconnection() {
this.openlpService.reconnectWebSocketIfNeeded();
}

View File

@ -21,6 +21,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatBottomSheetModule } from '@angular/material/bottom-sheet';
import { MatSliderModule } from '@angular/material/slider';
import { AppComponent } from './app.component';
import { PageTitleService } from './page-title.service';
@ -46,6 +47,8 @@ import { ServiceListComponent } from './components/service/service-list/service-
import { ChordViewItemComponent } from './components/chord-view/chord-view-item/chord-view-item.component';
import { StageViewItemComponent } from './components/stage-view/stage-view-item/stage-view-item.component';
import { DisplayModeSelectorComponent } from './components/display-mode-selector/display-mode-selector.component';
import { SettingsComponent } from './components/settings/settings.component';
import { StageChordPreviewComponent } from './components/settings/stage-chord-preview/stage-chord-preview.component';
@NgModule({
@ -69,7 +72,9 @@ import { DisplayModeSelectorComponent } from './components/display-mode-selector
SlideListComponent,
SlideItemComponent,
ThemesComponent,
DisplayModeSelectorComponent
DisplayModeSelectorComponent,
SettingsComponent,
StageChordPreviewComponent
],
imports: [
BrowserModule,
@ -93,7 +98,8 @@ import { DisplayModeSelectorComponent } from './components/display-mode-selector
MatTabsModule,
MatToolbarModule,
MatTooltipModule,
MatBottomSheetModule
MatBottomSheetModule,
MatSliderModule
],
providers: [
PageTitleService,

View File

@ -9,6 +9,7 @@ import { ChordViewComponent } from './components/chord-view/chord-view.component
import { MainViewComponent } from './components/main-view/main-view.component';
import { StageViewComponent } from './components/stage-view/stage-view.component';
import { ThemesComponent } from './components/themes/themes.component';
import { SettingsComponent } from './components/settings/settings.component';
const routes: Routes = [
{ path: '', redirectTo: '/service', pathMatch: 'full' },
@ -19,7 +20,8 @@ const routes: Routes = [
{ path: 'chords', component: ChordViewComponent },
{ path: 'main', component: MainViewComponent },
{ path: 'stage', component: StageViewComponent },
{ path: 'themes', component: ThemesComponent}
{ path: 'themes', component: ThemesComponent},
{ path: 'settings', component: SettingsComponent}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],

View File

@ -1,18 +1,19 @@
$mobile-breakpoint: 1024px;
@mixin slide-font-size($scale: 1, $desktop-scale: $scale) {
font-size: calc(#{4vw * $scale} + #{1.5vh * $scale});
@mixin slide-font-size($scale: 1, $desktop-scale: $scale, $stage-var-name: stage) {
$var-sentence: var(--openlp-#{$stage-var-name}-font-scale);
font-size: calc((#{4vw * $scale} * #{$var-sentence}) + (#{1.5vh * $scale} * #{$var-sentence}));
@media (orientation: landscape) {
font-size: #{6vmin * $scale};
font-size: calc(#{6vmin * $scale} * #{$var-sentence});
}
@media (orientation: landscape) and (max-aspect-ratio: 16/9) {
font-size: #{3vw * $scale};
font-size: calc(#{3vw * $scale} * #{$var-sentence});
}
@media screen and (min-width: $mobile-breakpoint) {
font-size: calc(#{3.1vw * $desktop-scale} + #{1.5vh * $desktop-scale});
font-size: calc((#{3.1vw * $desktop-scale} * #{$var-sentence}) + (#{1.5vh * $desktop-scale} * #{$var-sentence}));
//font-size: #{4vw * $scale};
//font-size: #{5.6vmin * $scale};
}

View File

@ -1,4 +1,8 @@
<div class="overlay">
<div
class="overlay"
[class.embedded]="embedded"
[style.--openlp-stage-font-scale]="fontScale"
>
<div class="overlay-content">
<div class="tags">
<span *ngFor="let tag of tags" [class.active]="tag.active">{{ tag.text }}</span>
@ -23,7 +27,7 @@
</div>
</div>
<div class="toolbar">
<a class="back-button" mat-mini-fab color="" routerLink="/" [matTooltip]="'Go back to controller'">
<a class="back-button" mat-mini-fab color="" routerLink="/" [matTooltip]="'Go back to controller'" *ngIf="!embedded">
<mat-icon>arrow_back</mat-icon>
</a>
<div class="transpose">

View File

@ -1,7 +1,5 @@
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { OpenLPService } from '../../openlp.service';
import { Slide } from '../../responses';
import { Observable } from 'rxjs';
import { StageViewComponent } from '../stage-view/stage-view.component';
@Component({
@ -16,6 +14,7 @@ export class ChordViewComponent extends StageViewComponent {
songTransposeMap = new Map();
// current songs transpose level
transposeLevel = 0;
stageProperty = 'chords';
currentSlide = 0;
useNewTransposeEndpoint = this.openlpService.assertApiVersionMinimum(2, 2);

View File

@ -1,28 +1,35 @@
@import "./overlay-common";
:root {
--openlp-stage-font-scale: 1;
--openlp-stage-image-scale: 1;
}
.overlay {
background: black;
width: 100%;
height: 100%;
position: fixed;
left: 0;
top: 0;
z-index: 1200;
overflow: hidden;
color: white;
display: flex;
justify-content: flex-start;
flex-direction: row;
&:not(.embedded) {
position: fixed;
left: 0;
top: 0;
z-index: 1200;
}
.active-slide-img {
/* properly size current slide thumbnail */
/* relative sizes might not work in real world */
/* If we get larger thumbnail sizes, we want to limit their size */
max-height: 75%;
min-height: 250px;
max-height: calc(75% * var(--openlp-stage-image-scale));
min-height: calc(250px * var(--openlp-stage-image-scale));
}
.active-slide-img-text {
font-size: 1.8rem;
@include slide-font-size(0.75);
}
.next-slides-img {
/* properly size thumbnail displayed in 2nd and subsequent slides */
@ -32,7 +39,7 @@
}
.next-slides-text {
font-size: 1.4rem;
@include slide-font-size(0.5);
}
&-content {

View File

@ -0,0 +1,59 @@
<div class="settings-panel">
<mat-card>
<mat-card-header>
User Interface
</mat-card-header>
<mat-card-content>
<div class="settings-item">
<mat-slide-toggle
color="primary"
[checked]="settings.fastSwitching"
(change)="setSetting('fastSwitching', $event.checked)"
>
Enable Fast Switching panel
</mat-slide-toggle>
</div>
</mat-card-content>
</mat-card>
<mat-card>
<mat-card-header>
Stage and Chords Appearance
</mat-card-header>
<mat-card-content>
<mat-tab-group>
<mat-tab label="Stage">
<ng-template matTabContent>
<ng-container>
<openlp-stage-chord-preview stageType="stage"></openlp-stage-chord-preview>
<ng-container *ngTemplateOutlet="stageSettings; context: {prefix: 'stage'}"></ng-container>
</ng-container>
</ng-template>
</mat-tab>
<mat-tab label="Chords">
<ng-template matTabContent>
<openlp-stage-chord-preview stageType="chords"></openlp-stage-chord-preview>
<ng-container *ngTemplateOutlet="stageSettings; context: {prefix: 'chords'}"></ng-container>
</ng-template>
</mat-tab>
</mat-tab-group>
</mat-card-content>
</mat-card>
</div>
<ng-template #stageSettings let-prefix="prefix">
<div class="stage-settings">
<div class="settings-item">
<label>Font Scale: {{settings[prefix + 'FontScale'] ?? 100}}%</label>
<mat-slider
min="25"
max="200"
step="6.25"
>
<input
matSliderThumb
[value]="settings[prefix + 'FontScale']"
(valueChange)="setSetting(prefix + 'FontScale', $event)"
>
</mat-slider>
</div>
</div>
</ng-template>

View File

@ -0,0 +1,26 @@
.settings-panel {
max-width: 600px;
margin: 0 auto;
@media screen and (min-height: 836px) {
max-width: 768px;
}
}
.settings-item {
font-size: 1rem;
&:first-child {
margin-top: 1em;
}
label {
width: 100%;
display: block;
margin-bottom: -1em;
}
mat-slider {
width: calc(100% - 3rem);
margin: 0 auto;
}
}

View File

@ -0,0 +1,40 @@
import { Component, OnDestroy, ViewChild } from '@angular/core';
import { Subscription } from 'rxjs';
import { OpenLPService } from '../../openlp.service';
import { PageTitleService } from '../../page-title.service';
import { SettingsProperties, SettingsPropertiesItem, SettingsService } from '../../settings.service';
@Component({
selector: 'openlp-settings',
templateUrl: `./settings.component.html`,
styleUrls: [`./settings.component.scss`]
})
export class SettingsComponent implements OnDestroy {
constructor(
protected pageTitleService: PageTitleService,
protected openlpService: OpenLPService,
protected settingsService: SettingsService,
) {
this.settingsSubscription$ = settingsService.settingChanged$.subscribe(this._settingChanged);
pageTitleService.changePageTitle('Settings');
}
protected settingsSubscription$: Subscription;
settings: Partial<SettingsProperties> = this.settingsService.getAll();
setSetting<SP extends keyof SettingsProperties, SV = SettingsProperties[SP]>(property: SP, value: SV) {
this.settingsService.set(property, value);
}
_settingChanged = <SP extends keyof SettingsProperties, SV = SettingsProperties[SP]>(
value: SettingsPropertiesItem<SP, SV>
) => {
this.settings = {...this.settings, [value.property]: value.value};
};
ngOnDestroy(): void {
this.settingsSubscription$.unsubscribe();
}
}

View File

@ -0,0 +1,14 @@
<div class="stage-preview-container" #stageViewContainer>
<app-stage-view
#stageView
*ngIf="stageType === 'stage'"
[embedded]="true"
[style.--openlp-stage-font-scale]="fontScale"
></app-stage-view>
<app-chord-view
#chordsView
*ngIf="stageType === 'chords'"
[embedded]="true"
[style.--openlp-stage-font-scale]="fontScale"
></app-chord-view>
</div>

View File

@ -0,0 +1,14 @@
.stage-preview-container {
position: relative;
> * {
pointer-events: none;
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
border: none;
transform-origin: 0 0;
}
}

View File

@ -0,0 +1,107 @@
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
Input,
OnChanges,
OnDestroy,
OnInit,
SimpleChanges,
ViewChild
} from '@angular/core';
import { fromEvent, Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { SettingsProperties, SettingsService } from 'src/app/settings.service';
@Component({
selector: 'openlp-stage-chord-preview',
templateUrl: './stage-chord-preview.component.html',
styleUrls: ['./stage-chord-preview.component.scss'],
})
export class StageChordPreviewComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges {
constructor(
protected settingsService: SettingsService,
protected ref: ChangeDetectorRef
) {
this.windowResizeSubscription$ = fromEvent(window, 'resize')
.pipe(debounceTime(300))
.subscribe(() => this._resizeElement());
}
@Input() stageType: 'stage' | 'chords' = 'stage';
@ViewChild('stageView', {read: ElementRef}) stageView: ElementRef<HTMLElement>;
@ViewChild('chordsView', {read: ElementRef}) chordsView: ElementRef<HTMLElement>;
@ViewChild('stageViewContainer') stageViewContainer: ElementRef<HTMLElement>;
fontScale: number;
protected windowResizeSubscription$: Subscription;
protected settingChangedSubscription$: Subscription;
ngOnInit(): void {
this.fontScale = this.settingsService.get(
this.stageType + 'FontScale' as keyof SettingsProperties
) as number / 100;
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['stageType'].currentValue !== changes['stageType'].previousValue) {
this.settingChangedSubscription$?.unsubscribe();
this.settingChangedSubscription$ = this.settingsService
.onPropertyChanged(changes['stageType'].currentValue + 'FontScale' as keyof SettingsProperties)
.subscribe(value => {
this.fontScale = value as number / 100;
this.ref.detectChanges();
});
}
}
ngAfterViewInit(): void {
if (this._getStageViewElement()?.nativeElement) {
this._resizeElement();
}
}
ngOnDestroy(): void {
this.windowResizeSubscription$?.unsubscribe();
this.settingChangedSubscription$?.unsubscribe();
}
_getStageViewElement() {
switch (this.stageType) {
case 'stage':
return this.stageView;
case 'chords':
return this.chordsView;
}
}
_resizeElement() {
const viewElement = this._getStageViewElement();
if (this.stageViewContainer?.nativeElement && viewElement?.nativeElement) {
// Resetting container to 100% width before calculating
this.stageViewContainer.nativeElement.style.width = '100%';
this.stageViewContainer.nativeElement.style.height = 'auto';
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const {width: containerWidth} = this.stageViewContainer?.nativeElement.getBoundingClientRect();
let zoomScale = containerWidth / windowWidth;
const targetContainerHeight = containerWidth * (windowHeight / windowWidth);
let scaleTranslate = '';
if (targetContainerHeight > (windowHeight * 0.6)) {
zoomScale *= 0.5;
scaleTranslate = ' translateX(50%)';
}
// Setting the container width + height to scale after
this.stageViewContainer.nativeElement.style.width = (windowWidth * zoomScale) + 'px';
this.stageViewContainer.nativeElement.style.height = (windowHeight * zoomScale) + 'px';
viewElement.nativeElement.style.width = windowWidth + 'px';
viewElement.nativeElement.style.height = windowHeight + 'px';
viewElement.nativeElement.style.transform = `scale(${zoomScale})${scaleTranslate}`;
}
}
}

View File

@ -1,4 +1,8 @@
<div class="overlay">
<div
class="overlay"
[class.embedded]="embedded"
[style.--openlp-stage-font-scale]="fontScale"
>
<div class="overlay-content">
<div class="tags">
<span *ngFor="let tag of tags" [class.active]="tag.active">{{ tag.text }}</span>
@ -11,7 +15,7 @@
</div>
</div>
<div class="toolbar">
<a class="back-button" mat-mini-fab color="" routerLink="/" [matTooltip]="'Go back to controller'">
<a class="back-button" mat-mini-fab color="" routerLink="/" [matTooltip]="'Go back to controller'" *ngIf="!embedded">
<mat-icon>arrow_back</mat-icon>
</a>
<button
@ -26,7 +30,7 @@
</button>
<div class="time">{{ (openlpService.getIsTwelveHourTime()) ? (time|date:'h:mm a') : (time|date:'HH:mm') }}</div>
</div>
<div class="sidebar" *ngIf="showNotes && notes">
<div class="sidebar" *ngIf="(showNotes || embedded) && notes">
<div class="notes" [innerHTML]="notes|nl2br"></div>
</div>
</div>

View File

@ -1,5 +1,7 @@
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Subscription } from 'rxjs';
import { SettingsProperties, SettingsService } from 'src/app/settings.service';
import { OpenLPService } from '../../openlp.service';
import { ServiceItem, Slide } from '../../responses';
@ -14,7 +16,8 @@ interface Tag {
styleUrls: ['./stage-view.component.scss', '../overlay.scss'],
encapsulation: ViewEncapsulation.None
})
export class StageViewComponent implements OnInit {
export class StageViewComponent implements OnInit, OnDestroy {
@Input() embedded = false;
serviceItem: ServiceItem = null;
notes = '';
currentSlides: Slide[] = [];
@ -22,15 +25,39 @@ export class StageViewComponent implements OnInit {
tags: Tag[] = [];
time = new Date();
showNotes = true;
serviceItemSubscription$: Subscription = null;
fontScale: number;
constructor(public openlpService: OpenLPService) {
serviceItemSubscription$: Subscription = null;
fontScaleSubscription$: Subscription;
stageProperty = 'stage';
constructor(
public openlpService: OpenLPService,
protected route: ActivatedRoute,
protected settingsService: SettingsService,
protected ref: ChangeDetectorRef
) {
setInterval(() => this.time = new Date(), 1000);
}
ngOnInit() {
this.updateCurrentSlides(null, null);
this.openlpService.stateChanged$.subscribe(item => this.updateCurrentSlides(item.item, item.slide));
this.fontScale = this.settingsService.get(
this.stageProperty + 'FontScale' as keyof SettingsProperties
) as number / 100;
this.fontScaleSubscription$ = this.settingsService
.onPropertyChanged(this.stageProperty + 'FontScale' as keyof SettingsProperties)
.subscribe(value => {
this.fontScale = value as number / 100;
this.ref.detectChanges();
});
}
ngOnDestroy(): void {
this.fontScaleSubscription$?.unsubscribe();
}
updateCurrentSlides(serviceItemId: string, currentSlide: number): void {

View File

@ -0,0 +1,89 @@
import { EventEmitter, Injectable } from '@angular/core';
// Set here the default value; if there's none, set as undefined and specify key type.
export class SettingsProperties {
fastSwitching = false;
stageFontScale = 100;
chordsFontScale = 100;
};
export interface SettingsPropertiesItem<SP extends keyof SettingsProperties, SV = SettingsProperties[SP]> {
property: SP;
value: SV;
}
const LOCAL_STORAGE_PREFIX = 'OpenLP-';
@Injectable({providedIn: 'root'})
export class SettingsService {
constructor() {
window.addEventListener('storage', this._handleStorageEvent);
}
defaultSettingsPropertiesInstance = new SettingsProperties();
settingChanged$: EventEmitter<SettingsPropertiesItem<any, any>> = new EventEmitter<any>();
listenersCache: {[key in keyof Partial<SettingsProperties>]: EventEmitter<any>} = {};
getAll(): Partial<SettingsProperties> {
const output: Partial<SettingsProperties> = {};
for (const key of Object.keys(this.defaultSettingsPropertiesInstance)) {
const value = this.get(key as keyof SettingsProperties);
if (value !== undefined) {
output[key] = value;
}
}
return output;
}
get<SP extends keyof SettingsProperties, SV = SettingsProperties[SP]>(property: SP): SV | undefined {
let propertyValue: any = localStorage.getItem(LOCAL_STORAGE_PREFIX + property);
if ((propertyValue === undefined || propertyValue === null)
&& this.defaultSettingsPropertiesInstance.hasOwnProperty(property)
) {
propertyValue = this.defaultSettingsPropertiesInstance[property];
this.set(property, propertyValue);
}
if (propertyValue) {
return JSON.parse(propertyValue);
}
return undefined;
}
set<SP extends keyof SettingsProperties, SV = SettingsProperties[SP]>(property: SP, value: SV) {
if (value === undefined) {
localStorage.removeItem(LOCAL_STORAGE_PREFIX + property);
this._emitEvent(property, undefined);
} else {
localStorage.setItem(LOCAL_STORAGE_PREFIX + property, JSON.stringify(value));
this._emitEvent(property, value);
}
}
remove<SP extends keyof SettingsProperties>(property: SP) {
this.set(property, undefined);
}
onPropertyChanged<SP extends keyof SettingsProperties, SV = SettingsProperties[SP]>(
property: SP
): EventEmitter<SV> {
if (!this.listenersCache[property]) {
this.listenersCache[property] = new EventEmitter<SV>();
}
return this.listenersCache[property];
}
protected _handleStorageEvent = (event: StorageEvent) => {
if (event.storageArea === localStorage) {
this._emitEvent(event.key.replace(LOCAL_STORAGE_PREFIX, '') as any, JSON.parse(event.newValue));
}
};
protected _emitEvent<SP extends keyof SettingsProperties, SV = SettingsProperties[SP]>(property: SP, value: SV) {
this.settingChanged$.emit({property, value});
this.listenersCache?.[property]?.emit(value);
}
}

View File

@ -107,3 +107,7 @@ footer {
.mat-mdc-tooltip {
font-size: 1rem;
}
footer {
z-index: 1;
}