basically done (at least the essential part)

This commit is contained in:
2024-06-29 12:05:50 +02:00
parent f314732f3f
commit 429bb53f36
18 changed files with 1546 additions and 768 deletions

View File

@@ -11,6 +11,7 @@
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@jridgewell/sourcemap-codec": "^1.4.15",
"@melloware/coloris": "^0.24.0",
"@rollup/plugin-inject": "^5.0.5",
"buffer": "^6.0.3",
"colorthief": "^2.2.0",
@@ -588,6 +589,12 @@
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
"license": "MIT"
},
"node_modules/@melloware/coloris": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@melloware/coloris/-/coloris-0.24.0.tgz",
"integrity": "sha512-9RGKHqZJsUSsxb/0xaBCK5OKywobiK/xRtV8f4KQDmviqmVfkMLR3kK4DRuTTLSFdSOqkV0OQ/Niitu+rlXXYw==",
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",

View File

@@ -15,6 +15,7 @@
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@jridgewell/sourcemap-codec": "^1.4.15",
"@melloware/coloris": "^0.24.0",
"@rollup/plugin-inject": "^5.0.5",
"buffer": "^6.0.3",
"colorthief": "^2.2.0",

View File

@@ -71,9 +71,9 @@
* @param {string} priority The priority of the message: 'low', 'normal', 'critical'
* @returns {number}
*/
const createNotification = ( message: string, showDuration: number, messageType: string, priority: string, redirect?: string ): number => {
const createNotification = ( message: string, showDuration: number, msgType: string, priority: string, redirect?: string ): number => {
/*
Takes a notification options array that contains: message, showDuration (in seconds), messageType (ok, error, progress, info) and priority (low, normal, critical).
Takes a notification options array that contains: message, showDuration (in seconds), msgType (ok, error, progress, info) and priority (low, normal, critical).
Returns a notification ID which can be used to cancel the notification. The component will throttle notifications and display
one at a time and prioritize messages with higher priority. Use vue refs to access these methods.
*/
@@ -89,10 +89,10 @@
currentID.value[ 'low' ] += 1;
id = currentID.value[ 'low' ];
}
notifications.value[ id ] = { 'message': message, 'showDuration': showDuration, 'messageType': messageType, 'priority': priority, 'id': id, redirect: redirect };
notifications.value[ id ] = { 'message': message, 'showDuration': showDuration, 'messageType': msgType, 'priority': priority, 'id': id, redirect: redirect };
queue.value.push( id );
console.log( 'scheduled notification: ' + id + ' (' + message + ')' );
if ( ( new Date().getTime() - notificationDisplayStartTime.value ) / 1000 >= ( notifications.value[ currentDID.value ] ? notifications.value[ currentDID.value ].showDuration : 0 ) ) {
if ( ( new Date().getTime() - notificationDisplayStartTime.value ) / 1000 >= ( notifications.value[ currentDID.value ] ? notifications.value[ currentDID.value ].showDuration : 0 ) || messageType.value === 'hide' ) {
handleNotifications();
}
return id;

View File

@@ -31,13 +31,18 @@
<p class="playback-pos">{{ nicePlaybackPos }}</p>
<p class="playback-duration" @click="toggleRemaining()" title="Toggle between remaining time and song duration">{{ niceDuration }}</p>
</div>
<sliderView :position="pos" :active="true" :duration="duration" name="main" @pos="( pos ) => player.goToPos( pos )"></sliderView>
<sliderView :position="pos" :active="true" :duration="duration" name="main" @pos="( pos ) => goToPos( pos )"></sliderView>
</div>
<div class="shuffle-repeat" v-if="isShowingFullScreenPlayer">
<span class="material-symbols-outlined controls" @click="control( 'repeat' )" style="margin-right: auto;">repeat{{ repeatMode }}</span>
<span class="material-symbols-outlined controls" @click="control( 'start-share' )" style="margin-right: auto;" title="Share your playlist on a public playlist page (opens a configuration window)" v-if="!isConnectedToNotifier">share</span>
<span class="material-symbols-outlined controls" @click="control( 'stop-share' )" style="margin-right: auto;" title="Stop sharing your playlist on a public playlist page" v-else>close</span>
<div style="margin-right: auto;">
<span class="material-symbols-outlined controls" @click="control( 'start-share' )" title="Share your playlist on a public playlist page (opens a configuration window)" v-if="!isConnectedToNotifier">share</span>
<div v-else>
<span class="material-symbols-outlined controls" @click="control( 'stop-share' )" title="Stop sharing your playlist on a public playlist page">close</span>
<span class="material-symbols-outlined controls" @click="control( 'show-share' )" title="Show information on the share, including URL to connect to">info</span>
</div>
</div>
<span class="material-symbols-outlined controls" @click="control( 'shuffle' )">shuffle{{ shuffleMode }}</span>
</div>
</div>
@@ -52,6 +57,7 @@
@add-new-songs-apple-music="( song ) => addNewSongFromObject( song )"></playlistView>
</div>
<notificationsModule ref="notifications" location="bottomleft" size="bigger"></notificationsModule>
<popupModule @update="( data ) => popupReturnHandler( data )" ref="popup"></popupModule>
<audio src="" id="local-audio" controls="false"></audio>
</div>
</template>
@@ -69,6 +75,7 @@
import notificationsModule from './notificationsModule.vue';
import { useUserStore } from '@/stores/userStore';
import NotificationHandler from '@/scripts/notificationHandler';
import popupModule from './popupModule.vue';
const isPlaying = ref( false );
const repeatMode = ref( '' );
@@ -91,6 +98,9 @@
const notifications = ref( notificationsModule );
const notificationHandler = new NotificationHandler();
const isConnectedToNotifier = ref( false );
const popup = ref( popupModule );
const roomName = ref( '' );
let currentlyOpenPopup = '';
const emits = defineEmits( [ 'playerStateChange' ] );
@@ -103,7 +113,12 @@
player.control( 'pause' );
stopProgressTracker();
}
notificationHandler.emit( 'playback', isPlaying.value );
}
const goToPos = ( position: number ) => {
player.goToPos( position );
pos.value = position;
notificationHandler.emit( 'playback-start-update', new Date().getTime() - pos.value * 1000 );
}
const toggleRemaining = () => {
@@ -115,12 +130,12 @@
isPlaying.value = false;
player.control( 'pause' );
stopProgressTracker();
notificationHandler.emit( 'playback', isPlaying.value );
notificationHandler.emit( 'playback-update', isPlaying.value );
} else if ( action === 'play' ) {
isPlaying.value = true;
player.control( 'play' );
startProgressTracker();
notificationHandler.emit( 'playback', isPlaying.value );
notificationHandler.emit( 'playback-update', isPlaying.value );
} else if ( action === 'repeat' ) {
if ( repeatMode.value === '' ) {
repeatMode.value = '_on';
@@ -137,29 +152,29 @@
shuffleMode.value = '_on';
player.setShuffle( true );
getDetails();
notificationHandler.emit( 'playlist', playlist.value );
notificationHandler.emit( 'playlist-update', playlist.value );
} else {
shuffleMode.value = '';
player.setShuffle( false );
getDetails();
notificationHandler.emit( 'playlist', playlist.value );
notificationHandler.emit( 'playlist-update', playlist.value );
}
getDetails();
} else if ( action === 'forward' ) {
clickCountForward.value += 1;
if( player.control( 'skip-10' ) ) {
setTimeout( () => {
startProgressTracker();
getDetails();
}, 2000 );
startProgressTracker();
} else {
pos.value = player.getPlaybackPos();
notificationHandler.emit( 'playback-start-update', new Date().getTime() - pos.value * 1000 );
}
} else if ( action === 'back' ) {
clickCountBack.value += 1;
if( player.control( 'back-10' ) ) {
setTimeout( () => {
startProgressTracker();
getDetails();
}, 2000 );
startProgressTracker();
} else {
pos.value = player.getPlaybackPos();
notificationHandler.emit( 'playback-start-update', new Date().getTime() - pos.value * 1000 );
}
} else if ( action === 'next' ) {
stopProgressTracker();
@@ -167,27 +182,39 @@
coverArt.value = '';
currentlyPlayingSongArtist.value = '';
currentlyPlayingSongName.value = 'Loading...';
setTimeout( () => {
getDetails();
startProgressTracker();
}, 2000 );
startProgressTracker();
} else if ( action === 'previous' ) {
stopProgressTracker();
player.control( 'previous' );
coverArt.value = '';
currentlyPlayingSongArtist.value = '';
currentlyPlayingSongName.value = 'Loading...';
setTimeout( () => {
getDetails();
startProgressTracker();
}, 2000 );
startProgressTracker();
} else if ( action === 'start-share' ) {
// TODO: Open popup, then send data with popup returns
notificationHandler.connect( 'test' );
popup.value.openPopup( {
title: 'Define a share name',
popupType: 'input',
subtitle: 'A share allows others to join your playlist and see the current song, the playback position and the upcoming songs. You can get the link to the page, once the share is set up. Please choose a name, which will then be part of the URL with which others can join the share',
data: [
{
name: 'Share Name',
dataType: 'text',
id: 'roomName'
}
]
} );
currentlyOpenPopup = 'create-share';
} else if ( action === 'stop-share' ) {
if ( confirm( 'Do you really want to stop sharing?' ) ) {
notificationHandler.disconnect();
isConnectedToNotifier.value = false;
notifications.value.createNotification( 'Disconnected successfully!', 5, 'ok', 'normal' );
}
} else if ( action === 'show-share' ) {
alert( 'You are currently connected to share "' + roomName.value
+ '". \nYou can connect to it via https://music.janishutz.com/share/' + roomName.value
+ '. \n\nYou can connect to the fancy showcase screen using this link: https://music.janishutz.com/fancy/' + roomName.value
+ '. Be aware that this one will use significantly more system AND network resources, so only use that for a screen that is front and center, not for a QR code to have all people connect to.' );
}
}
@@ -230,11 +257,10 @@
currentlyPlayingSongName.value = 'Loading...';
player.setPlaylistByID( id ).then( () => {
isPlaying.value = true;
startProgressTracker();
setTimeout( () => {
startProgressTracker();
getDetails();
notificationHandler.emit( 'playlist', playlist.value );
// TODO: Add playback-start emit as well. For every time elapsed before starting to play current section, move start time back
notificationHandler.emit( 'playlist-update', playlist.value );
}, 2000 );
} );
}
@@ -255,9 +281,10 @@
player.setPlaylist( playlist.value );
player.prepare( 0 );
isPlaying.value = true;
startProgressTracker();
setTimeout( () => {
startProgressTracker();
getDetails();
notificationHandler.emit( 'playlist-update', playlist.value );
}, 2000 );
notifications.value.cancelNotification( n );
notifications.value.createNotification( 'Playlist loaded', 10, 'ok', 'normal' );
@@ -316,10 +343,7 @@
for ( const s in p ) {
if ( p[ s ].id === id ) {
player.prepare( parseInt( s ) );
setTimeout( () => {
getDetails();
startProgressTracker();
}, 2000 );
startProgressTracker();
break;
}
}
@@ -332,8 +356,9 @@
const startProgressTracker = () => {
hasReachedEnd = false;
isPlaying.value = true;
const playingSong = player.getPlayingSong();
prepNiceDurationTime( playingSong );
let playingSong = player.getPlayingSong();
hasStarted = false;
pos.value = 0;
progressTracker = setInterval( () => {
pos.value = player.getPlaybackPos();
if ( pos.value > playingSong.duration - 1 && !hasReachedEnd ) {
@@ -350,9 +375,13 @@
}
if ( pos.value > 0 && !hasStarted ) {
notificationHandler.emit( 'playlist-index', currentlyPlayingSongIndex.value );
notificationHandler.emit( 'playback', isPlaying.value );
notificationHandler.emit( 'playback-start', new Date().getTime() - pos.value * 1000 );
getDetails();
playingSong = player.getPlayingSong();
console.log( pos.value );
prepNiceDurationTime( playingSong );
notificationHandler.emit( 'playlist-index-update', currentlyPlayingSongIndex.value );
notificationHandler.emit( 'playback-update', isPlaying.value );
notificationHandler.emit( 'playback-start-update', new Date().getTime() - pos.value * 1000 );
hasStarted = true;
}
@@ -404,11 +433,13 @@
clearInterval( progressTracker );
} catch ( _ ) { /* empty */ }
isPlaying.value = false;
notificationHandler.emit( 'playback-update', isPlaying.value );
}
const moveSong = ( move: SongMove ) => {
player.moveSong( move );
getDetails();
notificationHandler.emit( 'playlist-update', playlist.value );
}
const addNewSongs = async ( songs: ReadFile[] ) => {
@@ -426,13 +457,11 @@
if ( !isPlaying.value ) {
player.prepare( 0 );
isPlaying.value = true;
setTimeout( () => {
startProgressTracker();
getDetails();
}, 2000 );
startProgressTracker();
}
notifications.value.cancelNotification( n );
notifications.value.createNotification( 'New songs added', 10, 'ok', 'normal' );
notificationHandler.emit( 'playlist-update', playlist.value );
}
const addNewSongFromObject = ( song: Song ) => {
@@ -442,10 +471,8 @@
if ( !isPlaying.value ) {
player.prepare( 0 );
isPlaying.value = true;
setTimeout( () => {
startProgressTracker();
getDetails();
}, 2000 );
startProgressTracker();
notificationHandler.emit( 'playlist-update', playlist.value );
}
}
@@ -468,6 +495,28 @@
}
} );
const popupReturnHandler = ( data: any ) => {
if ( currentlyOpenPopup === 'create-share' ) {
notificationHandler.connect( data.roomName ).then( () => {
roomName.value = notificationHandler.getRoomName();
isConnectedToNotifier.value = true;
notificationHandler.emit( 'playlist-index-update', currentlyPlayingSongIndex.value );
notificationHandler.emit( 'playback-update', isPlaying.value );
notificationHandler.emit( 'playback-start-update', new Date().getTime() - pos.value * 1000 );
notificationHandler.emit( 'playlist-update', playlist.value );
notifications.value.createNotification( 'Joined share "' + data.roomName + '"!', 5, 'ok', 'normal' );
} ).catch( e => {
if ( e === 'ERR_CONFLICT' ) {
notifications.value.createNotification( 'A share with this name exists already!', 5, 'error', 'normal' );
control( 'start-share' );
} else {
console.error( e );
notifications.value.createNotification( 'Could not create share!', 5, 'error', 'normal' );
}
} );
}
}
defineExpose( {
logIntoAppleMusic,
getPlaylists,

View File

@@ -188,7 +188,7 @@
.playlist-box {
height: calc( 100% - 100px );
width: 100%;
overflow: scroll;
overflow-y: scroll;
display: flex;
align-items: center;
flex-direction: column;

View File

@@ -174,6 +174,7 @@
overflow: hidden;
transition: all 0.5s;
transform: scale(1);
z-index: 99;
}
.incomplete-message {
@@ -195,7 +196,7 @@
padding: 2.5%;
border-radius: 20px;
position: relative;
overflow: scroll;
overflow-y: scroll;
display: block;
}
@@ -211,7 +212,7 @@
.popup-content {
position: unset;
height: 60%;
overflow: scroll;
overflow-y: scroll;
}
.textarea {

View File

@@ -23,6 +23,24 @@ const router = createRouter( {
'title': 'App'
}
},
{
path: '/share/:name',
name: 'share',
component: () => import( '../views/RemoteView.vue' ),
meta: {
'authRequired': false,
'title': 'Share'
}
},
{
path: '/fancy/:name',
name: 'fancy',
component: () => import( '../views/ShowcaseView.vue' ),
meta: {
'authRequired': false,
'title': 'Fancy View'
}
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',

View File

@@ -0,0 +1,141 @@
import ColorThief from 'colorthief';
const colorThief = new ColorThief();
const getImageData = (): Promise<number[][]> => {
return new Promise( ( resolve ) => {
const img = ( document.getElementById( 'current-image' ) as HTMLImageElement );
console.log( img );
if ( img.complete ) {
resolve( colorThief.getPalette( img ) );
} else {
img.addEventListener( 'load', () => {
resolve( colorThief.getPalette( img ) );
} );
}
} );
}
const createBackground = () => {
return new Promise( ( resolve ) => {
getImageData().then( palette => {
console.log( palette );
const colourDetails: number[][] = [];
const colours: string[] = [];
let differentEnough = true;
if ( palette[ 0 ] ) {
for ( const i in palette ) {
for ( const colour in colourDetails ) {
const colourDiff = ( Math.abs( colourDetails[ colour ][ 0 ] - palette[ i ][ 0 ] ) / 255
+ Math.abs( colourDetails[ colour ][ 1 ] - palette[ i ][ 1 ] ) / 255
+ Math.abs( colourDetails[ colour ][ 2 ] - palette[ i ][ 2 ] ) / 255 ) / 3 * 100;
if ( colourDiff > 15 ) {
differentEnough = true;
}
}
if ( differentEnough ) {
colourDetails.push( palette[ i ] );
colours.push( 'rgb(' + palette[ i ][ 0 ] + ',' + palette[ i ][ 1 ] + ',' + palette[ i ][ 2 ] + ')' );
}
differentEnough = false;
}
}
let outColours = 'conic-gradient(';
if ( colours.length < 3 ) {
for ( let i = 0; i < 3; i++ ) {
if ( colours[ i ] ) {
outColours += colours[ i ] + ',';
} else {
if ( i === 0 ) {
outColours += 'blue,';
} else if ( i === 1 ) {
outColours += 'green,';
} else if ( i === 2 ) {
outColours += 'red,';
}
}
}
} else if ( colours.length < 11 ) {
for ( const i in colours ) {
outColours += colours[ i ] + ',';
}
} else {
for ( let i = 0; i < 10; i++ ) {
outColours += colours[ i ] + ',';
}
}
outColours += colours[ 0 ] ?? 'blue' + ')';
resolve( outColours );
} );
} );
}
let callbackFun = () => {}
const subscribeToBeatUpdate = ( cb: () => void ) => {
callbackFun = cb;
micAudioHandler();
}
const unsubscribeFromBeatUpdate = () => {
callbackFun = () => {}
try {
clearInterval( micAnalyzer );
} catch ( e ) { /* empty */ }
}
const coolDown = () => {
beatDetected = false;
}
let micAnalyzer = 0;
let beatDetected = false;
const micAudioHandler = () => {
const audioContext = new ( window.AudioContext || window.webkitAudioContext )();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array( bufferLength );
beatDetected = false;
navigator.mediaDevices.getUserMedia( { audio: true } ).then( ( stream ) => {
const mic = audioContext.createMediaStreamSource( stream );
mic.connect( analyser );
analyser.getByteFrequencyData( dataArray );
let prevSpectrum: number[] = [];
const threshold = 10; // Adjust as needed
micAnalyzer = setInterval( () => {
analyser.getByteFrequencyData( dataArray );
// Convert the frequency data to a numeric array
const currentSpectrum = Array.from( dataArray );
if ( prevSpectrum ) {
// Calculate the spectral flux
const flux = calculateSpectralFlux( prevSpectrum, currentSpectrum );
if ( flux > threshold && !beatDetected ) {
// Beat detected
beatDetected = true;
callbackFun();
}
}
prevSpectrum = currentSpectrum;
}, 20 );
} );
}
const calculateSpectralFlux = ( prevSpectrum: number[], currentSpectrum: number[] ) => {
let flux = 0;
for ( let i = 0; i < prevSpectrum.length; i++ ) {
const diff = currentSpectrum[ i ] - prevSpectrum[ i ];
flux += Math.max( 0, diff );
}
return flux;
}
export default {
createBackground,
subscribeToBeatUpdate,
unsubscribeFromBeatUpdate,
coolDown,
}

View File

@@ -0,0 +1,81 @@
/*
* MusicPlayerV2 - notificationHandler.ts
*
* Created by Janis Hutz 06/26/2024, Licensed under the GPL V3 License
* https://janishutz.com, development@janishutz.com
*
*
*/
// These functions handle connections to the backend with socket.io
import { io, type Socket } from "socket.io-client"
class SocketConnection {
socket: Socket;
roomName: string;
isConnected: boolean;
constructor () {
this.socket = io( localStorage.getItem( 'url' ) ?? '', {
autoConnect: false,
} );
this.roomName = location.pathname.split( '/' )[ 2 ];
this.isConnected = false;
}
/**
* Create a room token and connect to
* @returns {Promise<string>}
*/
connect (): Promise<any> {
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 );
reject( 'ERR_ROOM_CONNECTING' );
}
} );
} );
}
/**
* Emit an event
* @param {string} event The event to emit
* @param {any} data
* @returns {void}
*/
emit ( event: string, data: any ): void {
if ( this.isConnected ) {
this.socket.emit( event, { 'roomName': this.roomName, 'data': data } );
}
}
/**
* Register a listener function for an event
* @param {string} event The event to listen for
* @param {( data: any ) => void} cb The callback function / listener function
* @returns {void}
*/
registerListener ( event: string, cb: ( data: any ) => void ): void {
if ( this.isConnected ) {
this.socket.on( event, cb );
}
}
/**
* Disconnect from the server
* @returns {any}
*/
disconnect (): void {
if ( this.isConnected ) {
this.socket.disconnect();
}
}
}
export default SocketConnection;

