Merge branch 'websocket-dropout-fix' into 'master'

Websocket reconnection routines

See merge request openlp/web-remote!43
This commit is contained in:
Raoul Snyman 2022-11-14 16:17:51 +00:00
commit a83d129630
7 changed files with 7801 additions and 7509 deletions

View File

@ -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": []

View File

@ -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"
}, },

View File

@ -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>

View File

@ -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;
} }

View File

@ -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();
}
} }

View File

@ -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);
} }
} }

15101
yarn.lock

File diff suppressed because it is too large Load Diff