diff --git a/codeRadio-desktop.js b/codeRadio-desktop.js new file mode 100644 index 0000000..1d07501 --- /dev/null +++ b/codeRadio-desktop.js @@ -0,0 +1,114 @@ +import { CodeRadio } from './player.js'; +import { Visualizer } from './visualizer.js'; + +if (!window.mobilecheck()) { + //set up the footer + const footer = document.createElement('footer'), + visContainer = document.createElement('div'), + details = document.createElement('details'), + connectionSpeed = document.createElement('select'), + speedOptions = []; + + connectionSpeed.addEventListener('change', evt => { + fCC_Player.mount = connectionSpeed.value; + }); + visContainer.id = 'visualizer'; + footer.innerHTML = ` +
+
+
+ +
+
+
+
+
+
+
+ Play Pause Button +
+
+ +
+ `; + //setup the info box for keyboard shortcuts + + details.innerHTML = ` + Keyboard Controls +
+
Play/Pause:
Spacebar or "k"
+
Volume:
Up Arrow / Down Arrow
+
`; + + document.body.appendChild(footer); + document.querySelector('main').appendChild(visContainer); + document.querySelector('main').appendChild(details); + //start the show + window.fCC_Player = new CodeRadio(); + window.fCC_Visualizer = new Visualizer(fCC_Player, visContainer); + var playButton = document.getElementById("playButton"), + slider = document.getElementById("slider"), + playContainer = document.getElementById("playContainer"), + progressInterval = false, + meta = { + container: document.getElementById("metaDisplay"), + picture: document.querySelector('[data-meta="picture"]'), + title: document.querySelector('[data-meta="title"]'), + artist: document.querySelector('[data-meta="artist"]'), + album: document.querySelector('[data-meta="album"]'), + duration: document.querySelector('[data-meta="duration"]'), + listeners: document.querySelector('[data-meta="listeners"]') + }; + + slider.addEventListener('input', () => fCC_Player.setTargetVolume(slider.value / 10)); + playContainer.addEventListener('click', () => fCC_Player.togglePlay()); + + fCC_Player.on('play', () => { + playContainer.classList.remove('cta'); + meta.container.classList.add('shown'); + playButton.src = "https://cdn-media-1.freecodecamp.org/code-radio/pause.svg"; + }); + fCC_Player.on('pause', () => { + playButton.src = "https://cdn-media-1.freecodecamp.org/code-radio/play.svg"; + meta.container.classList.remove('shown'); + }); + fCC_Player.on('volumeChange', (volume) => slider.value = Math.round(volume * 10)); + fCC_Player.on('listeners', (count) => document.querySelector('[data-meta="listeners"]').textContent = `Listeners: ${count}`); + fCC_Player.on('newSong', (songData) => { + if (speedOptions.length === 0) { + fCC_Player.mounts.forEach(mount => { + let option = document.createElement('option'); + option.value = mount.url; + option.textContent = mount.name; + if (fCC_Player.url === mount.url) option.selected = true; + connectionSpeed.appendChild(option); + speedOptions.push(option); + }); + document.querySelector('.site-nav-left').appendChild(connectionSpeed); + } + meta.duration.value = 0; + meta.duration.max = fCC_Player.duration; + if (!!songData.art && (!!navigator.connection && navigator.connection.downlink > 1.5)) { + meta.picture.style.backgroundImage = `url(${songData.art})`; + meta.container.classList.add("thumb"); + } else { + meta.container.classList.remove("thumb"); + meta.picture.style.backgroundImage = "url(https://cdn-media-1.freecodecamp.org/code-radio/cover_placeholder.gif)"; + } + meta.title.textContent = songData.title; + meta.artist.textContent = songData.artist; + meta.album.textContent = songData.album; + if (!progressInterval) progressInterval = setInterval(() => { + meta.duration.value = (new Date().valueOf() - fCC_Player.playedAt) / 1000; + }, 100); + }); + + container.classList.add("animation"); + if (!!navigator.connection && navigator.connection.downlink > 1.5) { + container.classList.add('saron'); + } +} \ No newline at end of file diff --git a/codeRadio-mobile.js b/codeRadio-mobile.js new file mode 100644 index 0000000..64908f1 --- /dev/null +++ b/codeRadio-mobile.js @@ -0,0 +1,37 @@ +if (!!window.mobilecheck()) { + const container = document.getElementById("container"); + container.innerHTML = ` +
+ +
+

+
`; + + //call api and update the listener numbber + var xhr = new XMLHttpRequest(); + + function reqListener() { + var listenerNum = JSON.parse(this.response)[0].listeners.total; + listenerMounter(listenerNum); + setTimeout(callMaker, 20000); + } + + function listenerMounter(number) { + var d = document.getElementById("listeners-num"); + d.textContent = number + " coders listening right now"; + } + + function callMaker() { + xhr.addEventListener("load", reqListener); + xhr.open("GET", "/app/api/nowplaying"); + + xhr.send(); + } + + callMaker(); +} \ No newline at end of file diff --git a/codeRadio.js b/codeRadio.js new file mode 100644 index 0000000..ff6666d --- /dev/null +++ b/codeRadio.js @@ -0,0 +1,32 @@ +window.mobilecheck = function () { + var check = false; + (function (a) { + if ( + /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test( + a + ) || + /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( + a.substr(0, 4) + ) + ) + check = true; + })(navigator.userAgent || navigator.vendor || window.opera); + return check; +}; + +function showMenu() { + toggleClass("toggle-button-nav", "reverse-toggle-color"); + toggleClass("nav", "show-main-nav-items"); + toggleClass("site-nav", "expand-nav"); +} + +function toggleClass(id, className) { + var d = document.getElementById(id); + d.classList.toggle(className); +} + +if (window && window.addEventListener) { + window.addEventListener("load", function () { + document.getElementById("toggle-button-nav").onclick = showMenu; + }); +} \ No newline at end of file diff --git a/index.html b/index.html index ca0d1d2..a70c34f 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,65 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - Code Radio - + freeCodeCamp.org Code Radio - - - - - - - - + + + diff --git a/interface.css b/interface.css index 939e353..03c10b8 100644 --- a/interface.css +++ b/interface.css @@ -14,7 +14,7 @@ body { overflow: hidden; } -footer.footer { +footer { background-color: #0a0a23; height: 70px; width: 100%; @@ -50,8 +50,15 @@ footer.footer { top: 18px; font-size: 20px; } + #metaDisplay { width: calc(100% - 210px); + opacity: 0; + transition: opacity 0.5s ease-out; +} + +#metaDisplay.shown { + opacity: 1; } #playContainer { @@ -59,6 +66,32 @@ footer.footer { width: 70px; padding: 15px; cursor: pointer; + position: relative; +} + +#playContainer::before { + content:"Push Play or Space Bar to Start Music"; + font-size: 21px; + padding: 10px 100px 10px 20px; + font-weight: bold; + box-sizing: border-box; + position: absolute; + height: 100px; + width: 250px; + top: -110px; + left: -170px; + background-color: #1b1b32; + border-radius: 5px; + opacity: 0; + transition: opacity 0.5s ease-out; + background-image: url(https://cdn-media-1.freecodecamp.org/code-radio/cta.png); + background-position: 150px center; + background-repeat: no-repeat; + pointer-events: none; +} + +#playContainer.cta::before { + opacity: 1; } #playContainer:hover { @@ -147,7 +180,7 @@ footer.footer { } .animation { - background-image: url("https://cdn-media-1.freecodecamp.org/code-radio/bg.png"); + background-image: url("https://cdn-media-1.freecodecamp.org/code-radio/bg.jpg"); background-position: left bottom; background-repeat: no-repeat; position: relative; @@ -155,7 +188,7 @@ footer.footer { height: calc(100% - 158px); } -.animation::before { +.animation.saron::before { content: ""; background-image: url("https://cdn-media-1.freecodecamp.org/code-radio/Saron3.gif"); left: 388px; @@ -165,10 +198,9 @@ footer.footer { position: absolute; } -.details { - display: none; +details { position: absolute; - bottom: 50px; + bottom: 100px; right: 50px; background-color: rgba(10, 10, 35, 0.6); color: #fff; @@ -177,8 +209,8 @@ footer.footer { transition: opacity 0.25s ease-out; } -.details:hover, -.details[open] { +details:hover, +details[open] { opacity: 1; } @@ -310,6 +342,11 @@ dd { height: auto; } +nav select { + position: relative; + top: 15px; +} + @media (max-width: 500px) { .site-header { padding-right: 0; @@ -456,10 +493,6 @@ body { flex-direction: column; height: 200px; } - #container.animation, - #visualizer { - display: none; - } footer.footer { display: none; diff --git a/player.js b/player.js index 4ee9d66..8cf2eba 100644 --- a/player.js +++ b/player.js @@ -1,15 +1,27 @@ -class CodeRadio { +const _events = Symbol('events'), + _url = Symbol('url'), + _player = Symbol('player'), + _alternateMounts = Symbol('alternateMounts'), + _currentSong = Symbol('currentSong'), + _songStartedAt = Symbol('songStartedAt'), + _songDuration = Symbol('songDuration'), + _progressInterval = Symbol('progressInterval'), + _listeners = Symbol('listeners'), + _audioConfig = Symbol('audioConfig'), + _fastConnection = Symbol('fastConnection'); + +export class CodeRadio { + constructor() { /*** * General configuration options */ this.config = { - baseColour: "rgb(10, 10, 35)", - translucent: "rgba(10, 10, 35, 0.6)", - multiplier: 0.7529, metadataTimer: 2000 }; + this[_fastConnection] = (!!navigator.connection) ? (navigator.connection.downlink > 1.5) : false; + /*** * The equalizer data is held as a seperate data set * to allow for easy implementation of visualizers. @@ -27,7 +39,7 @@ class CodeRadio { // Some basic configuration for nicer audio transitions // (Used in earlier projects and just maintained) - this.audioConfig = { + this[_audioConfig] = { targetVolume: 0, maxVolume: 0.5, volumeSteps: 0.1, @@ -39,33 +51,25 @@ class CodeRadio { * to it being a single audio element, there should be * no memory leaks of extra floating audio elements. */ - this._url = ""; - this._player = new Audio(); - this._player.volume = this.audioConfig.maxVolume; - this._player.crossOrigin = "anonymous"; + this[_url] = ""; + this[_alternateMounts] = []; + this[_player] = new Audio(); + this[_player].volume = this[_audioConfig].maxVolume; + this[_player].crossOrigin = "anonymous"; // Note: the crossOrigin is needed to fix a CORS JavaScript requirement /*** * There are a few *private* variables used */ - this._currentSong = {}; - this._songStartedAt = 0; - this._songDuration = 0; - this._progressInterval = false; + this[_currentSong] = {}; + this[_songStartedAt] = 0; + this[_songDuration] = 0; + this[_progressInterval] = false; + this[_listeners] = 0; this.getNowPlaying(); - this.meta = { - container: document.getElementById("nowPlaying"), - picture: document.querySelector('[data-meta="picture"]'), - title: document.querySelector('[data-meta="title"]'), - artist: document.querySelector('[data-meta="artist"]'), - album: document.querySelector('[data-meta="album"]'), - duration: document.querySelector('[data-meta="duration"]'), - listeners: document.querySelector('[data-meta="listeners"]') - }; - - this.setupEventListeners(); + document.addEventListener("keydown", evt => this.keyboardControl(evt)); } /*** @@ -74,15 +78,37 @@ class CodeRadio { * resets the URL. */ set url(url = false) { - if (url && this._url === "") { - this._url = url; - this._player.src = url; - this._player.play(); + if (url) { + let playing = this.playing; + if (playing) this.pause(); + this[_url] = url; + this[_player].src = url; + if (playing) this.play(); } } get url() { - return this._url; + return this[_url]; + } + + get player() { + return this[_player]; + } + + set player(v) { + throw new Error('You cannot set the value of a readonly attribute'); + } + + set mounts(mounts) { + throw new Error('You cannot set the value of a readonly attribute'); + } + + set mount(mount) { + this.url = mount; + } + + get mounts() { + return this[_alternateMounts]; } /*** @@ -91,34 +117,56 @@ class CodeRadio { * the metadata is updated. */ set currentSong(songData = {}) { - this._currentSong = songData; - this.renderMetadata(); + throw new Error('You cannot set the value of a readonly attribute'); } get currentSong() { - return this._currentSong; + return this[_currentSong]; } /*** * In order to get the constant durations, we simply take the * duration for the max of the meter and set the played at to 0 */ - set played_at(t = 0) { - this._songStartedAt = t * 1000; // Time comes in a seconds so we multiply by 1000 to set millis - this.meta.duration.value = 0; + set playedAt(t = 0) { + throw new Error('You cannot set the value of a readonly attribute'); } - get played_at() { - return this._songStartedAt; + get playedAt() { + return this[_songStartedAt]; } set duration(d = 0) { - this._songDuration = d; - this.meta.duration.max = this._songDuration; + throw new Error('You cannot set the value of a readonly attribute'); } get duration() { - return this._songDuration; + return this[_songDuration]; + } + + set listeners(v = 0) { + throw new Error('You cannot set the value of a readonly attribute'); + } + + get listeners() { + return this[_listeners]; + } + + get playing() { + return !this[_player].paused; + } + + get paused() { + return this[_player].paused; + } + + setMountToConnection() { + this[_fastConnection] = (!!navigator.connection) ? (navigator.connection.downlink > 1.5) : false; + if (this[_fastConnection]) { + this.url = this.mounts.find(mount => !!mount.is_default).url; + } else { + this.url = this.mounts.find(mount => mount.bitrate < this.mounts.find(m => !!m.is_default).bitrate).url || this.mounts.find(mount => !!mount.is_default).url; + } } getNowPlaying() { @@ -129,26 +177,21 @@ class CodeRadio { np = np[0]; // There is only ever 1 song "Now Playing" so let's simplify the response // We look through the available mounts to find the default mount (or just the listen_url) - if (this.url === "") - this.url = np.station.mounts.find(mount => !!mount.is_default).url; + if (this.url === "") { + this[_alternateMounts] = np.station.mounts; + this.setMountToConnection(); + } // We only need to update th metadata if the song has been changed - if ( - !this.currentSong.id || - np.now_playing.song.id !== this.currentSong.id - ) { - this.currentSong = np.now_playing.song; - this.played_at = np.now_playing.played_at; - this.duration = np.now_playing.duration; - this.meta.listeners.textContent = `coders listening right now: ${ - np.listeners.current - }`; - if (!this._progressInterval) { - this._progressInterval = setInterval( - () => this.updateProgress(), - 100 - ); + if (np.now_playing.song.id !== this.currentSong.id) { + this[_currentSong] = np.now_playing.song; + this[_songStartedAt] = np.now_playing.played_at * 1000; + this[_songDuration] = np.now_playing.duration; + if (this[_listeners] !== np.listeners.current) { + this[_listeners] = np.listeners.current; + this.emit('listeners', this[_listeners]); } + this.emit('newSong', this[_currentSong]); } // Since the server doesn't have a socket connection (yet), we need to long poll it for the current song @@ -160,23 +203,6 @@ class CodeRadio { }); } - /*** - * Yay, let's get some keyboard shortcuts in this tool - */ - setupEventListeners() { - document.addEventListener("keydown", evt => this.keyboardControl(evt)); - - // In order to get around some mobile browser limitations, we can only generate a lot - // of the audio context stuff AFTER the audio has been triggered. We can't see it until - // then anyway so it makes no difference to desktop. - this._player.addEventListener("play", () => { - if (!this.eq.context) { - this.initiateEQ(); - this.createVisualizer(); - } - }); - } - keyboardControl(evt = {}) { // Quick note: if you're wanting to do similar in your projects, keyCode use to be the // standard however it is being depricated for the key attribute @@ -186,111 +212,27 @@ class CodeRadio { this.togglePlay(); break; case "ArrowUp": - this.setTargetVolume( - Math.min(this.audioConfig.maxVolume + this.audioConfig.volumeSteps, 1) - ); + this.setTargetVolume(Math.min(this[_audioConfig].maxVolume + this[_audioConfig].volumeSteps, 1)); break; case "ArrowDown": - this.setTargetVolume( - Math.max(this.audioConfig.maxVolume - this.audioConfig.volumeSteps, 0) - ); + this.setTargetVolume(Math.max(this[_audioConfig].maxVolume - this[_audioConfig].volumeSteps, 0)); break; } } - initiateEQ() { - // Create a new Audio Context element to read the samples from - this.eq.context = new AudioContext(); - - // Apply the audio element as the source where to pull all the data from - this.eq.src = this.eq.context.createMediaElementSource(this._player); - - // Use some amazing trickery that allows javascript to analyse the current state - this.eq.analyser = this.eq.context.createAnalyser(); - this.eq.src.connect(this.eq.analyser); - this.eq.analyser.connect(this.eq.context.destination); - this.eq.analyser.fftSize = 256; - - // Create a buffer array for the number of frequencies available (minus the high pitch useless ones that never really do anything anyway) - this.eq.bands = new Uint8Array(this.eq.analyser.frequencyBinCount - 32); - this.updateEQBands(); - } - - /*** - * The equalizer bands available need to be updated - * constantly in order to ensure that the value for any - * visualizer is up to date. - */ - updateEQBands() { - // Populate the buffer with the audio source’s current data - this.eq.analyser.getByteFrequencyData(this.eq.bands); - - // Can’t stop, won’t stop - requestAnimationFrame(() => this.updateEQBands()); - } - - /*** - * When starting the page, the visualizer dom is needed to be - * created. - */ - createVisualizer() { - let container = document.createElement("canvas"); - document.getElementById("visualizer").appendChild(container); - container.width = container.parentNode.offsetWidth; - container.height = container.parentNode.offsetHeight; - - this.visualizer = { - ctx: container.getContext("2d"), - height: container.height, - width: container.width, - barWidth: container.width / this.eq.bands.length - }; - - this.drawVisualizer(); - } - - /*** - * As a base visualizer, the equalizer bands are drawn using - * canvas in the window directly above the song into. - */ - drawVisualizer() { - if (this.eq.bands.reduce((a, b) => a + b, 0) !== 0) - requestAnimationFrame(() => this.drawVisualizer()); - // Because timeupdate events are not triggered at browser speed, we use requestanimationframe for higher framerates - else setTimeout(() => this.drawVisualizer(), 250); // If there is no music or audio in the song, then reduce the FPS - - let y, - x = 0; // Intial bar x coordinate - this.visualizer.ctx.clearRect( - 0, - 0, - this.visualizer.width, - this.visualizer.height - ); // Clear the complete canvas - this.visualizer.ctx.fillStyle = this.config.translucent; // Set the primary colour of the brand (probably moving to a higher object level variable soon) - this.visualizer.ctx.beginPath(); // Start creating a canvas polygon - this.visualizer.ctx.moveTo(x, 0); // Start at the bottom left - this.eq.bands.forEach(band => { - y = this.config.multiplier * band; // Get the overall hight associated to the current band and convert that into a Y position on the canvas - this.visualizer.ctx.lineTo(x, y); // Draw a line from the current position to the wherever the Y position is - this.visualizer.ctx.lineTo(x + this.visualizer.barWidth, y); // Continue that line to meet the width of the bars (canvas width ÷ bar count) - x += this.visualizer.barWidth; // Add pixels to the x for the next bar - }); - this.visualizer.ctx.lineTo(x, 0); // Bring the line back down to the bottom of the canvas - this.visualizer.ctx.fill(); // Fill it - } - play() { - if (this._player.paused) { - this._player.volume = 0; - this._player.play(); + if (this[_player].paused) { + this[_player].volume = 0; + this[_player].play(); + this.emit('play'); this.fadeUp(); return this; } } pause() { - this._player.pause(); + this[_player].pause(); + this.emit('pause'); return this; } @@ -301,31 +243,25 @@ class CodeRadio { */ togglePlay() { // If there already is a source, confirm it’s playing or not - if (!!this._player.src) { + if (!!this[_player].src) { // If the player is paused, set the volume to 0 and fade up - if (this._player.paused) { - this._player.volume = 0; - this._player.play(); - this.fadeUp(); - + if (this[_player].paused) this.play(); // if it is already playing, fade the music out (resulting in a pause) - } else this.fade(); + else this.fade(); } return this; } setTargetVolume(v) { - this.audioConfig.maxVolume = parseFloat( - Math.max(0, Math.min(1, v).toFixed(1)) - ); - this._player.volume = this.audioConfig.maxVolume; + this[_audioConfig].maxVolume = parseFloat(Math.max(0, Math.min(1, v).toFixed(1))); + this[_player].volume = this[_audioConfig].maxVolume; + this.emit('volumeChange', this[_player].volume); } // Simple fade command to initiate the playing and pausing in a more fluid method fade(direction = "down") { - this.audioConfig.targetVolume = - direction.toLowerCase() === "up" ? this.audioConfig.maxVolume : 0; + this[_audioConfig].targetVolume = direction.toLowerCase() === "up" ? this[_audioConfig].maxVolume : 0; this.updateVolume(); return this; } @@ -341,50 +277,58 @@ class CodeRadio { // In order to have nice fading, this method adjusts the volume dynamically over time. updateVolume() { // In order to fix floating math issues, we set the toFixed in order to avoid 0.999999999999 increments - let currentVolume = parseFloat(this._player.volume.toFixed(1)); + let currentVolume = parseFloat(this[_player].volume.toFixed(1)); // If the volume is correctly set to the target, no need to change it - if (currentVolume === this.audioConfig.targetVolume) { + if (currentVolume === this[_audioConfig].targetVolume) { // If the audio is set to 0 and it’s been met, pause the audio - if (this.audioConfig.targetVolume === 0) this._player.pause(); + if (this[_audioConfig].targetVolume === 0) this.pause(); // Unmet audio volume settings require it to be changed } else { // We capture the value of the next increment by either the configuration or the difference between the current and target if it's smaller than the increment - let volumeNextIncrement = Math.min( - this.audioConfig.volumeSteps, - Math.abs(this.audioConfig.targetVolume - this._player.volume) - ); + let volumeNextIncrement = Math.min(this[_audioConfig].volumeSteps, Math.abs(this[_audioConfig].targetVolume - this[_player].volume)); // Adjust the audio based on if the target is higher or lower than the current - this._player.volume += - this.audioConfig.targetVolume > this._player.volume + this[_player].volume += + this[_audioConfig].targetVolume > this[_player].volume ? volumeNextIncrement : -volumeNextIncrement; + + this.emit('volumeChange', this[_player].volume); // The speed at which the audio lowers is also controlled. - setTimeout( - () => this.updateVolume(), - this.audioConfig.volumeTransitionSpeed - ); + setTimeout(() => this.updateVolume(), this[_audioConfig].volumeTransitionSpeed); } } + + on(trigger, fn, once = false) { + if (typeof fn != 'function') throw new Error(`Invalid Listener: ${trigger}. Must be a function`); + if (!this[_events]) this[_events] = {}; + if (!this[_events][trigger]) this[_events][trigger] = new Array(); + this[_events][trigger].push({ + listener: fn, + once: !!once + }); + } - renderMetadata() { - if (!!this._currentSong.art) { - this.meta.picture.style.backgroundImage = `url(${this._currentSong.art})`; - this.meta.container.classList.add("thumb"); - } else { - this.meta.container.classList.remove("thumb"); - this.meta.picture.style.backgroundImage = ""; - } - this.meta.title.textContent = this._currentSong.title; - this.meta.artist.textContent = this._currentSong.artist; - this.meta.album.textContent = this._currentSong.album; + once(trigger, fn) { + this.on(trigger, fn, true); + } + + off(trigger, fn) { + if (!this[_events] || !this[_events][trigger]) return; + this[_events][trigger] = this[_events][trigger].map(evt => (evt !== fn)); } - updateProgress() { - this.meta.duration.value = - (new Date().valueOf() - this._songStartedAt) / 1000; + emit(trigger, data) { + return new Promise((resolve, reject) => { + if (!this[_events] || !this[_events][trigger]) return; + this[_events][trigger].forEach((evt, i) => { + evt.listener(data); + if (evt.once) this[_events][trigger].splice(i, 1); + }); + resolve(); + }); } } diff --git a/visualizer.js b/visualizer.js new file mode 100644 index 0000000..8f16dd0 --- /dev/null +++ b/visualizer.js @@ -0,0 +1,102 @@ +const _player = Symbol('player') + +export class Visualizer { + + constructor(fCC_Player, container = false) { + this[_player] = fCC_Player.player; + this.eq = {}; + this.config = { + baseColour: "rgb(10, 10, 35)", + translucent: "rgba(10, 10, 35, 0.6)", + multiplier: 0.7529, + }; + this.container = container || document.getElementById("visualizer"); + + // In order to get around some mobile browser limitations, we can only generate a lot + // of the audio context stuff AFTER the audio has been triggered. We can't see it until + // then anyway so it makes no difference to desktop. + fCC_Player.player.addEventListener('play', () => { + if (!this.eq.context) { + this.initiateEQ(); + this.createVisualizer(); + } + }) + + } + + initiateEQ() { + // Create a new Audio Context element to read the samples from + this.eq.context = new AudioContext(); + // Apply the audio element as the source where to pull all the data from + this.eq.src = this.eq.context.createMediaElementSource(this[_player]); + + // Use some amazing trickery that allows javascript to analyse the current state + this.eq.analyser = this.eq.context.createAnalyser(); + this.eq.src.connect(this.eq.analyser); + this.eq.analyser.connect(this.eq.context.destination); + this.eq.analyser.fftSize = 256; + + // Create a buffer array for the number of frequencies available (minus the high pitch useless ones that never really do anything anyway) + this.eq.bands = new Uint8Array(this.eq.analyser.frequencyBinCount - 32); + this.updateEQBands(); + } + + /*** + * The equalizer bands available need to be updated + * constantly in order to ensure that the value for any + * visualizer is up to date. + */ + updateEQBands() { + // Populate the buffer with the audio source’s current data + this.eq.analyser.getByteFrequencyData(this.eq.bands); + + // Can’t stop, won’t stop + requestAnimationFrame(() => this.updateEQBands()); + } + + /*** + * When starting the page, the visualizer dom is needed to be + * created. + */ + createVisualizer() { + let container = document.createElement("canvas"); + this.container.appendChild(container); + container.width = container.parentNode.offsetWidth; + container.height = container.parentNode.offsetHeight; + + this.visualizer = { + ctx: container.getContext("2d"), + height: container.height, + width: container.width, + barWidth: container.width / this.eq.bands.length + }; + + this.drawVisualizer(); + } + + /*** + * As a base visualizer, the equalizer bands are drawn using + * canvas in the window directly above the song into. + */ + drawVisualizer() { + if (this.eq.bands.reduce((a, b) => a + b, 0) !== 0) requestAnimationFrame(() => this.drawVisualizer()); + // Because timeupdate events are not triggered at browser speed, we use requestanimationframe for higher framerates + else setTimeout(() => this.drawVisualizer(), 250); // If there is no music or audio in the song, then reduce the FPS + + let y, + x = 0; // Intial bar x coordinate + this.visualizer.ctx.clearRect(0, 0, this.visualizer.width, this.visualizer.height); // Clear the complete canvas + this.visualizer.ctx.fillStyle = this.config.translucent; // Set the primary colour of the brand (probably moving to a higher object level variable soon) + this.visualizer.ctx.beginPath(); // Start creating a canvas polygon + this.visualizer.ctx.moveTo(x, 0); // Start at the bottom left + this.eq.bands.forEach(band => { + y = this.config.multiplier * band; // Get the overall hight associated to the current band and convert that into a Y position on the canvas + this.visualizer.ctx.lineTo(x, y); // Draw a line from the current position to the wherever the Y position is + this.visualizer.ctx.lineTo(x + this.visualizer.barWidth, y); // Continue that line to meet the width of the bars (canvas width ÷ bar count) + x += this.visualizer.barWidth; // Add pixels to the x for the next bar + }); + this.visualizer.ctx.lineTo(x, 0); // Bring the line back down to the bottom of the canvas + this.visualizer.ctx.fill(); // Fill it + } + +} \ No newline at end of file