View File

@@ -15,6 +15,7 @@ class NotificationHandler {
socket: Socket;
roomName: string;
roomToken: string;
isConnected: boolean;
constructor () {
this.socket = io( localStorage.getItem( 'url' ) ?? '', {
@@ -22,6 +23,7 @@ class NotificationHandler {
} );
this.roomName = '';
this.roomToken = '';
this.isConnected = false;
}
/**
@@ -41,7 +43,8 @@ class NotificationHandler {
name: this.roomName,
token: this.roomToken
}, ( res: { status: boolean, msg: string } ) => {
if ( res.status === true) {
if ( res.status === true ) {
this.isConnected = true;
resolve();
} else {
reject( 'ERR_ROOM_CONNECTING' );
@@ -64,7 +67,9 @@ class NotificationHandler {
* @returns {void}
*/
emit ( event: string, data: any ): void {
this.socket.emit( event, data );
if ( this.isConnected ) {
this.socket.emit( event, { 'roomToken': this.roomToken, 'roomName': this.roomName, 'data': data } );
}
}
/**
@@ -74,7 +79,9 @@ class NotificationHandler {
* @returns {void}
*/
registerListener ( event: string, cb: ( data: any ) => void ): void {
this.socket.on( event, cb );
if ( this.isConnected ) {
this.socket.on( event, cb );
}
}
/**
@@ -82,15 +89,21 @@ class NotificationHandler {
* @returns {any}
*/
disconnect (): void {
this.socket.disconnect();
this.socket.emit( 'delete-room', {
name: this.roomName,
token: this.roomToken
}, ( res: { status: boolean, msg: string } ) => {
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.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!' );
}
} );
}
}
getRoomName (): string {
return this.roomName;
}
}

