From e54f5178a1af03995d28348397dd8fdff3ecb2b9 Mon Sep 17 00:00:00 2001 From: Janis Hutz Date: Wed, 14 Aug 2024 12:18:24 +0200 Subject: [PATCH] basically done --- MusicPlayerV2-GUI/index.html | 3 + .../src/components/playlistView.vue | 13 +- MusicPlayerV2-GUI/src/scripts/connection.ts | 89 ++++++++-- .../src/scripts/notificationHandler.ts | 142 ++++++++++++--- MusicPlayerV2-GUI/src/scripts/song.d.ts | 4 + backend/package-lock.json | 1 + backend/src/app.ts | 167 +++++++++++++++++- 7 files changed, 381 insertions(+), 38 deletions(-) diff --git a/MusicPlayerV2-GUI/index.html b/MusicPlayerV2-GUI/index.html index 3f895d1..369b6aa 100644 --- a/MusicPlayerV2-GUI/index.html +++ b/MusicPlayerV2-GUI/index.html @@ -14,6 +14,9 @@
+ diff --git a/MusicPlayerV2-GUI/src/components/playlistView.vue b/MusicPlayerV2-GUI/src/components/playlistView.vue index 92e5055..dad2d27 100644 --- a/MusicPlayerV2-GUI/src/components/playlistView.vue +++ b/MusicPlayerV2-GUI/src/components/playlistView.vue @@ -28,7 +28,7 @@ arrow_downward

{{ song.title }}

- +

{{ getTimeUntil( song ) }}

