diff --git a/frontend/src/app.js b/frontend/src/app.js index 6ca61e1..42161eb 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -43,10 +43,6 @@ app.get( '/showcase.js', ( req, res ) => { res.sendFile( path.join( __dirname + '/client/showcase.js' ) ); } ); -app.get( '/colorPaletteExtractor.js', ( req, res ) => { - res.sendFile( path.join( __dirname + '/client/colorPaletteExtractor.js' ) ); -} ); - app.get( '/showcase.css', ( req, res ) => { res.sendFile( path.join( __dirname + '/client/showcase.css' ) ); } ); diff --git a/frontend/src/client/backgroundAnim.css b/frontend/src/client/backgroundAnim.css index 80dd2f8..5319940 100644 --- a/frontend/src/client/backgroundAnim.css +++ b/frontend/src/client/backgroundAnim.css @@ -11,14 +11,17 @@ background-position: center; } -.beat { +.beat, .beat-manual { height: 100%; width: 100%; - background-color: rgba( 0, 0, 0, 0.2 ); - animation: beatAnim 0.6s infinite linear; + background-color: rgba( 0, 0, 0, 0.15 ); display: none; } +.beat { + animation: beatAnim 0.6s infinite linear; +} + @keyframes beatAnim { 0% { background-color: rgba( 0, 0, 0, 0.2 ); diff --git a/frontend/src/client/colorPaletteExtractor.js b/frontend/src/client/colorPaletteExtractor.js deleted file mode 100644 index 5a25c81..0000000 --- a/frontend/src/client/colorPaletteExtractor.js +++ /dev/null @@ -1,150 +0,0 @@ -// https://github.com/zygisS22/color-palette-extraction/blob/master/index.js - - -const buildRgb = ( imageData ) => { - const rgbValues = []; - // note that we are loopin every 4! - // for every Red, Green, Blue and Alpha - for (let i = 0; i < imageData.length; i += 4) { - const rgb = { - r: imageData[ i ], - g: imageData[ i + 1 ], - b: imageData[ i + 2 ], - }; - - rgbValues.push(rgb); - } - - return rgbValues; -}; - -// returns what color channel has the biggest difference -const findBiggestColorRange = (rgbValues) => { - /** - * Min is initialized to the maximum value posible - * from there we procced to find the minimum value for that color channel - * - * Max is initialized to the minimum value posible - * from there we procced to fin the maximum value for that color channel - */ - let rMin = Number.MAX_VALUE; - let gMin = Number.MAX_VALUE; - let bMin = Number.MAX_VALUE; - - let rMax = Number.MIN_VALUE; - let gMax = Number.MIN_VALUE; - let bMax = Number.MIN_VALUE; - - rgbValues.forEach((pixel) => { - rMin = Math.min(rMin, pixel.r); - gMin = Math.min(gMin, pixel.g); - bMin = Math.min(bMin, pixel.b); - - rMax = Math.max(rMax, pixel.r); - gMax = Math.max(gMax, pixel.g); - bMax = Math.max(bMax, pixel.b); - }); - - const rRange = rMax - rMin; - const gRange = gMax - gMin; - const bRange = bMax - bMin; - - // determine which color has the biggest difference - const biggestRange = Math.max(rRange, gRange, bRange); - if (biggestRange === rRange) { - return 'r'; - } else if (biggestRange === gRange) { - return 'g'; - } else { - return 'b'; - } -}; - -/** - * Median cut implementation - * can be found here -> https://en.wikipedia.org/wiki/Median_cut - */ -const quantization = ( rgbValues, depth ) => { - const MAX_DEPTH = 4; - - // Base case - if ( depth === MAX_DEPTH || rgbValues.length === 0 ) { - const color = rgbValues.reduce( - ( prev, curr ) => { - prev.r += curr.r; - prev.g += curr.g; - prev.b += curr.b; - - return prev; - }, - { - r: 0, - g: 0, - b: 0, - } - ); - - color.r = Math.round( color.r / rgbValues.length ); - color.g = Math.round( color.g / rgbValues.length ); - color.b = Math.round( color.b / rgbValues.length ); - - return [color]; - } - - /** - * Recursively do the following: - * 1. Find the pixel channel (red,green or blue) with biggest difference/range - * 2. Order by this channel - * 3. Divide in half the rgb colors list - * 4. Repeat process again, until desired depth or base case - */ - const componentToSortBy = findBiggestColorRange( rgbValues ); - rgbValues.sort( ( p1, p2 ) => { - return p1[ componentToSortBy ] - p2[ componentToSortBy ]; - } ); - - const mid = rgbValues.length / 2; - return [ - ...quantization( rgbValues.slice( 0, mid ), depth + 1 ), - ...quantization( rgbValues.slice( mid + 1 ), depth + 1 ), - ]; -}; - -const getColourPalette = ( imageURL ) => { - return new Promise( ( resolve, reject ) => { - - // Set the canvas size to be the same as of the uploaded image - let image = new Image(); - image.src = imageURL; - const canvas = document.getElementById( 'canvas' ); - setTimeout( () => { - canvas.width = image.width ?? 500; - canvas.height = image.height ?? 500; - const ctx = canvas.getContext( '2d' ); - try { - ctx.drawImage( image, 0, 0 ); - } catch ( err ) { - reject( err ); - return; - } - - /** - * getImageData returns an array full of RGBA values - * each pixel consists of four values: the red value of the colour, the green, the blue and the alpha - * (transparency). For array value consistency reasons, - * the alpha is not from 0 to 1 like it is in the RGBA of CSS, but from 0 to 255. - */ - const imageData = ctx.getImageData( 0, 0, canvas.width, canvas.height ); - - // Convert the image data to RGB values so its much simpler - const rgbArray = buildRgb( imageData.data ); - - /** - * Color quantization - * A process that reduces the number of colors used in an image - * while trying to visually maintin the original image as much as possible - */ - resolve( quantization( rgbArray, 0 ) ); - }, 1000 ); - } ); -}; \ No newline at end of file diff --git a/frontend/src/client/showcase.css b/frontend/src/client/showcase.css index 68baef3..b077f1b 100644 --- a/frontend/src/client/showcase.css +++ b/frontend/src/client/showcase.css @@ -169,4 +169,16 @@ body { width: 30vw; border: none; border-radius: 50px; +} + +.mode-selector-wrapper { + opacity: 0; + position: fixed; + right: 0.5%; + top: 0.5%; + padding: 0.5%; +} + +.mode-selector-wrapper:hover { + opacity: 1; } \ No newline at end of file diff --git a/frontend/src/client/showcase.html b/frontend/src/client/showcase.html index 1577519..78afdb1 100644 --- a/frontend/src/client/showcase.html +++ b/frontend/src/client/showcase.html @@ -16,13 +16,20 @@
music_note - +

{{ playingSong.title }}

{{ playingSong.artist }}

+
+ +
music_note @@ -51,12 +58,14 @@
+
- + + \ No newline at end of file diff --git a/frontend/src/client/showcase.js b/frontend/src/client/showcase.js index 3f1938b..f09e5e1 100644 --- a/frontend/src/client/showcase.js +++ b/frontend/src/client/showcase.js @@ -16,6 +16,10 @@ createApp( { progressBar: 0, timeTracker: null, timeCorrector: null, + visualizationSettings: 'mic', + micAnalyzer: null, + beatDetected: false, + colorThief: null, }; }, computed: { @@ -63,21 +67,26 @@ createApp( { clearInterval( this.timeCorrector ); this.oldPos = this.pos; }, - // getTimeUntil( song ) { - // let timeRemaining = 0; - // for ( let i = this.queuePos; i < this.songs.length; i++ ) { - // if ( this.songs[ i ] == song ) { - // break; - // } - // timeRemaining += parseInt( this.songs[ i ].duration ); - // } - // if ( timeRemaining === 0 ) { - // return 'Currently playing'; - // } else { - // return 'Playing in about ' + Math.ceil( timeRemaining / 60 ) + 'min'; - // } - // }, + 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; @@ -95,7 +104,7 @@ createApp( { this.startTime = new Date().getTime(); this.progressBar = this.pos / this.playingSong.duration * 1000; this.queuePos = data.data.queuePos ?? 0; - getColourPalette( '/getSongCover?filename=' + data.data.playingSong.filename ).then( palette => { + this.getImageData().then( palette => { this.colourPalette = palette; this.handleBackground(); } ).catch( () => { @@ -114,11 +123,11 @@ createApp( { this.songs = data.data; } else if ( data.type === 'playingSong' ) { this.playingSong = data.data; - getColourPalette( '/getSongCover?filename=' + data.data.filename ).then( palette => { + 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.colourPalette = [ [ 255, 0, 0 ], [ 0, 255, 0 ], [ 0, 0, 255 ] ]; this.handleBackground(); } ); } else if ( data.type === 'queuePos' ) { @@ -140,25 +149,142 @@ createApp( { }, handleBackground() { // TODO: Add hotkeys - // TODO: Check that colours are not too similar - let colours = {}; + let colourDetails = []; + let colours = []; + let differentEnough = true; if ( this.colourPalette[ 0 ] ) { - for ( let i = 0; i < 3; i++ ) { - colours[ i ] = 'rgb(' + this.colourPalette[ i ].r + ',' + this.colourPalette[ i ].g + ',' + this.colourPalette[ i ].b + ')'; + 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; } } - $( '#background' ).css( 'background', `conic-gradient( ${ colours[ 0 ] }, ${ colours[ 1 ] }, ${ colours[ 2 ] }, ${ colours[ 0 ] } )` ); - 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 ) ); + 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 { - $( '.beat' ).hide(); + for ( let i = 0; i < 10; i++ ) { + outColours += colours[ i ] + ','; + } } + outColours += colours[ 0 ] ?? 'blue' + ')'; + + $( '#background' ).css( 'background', outColours ); + this.setVisualization(); + }, + setVisualization () { + 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' ).hide(); + try { + clearInterval( this.micAnalyzer ); + } catch ( err ) {} + this.micAudioHandler(); + } + }, + 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.connect( audioContext.destination ); + 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 * 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; } }, mounted() { this.connect(); + if ( this.visualizationSettings === 'mic' ) { + this.micAudioHandler(); + } }, watch: { isPlaying( value ) {