View File

@@ -1,357 +0,0 @@
// IMPORTANT: Old, unfinished version that doesn't ship! See ./music-player.ts for the actual code!
type Origin = 'apple-music' | 'disk';
interface Song {
/**
* The ID. Either the apple music ID, or if from local disk, an ID starting in local_
*/
id: string;
/**
* Origin of the song
*/
origin: Origin;
/**
* The cover image as a URL
*/
cover: string;
/**
* The artist of the song
*/
artist: string;
/**
* The name of the song
*/
title: string;
/**
* Duration of the song in milliseconds
*/
duration: number;
/**
* (OPTIONAL) The genres this song belongs to. Can be displayed on the showcase screen, but requires settings there
*/
genres?: string[];
/**
* (OPTIONAL) This will be displayed in brackets on the showcase screens
*/
additionalInfo?: string;
}
interface Config {
devToken: string;
userToken: string;
}
class MusicKitJSWrapper {
playingSongID: number;
playlist: Song[];
queue: number[];
config: Config;
musicKit: any;
isLoggedIn: boolean;
isPreparedToPlay: boolean;
repeatMode: string;
isShuffleEnabled: boolean;
constructor () {
this.playingSongID = 0;
this.playlist = [];
this.queue = [];
this.config = {
devToken: '',
userToken: '',
};
this.isShuffleEnabled = false;
this.repeatMode = '';
this.isPreparedToPlay = false;
this.isLoggedIn = false;
const self = this;
if ( !window.MusicKit ) {
document.addEventListener( 'musickitloaded', () => {
self.init();
} );
} else {
this.init();
}
}
logIn () {
if ( !this.musicKit.isAuthorized ) {
this.musicKit.authorize().then( () => {
this.isLoggedIn = true;
this.init();
} );
} else {
this.musicKit.authorize().then( () => {
this.isLoggedIn = true;
this.init();
} );
}
}
init () {
fetch( localStorage.getItem( 'url' ) + '/getAppleMusicDevToken', { credentials: 'include' } ).then( res => {
if ( res.status === 200 ) {
res.text().then( token => {
// MusicKit global is now defined
MusicKit.configure( {
developerToken: token,
app: {
name: 'MusicPlayer',
build: '2'
},
storefrontId: 'CH',
} ).then( () => {
this.config.devToken = token;
this.musicKit = MusicKit.getInstance();
if ( this.musicKit.isAuthorized ) {
this.isLoggedIn = true;
this.config.userToken = this.musicKit.musicUserToken;
}
this.musicKit.shuffleMode = MusicKit.PlayerShuffleMode.off;
this.apiGetRequest( 'https://api.music.apple.com/v1/me/library/playlists', this.handleAPIReturns );
} );
} );
}
} );
}
handleAPIReturns ( data: object ) {
console.log( data );
}
getUserPlaylists () {
}
apiGetRequest ( url: string, callback: ( data: object ) => void ) {
if ( this.config.devToken != '' && this.config.userToken != '' ) {
fetch( url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${ this.config.devToken }`,
'Music-User-Token': this.config.userToken
}
} ).then( res => {
if ( res.status === 200 ) {
res.json().then( json => {
try {
callback( { 'status': 'ok', 'data': json } );
} catch( err ) { /* empty */}
} );
} else {
try {
callback( { 'status': 'error', 'error': res.status } );
} catch( err ) { /* empty */}
}
} );
} else return false;
}
/**
* Start playing the song at the current songID.
* @returns {void}
*/
play (): void {
this.musicKit.play();
}
/**
* Start playing the current song
* @returns {void}
*/
pause (): void {
this.musicKit.pause()
}
/**
* Skip to the next song
* @returns {void}
*/
skip (): void {
if ( this.playingSongID < this.queue.length - 1 ) {
this.playingSongID += 1;
} else {
this.playingSongID = 0;
this.pause();
}
}
/**
* Return to start of song, or if within four seconds of start of the song, go to previous song.
* @returns {void}
*/
previous (): void {
if ( this.playingSongID > 0 ) {
this.playingSongID -= 1;
} else {
this.playingSongID = this.queue.length - 1;
}
}
/**
* Go to a specific position in the song. If position > song duration, go to next song
* @param {number} pos The position in milliseconds since start of the song
* @returns {void}
*/
goToPos ( pos: number ): void {
// TODO: Implement for non-apple-music too
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.seekToTime( pos );
}
}
/**
* Set, if the queue should be shuffled
* @param {boolean} enable True to enable shuffle, false to disable
* @returns {void}
*/
shuffle ( enable: boolean ): void {
this.isShuffleEnabled = enable;
this.preparePlaying( false );
}
/**
* Set the repeat mode
* @param {string} repeatType The repeat type. Can be '', '_on' or '_one_on'
* @returns {void}
*/
repeat ( repeatType: string ): void {
this.repeatMode = repeatType;
}
/**
* Set the playlist to play.
* @param {Song[]} pl Playlist to play. An array of songs
* @returns {void}
*/
setPlaylist ( pl: Song[] ): void {
this.playlist = pl;
this.pause();
this.playingSongID = 0;
this.queue = [];
}
/**
* Prepare to play songs. Should be called whenever the playlist is changed or at beginning
* @param {boolean?} reset (OPTIONAL) Reset the players or keep playing, but shuffle playlist?
* @returns {void}
*/
preparePlaying ( reset?: boolean ): void {
this.queue = [];
this.isPreparedToPlay = true;
// TODO: finish
}
/**
* Set which song (by Song-ID) to play.
* @param {string} id The song ID (apple music ID or internal ID, if from local drive)
* @returns {void}
*/
setCurrentlyPlayingSongID ( id: string ): void {
// TODO: Implement playlist etc handling
this.setPlayingSong( id, 'apple-music' );
}
/**
* Insert a song into the currently playing playlist
* @param {Song} song A song using the Song object
* @param {number} pos Position in the queue to insert it into
* @returns {void}
*/
insertSong ( song: Song, pos: number ): void {
}
/**
* Remove a song from the queue
* @param {string} id Song ID to remove.
* @returns {void}
*/
removeSong ( id: string ): void {
// TODO: Remove from queue too
}
/**
* Get the playlist, as it will play
* @returns {Song[]}
*/
getOrderedPlaylist (): Song[] {
return this.playlist;
}
/**
* Get the playlist, ignoring order specified by the queue.
* @returns {Song[]}
*/
getPlaylist (): Song[] {
return this.playlist;
}
/**
* Get the position of the playback head. Returns time in ms
* @returns {number}
*/
getPlaybackPos (): number {
return 0;
}
/**
* Returns the currently playing song object
* @returns {Song}
*/
getPlayingSong (): Song {
return this.playlist[ this.playingSongID ];
}
/**
* Returns the ID of the currently playing song
* @returns {string}
*/
getPlayingSongID (): string {
return this.playlist[ this.playingSongID ].id;
}
/**
* Returns the index in the playlist of the currently playing song
* @returns {number}
*/
getPlayingIndex (): number {
return this.playingSongID;
}
/**
* Set the currently playing song by Apple Music ID or disk path
* @param {string} id The ID of the song or disk path
* @param {Origin} origin The origin of the song.
* @returns {void}
*/
setPlayingSong ( id: string, origin: Origin ): void {
if ( origin === 'apple-music' ) {
this.musicKit.setQueue( { 'song': id } ).then( () => {
setTimeout( () => {
this.play();
}, 500 );
} ).catch( ( err ) => {
console.log( err );
} );
} else {
// TODO: Implement
}
}
}
export default MusicKitJSWrapper;

View File

@@ -10,17 +10,17 @@
import { defineStore } from 'pinia';
export const useUserStore = defineStore( 'user', {
state: () => ( { 'isUserAuth': false, 'isAdminAuth': false, 'isUsingKeyboard': false, 'username': '' } ),
state: () => ( { 'isUserAuth': false, 'hasSubscribed': false, 'isUsingKeyboard': false, 'username': '' } ),
getters: {
getUserAuthenticated: ( state ) => state.isUserAuth,
getAdminAuthenticated: ( state ) => state.isAdminAuth,
getSubscriptionStatus: ( state ) => state.hasSubscribed,
},
actions: {
setUserAuth ( auth: boolean ) {
this.isUserAuth = auth;
},
setAdminAuth ( auth: boolean ) {
this.isAdminAuth = auth;
setSubscriptionStatus ( status: boolean ) {
this.hasSubscribed = status;
},
setUsername ( username: string ) {
this.username = username;

View File

@@ -1,6 +1,9 @@
<template>
<div class="app-view">
<div class="home-view" v-if="isReady">
<div class="loading-view" v-if="!hasFinishedLoading">
<h1>Loading...</h1>
</div>
<div class="home-view" v-else-if="hasFinishedLoading && isReady">
<libraryView class="library-view" :playlists="playlists" @selected-playlist="( id ) => { selectPlaylist( id ) }"
:is-logged-in="isLoggedIntoAppleMusic" @custom-playlist="( pl ) => selectCustomPlaylist( pl )"></libraryView>
</div>
@@ -11,6 +14,7 @@
</div>
<playerView :class="'player-view' + ( isReady ? ( isShowingFullScreenPlayer ? ' full-screen-player' : '' ) : ' player-hidden' )" @player-state-change="( state ) => { handlePlayerStateChange( state ) }"
ref="player"></playerView>
<!-- TODO: Call to backend to check if user has access -->
</div>
</template>
@@ -25,6 +29,7 @@
const isShowingFullScreenPlayer = ref( false );
const player = ref( playerView );
const playlists = ref( [] );
const hasFinishedLoading = ref( true );
const handlePlayerStateChange = ( newState: string ) => {
if ( newState === 'hide' ) {
@@ -66,6 +71,15 @@
const selectCustomPlaylist = ( playlist: ReadFile[] ) => {
player.value.selectCustomPlaylist( playlist );
}
// fetch( localStorage.getItem( 'url' ) + '/checkUserStatus', { credentials: 'include' } ).then( res => {
// if ( res.status === 200 ) {
// res.json().then( json => {
// } );
// }
// } );
</script>
<style scoped>

View File

@@ -0,0 +1,321 @@
<template>
<div>
<div class="info">Designed and developed by Janis Hutz <a href="https://janishutz.com" target="_blank" style="text-decoration: none; color: white;">https://janishutz.com</a></div>
<div class="remote-view">
<div v-if="hasLoaded && !showCouldNotFindRoom" style="width: 100%">
<div class="current-song-wrapper">
<img v-if="playlist[ playingSong ]" :src="playlist[ playingSong ].cover" class="fancy-view-song-art" id="current-image" crossorigin="anonymous">
<span v-else class="material-symbols-outlined">music_note</span>
<div class="current-song">
<progress max="1000" id="progress" :value="progressBar"></progress>
<h1>{{ playlist[ playingSong ] ? playlist[ playingSong ].title : 'Not playing' }}</h1>
<p class="additional-info" v-if="playlist[ playingSong ] ? ( playlist[ playingSong ].additionalInfo !== '' ) : false">{{ playlist[ playingSong ] ? playlist[ playingSong ].additionalInfo : '' }}</p>
<p>{{ playlist[ playingSong ] ? playlist[ playingSong ].artist : '' }}</p>
</div>
</div>
<div class="song-list-wrapper">
<div v-for="song in songQueue" v-bind:key="song.id" class="song-list">
<div class="song-details-wrapper">
<h3>{{ song.title }}</h3>
<p>{{ song.artist }}</p>
</div>
<div class="time-until">
{{ getTimeUntil( song.id ) }}
</div>
</div>
<!-- <img :src="" alt=""> -->
</div>
</div>
<div v-else-if="!hasLoaded && !showCouldNotFindRoom">
<h1>Loading...</h1>
</div>
<div v-else>
<h1>Couldn't connect!</h1>
<p>There does not appear to be a share with the specified name, or an error occurred when connecting.</p>
<p>You may reload the page to try again!</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SocketConnection from '@/scripts/connection';
import type { Song } from '@/scripts/song';
import { computed, ref, type Ref } from 'vue';
const isPlaying = ref( false );
const playlist: Ref<Song[]> = ref( [] );
const pos = ref( 0 );
const playingSong = ref( 0 );
const progressBar = ref( 0 );
const hasLoaded = ref( false );
const showCouldNotFindRoom = ref( false );
const playbackStart = ref( 0 );
let timeTracker = 0;
const conn = new SocketConnection();
conn.connect().then( d => {
playlist.value = d.playlist;
isPlaying.value = d.playbackStatus;
playingSong.value = d.playlistIndex;
playbackStart.value = d.playbackStart;
if ( isPlaying.value ) {
startTimeTracker();
}
pos.value = ( new Date().getTime() - parseInt( d.playbackStart ) ) / 1000;
progressBar.value = ( pos.value / playlist.value[ playingSong.value ].duration ) * 1000;
hasLoaded.value = true;
conn.registerListener( 'playlist', ( data ) => {
playlist.value = data;
} );
conn.registerListener( 'playback', ( data ) => {
isPlaying.value = data;
if ( isPlaying.value ) {
startTimeTracker();
} else {
stopTimeTracker();
}
} );
conn.registerListener( 'playback-start', ( data ) => {
playbackStart.value = data;
pos.value = ( new Date().getTime() - parseInt( data ) ) / 1000;
} );
conn.registerListener( 'playlist-index', ( data ) => {
playingSong.value = parseInt( data );
} );
} ).catch( () => {
showCouldNotFindRoom.value = true;
} );
const songQueue = computed( () => {
let ret: Song[] = [];
let pos = 0;
for ( let song in playlist.value ) {
if ( pos >= playingSong.value ) {
ret.push( playlist.value[ song ] );
}
pos += 1;
}
return ret;
} );
// TODO: Handle disconnect from updater (=> have it disconnect)
const getTimeUntil = computed( () => {
return ( song: string ) => {
let timeRemaining = 0;
for ( let i = playingSong.value; i < Object.keys( playlist.value ).length - 1; i++ ) {
if ( playlist.value[ i ].id == song ) {
break;
}
timeRemaining += playlist.value[ i ].duration;
}
if ( isPlaying.value ) {
if ( timeRemaining === 0 ) {
return 'Currently playing';
} else {
return 'Playing in less than ' + Math.ceil( timeRemaining / 60 - pos.value / 60 ) + 'min';
}
} else {
if ( timeRemaining === 0 ) {
return 'Plays next';
} else {
return 'Playing less than ' + Math.ceil( timeRemaining / 60 - pos.value / 60 ) + 'min after starting to play';
}
}
}
} );
const startTimeTracker = () => {
try {
clearInterval( timeTracker );
} catch ( err ) { /* empty */ }
timeTracker = setInterval( () => {
pos.value = ( new Date().getTime() - playbackStart.value ) / 1000;
progressBar.value = ( pos.value / playlist.value[ playingSong.value ].duration ) * 1000;
if ( isNaN( progressBar.value ) ) {
progressBar.value = 0;
}
}, 100 );
}
const stopTimeTracker = () => {
clearInterval( timeTracker );
}
</script>
<style scoped>
.remote-view {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
text-align: justify;
}
.loaded {
display: block;
}
.loading {
display: flex;
height: 100vh;
}
.playing-symbols {
position: absolute;
left: 10vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
width: 5vw;
height: 5vw;
background-color: rgba( 0, 0, 0, 0.6 );
}
.playing-symbols-wrapper {
width: 4vw;
height: 5vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
}
.song-list-wrapper {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin-bottom: 5%;
}
.song-list {
display: flex;
flex-direction: row;
align-items: center;
width: 80%;
margin: 2px;
padding: 1vh;
border: 1px white solid;
background-color: rgba( 0, 0, 0, 0.4 );
}
.song-details-wrapper {
margin: 0;
display: block;
margin-left: 10px;
margin-right: auto;
width: 65%;
text-align: justify;
}
.song-list .song-image {
width: 5vw;
height: 5vw;
object-fit: cover;
object-position: center;
font-size: 5vw;
}
.pause-icon {
width: 5vw;
height: 5vw;
object-fit: cover;
object-position: center;
font-size: 5vw !important;
user-select: none;
}
.current-song-wrapper {
display: flex;
align-items: center;
flex-direction: column;
width: 100%;
margin-bottom: 2%;
margin-top: 1%;
}
.current-song {
display: flex;
align-items: center;
flex-direction: column;
margin-top: 1vh;
padding: 1vh;
max-width: 80%;
text-align: center;
background-color: rgba( 0, 0, 0, 0.4 );
}
.fancy-view-song-art {
height: 30vh;
width: 30vh;
object-fit: cover;
object-position: center;
margin-bottom: 10px;
font-size: 30vh !important;
}
#app {
background-color: rgba( 0, 0, 0, 0 );
}
#progress, #progress::-webkit-progress-bar {
background-color: rgba(45, 28, 145);
color: rgba(45, 28, 145);
width: 30vw;
border: none;
border-radius: 0px;
accent-color: white;
-webkit-appearance: none;
appearance: none;
}
#progress::-moz-progress-bar {
background-color: white;
}
#progress::-webkit-progress-value {
background-color: white !important;
}
.mode-selector-wrapper {
opacity: 0;
position: fixed;
right: 0.5%;
top: 0.5%;
padding: 0.5%;
}
.mode-selector-wrapper:hover {
opacity: 1;
}
.additional-info {
font-size: 250%;
margin: 0;
font-weight: bolder;
}
.info {
position: fixed;
font-size: 12px;
transform: rotate(270deg);
left: -150px;
margin: 0;
padding: 0;
top: 50%;
}
.time-until {
width: 30%;
text-align: end;
}
</style>

View File

@@ -1,416 +1,479 @@
<template>
<div>
<div class="info">Designed and developed by Janis Hutz <a href="https://janishutz.com" target="_blank" style="text-decoration: none; color: white;">https://janishutz.com</a></div>
<div class="content" id="app">
<div v-if="hasLoaded" style="width: 100%">
<div class="remote-view">
<div v-if="hasLoaded && !showCouldNotFindRoom" class="showcase-wrapper">
<div class="current-song-wrapper">
<span class="material-symbols-outlined fancy-view-song-art" v-if="!playingSong.hasCoverArt">music_note</span>
<img v-else-if="playingSong.hasCoverArt && playingSong.coverArtOrigin === 'api'" :src="playingSong.coverArtURL" class="fancy-view-song-art" id="current-image" crossorigin="anonymous">
<img v-else :src="'/getSongCover?filename=' + playingSong.filename" class="fancy-view-song-art" id="current-image">
<img v-if="playlist[ playingSong ]" :src="playlist[ playingSong ].cover" class="fancy-view-song-art" id="current-image" crossorigin="anonymous">
<span v-else class="material-symbols-outlined">music_note</span>
<div class="current-song">
<progress max="1000" id="progress" :value="progressBar"></progress>
<h1>{{ playingSong.title }}</h1>
<p class="dancing-style" v-if="playingSong.dancingStyle">{{ playingSong.dancingStyle }}</p>
<p>{{ playingSong.artist }}</p>
<h1>{{ playlist[ playingSong ] ? playlist[ playingSong ].title : 'Not playing' }}</h1>
<p class="additional-info" v-if="playlist[ playingSong ] ? ( playlist[ playingSong ].additionalInfo !== '' ) : false">{{ playlist[ playingSong ] ? playlist[ playingSong ].additionalInfo : '' }}</p>
<p>{{ playlist[ playingSong ] ? playlist[ playingSong ].artist : '' }}</p>
</div>
</div>
<div class="mode-selector-wrapper">
<select v-model="visualizationSettings" @change="setVisualization()">
<select v-model="visualizationSettings" @change="handleAnimationChange()">
<option value="mic">Microphone (Mic access required)</option>
<option value="bpm">BPM (might not be 100% accurate)</option>
<option value="off">No visualization except background</option>
</select>
</div>
<div class="song-list-wrapper">
<div v-for="song in songQueue" class="song-list">
<span class="material-symbols-outlined song-image" v-if="!song.hasCoverArt && ( playingSong.filename !== song.filename || isPlaying )">music_note</span>
<img v-else-if="song.hasCoverArt && ( playingSong.filename !== song.filename || isPlaying ) && song.coverArtOrigin === 'api'" :src="song.coverArtURL" class="song-image">
<img v-else-if="song.hasCoverArt && ( playingSong.filename !== song.filename || isPlaying ) && song.coverArtOrigin !== 'api'" :src="'/getSongCover?filename=' + song.filename" class="song-image">
<div v-if="playingSong.filename === song.filename && isPlaying" class="playing-symbols">
<div v-for="song in songQueue" v-bind:key="song.id" class="song-list">
<img :src="song.cover" class="song-image">
<div v-if="( playlist[ playingSong ] ? playlist[ playingSong ].id : '' ) === song.id && isPlaying" class="playing-symbols">
<div class="playing-symbols-wrapper">
<div class="playing-bar" id="bar-1"></div>
<div class="playing-bar" id="bar-2"></div>
<div class="playing-bar" id="bar-3"></div>
</div>
</div>
<span class="material-symbols-outlined pause-icon" v-if="!isPlaying && playingSong.filename === song.filename">pause</span>
<div class="song-details-wrapper">
<h3>{{ song.title }}</h3>
<p>{{ song.artist }}</p>
</div>
<div class="time-until">
{{ getTimeUntil( song ) }}
{{ getTimeUntil( song.id ) }}
</div>
</div>
<!-- <img :src="" alt=""> -->
</div>
</div>
<div v-else>
<div v-else-if="!hasLoaded && !showCouldNotFindRoom" class="showcase-wrapper">
<h1>Loading...</h1>
</div>
<div v-else class="showcase-wrapper">
<h1>Couldn't connect!</h1>
<p>There does not appear to be a share with the specified name, or an error occurred when connecting.</p>
<p>You may reload the page to try again!</p>
</div>
<div class="background" id="background">
<div class="beat"></div>
<div class="beat-manual"></div>
</div>
</div>
<!-- TODO: Get ColorThief either from CDN or preferably as NPM module -->
<!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/color-thief/2.3.0/color-thief.umd.js"></script> -->
</div>
</template>
<script setup lang="ts">
import SocketConnection from '@/scripts/connection';
import type { Song } from '@/scripts/song';
import { computed, ref, type Ref } from 'vue';
import { ColorThief } from 'colorthief';
import bizualizer from '@/scripts/bizualizer';
const hasLoaded = ref( false );
const songs: Ref<Song[]> = ref( [] );
const playingSong = ref( 0 );
const isPlaying = ref( false );
const playlist: Ref<Song[]> = ref( [] );
const pos = ref( 0 );
const colourPalette: string[] = [];
const playingSong = ref( 0 );
const progressBar = ref( 0 );
const timeTracker = ref( 0 );
const hasLoaded = ref( false );
const showCouldNotFindRoom = ref( false );
const playbackStart = ref( 0 );
let timeTracker = 0;
const visualizationSettings = ref( 'mic' );
const micAnalyzer = ref( 0 );
const beatDetected = ref( false );
const colorThief = new ColorThief();
const conn = new SocketConnection();
conn.connect().then( d => {
playlist.value = d.playlist;
isPlaying.value = d.playbackStatus;
playingSong.value = d.playlistIndex;
playbackStart.value = d.playbackStart;
if ( isPlaying.value ) {
startTimeTracker();
}
pos.value = ( new Date().getTime() - parseInt( d.playbackStart ) ) / 1000;
progressBar.value = ( pos.value / playlist.value[ playingSong.value ].duration ) * 1000;
hasLoaded.value = true;
conn.registerListener( 'playlist', ( data ) => {
playlist.value = data;
} );
conn.registerListener( 'playback', ( data ) => {
isPlaying.value = data;
if ( isPlaying.value ) {
startTimeTracker();
} else {
stopTimeTracker();
}
} );
conn.registerListener( 'playback-start', ( data ) => {
playbackStart.value = data;
pos.value = ( new Date().getTime() - parseInt( data ) ) / 1000;
} );
conn.registerListener( 'playlist-index', ( data ) => {
playingSong.value = parseInt( data );
setTimeout( () => {
setBackground();
}, 1000 )
} );
} ).catch( () => {
showCouldNotFindRoom.value = true;
} );
const songQueue = computed( () => {
let ret = [];
let ret: Song[] = [];
let pos = 0;
for ( let song in songs.value ) {
for ( let song in playlist.value ) {
if ( pos >= playingSong.value ) {
ret.push( songs.value[ song ] );
ret.push( playlist.value[ song ] );
}
pos += 1;
}
return ret;
} );
// TODO: Handle disconnect from updater (=> have it disconnect)
const getTimeUntil = computed( () => {
return ( song ) => {
return ( song: string ) => {
let timeRemaining = 0;
for ( let i = this.queuePos; i < Object.keys( this.songs ).length - 1; i++ ) {
if ( this.songs[ i ] == song ) {
for ( let i = playingSong.value; i < Object.keys( playlist.value ).length - 1; i++ ) {
if ( playlist.value[ i ].id == song ) {
break;
}
timeRemaining += parseInt( this.songs[ i ].duration );
timeRemaining += playlist.value[ i ].duration;
}
if ( isPlaying.value ) {
if ( timeRemaining === 0 ) {
return 'Currently playing';
} else {
return 'Playing in less than ' + Math.ceil( timeRemaining / 60 - this.pos / 60 ) + 'min';
return 'Playing in less than ' + Math.ceil( timeRemaining / 60 - pos.value / 60 ) + 'min';
}
} else {
if ( timeRemaining === 0 ) {
return 'Plays next';
} else {
return 'Playing less than ' + Math.ceil( timeRemaining / 60 - this.pos / 60 ) + 'min after starting to play';
return 'Playing less than ' + Math.ceil( timeRemaining / 60 - pos.value / 60 ) + 'min after starting to play';
}
}
}
} );
methods: {
startTimeTracker () {
this.timeTracker = setInterval( () => {
this.pos = ( new Date().getTime() - this.playingSong.startTime ) / 1000 + this.oldPos;
this.progressBar = ( this.pos / this.playingSong.duration ) * 1000;
if ( isNaN( this.progressBar ) ) {
this.progressBar = 0;
}
}, 100 );
},
stopTimeTracker () {
clearInterval( this.timeTracker );
this.oldPos = this.pos;
},
getImageData() {
return new Promise( ( resolve, reject ) => {
if ( this.playingSong.hasCoverArt ) {
setTimeout( () => {
const img = document.getElementById( 'current-image' );
if ( img.complete ) {
resolve( this.colorThief.getPalette( img ) );
} else {
img.addEventListener( 'load', () => {
resolve( this.colorThief.getPalette( img ) );
} );
}
}, 500 );
} else {
reject( 'no image' );
}
} );
},
connect() {
this.colorThief = new ColorThief();
let source = new EventSource( '/clientDisplayNotifier', { withCredentials: true } );
source.onmessage = ( e ) => {
let data;
try {
data = JSON.parse( e.data );
} catch ( err ) {
data = { 'type': e.data };
}
if ( data.type === 'basics' ) {
this.isPlaying = data.data.isPlaying ?? false;
this.playingSong = data.data.playingSong ?? {};
this.songs = data.data.songQueue ?? [];
this.pos = data.data.pos ?? 0;
this.oldPos = data.data.pos ?? 0;
this.progressBar = this.pos / this.playingSong.duration * 1000;
this.queuePos = data.data.queuePos ?? 0;
this.getImageData().then( palette => {
this.colourPalette = palette;
this.handleBackground();
} ).catch( () => {
this.colourPalette = [ { 'r': 255, 'g': 0, 'b': 0 }, { 'r': 0, 'g': 255, 'b': 0 }, { 'r': 0, 'g': 0, 'b': 255 } ];
this.handleBackground();
} );
} else if ( data.type === 'pos' ) {
this.pos = data.data;
this.oldPos = data.data;
this.progressBar = data.data / this.playingSong.duration * 1000;
} else if ( data.type === 'isPlaying' ) {
this.isPlaying = data.data;
this.handleBackground();
} else if ( data.type === 'songQueue' ) {
this.songs = data.data;
} else if ( data.type === 'playingSong' ) {
this.playingSong = data.data;
this.getImageData().then( palette => {
this.colourPalette = palette;
this.handleBackground();
} ).catch( () => {
this.colourPalette = [ [ 255, 0, 0 ], [ 0, 255, 0 ], [ 0, 0, 255 ] ];
this.handleBackground();
} );
} else if ( data.type === 'queuePos' ) {
this.queuePos = data.data;
}
};
source.onopen = () => {
this.isReconnecting = false;
this.hasLoaded = true;
};
const startTimeTracker = () => {
try {
clearInterval( timeTracker );
} catch ( err ) { /* empty */ }
let self = this;
source.addEventListener( 'error', function( e ) {
if ( e.eventPhase == EventSource.CLOSED ) source.close();
if ( e.target.readyState == EventSource.CLOSED ) {
console.log( 'disconnected' );
}
// TODO: Notify about disconnect
setTimeout( () => {
if ( !self.isReconnecting ) {
self.isReconnecting = true;
self.tryReconnect();
}
}, 1000 );
}, false );
},
tryReconnect() {
const int = setInterval( () => {
if ( !this.isReconnecting ) {
clearInterval( int );
} else {
connectToSSESource();
}
}, 1000 );
},
handleBackground() {
let colourDetails = [];
let colours = [];
let differentEnough = true;
if ( this.colourPalette[ 0 ] ) {
for ( let i in this.colourPalette ) {
for ( let colour in colourDetails ) {
const colourDiff = ( Math.abs( colourDetails[ colour ][ 0 ] - this.colourPalette[ i ][ 0 ] ) / 255
+ Math.abs( colourDetails[ colour ][ 1 ] - this.colourPalette[ i ][ 1 ] ) / 255
+ Math.abs( colourDetails[ colour ][ 2 ] - this.colourPalette[ i ][ 2 ] ) / 255 ) / 3 * 100;
if ( colourDiff > 15 ) {
differentEnough = true;
}
}
if ( differentEnough ) {
colourDetails.push( this.colourPalette[ i ] );
colours.push( 'rgb(' + this.colourPalette[ i ][ 0 ] + ',' + this.colourPalette[ i ][ 1 ] + ',' + this.colourPalette[ i ][ 2 ] + ')' );
}
differentEnough = false;
}
setTimeout( () => {
handleAnimationChange();
setBackground();
}, 1000 );
timeTracker = setInterval( () => {
pos.value = ( new Date().getTime() - playbackStart.value ) / 1000;
progressBar.value = ( pos.value / playlist.value[ playingSong.value ].duration ) * 1000;
if ( isNaN( progressBar.value ) ) {
progressBar.value = 0;
}
let outColours = 'conic-gradient(';
if ( colours.length < 3 ) {
for ( let i = 0; i < 3; i++ ) {
if ( colours[ i ] ) {
outColours += colours[ i ] + ',';
} else {
if ( i === 0 ) {
outColours += 'blue,';
} else if ( i === 1 ) {
outColours += 'green,';
} else if ( i === 2 ) {
outColours += 'red,';
}
}
}
} else if ( colours.length < 11 ) {
for ( let i in colours ) {
outColours += colours[ i ] + ',';
}
} else {
for ( let i = 0; i < 10; i++ ) {
outColours += colours[ i ] + ',';
}
}
outColours += colours[ 0 ] ?? 'blue' + ')';
}, 100 );
}
const stopTimeTracker = () => {
clearInterval( timeTracker );
$( '#background' ).css( 'background', outColours );
this.setVisualization();
},
setVisualization () {
if ( Object.keys( this.playingSong ).length > 0 ) {
if ( this.visualizationSettings === 'bpm' ) {
if ( this.playingSong.bpm && this.isPlaying ) {
$( '.beat' ).show();
$( '.beat' ).css( 'animation-duration', 60 / this.playingSong.bpm );
$( '.beat' ).css( 'animation-delay', this.pos % ( 60 / this.playingSong.bpm * this.pos ) + this.playingSong.bpmOffset - ( 60 / this.playingSong.bpm * this.pos / 2 ) );
} else {
$( '.beat' ).hide();
}
try {
clearInterval( this.micAnalyzer );
} catch ( err ) {}
} else if ( this.visualizationSettings === 'off' ) {
$( '.beat' ).hide();
try {
clearInterval( this.micAnalyzer );
} catch ( err ) {}
} else if ( this.visualizationSettings === 'mic' ) {
$( '.beat-manual' ).hide();
try {
clearInterval( this.micAnalyzer );
} catch ( err ) {}
this.micAudioHandler();
}
} else {
console.log( 'not playing yet' );
}
},
micAudioHandler () {
const audioContext = new ( window.AudioContext || window.webkitAudioContext )();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array( bufferLength );
handleAnimationChange();
}
navigator.mediaDevices.getUserMedia( { audio: true } ).then( ( stream ) => {
const mic = audioContext.createMediaStreamSource( stream );
mic.connect( analyser );
analyser.getByteFrequencyData( dataArray );
let prevSpectrum = null;
let threshold = 10; // Adjust as needed
this.beatDetected = false;
this.micAnalyzer = setInterval( () => {
analyser.getByteFrequencyData( dataArray );
// Convert the frequency data to a numeric array
const currentSpectrum = Array.from( dataArray );
if ( prevSpectrum ) {
// Calculate the spectral flux
const flux = this.calculateSpectralFlux( prevSpectrum, currentSpectrum );
if ( flux > threshold && !this.beatDetected ) {
// Beat detected
this.beatDetected = true;
this.animateBeat();
}
}
prevSpectrum = currentSpectrum;
}, 20 );
} );
},
animateBeat () {
$( '.beat-manual' ).stop();
const duration = Math.ceil( 60 / ( this.playingSong.bpm ?? 180 ) * 500 ) - 50;
$( '.beat-manual' ).fadeIn( 50 );
const animateBeat = () => {
$( '.beat-manual' ).stop();
const duration = Math.ceil( 60 / 120 * 500 ) - 50;
$( '.beat-manual' ).fadeIn( 50 );
setTimeout( () => {
$( '.beat-manual' ).fadeOut( duration );
setTimeout( () => {
$( '.beat-manual' ).fadeOut( duration );
setTimeout( () => {
$( '.beat-manual' ).stop();
this.beatDetected = false;
}, duration );
}, 50 );
},
calculateSpectralFlux( prevSpectrum, currentSpectrum ) {
let flux = 0;
for ( let i = 0; i < prevSpectrum.length; i++ ) {
const diff = currentSpectrum[ i ] - prevSpectrum[ i ];
flux += Math.max( 0, diff );
}
return flux;
},
notifier() {
if ( parseInt( this.lastDispatch ) + 5000 < new Date().getTime() ) {
bizualizer.coolDown();
$( '.beat-manual' ).stop();
}, duration );
}, 50 );
}
}
Notification.requestPermission();
console.warn( '[ notifier ]: Status is now enabled \n\n-> Any leaving or tampering with the website will send a notification to the host' );
// Detect if window is currently in focus
window.onblur = () => {
this.sendNotification( 'blur' );
}
// Detect if browser window becomes hidden (also with blur event)
document.onvisibilitychange = () => {
if ( document.visibilityState === 'hidden' ) {
this.sendNotification( 'visibility' );
}
};
},
sendNotification( notification ) {
let fetchOptions = {
method: 'post',
body: JSON.stringify( { 'type': notification } ),
headers: {
'Content-Type': 'application/json',
'charset': 'utf-8'
},
};
fetch( '/clientStatusUpdate', fetchOptions ).catch( err => {
console.error( err );
} );
new Notification( 'YOU ARE UNDER SURVEILLANCE', {
body: 'Please return to the original webpage immediately!',
requireInteraction: true,
} )
}
},
mounted() {
this.connect();
this.notifier();
// if ( this.visualizationSettings === 'mic' ) {
// this.micAudioHandler();
// }
},
watch: {
isPlaying( value ) {
if ( value ) {
this.startTimeTracker();
} else {
this.stopTimeTracker();
}
const handleAnimationChange = () => {
if ( visualizationSettings.value === 'mic' && isPlaying.value ) {
bizualizer.subscribeToBeatUpdate( animateBeat );
} else {
bizualizer.unsubscribeFromBeatUpdate()
}
}
} ).mount( '#app' );
</script>
const setBackground = () => {
bizualizer.createBackground().then( bg => {
$( '#background' ).css( 'background', bg );
} );
}
const notifier = () => {
Notification.requestPermission();
console.warn( '[ notifier ]: Status is now enabled \n\n-> Any leaving or tampering with the website will send a notification to the host' );
// Detect if window is currently in focus
window.onblur = () => {
sendNotification();
}
// Detect if browser window becomes hidden (also with blur event)
document.onvisibilitychange = () => {
if ( document.visibilityState === 'hidden' ) {
sendNotification();
}
};
}
const sendNotification = () => {
new Notification( 'YOU ARE UNDER SURVEILLANCE', {
body: 'Please return to the original webpage immediately!',
requireInteraction: true,
} );
}
</script>
<style scoped>
.remote-view {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
text-align: justify;
}
.showcase-wrapper {
width: 100%;
z-index: 5;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.playing-symbols {
position: absolute;
left: 10vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
width: 5vw;
height: 5vw;
background-color: rgba( 0, 0, 0, 0.6 );
}
.playing-symbols-wrapper {
width: 4vw;
height: 5vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
}
.playing-bar {
height: 60%;
background-color: white;
width: 10%;
border-radius: 50px;
margin: auto;
}
#bar-1 {
animation: music-playing 0.9s infinite ease-in-out;
}
#bar-2 {
animation: music-playing 0.9s infinite ease-in-out;
animation-delay: 0.3s;
}
#bar-3 {
animation: music-playing 0.9s infinite ease-in-out;
animation-delay: 0.6s;
}
@keyframes music-playing {
0% {
transform: scaleY( 1 );
}
50% {
transform: scaleY( 0.5 );
}
100% {
transform: scaleY( 1 );
}
}
.song-list-wrapper {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.song-list {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 80%;
margin: 2px;
padding: 1vh;
border: 1px white solid;
background-color: rgba( 0, 0, 0, 0.4 );
}
.song-details-wrapper {
margin: 0;
display: block;
margin-left: 10px;
margin-right: auto;
text-align: justify;
}
.song-list .song-image {
width: 5vw;
height: 5vw;
object-fit: cover;
object-position: center;
font-size: 5vw;
}
.pause-icon {
width: 5vw;
height: 5vw;
object-fit: cover;
object-position: center;
font-size: 5vw !important;
user-select: none;
}
.current-song-wrapper {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 55vh;
width: 100%;
margin-bottom: 0.5%;
margin-top: 0.25%;
}
.current-song {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin-top: 1vh;
padding: 1vh;
text-align: center;
background-color: rgba( 0, 0, 0, 0.4 );
}
.fancy-view-song-art {
height: 30vh;
width: 30vh;
object-fit: cover;
object-position: center;
margin-bottom: 10px;
font-size: 30vh !important;
}
#app {
background-color: rgba( 0, 0, 0, 0 );
}
#progress, #progress::-webkit-progress-bar {
background-color: rgba(45, 28, 145);
color: rgba(45, 28, 145);
width: 30vw;
border: none;
border-radius: 0px;
accent-color: white;
-webkit-appearance: none;
appearance: none;
}
#progress::-moz-progress-bar {
background-color: white;
}
#progress::-webkit-progress-value {
background-color: white !important;
}
.mode-selector-wrapper {
opacity: 0;
position: fixed;
right: 0.5%;
top: 0.5%;
padding: 0.5%;
}
.mode-selector-wrapper:hover {
opacity: 1;
}
.dancing-style {
font-size: 250%;
margin: 0;
font-weight: bolder;
}
.info {
position: fixed;
font-size: 12px;
transform: rotate(270deg);
left: -150px;
margin: 0;
padding: 0;
top: 50%;
z-index: 100;
}
</style>
<style scoped>
.background {
position: fixed;
left: -50vw;
width: 200vw;
height: 200vw;
top: -50vw;
z-index: 1;
filter: blur(10px);
background: conic-gradient( blue, green, red, blue );
animation: gradientAnim 10s infinite linear;
background-position: center;
}
.beat, .beat-manual {
height: 100%;
width: 100%;
background-color: rgba( 0, 0, 0, 0.1 );
display: none;
}
.beat {
animation: beatAnim 0.6s infinite linear;
}
@keyframes beatAnim {
0% {
background-color: rgba( 0, 0, 0, 0.2 );
}
20% {
background-color: rgba( 0, 0, 0, 0 );
}
100% {
background-color: rgba( 0, 0, 0, 0.2 );
}
}
@keyframes gradientAnim {
from {
transform: rotate( 0deg );
}
to {
transform: rotate( 360deg );
}
}
</style>

View File

@@ -0,0 +1,416 @@
<template>
<div>
<div class="info">Designed and developed by Janis Hutz <a href="https://janishutz.com" target="_blank" style="text-decoration: none; color: white;">https://janishutz.com</a></div>
<div class="content" id="app">
<div v-if="hasLoaded" style="width: 100%">
<div class="current-song-wrapper">
<span class="material-symbols-outlined fancy-view-song-art" v-if="!playingSong.hasCoverArt">music_note</span>
<img v-else-if="playingSong.hasCoverArt && playingSong.coverArtOrigin === 'api'" :src="playingSong.coverArtURL" class="fancy-view-song-art" id="current-image" crossorigin="anonymous">
<img v-else :src="'/getSongCover?filename=' + playingSong.filename" class="fancy-view-song-art" id="current-image">
<div class="current-song">
<progress max="1000" id="progress" :value="progressBar"></progress>
<h1>{{ playingSong.title }}</h1>
<p class="dancing-style" v-if="playingSong.dancingStyle">{{ playingSong.dancingStyle }}</p>
<p>{{ playingSong.artist }}</p>
</div>
</div>
<div class="mode-selector-wrapper">
<select v-model="visualizationSettings" @change="setVisualization()">
<option value="mic">Microphone (Mic access required)</option>
<option value="bpm">BPM (might not be 100% accurate)</option>
<option value="off">No visualization except background</option>
</select>
</div>
<div class="song-list-wrapper">
<div v-for="song in songQueue" class="song-list">
<span class="material-symbols-outlined song-image" v-if="!song.hasCoverArt && ( playingSong.filename !== song.filename || isPlaying )">music_note</span>
<img v-else-if="song.hasCoverArt && ( playingSong.filename !== song.filename || isPlaying ) && song.coverArtOrigin === 'api'" :src="song.coverArtURL" class="song-image">
<img v-else-if="song.hasCoverArt && ( playingSong.filename !== song.filename || isPlaying ) && song.coverArtOrigin !== 'api'" :src="'/getSongCover?filename=' + song.filename" class="song-image">
<div v-if="playingSong.filename === song.filename && isPlaying" class="playing-symbols">
<div class="playing-symbols-wrapper">
<div class="playing-bar" id="bar-1"></div>
<div class="playing-bar" id="bar-2"></div>
<div class="playing-bar" id="bar-3"></div>
</div>
</div>
<span class="material-symbols-outlined pause-icon" v-if="!isPlaying && playingSong.filename === song.filename">pause</span>
<div class="song-details-wrapper">
<h3>{{ song.title }}</h3>
<p>{{ song.artist }}</p>
</div>
<div class="time-until">
{{ getTimeUntil( song ) }}
</div>
</div>
<!-- <img :src="" alt=""> -->
</div>
</div>
<div v-else>
<h1>Loading...</h1>
</div>
<div class="background" id="background">
<div class="beat"></div>
<div class="beat-manual"></div>
</div>
</div>
<!-- TODO: Get ColorThief either from CDN or preferably as NPM module -->
<!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/color-thief/2.3.0/color-thief.umd.js"></script> -->
</div>
</template>
<script setup lang="ts">
import type { Song } from '@/scripts/song';
import { computed, ref, type Ref } from 'vue';
import { ColorThief } from 'colorthief';
const hasLoaded = ref( false );
const songs: Ref<Song[]> = ref( [] );
const playingSong = ref( 0 );
const isPlaying = ref( false );
const pos = ref( 0 );
const colourPalette: string[] = [];
const progressBar = ref( 0 );
const timeTracker = ref( 0 );
const visualizationSettings = ref( 'mic' );
const micAnalyzer = ref( 0 );
const beatDetected = ref( false );
const colorThief = new ColorThief();
const songQueue = computed( () => {
let ret = [];
let pos = 0;
for ( let song in songs.value ) {
if ( pos >= playingSong.value ) {
ret.push( songs.value[ song ] );
}
pos += 1;
}
return ret;
} );
const getTimeUntil = computed( () => {
return ( song ) => {
let timeRemaining = 0;
for ( let i = this.queuePos; i < Object.keys( this.songs ).length - 1; i++ ) {
if ( this.songs[ i ] == song ) {
break;
}
timeRemaining += parseInt( this.songs[ i ].duration );
}
if ( isPlaying.value ) {
if ( timeRemaining === 0 ) {
return 'Currently playing';
} else {
return 'Playing in less than ' + Math.ceil( timeRemaining / 60 - this.pos / 60 ) + 'min';
}
} else {
if ( timeRemaining === 0 ) {
return 'Plays next';
} else {
return 'Playing less than ' + Math.ceil( timeRemaining / 60 - this.pos / 60 ) + 'min after starting to play';
}
}
}
} );
methods: {
startTimeTracker () {
this.timeTracker = setInterval( () => {
this.pos = ( new Date().getTime() - this.playingSong.startTime ) / 1000 + this.oldPos;
this.progressBar = ( this.pos / this.playingSong.duration ) * 1000;
if ( isNaN( this.progressBar ) ) {
this.progressBar = 0;
}
}, 100 );
},
stopTimeTracker () {
clearInterval( this.timeTracker );
this.oldPos = this.pos;
},
getImageData() {
return new Promise( ( resolve, reject ) => {
if ( this.playingSong.hasCoverArt ) {
setTimeout( () => {
const img = document.getElementById( 'current-image' );
if ( img.complete ) {
resolve( this.colorThief.getPalette( img ) );
} else {
img.addEventListener( 'load', () => {
resolve( this.colorThief.getPalette( img ) );
} );
}
}, 500 );
} else {
reject( 'no image' );
}
} );
},
connect() {
this.colorThief = new ColorThief();
let source = new EventSource( '/clientDisplayNotifier', { withCredentials: true } );
source.onmessage = ( e ) => {
let data;
try {
data = JSON.parse( e.data );
} catch ( err ) {
data = { 'type': e.data };
}
if ( data.type === 'basics' ) {
this.isPlaying = data.data.isPlaying ?? false;
this.playingSong = data.data.playingSong ?? {};
this.songs = data.data.songQueue ?? [];
this.pos = data.data.pos ?? 0;
this.oldPos = data.data.pos ?? 0;
this.progressBar = this.pos / this.playingSong.duration * 1000;
this.queuePos = data.data.queuePos ?? 0;
this.getImageData().then( palette => {
this.colourPalette = palette;
this.handleBackground();
} ).catch( () => {
this.colourPalette = [ { 'r': 255, 'g': 0, 'b': 0 }, { 'r': 0, 'g': 255, 'b': 0 }, { 'r': 0, 'g': 0, 'b': 255 } ];
this.handleBackground();
} );
} else if ( data.type === 'pos' ) {
this.pos = data.data;
this.oldPos = data.data;
this.progressBar = data.data / this.playingSong.duration * 1000;
} else if ( data.type === 'isPlaying' ) {
this.isPlaying = data.data;
this.handleBackground();
} else if ( data.type === 'songQueue' ) {
this.songs = data.data;
} else if ( data.type === 'playingSong' ) {
this.playingSong = data.data;
this.getImageData().then( palette => {
this.colourPalette = palette;
this.handleBackground();
} ).catch( () => {
this.colourPalette = [ [ 255, 0, 0 ], [ 0, 255, 0 ], [ 0, 0, 255 ] ];
this.handleBackground();
} );
} else if ( data.type === 'queuePos' ) {
this.queuePos = data.data;
}
};
source.onopen = () => {
this.isReconnecting = false;
this.hasLoaded = true;
};
let self = this;
source.addEventListener( 'error', function( e ) {
if ( e.eventPhase == EventSource.CLOSED ) source.close();
if ( e.target.readyState == EventSource.CLOSED ) {
console.log( 'disconnected' );
}
// TODO: Notify about disconnect
setTimeout( () => {
if ( !self.isReconnecting ) {
self.isReconnecting = true;
self.tryReconnect();
}
}, 1000 );
}, false );
},
tryReconnect() {
const int = setInterval( () => {
if ( !this.isReconnecting ) {
clearInterval( int );
} else {
connectToSSESource();
}
}, 1000 );
},
handleBackground() {
let colourDetails = [];
let colours = [];
let differentEnough = true;
if ( this.colourPalette[ 0 ] ) {
for ( let i in this.colourPalette ) {
for ( let colour in colourDetails ) {
const colourDiff = ( Math.abs( colourDetails[ colour ][ 0 ] - this.colourPalette[ i ][ 0 ] ) / 255
+ Math.abs( colourDetails[ colour ][ 1 ] - this.colourPalette[ i ][ 1 ] ) / 255
+ Math.abs( colourDetails[ colour ][ 2 ] - this.colourPalette[ i ][ 2 ] ) / 255 ) / 3 * 100;
if ( colourDiff > 15 ) {
differentEnough = true;
}
}
if ( differentEnough ) {
colourDetails.push( this.colourPalette[ i ] );
colours.push( 'rgb(' + this.colourPalette[ i ][ 0 ] + ',' + this.colourPalette[ i ][ 1 ] + ',' + this.colourPalette[ i ][ 2 ] + ')' );
}
differentEnough = false;
}
}
let outColours = 'conic-gradient(';
if ( colours.length < 3 ) {
for ( let i = 0; i < 3; i++ ) {
if ( colours[ i ] ) {
outColours += colours[ i ] + ',';
} else {
if ( i === 0 ) {
outColours += 'blue,';
} else if ( i === 1 ) {
outColours += 'green,';
} else if ( i === 2 ) {
outColours += 'red,';
}
}
}
} else if ( colours.length < 11 ) {
for ( let i in colours ) {
outColours += colours[ i ] + ',';
}
} else {
for ( let i = 0; i < 10; i++ ) {
outColours += colours[ i ] + ',';
}
}
outColours += colours[ 0 ] ?? 'blue' + ')';
$( '#background' ).css( 'background', outColours );
this.setVisualization();
},
setVisualization () {
if ( Object.keys( this.playingSong ).length > 0 ) {
if ( this.visualizationSettings === 'bpm' ) {
if ( this.playingSong.bpm && this.isPlaying ) {
$( '.beat' ).show();
$( '.beat' ).css( 'animation-duration', 60 / this.playingSong.bpm );
$( '.beat' ).css( 'animation-delay', this.pos % ( 60 / this.playingSong.bpm * this.pos ) + this.playingSong.bpmOffset - ( 60 / this.playingSong.bpm * this.pos / 2 ) );
} else {
$( '.beat' ).hide();
}
try {
clearInterval( this.micAnalyzer );
} catch ( err ) {}
} else if ( this.visualizationSettings === 'off' ) {
$( '.beat' ).hide();
try {
clearInterval( this.micAnalyzer );
} catch ( err ) {}
} else if ( this.visualizationSettings === 'mic' ) {
$( '.beat-manual' ).hide();
try {
clearInterval( this.micAnalyzer );
} catch ( err ) {}
this.micAudioHandler();
}
} else {
console.log( 'not playing yet' );
}
},
micAudioHandler () {
const audioContext = new ( window.AudioContext || window.webkitAudioContext )();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array( bufferLength );
navigator.mediaDevices.getUserMedia( { audio: true } ).then( ( stream ) => {
const mic = audioContext.createMediaStreamSource( stream );
mic.connect( analyser );
analyser.getByteFrequencyData( dataArray );
let prevSpectrum = null;
let threshold = 10; // Adjust as needed
this.beatDetected = false;
this.micAnalyzer = setInterval( () => {
analyser.getByteFrequencyData( dataArray );
// Convert the frequency data to a numeric array
const currentSpectrum = Array.from( dataArray );
if ( prevSpectrum ) {
// Calculate the spectral flux
const flux = this.calculateSpectralFlux( prevSpectrum, currentSpectrum );
if ( flux > threshold && !this.beatDetected ) {
// Beat detected
this.beatDetected = true;
this.animateBeat();
}
}
prevSpectrum = currentSpectrum;
}, 20 );
} );
},
animateBeat () {
$( '.beat-manual' ).stop();
const duration = Math.ceil( 60 / ( this.playingSong.bpm ?? 180 ) * 500 ) - 50;
$( '.beat-manual' ).fadeIn( 50 );
setTimeout( () => {
$( '.beat-manual' ).fadeOut( duration );
setTimeout( () => {
$( '.beat-manual' ).stop();
this.beatDetected = false;
}, duration );
}, 50 );
},
calculateSpectralFlux( prevSpectrum, currentSpectrum ) {
let flux = 0;
for ( let i = 0; i < prevSpectrum.length; i++ ) {
const diff = currentSpectrum[ i ] - prevSpectrum[ i ];
flux += Math.max( 0, diff );
}
return flux;
},
notifier() {
if ( parseInt( this.lastDispatch ) + 5000 < new Date().getTime() ) {
}
Notification.requestPermission();
console.warn( '[ notifier ]: Status is now enabled \n\n-> Any leaving or tampering with the website will send a notification to the host' );
// Detect if window is currently in focus
window.onblur = () => {
this.sendNotification( 'blur' );
}
// Detect if browser window becomes hidden (also with blur event)
document.onvisibilitychange = () => {
if ( document.visibilityState === 'hidden' ) {
this.sendNotification( 'visibility' );
}
};
},
sendNotification( notification ) {
let fetchOptions = {
method: 'post',
body: JSON.stringify( { 'type': notification } ),
headers: {
'Content-Type': 'application/json',
'charset': 'utf-8'
},
};
fetch( '/clientStatusUpdate', fetchOptions ).catch( err => {
console.error( err );
} );
new Notification( 'YOU ARE UNDER SURVEILLANCE', {
body: 'Please return to the original webpage immediately!',
requireInteraction: true,
} )
}
},
mounted() {
this.connect();
this.notifier();
// if ( this.visualizationSettings === 'mic' ) {
// this.micAudioHandler();
// }
},
watch: {
isPlaying( value ) {
if ( value ) {
this.startTimeTracker();
} else {
this.stopTimeTracker();
}
}
}
} ).mount( '#app' );
</script>

View File

@@ -119,16 +119,18 @@ const run = () => {
}
} )
socket.on( 'playlist', ( data: { roomName: string, roomToken: string, data: Song[] } ) => {
socket.on( 'playlist-update', ( data: { roomName: string, roomToken: string, data: Song[] } ) => {
if ( socketData[ data.roomName ] ) {
if ( socketData[ data.roomName ].roomToken === data.roomToken ) {
socketData[ data.roomName ].playlist = data.data;
io.to( data.roomName ).emit( 'playlist', data.data );
if ( socketData[ data.roomName ].playlist !== data.data ) {
socketData[ data.roomName ].playlist = data.data;
io.to( data.roomName ).emit( 'playlist', data.data );
}
}
}
} );
socket.on( 'playback', ( data: { roomName: string, roomToken: string, data: boolean } ) => {
socket.on( 'playback-update', ( data: { roomName: string, roomToken: string, data: boolean } ) => {
if ( socketData[ data.roomName ] ) {
if ( socketData[ data.roomName ].roomToken === data.roomToken ) {
socketData[ data.roomName ].playbackStatus = data.data;
@@ -137,7 +139,7 @@ const run = () => {
}
} );
socket.on( 'playlist-index', ( data: { roomName: string, roomToken: string, data: number } ) => {
socket.on( 'playlist-index-update', ( data: { roomName: string, roomToken: string, data: number } ) => {
if ( socketData[ data.roomName ] ) {
if ( socketData[ data.roomName ].roomToken === data.roomToken ) {
socketData[ data.roomName ].playlistIndex = data.data;
@@ -146,7 +148,7 @@ const run = () => {
}
} );
socket.on( 'playback-start', ( data: { roomName: string, roomToken: string, data: number } ) => {
socket.on( 'playback-start-update', ( data: { roomName: string, roomToken: string, data: number } ) => {
if ( socketData[ data.roomName ] ) {
if ( socketData[ data.roomName ].roomToken === data.roomToken ) {
socketData[ data.roomName ].playbackStart = data.data;
@@ -174,10 +176,15 @@ const run = () => {
playlistIndex: 0,
roomName: roomName,
roomToken: roomToken,
ownerUID: sdk.getUserData( request ).uid,
};
response.send( roomToken );
} else {
response.status( 409 ).send( 'ERR_CONFLICT' );
if ( socketData[ roomName ].ownerUID === sdk.getUserData( request ).uid ) {
response.send( socketData[ roomName ].roomToken );
} else {
response.status( 409 ).send( 'ERR_CONFLICT' );
}
}
} else {
response.status( 403 ).send( 'ERR_FORBIDDEN' );
@@ -203,6 +210,8 @@ const run = () => {
res.send( jwtToken );
} );
// TODO: Get user's subscriptions using store sdk
app.use( ( request: express.Request, response: express.Response, next: express.NextFunction ) => {
response.status( 404 ).send( 'ERR_NOT_FOUND' );
// response.sendFile( path.join( __dirname + '' ) )

View File

@@ -5,6 +5,7 @@ export interface Room {
playlistIndex: number;
roomName: string;
roomToken: string;
ownerUID: string;
}
export interface Song {