@@ -43,6 +43,9 @@ import type { AppleMusicSongData, ReadFile, Song } from '@/scripts/song'; import { computed, ref } from 'vue'; import searchView from './searchView.vue'; + import { useUserStore } from '@/stores/userStore'; + + const userStore = useUserStore(); const search = ref( searchView ); const props = defineProps( { @@ -83,6 +86,14 @@ return pl; } ); + const kbControl = ( action: string ) => { + if ( action === 'off' ) { + userStore.setKeyboardUsageStatus( false ); + } else { + userStore.setKeyboardUsageStatus( true ); + } + } + const openSearch = () => { if ( search.value ) { search.value.controlSearch( 'show' ); diff --git a/MusicPlayerV2-GUI/src/scripts/connection.ts b/MusicPlayerV2-GUI/src/scripts/connection.ts index 5edacc4..62cac43 100644 --- a/MusicPlayerV2-GUI/src/scripts/connection.ts +++ b/MusicPlayerV2-GUI/src/scripts/connection.ts @@ -9,12 +9,16 @@ // These functions handle connections to the backend with socket.io -import { io, type Socket } from "socket.io-client" +import { io, type Socket } from "socket.io-client"; +import type { SSEMap } from "./song"; class SocketConnection { socket: Socket; roomName: string; isConnected: boolean; + useSocket: boolean; + eventSource?: EventSource; + toBeListenedForItems: SSEMap; constructor () { this.socket = io( localStorage.getItem( 'url' ) ?? '', { @@ -22,6 +26,8 @@ class SocketConnection { } ); this.roomName = location.pathname.split( '/' )[ 2 ]; this.isConnected = false; + this.useSocket = localStorage.getItem( 'music-player-config' ) === 'ws'; + this.toBeListenedForItems = {}; } /** @@ -30,16 +36,51 @@ class SocketConnection { */ connect (): Promise { return new Promise( ( resolve, reject ) => { - this.socket.connect(); - this.socket.emit( 'join-room', this.roomName, ( res: { status: boolean, msg: string, data: any } ) => { - if ( res.status === true ) { - this.isConnected = true; - resolve( res.data ); - } else { - console.debug( res.msg ); + if ( this.useSocket ) { + this.socket.connect(); + this.socket.emit( 'join-room', this.roomName, ( res: { status: boolean, msg: string, data: any } ) => { + if ( res.status === true ) { + this.isConnected = true; + resolve( res.data ); + } else { + console.debug( res.msg ); + reject( 'ERR_ROOM_CONNECTING' ); + } + } ); + } else { + fetch( localStorage.getItem( 'url' ) + '/socket/joinRoom?room=' + this.roomName, { credentials: 'include' } ).then( res => { + if ( res.status === 200 ) { + this.eventSource = new EventSource( localStorage.getItem( 'url' ) + '/socket/connection?room=' + this.roomName, { withCredentials: true } ); + + this.eventSource.onmessage = ( e ) => { + const d = JSON.parse( e.data ); + if ( this.toBeListenedForItems[ d.type ] ) { + this.toBeListenedForItems[ d.type ]( d.data ); + } else if ( d.type === 'basics' ) { + resolve( d.data ); + } + } + + this.eventSource.onerror = ( e ) => { + if ( this.isConnected ) { + this.isConnected = false; + console.log( '[ SSE Connection ] - ' + new Date().toISOString() + ': Reconnecting due to connection error!' ); + console.debug( e ); + + this.eventSource = undefined; + + setTimeout( () => { + this.connect(); + }, 500 ); + } + }; + } else { + reject( 'ERR_ROOM_CONNECTING' ); + } + } ).catch( () => { reject( 'ERR_ROOM_CONNECTING' ); - } - } ); + } ); + } } ); } @@ -51,7 +92,19 @@ class SocketConnection { */ emit ( event: string, data: any ): void { if ( this.isConnected ) { - this.socket.emit( event, { 'roomName': this.roomName, 'data': data } ); + if ( this.useSocket ) { + this.socket.emit( event, { 'roomName': this.roomName, 'data': data } ); + } else { + fetch( localStorage.getItem( 'url' ) + '/socket/update', { + method: 'post', + body: JSON.stringify( { 'event': event, 'roomName': this.roomName, 'data': data } ), + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'charset': 'utf-8' + } + } ).catch( () => {} ); + } } } @@ -62,8 +115,12 @@ class SocketConnection { * @returns {void} */ registerListener ( event: string, cb: ( data: any ) => void ): void { - if ( this.isConnected ) { - this.socket.on( event, cb ); + if ( this.useSocket ) { + if ( this.isConnected ) { + this.socket.on( event, cb ); + } + } else { + this.toBeListenedForItems[ event ] = cb; } } @@ -73,7 +130,11 @@ class SocketConnection { */ disconnect (): void { if ( this.isConnected ) { - this.socket.disconnect(); + if ( this.useSocket ) { + this.socket.disconnect(); + } else { + this.eventSource!.close(); + } } } } diff --git a/MusicPlayerV2-GUI/src/scripts/notificationHandler.ts b/MusicPlayerV2-GUI/src/scripts/notificationHandler.ts index 224e556..59d148f 100644 --- a/MusicPlayerV2-GUI/src/scripts/notificationHandler.ts +++ b/MusicPlayerV2-GUI/src/scripts/notificationHandler.ts @@ -10,12 +10,17 @@ // These functions handle connections to the backend with socket.io import { io, type Socket } from "socket.io-client" +import type { SSEMap } from "./song"; class NotificationHandler { socket: Socket; roomName: string; roomToken: string; isConnected: boolean; + useSocket: boolean; + eventSource?: EventSource; + toBeListenedForItems: SSEMap; + reconnectRetryCount: number; constructor () { this.socket = io( localStorage.getItem( 'url' ) ?? '', { @@ -24,6 +29,9 @@ class NotificationHandler { this.roomName = ''; this.roomToken = ''; this.isConnected = false; + this.useSocket = localStorage.getItem( 'music-player-config' ) === 'ws'; + this.toBeListenedForItems = {}; + this.reconnectRetryCount = 0; } /** @@ -39,18 +47,24 @@ class NotificationHandler { res.text().then( text => { this.roomToken = text; this.roomName = roomName; - this.socket.connect(); - this.socket.emit( 'create-room', { - name: this.roomName, - token: this.roomToken - }, ( res: { status: boolean, msg: string } ) => { - if ( res.status === true ) { - this.isConnected = true; + if ( this.useSocket ) { + this.socket.connect(); + this.socket.emit( 'create-room', { + name: this.roomName, + token: this.roomToken + }, ( res: { status: boolean, msg: string } ) => { + if ( res.status === true ) { + this.isConnected = true; + resolve(); + } else { + reject( 'ERR_ROOM_CONNECTING' ); + } + } ); + } else { + this.sseConnect().then( data => { resolve(); - } else { - reject( 'ERR_ROOM_CONNECTING' ); - } - } ); + } ).catch( ); + } } ); } else if ( res.status === 409 ) { reject( 'ERR_CONFLICT' ); @@ -61,6 +75,56 @@ class NotificationHandler { } ); } + sseConnect (): Promise { + return new Promise( ( resolve, reject ) => { + if ( this.reconnectRetryCount < 5 ) { + fetch( localStorage.getItem( 'url' ) + '/socket/joinRoom?room=' + this.roomName, { credentials: 'include' } ).then( res => { + if ( res.status === 200 ) { + this.eventSource = new EventSource( localStorage.getItem( 'url' ) + '/socket/connection?room=' + this.roomName, { withCredentials: true } ); + this.eventSource.onopen = () => { + this.isConnected = true; + this.reconnectRetryCount = 0; + resolve(); + } + + this.eventSource.onmessage = ( e ) => { + const d = JSON.parse( e.data ); + if ( this.toBeListenedForItems[ d.type ] ) { + this.toBeListenedForItems[ d.type ]( d.data ); + } + } + + this.eventSource.onerror = ( e ) => { + if ( this.isConnected ) { + this.isConnected = false; + console.log( '[ SSE Connection ] - ' + new Date().toISOString() + ': Reconnecting due to connection error!' ); + console.debug( e ); + + this.eventSource = undefined; + + this.reconnectRetryCount += 1; + setTimeout( () => { + this.sseConnect(); + }, 500 * this.reconnectRetryCount ); + } + }; + } else { + reject( 'ERR_ROOM_CONNECTING' ); + } + } ).catch( () => { + reject( 'ERR_ROOM_CONNECTING' ); + } ); + } else { + if ( confirm( 'Connection lost and it could not be reestablished. Please click ok to retry or press cancel to stop retrying. Your share will be deleted as a result thereof.' ) ) { + this.reconnectRetryCount = 0; + this.sseConnect(); + } else { + this.disconnect(); + } + } + } ); + } + /** * Emit an event * @param {string} event The event to emit @@ -69,7 +133,19 @@ class NotificationHandler { */ emit ( event: string, data: any ): void { if ( this.isConnected ) { - this.socket.emit( event, { 'roomToken': this.roomToken, 'roomName': this.roomName, 'data': data } ); + if ( this.useSocket ) { + this.socket.emit( event, { 'roomToken': this.roomToken, 'roomName': this.roomName, 'data': data } ); + } else { + fetch( localStorage.getItem( 'url' ) + '/socket/update', { + method: 'post', + body: JSON.stringify( { 'event': event, 'roomName': this.roomName, 'roomToken': this.roomToken, 'data': data } ), + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'charset': 'utf-8' + } + } ).catch( () => {} ); + } } } @@ -81,7 +157,11 @@ class NotificationHandler { */ registerListener ( event: string, cb: ( data: any ) => void ): void { if ( this.isConnected ) { - this.socket.on( event, cb ); + if ( this.useSocket ) { + this.socket.on( event, cb ); + } else { + this.toBeListenedForItems[ event ] = cb; + } } } @@ -91,15 +171,33 @@ class NotificationHandler { */ disconnect (): void { if ( this.isConnected ) { - this.socket.emit( 'delete-room', { - name: this.roomName, - token: this.roomToken - }, ( res: { status: boolean, msg: string } ) => { - this.socket.disconnect(); - if ( !res.status ) { - alert( 'Unable to delete the room you were just in. The name will be blocked until the next server restart!' ); - } - } ); + if ( this.useSocket ) { + this.socket.emit( 'delete-room', { + name: this.roomName, + token: this.roomToken + }, ( res: { status: boolean, msg: string } ) => { + this.socket.disconnect(); + if ( !res.status ) { + alert( 'Unable to delete the room you were just in. The name will be blocked until the next server restart!' ); + } + } ); + } else { + fetch( localStorage.getItem( 'url' ) + '/socket/deleteRoom', { + method: 'post', + body: JSON.stringify( { 'roomName': this.roomName, 'roomToken': this.roomToken } ), + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'charset': 'utf-8' + } + } ).then( res => { + if ( res.status === 200 ) { + this.eventSource!.close(); + } else { + alert( 'Unable to delete the room you were just in. The name will be blocked until the next server restart!' ); + } + } ).catch( () => {} ); + } } } diff --git a/MusicPlayerV2-GUI/src/scripts/song.d.ts b/MusicPlayerV2-GUI/src/scripts/song.d.ts index 1c01058..b879b06 100644 --- a/MusicPlayerV2-GUI/src/scripts/song.d.ts +++ b/MusicPlayerV2-GUI/src/scripts/song.d.ts @@ -88,4 +88,8 @@ export interface AppleMusicSongData { export interface SongMove { songID: string; newPos: number; +} + +export interface SSEMap { + [key: string]: ( data: any ) => void; } \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index c875447..84ce7fa 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -43,6 +43,7 @@ } }, "../../store/sdk/dist": { + "name": "store.janishutz.com-sdk", "version": "1.0.0", "license": "ISC", "dependencies": { diff --git a/backend/src/app.ts b/backend/src/app.ts index ef801e3..339441f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -10,6 +10,7 @@ import { Server } from 'socket.io'; import crypto from 'node:crypto'; import type { Room, Song } from './definitions'; import storeSDK from 'store.janishutz.com-sdk'; +import bodyParser from 'body-parser'; declare let __dirname: string | undefined if ( typeof( __dirname ) === 'undefined' ) { @@ -182,11 +183,175 @@ const run = () => { } ); + /* + ROUTES FOR SERVER SENT EVENTS VERSION + */ + // Connected clients have their session ID as key + interface SocketClientList { + [key: string]: SocketClient; + } + + interface SocketClient { + response: express.Response; + room: string; + } + + interface ClientReferenceList { + /** + * Find all clients connected to one room + */ + [key: string]: string[]; + } + + const importantClients: SocketClientList = {}; + const connectedClients: SocketClientList = {}; + const clientReference: ClientReferenceList = {}; + + app.get( '/socket/connection', ( request: express.Request, response: express.Response ) => { + if ( request.query.room ) { + if ( socketData[ String( request.query.room ) ] ) { + response.writeHead( 200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + } ); + response.status( 200 ); + response.flushHeaders(); + response.write( `data: ${ JSON.stringify( { 'type': 'basics', 'data': socketData[ String( request.query.room ) ] } ) }\n\n` ); + const sid = sdk.getSessionID( request ); + if ( sdk.checkAuth( request ) ) { + importantClients[ sid ] = { 'response': response, 'room': String( request.query.room ) }; + } + connectedClients[ sid ] = { 'response': response, 'room': String( request.query.room ) }; + if ( !clientReference[ String( request.query.room ) ] ) { + clientReference[ String( request.query.room ) ] = []; + } + if ( !clientReference[ String( request.query.room ) ].includes( sid ) ) { + clientReference[ String( request.query.room ) ].push( sid ); + } + request.on( 'close', () => { + try { + importantClients[ sid ] = undefined; + } catch ( e ) { /* empty */ } + const cl = clientReference[ String( request.query.room ) ]; + for ( let c in cl ) { + if ( cl[ c ] === sid ) { + cl.splice( parseInt( c ), 1 ); + break; + } + } + connectedClients[ sid ] = undefined; + } ); + } else { + response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' ); + } + } else { + response.status( 404 ).send( 'ERR_NO_ROOM_SPECIFIED' ); + } + } ); + + app.get( '/socket/getData', ( request: express.Request, response: express.Response ) => { + if ( request.query.room ) { + response.send( socketData[ String( request.query.room ) ] ); + } else { + response.status( 400 ).send( 'ERR_NO_ROOM_SPECIFIED' ); + } + } ); + + app.get( '/socket/joinRoom', ( request: express.Request, response: express.Response ) => { + if ( request.query.room ) { + if ( socketData[ String( request.query.room ) ] ) { + response.send( 'ok' ); + } else { + response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' ); + } + } else { + response.status( 404 ).send( 'ERR_NO_ROOM_SPECIFIED' ); + } + } ); + + app.post( '/socket/update', bodyParser.json(), ( request: express.Request, response: express.Response ) => { + if ( socketData[ request.body.roomName ] ) { + if ( request.body.update === 'tampering' ) { + const clients = clientReference[ request.body.roomName ]; + for ( let client in clients ) { + if ( importantClients[ clients[ client ] ] ) { + importantClients[ clients[ client ] ].response.write( 'data: ' + JSON.stringify( { 'update': 'tampering', 'data': true } ) + '\n\n' ); + } + } + } else { + if ( socketData[ request.body.roomName ].roomToken === request.body.roomToken ) { + let send = false; + let update = ''; + + if ( request.body.event === 'playback-start-update' ) { + send = true; + update = 'playback-start'; + socketData[ request.body.roomName ].playbackStart = request.body.data; + } else if ( request.body.event === 'playback-update' ) { + send = true; + update = 'playback'; + socketData[ request.body.roomName ].playbackStatus = request.body.data; + } else if ( request.body.event === 'playlist-update' ) { + send = true; + update = 'playlist'; + socketData[ request.body.roomName ].playlist = request.body.data; + } else if ( request.body.event === 'playlist-index-update' ) { + send = true; + update = 'playlist-index'; + socketData[ request.body.roomName ].playlistIndex = request.body.data; + } + + if ( send ) { + const clients = clientReference[ request.body.roomName ]; + for ( let client in clients ) { + if ( connectedClients[ clients[ client ] ] ) { + connectedClients[ clients[ client ] ].response.write( 'data: ' + JSON.stringify( { 'type': update, 'data': request.body.data } ) + '\n\n' ); + } + } + response.send( 'ok' ); + } else { + response.status( 404 ).send( 'ERR_CANNOT_SEND' ); + } + } + } + } + } ); + + + + app.post( '/socket/deleteRoom', bodyParser.json(), ( request: express.Request, response: express.Response ) => { + if ( request.body.roomName ) { + if ( socketData[ request.body.roomName ] ) { + if ( socketData[ request.body.roomName ].roomToken === request.body.roomToken ) { + socketData[ request.body.roomName ] = undefined; + const clients = clientReference[ request.body.roomName ]; + for ( let client in clients ) { + if ( connectedClients[ clients[ client ] ] ) { + connectedClients[ clients[ client ] ].response.write( 'data: ' + JSON.stringify( { 'update': 'delete-share', 'data': true } ) + '\n\n' ); + } + } + } else { + response.send( 403 ).send( 'ERR_UNAUTHORIZED' ); + } + } else { + response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' ); + } + } else { + response.status( 400 ).send( 'ERR_NO_ROOM_NAME' ); + } + } ); + + + + /* + GENERAL ROUTES + */ app.get( '/', ( request: express.Request, response: express.Response ) => { response.send( 'Please visit https://music.janishutz.com to use this service' ); } ); - + app.get( '/createRoomToken', ( request: express.Request, response: express.Response ) => { if ( sdk.checkAuth( request ) ) { const roomName = String( request.query.roomName ) ?? '';