mirror of
https://gitlab.com/openlp/web-remote.git
synced 2024-12-22 11:32:47 +00:00
Merge branch 'websocket-dropout-fix' into 'master'
Websocket reconnection routines See merge request openlp/web-remote!43
This commit is contained in:
commit
a83d129630
@ -27,7 +27,7 @@
|
|||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss",
|
"src/styles.scss",
|
||||||
"node_modules/material-design-icons/iconfont/material-icons.css",
|
"node_modules/material-icons/iconfont/filled.css",
|
||||||
"node_modules/@fontsource/roboto/400.css"
|
"node_modules/@fontsource/roboto/400.css"
|
||||||
],
|
],
|
||||||
"scripts": []
|
"scripts": []
|
||||||
|
@ -38,7 +38,7 @@
|
|||||||
"@fontsource/roboto": "^4.4.5",
|
"@fontsource/roboto": "^4.4.5",
|
||||||
"core-js": "^3.8.1",
|
"core-js": "^3.8.1",
|
||||||
"hammerjs": "^2.0.8",
|
"hammerjs": "^2.0.8",
|
||||||
"material-design-icons": "^3.0.1",
|
"material-icons": "^1.12.1",
|
||||||
"rxjs": "^6.6.3",
|
"rxjs": "^6.6.3",
|
||||||
"zone.js": "^0.10.3"
|
"zone.js": "^0.10.3"
|
||||||
},
|
},
|
||||||
|
@ -3,6 +3,15 @@
|
|||||||
<button mat-icon-button (click)="menu.toggle()"><mat-icon>menu</mat-icon></button>
|
<button mat-icon-button (click)="menu.toggle()"><mat-icon>menu</mat-icon></button>
|
||||||
<span class="page-title">{{pageTitle}}</span>
|
<span class="page-title">{{pageTitle}}</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="forceWebSocketReconnection()"
|
||||||
|
class="connection-status"
|
||||||
|
[matTooltip]="webSocketOpen ? 'Connected to OpenLP' : 'Disconnected'"
|
||||||
|
>
|
||||||
|
<mat-icon *ngIf="webSocketOpen">link</mat-icon>
|
||||||
|
<mat-icon *ngIf="!webSocketOpen">link_off</mat-icon>
|
||||||
|
</button>
|
||||||
<span class="app-version">v{{appVersion}}</span>
|
<span class="app-version">v{{appVersion}}</span>
|
||||||
</mat-toolbar-row>
|
</mat-toolbar-row>
|
||||||
</mat-toolbar>
|
</mat-toolbar>
|
||||||
|
@ -28,6 +28,10 @@ mat-slide-toggle {
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.fast-switcher {
|
.fast-switcher {
|
||||||
background-color: whitesmoke;
|
background-color: whitesmoke;
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, HostListener, OnInit } from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
||||||
|
|
||||||
import { State } from './responses';
|
import { State } from './responses';
|
||||||
import { OpenLPService } from './openlp.service';
|
import { OpenLPService, WebSocketStatus } from './openlp.service';
|
||||||
import { WindowRef } from './window-ref.service';
|
import { WindowRef } from './window-ref.service';
|
||||||
import { PageTitleService } from './page-title.service';
|
import { PageTitleService } from './page-title.service';
|
||||||
import { LoginComponent } from './components/login/login.component';
|
import { LoginComponent } from './components/login/login.component';
|
||||||
|
import { fromEvent } from 'rxjs';
|
||||||
|
import { debounceTime } from 'rxjs/operators';
|
||||||
// import { version } from '../../package.json';
|
// import { version } from '../../package.json';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -20,12 +22,20 @@ export class AppComponent implements OnInit {
|
|||||||
showLogin = false;
|
showLogin = false;
|
||||||
pageTitle = 'OpenLP Remote';
|
pageTitle = 'OpenLP Remote';
|
||||||
appVersion = '0.0';
|
appVersion = '0.0';
|
||||||
|
webSocketOpen = false;
|
||||||
|
|
||||||
constructor(private pageTitleService: PageTitleService, private openlpService: OpenLPService,
|
constructor(private pageTitleService: PageTitleService, private openlpService: OpenLPService,
|
||||||
private dialog: MatDialog, private windowRef: WindowRef) {
|
private dialog: MatDialog, private windowRef: WindowRef) {
|
||||||
pageTitleService.pageTitleChanged$.subscribe(pageTitle => this.pageTitle = pageTitle);
|
pageTitleService.pageTitleChanged$.subscribe(pageTitle => this.pageTitle = pageTitle);
|
||||||
openlpService.stateChanged$.subscribe(item => this.state = item);
|
openlpService.stateChanged$.subscribe(item => this.state = item);
|
||||||
|
openlpService.webSocketStateChanged$.subscribe(status => this.webSocketOpen = status === WebSocketStatus.Open);
|
||||||
this.appVersion = windowRef.nativeWindow.appVersion || '0.0';
|
this.appVersion = windowRef.nativeWindow.appVersion || '0.0';
|
||||||
|
this.webSocketOpen = openlpService.webSocketStatus === WebSocketStatus.Open;
|
||||||
|
// Try to force websocket reconnection as user is now focused on window and will try to interact soon
|
||||||
|
// Adding a debounce to avoid event flooding
|
||||||
|
fromEvent(window, 'focus')
|
||||||
|
.pipe(debounceTime(300))
|
||||||
|
.subscribe(() => this.forceWebSocketReconnection());
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@ -92,4 +102,8 @@ export class AppComponent implements OnInit {
|
|||||||
sliderChanged(event: MatSlideToggleChange) {
|
sliderChanged(event: MatSlideToggleChange) {
|
||||||
this.fastSwitching = event.checked;
|
this.fastSwitching = event.checked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
forceWebSocketReconnection() {
|
||||||
|
this.openlpService.reconnectWebSocketIfNeeded();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, EventEmitter } from '@angular/core';
|
import { Injectable, EventEmitter } from '@angular/core';
|
||||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable, Subscription } from 'rxjs';
|
||||||
|
import { finalize, shareReplay } from 'rxjs/operators';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PluginDescription,
|
PluginDescription,
|
||||||
@ -34,13 +35,24 @@ const httpOptions = {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WEBSOCKET_RECONNECT_TIMEOUT = 5 * 1000;
|
||||||
|
|
||||||
|
export enum WebSocketStatus {
|
||||||
|
Open, Closed
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OpenLPService {
|
export class OpenLPService {
|
||||||
private apiURL: string;
|
private apiURL: string;
|
||||||
|
private host: string;
|
||||||
public stateChanged$: EventEmitter<State>;
|
public stateChanged$: EventEmitter<State>;
|
||||||
|
public webSocketStateChanged$: EventEmitter<WebSocketStatus>;
|
||||||
private isTwelveHourTime = true;
|
private isTwelveHourTime = true;
|
||||||
|
|
||||||
|
private webSocketTimeoutHandle: any = 0;
|
||||||
|
private ws: WebSocket = null;
|
||||||
|
private retrieveSystemInformationSubscription: Subscription;
|
||||||
|
|
||||||
constructor(private http: HttpClient) {
|
constructor(private http: HttpClient) {
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
let port: string;
|
let port: string;
|
||||||
@ -51,20 +63,10 @@ export class OpenLPService {
|
|||||||
port = '4316';
|
port = '4316';
|
||||||
}
|
}
|
||||||
this.apiURL = `http://${host}:${port}/api/v2`;
|
this.apiURL = `http://${host}:${port}/api/v2`;
|
||||||
|
this.host = host;
|
||||||
this.stateChanged$ = new EventEmitter<State>();
|
this.stateChanged$ = new EventEmitter<State>();
|
||||||
this.retrieveSystemInformation().subscribe(info => {
|
this.webSocketStateChanged$ = new EventEmitter<WebSocketStatus>();
|
||||||
const ws = new WebSocket(`ws://${host}:${info.websocket_port}`);
|
this.createWebSocket();
|
||||||
ws.onmessage = (event) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
const state = deserialize(JSON.parse(reader.result as string).results, State);
|
|
||||||
this.isTwelveHourTime = state.twelve;
|
|
||||||
this.stateChanged$.emit(state);
|
|
||||||
};
|
|
||||||
reader.readAsText(event.data);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthToken(token: string): void {
|
setAuthToken(token: string): void {
|
||||||
@ -76,118 +78,204 @@ export class OpenLPService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
retrieveSystemInformation(): Observable<SystemInformation> {
|
retrieveSystemInformation(): Observable<SystemInformation> {
|
||||||
return this.http.get<SystemInformation>(`${this.apiURL}/core/system`, httpOptions);
|
return this.doGet<SystemInformation>(`${this.apiURL}/core/system`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getMainImage(): Observable<MainView> {
|
getMainImage(): Observable<MainView> {
|
||||||
return this.http.get<MainView>(`${this.apiURL}/core/live-image`, httpOptions);
|
return this.doGet<MainView>(`${this.apiURL}/core/live-image`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSearchablePlugins(): Observable<PluginDescription[]> {
|
getSearchablePlugins(): Observable<PluginDescription[]> {
|
||||||
return this.http.get<PluginDescription[]>(`${this.apiURL}/core/plugins`, httpOptions);
|
return this.doGet<PluginDescription[]>(`${this.apiURL}/core/plugins`);
|
||||||
}
|
}
|
||||||
|
|
||||||
search(plugin, text): Observable<any> {
|
search(plugin, text): Observable<any> {
|
||||||
return this.http.get(`${this.apiURL}/plugins/${plugin}/search?text=${text}`, httpOptions);
|
return this.doGet(`${this.apiURL}/plugins/${plugin}/search?text=${text}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSearchOptions(plugin): Observable<any> {
|
getSearchOptions(plugin): Observable<any> {
|
||||||
return this.http.get(`${this.apiURL}/plugins/${plugin}/search-options`, httpOptions);
|
return this.doGet(`${this.apiURL}/plugins/${plugin}/search-options`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearchOption(plugin, option, value): Observable<any> {
|
setSearchOption(plugin, option, value): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/plugins/${plugin}/search-options`, {'option': option, 'value': value}, httpOptions);
|
return this.doPost(`${this.apiURL}/plugins/${plugin}/search-options`, {'option': option, 'value': value});
|
||||||
}
|
}
|
||||||
|
|
||||||
getServiceItems(): Observable<ServiceItem[]> {
|
getServiceItems(): Observable<ServiceItem[]> {
|
||||||
return this.http.get<ServiceItem[]>(`${this.apiURL}/service/items`, httpOptions);
|
return this.doGet<ServiceItem[]>(`${this.apiURL}/service/items`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setServiceItem(id: any): Observable<any> {
|
setServiceItem(id: any): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/service/show`, {'id': id}, httpOptions);
|
return this.doPost(`${this.apiURL}/service/show`, {'id': id});
|
||||||
}
|
}
|
||||||
|
|
||||||
nextItem(): Observable<any> {
|
nextItem(): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/service/progress`, {'action': 'next'}, httpOptions);
|
return this.doPost(`${this.apiURL}/service/progress`, {'action': 'next'});
|
||||||
}
|
}
|
||||||
|
|
||||||
previousItem(): Observable<any> {
|
previousItem(): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/service/progress`, {'action': 'previous'}, httpOptions);
|
return this.doPost(`${this.apiURL}/service/progress`, {'action': 'previous'});
|
||||||
}
|
}
|
||||||
|
|
||||||
getServiceItem(): Observable<any> {
|
getServiceItem(): Observable<any> {
|
||||||
return this.http.get<Slide[]>(`${this.apiURL}/controller/live-items`, httpOptions);
|
return this.doGet<Slide[]>(`${this.apiURL}/controller/live-items`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getNotes(): Observable<any> {
|
getNotes(): Observable<any> {
|
||||||
return this.http.get(`${this.apiURL}/controller/notes`, httpOptions);
|
return this.doGet(`${this.apiURL}/controller/notes`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSlide(id: any): Observable<any> {
|
setSlide(id: any): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/controller/show`, {'id': id}, httpOptions);
|
return this.doPost(`${this.apiURL}/controller/show`, {'id': id});
|
||||||
}
|
}
|
||||||
|
|
||||||
nextSlide(): Observable<any> {
|
nextSlide(): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/controller/progress`, {'action': 'next'}, httpOptions);
|
return this.doPost(`${this.apiURL}/controller/progress`, {'action': 'next'});
|
||||||
}
|
}
|
||||||
|
|
||||||
previousSlide(): Observable<any> {
|
previousSlide(): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/controller/progress`, {'action': 'previous'}, httpOptions);
|
return this.doPost(`${this.apiURL}/controller/progress`, {'action': 'previous'});
|
||||||
}
|
}
|
||||||
|
|
||||||
getThemeLevel(): Observable<any> {
|
getThemeLevel(): Observable<any> {
|
||||||
return this.http.get(`${this.apiURL}/controller/theme-level`, httpOptions);
|
return this.doGet(`${this.apiURL}/controller/theme-level`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getThemes(): Observable<Theme[]> {
|
getThemes(): Observable<Theme[]> {
|
||||||
return this.http.get<Theme[]>(`${this.apiURL}/controller/themes`, httpOptions);
|
return this.doGet<Theme[]>(`${this.apiURL}/controller/themes`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setThemeLevel(level): Observable<any> {
|
setThemeLevel(level): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/controller/theme-level`, {'level': level}, httpOptions);
|
return this.doPost(`${this.apiURL}/controller/theme-level`, {'level': level});
|
||||||
}
|
}
|
||||||
|
|
||||||
getTheme(): Observable<any> {
|
getTheme(): Observable<any> {
|
||||||
return this.http.get(`${this.apiURL}/controller/theme`, httpOptions);
|
return this.doGet(`${this.apiURL}/controller/theme`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(theme: string): Observable<any> {
|
setTheme(theme: string): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/controller/theme`, {'theme': theme}, httpOptions);
|
return this.doPost(`${this.apiURL}/controller/theme`, {'theme': theme});
|
||||||
}
|
}
|
||||||
|
|
||||||
blankDisplay(): Observable<any> {
|
blankDisplay(): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/core/display`, {'display': 'blank'}, httpOptions);
|
return this.doPost(`${this.apiURL}/core/display`, {'display': 'blank'});
|
||||||
}
|
}
|
||||||
|
|
||||||
themeDisplay(): Observable<any> {
|
themeDisplay(): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/core/display`, {'display': 'theme'}, httpOptions);
|
return this.doPost(`${this.apiURL}/core/display`, {'display': 'theme'});
|
||||||
}
|
}
|
||||||
|
|
||||||
desktopDisplay(): Observable<any> {
|
desktopDisplay(): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/core/display`, {'display': 'desktop'}, httpOptions);
|
return this.doPost(`${this.apiURL}/core/display`, {'display': 'desktop'});
|
||||||
}
|
}
|
||||||
|
|
||||||
showDisplay(): Observable<any> {
|
showDisplay(): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/core/display`, {'display': 'show'}, httpOptions);
|
return this.doPost(`${this.apiURL}/core/display`, {'display': 'show'});
|
||||||
}
|
}
|
||||||
|
|
||||||
showAlert(text): Observable<any> {
|
showAlert(text): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/plugins/alerts`, {'text': text}, httpOptions);
|
return this.doPost(`${this.apiURL}/plugins/alerts`, {'text': text});
|
||||||
}
|
}
|
||||||
|
|
||||||
sendItemLive(plugin, id): Observable<any> {
|
sendItemLive(plugin, id): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/plugins/${plugin}/live`, {'id': id}, httpOptions);
|
return this.doPost(`${this.apiURL}/plugins/${plugin}/live`, {'id': id});
|
||||||
}
|
}
|
||||||
|
|
||||||
addItemToService(plugin, id): Observable<any> {
|
addItemToService(plugin, id): Observable<any> {
|
||||||
return this.http.post(`${this.apiURL}/plugins/${plugin}/add`, {'id': id}, httpOptions);
|
return this.doPost(`${this.apiURL}/plugins/${plugin}/add`, {'id': id});
|
||||||
}
|
}
|
||||||
|
|
||||||
transposeSong(transpose_value): Observable<any> {
|
transposeSong(transpose_value): Observable<any> {
|
||||||
return this.http.get(`${this.apiURL}/plugins/songs/transpose-live-item/${transpose_value}`, httpOptions);
|
return this.doGet(`${this.apiURL}/plugins/songs/transpose-live-item/${transpose_value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
login(credentials: Credentials): Observable<AuthToken> {
|
login(credentials: Credentials): Observable<AuthToken> {
|
||||||
return this.http.post<AuthToken>(`${this.apiURL}/core/login`, credentials, httpOptions);
|
return this.doPost<AuthToken>(`${this.apiURL}/core/login`, credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected doGet<T>(url: string): Observable<T> {
|
||||||
|
return this.http.get<T>(url, httpOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected doPost<T>(url: string, body: any): Observable<T> {
|
||||||
|
// User is expecting instant response, so we'll accelerate the websocket reconnection process if needed.
|
||||||
|
this.reconnectWebSocketIfNeeded();
|
||||||
|
return this.http.post<T>(url, body, httpOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
get webSocketStatus(): WebSocketStatus {
|
||||||
|
if (this.ws) {
|
||||||
|
switch (this.ws.readyState) {
|
||||||
|
case WebSocket.OPEN:
|
||||||
|
return WebSocketStatus.Open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return WebSocketStatus.Closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectWebSocketIfNeeded() {
|
||||||
|
if (this.webSocketStatus === WebSocketStatus.Closed) {
|
||||||
|
this.createWebSocket();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createWebSocket() {
|
||||||
|
this.clearWebSocketTimeoutHandle();
|
||||||
|
if (this.retrieveSystemInformationSubscription) {
|
||||||
|
// Cancels ongoing request to avoid connection flooding
|
||||||
|
this.retrieveSystemInformationSubscription.unsubscribe();
|
||||||
|
}
|
||||||
|
this.retrieveSystemInformationSubscription = this.retrieveSystemInformation()
|
||||||
|
.pipe(
|
||||||
|
shareReplay(1),
|
||||||
|
finalize(() => this.retrieveSystemInformationSubscription = null)
|
||||||
|
)
|
||||||
|
.subscribe(info => {
|
||||||
|
if (this.ws) {
|
||||||
|
// Removing listeners to avoid loop
|
||||||
|
this.ws.onmessage = null;
|
||||||
|
this.ws.onclose = null;
|
||||||
|
this.ws.onerror = null;
|
||||||
|
this.ws.close();
|
||||||
|
this.webSocketStateChanged$.emit(WebSocketStatus.Closed);
|
||||||
|
}
|
||||||
|
const ws = this.ws = new WebSocket(`ws://${this.host}:${info.websocket_port}`);
|
||||||
|
ws.onopen = () => {
|
||||||
|
this.webSocketStateChanged$.emit(WebSocketStatus.Open);
|
||||||
|
};
|
||||||
|
ws.onmessage = this.readWebSocketMessage;
|
||||||
|
ws.onerror = this.handleWebSocketError;
|
||||||
|
ws.onclose = () => {
|
||||||
|
this.webSocketStateChanged$.emit(WebSocketStatus.Closed);
|
||||||
|
this.handleWebSocketError();
|
||||||
|
};
|
||||||
|
}, _ => this.handleWebSocketError());
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleWebSocketError = () => {
|
||||||
|
this.clearWebSocketTimeoutHandle();
|
||||||
|
this.webSocketTimeoutHandle = setTimeout(() => {
|
||||||
|
this.createWebSocket();
|
||||||
|
}, WEBSOCKET_RECONNECT_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearWebSocketTimeoutHandle() {
|
||||||
|
if (this.webSocketTimeoutHandle) {
|
||||||
|
clearTimeout(this.webSocketTimeoutHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readWebSocketMessage = (event: MessageEvent<any>) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const state = deserialize(JSON.parse(reader.result as string).results, State);
|
||||||
|
this.handleStateChange(state);
|
||||||
|
};
|
||||||
|
reader.readAsText(event.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleStateChange(state: State) {
|
||||||
|
this.isTwelveHourTime = state.twelve;
|
||||||
|
this.stateChanged$.emit(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user