;(function () { const id = '95699143-a0ec-4af7-b957-aa1f19c86c39' const sessionId = '91b5021f-30f1-4dd5-966d-0eb59733261d' const apiUrl = 'https://sharefol.io/api' const data = [{"id":"g9","data":{"description":"Tracks with strong rhythms influenced by funk, fusion, and more.","theme":{}},"text":"Groove / Funk / Fusion","parent":"p4","children":[{"id":"t17","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":0,"loopTo":0,"fadeOut":0,"loopFrom":0,"loopPointType":"end"},"energy":{"max":244.9687185277871,"min":-243.13989684743797,"average":68.19054733830214},"artist":"Jamphibious","album":null,"duration":9830,"waveform":"https://lyrely.s3.amazonaws.com/enormoushandsomelyToad/pointlessmakeshiftShrew.dat","metadata":{"album":null,"albumArtist":null,"composer":null,"genre":null,"grouping":null,"releaseDate":null,"bpm":null,"isrc":null,"description":"Jingle mock-up for a power up or level complete."}},"text":"Powered UP","parent":"g9"},{"id":"t37","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":2,"loopTo":0,"fadeOut":1,"loopFrom":0,"loopPointType":"end"},"energy":{"max":0,"min":0,"average":0},"artist":"Jamphibious","album":"Jamphibian","duration":120000,"waveform":"https://lyrely.s3.amazonaws.com/enormoushandsomelyToad/5cc95c9c-9716-4890-ab9b-13746a5f5b2f.dat","metadata":{"album":"Jamphibian","albumArtist":null,"composer":"Jordan Michael Reed","genre":"Funk","grouping":null,"releaseDate":"TBA","bpm":"128","isrc":null,"description":"Battle music from the in development rhythm action game \"Jamphibian!\""}},"text":"Break Da Beat","parent":"g9"},{"id":"t48","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":0,"loopTo":0,"fadeOut":0,"loopFrom":0,"loopPointType":"end"},"energy":{"max":0,"min":0,"average":0},"artist":"Jamphibious","album":"Treasure Tech Land","duration":180912,"waveform":"https://lyrely.s3.amazonaws.com/enormoushandsomelyToad/61d60412-d778-4d4a-a10d-f6468b11833a.dat","metadata":{"album":"Treasure Tech Land","albumArtist":"Jamphibious","composer":"Jordan Michael Reed","genre":"Fusion","grouping":null,"releaseDate":null,"bpm":"154","isrc":null,"description":"Gameplay theme written for the DOOM 2 mod \"Treasure Tech Land\""}},"text":"Blue Sky Road","parent":"g9"},{"id":"t18","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":0,"loopTo":0,"fadeOut":0,"loopFrom":0,"loopPointType":"end"},"energy":{"max":0,"min":0,"average":0},"artist":"Jamphibious","album":"Little Frog Game OST","duration":82388,"waveform":"https://lyrely.s3.amazonaws.com/enormoushandsomelyToad/4e2977ce-650b-4bae-9d02-5e0fa9d99393.dat","metadata":{"album":"Little Frog Game OST","albumArtist":null,"composer":"Jordan Michael Reed","genre":"VGM","grouping":null,"releaseDate":null,"bpm":null,"isrc":null,"description":"Music from the 2nd stage of Little Frog Game with a beach theme."}},"text":"Frog on the Beach","parent":"g9"},{"id":"t20","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":0,"loopTo":0,"fadeOut":0,"loopFrom":0,"loopPointType":"end"},"energy":{"max":247.5057222205267,"min":-242.0815149388104,"average":111.42632137285676},"artist":"Jamphibious","album":"Quantum Qitty","duration":195009,"waveform":"https://lyrely.s3.amazonaws.com/enormoushandsomelyToad/hotwillingStingray.dat","metadata":{"album":"Quantum Qitty","albumArtist":"Jamphibious","composer":"Jordan Michael Reed","genre":"VGM","grouping":"Game Jams","releaseDate":null,"bpm":null,"isrc":null,"description":"Stage theme from Quantum Qitty - a game created during Ludum Dare 49."}},"text":"Quantum Quest","parent":"g9"}]},{"id":"g8","data":{"description":"Select video game works with intense, high octane feel.","theme":{}},"text":"Upbeat / Intense / Action","parent":"p4","children":[{"id":"t46","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":2,"loopTo":2.47,"fadeOut":1,"loopFrom":0,"loopPointType":"end"},"energy":{"max":0,"min":0,"average":0},"artist":"Jamphibious","album":"SHMUP Prototype","duration":68406,"waveform":"https://lyrely.s3.amazonaws.com/enormoushandsomelyToad/d4cac26b-bd60-4cdc-b61e-cabd727c95d5.dat","metadata":{"album":"SHMUP Prototype","albumArtist":null,"composer":"Jordan Michael Reed","genre":"Shmup","grouping":null,"releaseDate":null,"bpm":null,"isrc":null,"description":"Music written for a cancelled shmup prototype game."}},"text":"Starship Engage","parent":"g8"},{"id":"t8","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":2,"loopTo":5.21,"fadeOut":1,"loopFrom":0,"loopPointType":"end"},"energy":{"max":0,"min":0,"average":0},"artist":"Jamphibious","album":"White Clothes","duration":91304,"waveform":"https://lyrely.s3.amazonaws.com/enormoushandsomelyToad/b0ebb35b-7603-4039-9c4e-60f88b2a1fab.dat","metadata":{"album":"White Clothes","albumArtist":null,"composer":"Jordan Michael Reed","genre":"RPG","grouping":null,"releaseDate":null,"bpm":"184","isrc":null,"description":null}},"text":"Luck and Pluck","parent":"g8"},{"id":"t9","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":2,"loopTo":3.45,"fadeOut":1,"loopFrom":0,"loopPointType":"end"},"energy":{"max":0,"min":0,"average":0},"artist":"Jamphibious","album":"White Clothes","duration":126534,"waveform":"https://lyrely.s3.amazonaws.com/enormoushandsomelyToad/14b20b32-bbcb-448a-8260-2e5ea55659a2.dat","metadata":{"album":"White Clothes","albumArtist":null,"composer":"Jordan Michael Reed","genre":"RPG","grouping":null,"releaseDate":null,"bpm":"154","isrc":null,"description":null}},"text":"Face the Darkness","parent":"g8"},{"id":"t13","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":0,"loopTo":0,"fadeOut":0,"loopFrom":0,"loopPointType":"end"},"energy":{"max":0,"min":0,"average":0},"artist":"Jamphibious","album":"Fraymakers","duration":110142,"waveform":"https://sharefolio-public.s3.amazonaws.com/questionableoldWolf/bf1423dd-c201-4ca4-95f0-696eadf29bd6.dat","metadata":{"album":"Fraymakers","albumArtist":null,"composer":"Super Soul Bros","genre":"Electronic","grouping":null,"releaseDate":null,"bpm":null,"isrc":null,"description":"Official OCRemix version of the Main Theme for Fraymakers."}},"text":"Fraymakers Theme (Jamphibious Remix)","parent":"g8"},{"id":"t38","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":2,"loopTo":0,"fadeOut":1,"loopFrom":0,"loopPointType":"end"},"energy":{"max":0,"min":0,"average":0},"artist":"Jamphibious","album":"NOISZ STΔRLIVHT","duration":33488,"waveform":"https://lyrely.s3.amazonaws.com/enormoushandsomelyToad/cd2c96d0-deb2-4494-8af8-c17446111e85.dat","metadata":{"album":"NOISZ STΔRLIVHT","albumArtist":"Jamphibious","composer":"Jordan Michael Reed","genre":"Action","grouping":null,"releaseDate":null,"bpm":null,"isrc":null,"description":"Intense music written for visual novel story segments in NOISZ STΔRLIVHT."}},"text":"Battle in VR","parent":"g8"},{"id":"t40","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":0,"loopTo":0,"fadeOut":0,"loopFrom":0,"loopPointType":"end"},"energy":{"max":0,"min":0,"average":0},"artist":"Jamphibious","album":null,"duration":10008,"waveform":"https://lyrely.s3.amazonaws.com/enormoushandsomelyToad/27af8534-4311-46dd-b6cf-43da60711faf.dat","metadata":{"album":null,"albumArtist":null,"composer":"Jordan Michael Reed","genre":null,"grouping":null,"releaseDate":null,"bpm":"182","isrc":null,"description":"End of stage jingle created for a shmup demo."}},"text":"Mission Complete!","parent":"g8"}]},{"id":"g7","data":{"description":"Select video game works with slow, relaxed, or downtempo feel.","theme":{}},"text":"Chill / Downtempo / Moody","parent":"p4","children":[{"id":"t36","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":2,"loopTo":0,"fadeOut":1,"loopFrom":0,"loopPointType":"end"},"energy":{"max":0,"min":0,"average":0},"artist":"Jamphibious","album":"NOISZ STΔRLIVHT","duration":42666,"waveform":"https://lyrely.s3.amazonaws.com/enormoushandsomelyToad/b9f7bb1b-d9f3-4505-b3ef-82dba93edae6.dat","metadata":{"album":"NOISZ STΔRLIVHT","albumArtist":"Jamphibious","composer":"Jordan Michael Reed","genre":"Ambient","grouping":null,"releaseDate":null,"bpm":null,"isrc":null,"description":"Calm music written for visual novel story segments in NOISZ STΔRLIVHT."}},"text":"Calm River","parent":"g7"},{"id":"t44","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":2,"loopTo":0,"fadeOut":1,"loopFrom":0,"loopPointType":"end"},"energy":{"max":0,"min":0,"average":0},"artist":"Jamphibious","album":"Jester Knight","duration":101818,"waveform":"https://lyrely.s3.amazonaws.com/enormoushandsomelyToad/39e1b995-c5fb-456d-b5d9-a1075b07ac65.dat","metadata":{"album":"Jester Knight","albumArtist":null,"composer":"Jordan Michael Reed","genre":"Spooky","grouping":null,"releaseDate":null,"bpm":null,"isrc":null,"description":"Dungeon music from the in development game \"Jester Knight\""}},"text":"JK - Slaughterhouse Dungeon","parent":"g7"},{"id":"t14","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":0,"loopTo":0,"fadeOut":0,"loopFrom":0,"loopPointType":"end"},"energy":{"max":0,"min":0,"average":0},"artist":"Jamphibious","album":null,"duration":135004,"waveform":"https://lyrely.s3.amazonaws.com/enormoushandsomelyToad/7d710fe1-bf45-4095-9042-f493c2091568.dat","metadata":{"album":null,"albumArtist":null,"composer":"Jordan Michael Reed","genre":"VGM","grouping":null,"releaseDate":null,"bpm":null,"isrc":null,"description":"Music created as a test piece for a sci-fi project."}},"text":"Unknown World","parent":"g7"},{"id":"t15","data":{"art":null,"theme":{"bgColor":"#333333","fgColor":"#793A80","visualizer":"default","highlightColor":"#1A7A3E","particleSystem":{"type":""}},"loop":{"count":0,"loopTo":0,"fadeOut":0,"loopFrom":0,"loopPointType":"end"},"energy":{"max":0,"min":0,"average":0},"artist":"Jamphibious","album":"IN","duration":224663,"waveform":"https://lyrely.s3.amazonaws.com/enormoushandsomelyToad/143607f3-3f41-4405-82e9-839b7f9b7736.dat","metadata":{"album":"IN","albumArtist":null,"composer":"Jordan Michael Reed","genre":"VGM","grouping":null,"releaseDate":null,"bpm":null,"isrc":null,"description":"Music from the game IN, created during the Global Game Jam 2022."}},"text":"Introspection","parent":"g7"}]}] const styles = {"bgColor":null,"fgColor":null,"durationText":{"bold":false,"size":"14px","color":"#fff","hidden":false,"allCaps":false},"primaryColor":"#1A7A3E","showWaveform":1,"trackArtSize":"","folderArtSize":"normal","letterSpacing":"0","highlightColor":null,"secondaryColor":"#787878","trackAlbumText":{"bold":false,"size":"12px","color":"#cecece","hidden":false,"allCaps":false},"trackTitleText":{"bold":true,"size":"14px","color":"#fff","hidden":false,"allCaps":false},"folderTitleText":{"bold":true,"size":"20px","color":"#fff","hidden":false,"allCaps":false},"metadataArtSize":"normal","trackArtistText":{"bold":false,"size":"12px","color":"#cecece","hidden":true,"allCaps":false},"trackBackground":null,"showPlayControls":1,"folderBorderColor":"#aaaaaa","metadataLabelText":{"bold":true,"size":"14px","color":"#cecece","hidden":false,"allCaps":false},"metadataValueText":{"bold":false,"size":"16px","color":"#fff","hidden":false,"allCaps":false},"trackDurationText":{"bold":false,"size":"14px","color":"#fff","hidden":false,"allCaps":false},"folderChevronColor":"#ffffff","currentlyPlayingText":{"bold":true,"size":"16px","color":"#fff","hidden":false,"allCaps":false},"showTrackInformation":1,"showTrackProgressBar":0,"trackBackgroundHover":"#111114","trackPlayButtonColor":"#fff","folderDescriptionText":{"bold":false,"size":"12px","color":"#dedede","hidden":false,"allCaps":false}} const isPreview = false function init() { let selector if (!isNaN(Number(id.slice(0, 1)))) { selector = `#\\3${id.slice(0, 1)} ${id.slice(1)}` } else { selector = `#${id}` } const divs = [...document.querySelectorAll(selector)].filter( (element) => !element.dataset.bound, ) let div = divs[0] if (!div) { div = document.createElement('div') // Create a new div let script = document.scripts[document.scripts.length - 1] // A reference to the currently running script script.parentElement.insertBefore(div, script) // Add the newly-created div to the page } new Player(id, data, div, styles) } class Player { constructor(id, data, container, styles) { this.id = id this.data = data this.container = container this.styles = { trackArtSize: 'normal', showWaveform: true, showPlayControls: true, showTrackInformation: true, showTrackProgressBar: false, fontFamily: 'Open Sans', letterSpacing: 0, fgColor: null, bgColor: null, highlightColor: null, primaryColor: '#d63b5c', secondaryColor: '#787878', trackBackground: null, trackBackgroundHover: '#111114', trackPlayButtonColor: '#fff', currentlyPlayingText: { size: '16px', color: '#fff', bold: true, hidden: false, allCaps: false, }, durationText: { size: '14px', color: '#fff', bold: false, hidden: false, allCaps: false, }, trackTitleText: { size: '14px', color: '#fff', bold: true, hidden: false, allCaps: false, }, trackArtistText: { size: '12px', color: '#cecece', bold: false, hidden: true, allCaps: false, }, trackAlbumText: { size: '12px', color: '#cecece', bold: false, hidden: false, allCaps: false, }, trackDurationText: { size: '14px', color: '#fff', bold: false, hidden: false, allCaps: false, }, metadataLabelText: { size: '14px', color: '#cecece', bold: true, hidden: false, allCaps: false, }, metadataValueText: { size: '16px', color: '#fff', bold: false, hidden: false, allCaps: false, }, metadataArtSize: 'normal', folderTitleText: { size: '20px', color: '#fff', bold: true, hidden: false, allCaps: false, }, folderDescriptionText: { size: '12px', color: '#dedede', bold: false, hidden: false, allCaps: false, }, folderBorderColor: '#aaaaaa', folderChevronColor: '#ffffff', folderArtSize: 'normal', ...styles, } this.uuid = 'mp' + id.split('-').pop() this.state = 'paused' this.audioController = 'element' this.tracks = {} this.trackOrder = [] this.groups = {} this.openGroups = [] this.uiDirty = false this.audioContext = undefined this.audioAnalyzer = undefined this.analyzer = undefined this.audioSource = undefined this.source = undefined this.gainNode = undefined this.audioGainNode = undefined this.startTime = 0 this.ellapsedTime = 0 this.pausedTime = 0 this.mousePosition = 0 this.selectedTrack = null this.defaultTrack = true this.loading = false this.showMetadata = false this.waveData = {} this.loadedTracks = {} this.waveCanvas = null this.loopState = localStorage.getItem('mp-loopState') || 'default' // default | infinite | disabled this.loop = { time: undefined, loopTo: undefined, count: 0, } this.tickMS = 27 this.crossfadeState = 'pending' this.crossfadeAmount = 0 this.songLookaheadTrigger = true this.volume = localStorage.getItem('mp-volume') || 0.75 this.defaultTheme = { bgColor: '#666', fgColor: '#5e263a', highlightColor: '#e44967', visualizer: 'default', } container.dataset.bound = 1 this.init() document.dispatchEvent( new CustomEvent('mpOnReady', { detail: { id: this.id, container } }), ) } init() { if (this.isVisible()) { this.insertIntoDom() this.audioElement = this.getElements('Audio')[0] this.waveformElement = this.getElements('WaveformCanvas')[0] this.audioElement.preservesPitch = false this.audioElement.mozPreservesPitch = false this.audioElement.webkitPreservesPitch = false this.bindEventHandlers() this.selectDefaultTrack() this.onVolumeUpdate() this.padGroups() this.particleSystem = null ;(() => { this.tick() })() } else { setTimeout(() => { this.init() }, 250) } } bindEventHandlers() { const tracks = [...this.getElements('TrackNode')] tracks.forEach((track) => { track.addEventListener('click', () => this.onTrackClick(track.dataset.id), ) }) const groups = [...this.getElements('GroupToggle')] groups.forEach((group) => { group.addEventListener('click', () => this.onGroupClick(group.dataset.id), ) }) this.audioElement.addEventListener('loadeddata', (evt) => { this.selectedTrack.data.loading = false this.startParticleSystem() this.uiDirty = true }) this.getElements('PlayButton').forEach((element) => element.addEventListener('click', (evt) => this.onPlayClick(evt)), ) this.getElements('PrevButton').forEach((element) => element.addEventListener('click', () => { const prevTrack = this.getPrevTrack(this.selectedTrack) this.playTrack(prevTrack.id, 0) }), ) this.getElements('NextButton').forEach((element) => element.addEventListener('click', () => { const nextTrack = this.getNextTrack(this.selectedTrack) this.playTrack(nextTrack.id, 0) }), ) this.getElements('WaveformCanvas')[0].addEventListener( 'mousedown', (evt) => { this.onCanvasMouseDown(evt) }, ) this.getElements('WaveformCanvas')[0].addEventListener( 'touchstart', (evt) => { this.onCanvasMouseDown(evt) }, ) this.getElements('WaveformCanvas')[0].addEventListener('mouseup', (evt) => this.onCanvasMouseUp(evt), ) this.getElements('WaveformCanvas')[0].addEventListener( 'touchend', (evt) => this.onCanvasMouseUp(evt), ) this.getElements('WaveformCanvas')[0].addEventListener( 'mouseleave', (evt) => this.onCanvasMouseLeave(evt.offsetX), ) this.getElements('WaveformCanvas')[0].addEventListener( 'touchcancel', (evt) => this.onCanvasMouseLeave(), ) this.getElements('WaveformCanvas')[0].addEventListener( 'mousemove', (evt) => { this.mousePosition = evt.offsetX }, ) this.getElements('WaveformCanvas')[0].addEventListener( 'touchmove', (evt) => { this.mousePosition = evt.touches[0].clientX - evt.target.getBoundingClientRect().left }, ) this.audioElement.addEventListener('ended', () => { this.onTrackEnded() }) this.getElements('LoopButton').forEach((element) => element.addEventListener('click', () => { this.onLoopButtonClick() }), ) this.getElements('MetadataToggle').forEach((element) => element.addEventListener('click', () => { this.showMetadata = !this.showMetadata this.uiDirty = true }), ) this.getElements('VolumeRange')[0].addEventListener('input', (evt) => { this.volume = evt.target.value if (this.audioContext) { let audioVolume = this.volume let gainVolume = this.volume const currentTime = this.audioContext.currentTime if (this.crossfadeAmount > 0) { audioVolume = Math.max(0, 1 - this.crossfadeAmount) * this.volume gainVolume = this.crossfadeAmount * this.volume } if (this.isSafari()) { this.audioElement.volume = audioVolume } if (this.audioGainNode !== undefined) { this.audioGainNode.gain.linearRampToValueAtTime( audioVolume, currentTime + 0.025, ) } if (this.gainNode !== undefined) { this.gainNode.gain.linearRampToValueAtTime( gainVolume, currentTime + 0.025, ) } } this.uiDirty = true }) window.addEventListener('keydown', (evt) => { const tagName = evt?.target?.tagName const badTags = ['BUTTON', 'INPUT', 'TEXTAREA'] if (evt.keyCode === 32 && !badTags.includes(tagName)) { this.onPlayClick() } }) document.addEventListener('mpOnPlay', (evt) => { const { detail } = evt || {} if (detail.id !== this.id && this.isPlaying()) { this.pausePlayback() } }) } onTrackEnded() { if (!this.songLookaheadTrigger) { // Song actually ended, wasn't paused const nextTrack = this.getNextTrack(this.selectedTrack) this.playTrack(nextTrack.id, 0) } } onVolumeUpdate() { const volume = this.volume const color = this.styles.secondaryColor const element = this.getElements('VolumeButton')[0] const volumeBar = this.getElements('VolumeBar')[0] if (volume === 0) { element.innerHTML = this.volumeMute(color) } else if (volume < 0.5) { element.innerHTML = this.volumeOne(color) } else { element.innerHTML = this.volumeTwo(color) } volumeBar.style.width = `${volume * 120}px` localStorage.setItem('mp-volume', volume) } onGroupClick(id) { this.groups[id].data.open = !this.groups[id].data.open this.uiDirty = true } onLoopButtonClick() { if (this.loopState === 'default') { this.loopState = 'infinite' } else if (this.loopState === 'infinite') { this.loopState = 'disabled' } else if (this.loopState === 'disabled') { this.loopState = 'default' } localStorage.setItem('mp-loopState', this.loopState) this.uiDirty = true } renderDuration(current, total) { const element = this.getElements('DurationContainer')[0] if (!Number.isNaN(total)) { element.innerHTML = `${renderAsMinutes(current)} / ${renderAsMinutes( total, )}` function renderAsMinutes(seconds) { const mins = Math.floor(seconds / 60) const remaining = Math.floor(seconds - mins * 60) return `${mins > 9 ? mins : '0' + mins}:${ remaining > 9 ? remaining : '0' + remaining }` } } } renderProgressBar(current, total) { const bars = this.getElements('TrackProgressBarAmount') const amount = !Number.isNaN(total) ? (current * 100) / total : 0 const currentTrackId = this.selectedTrack?.id bars.forEach((bar) => { const trackId = bar.dataset.track if (trackId === currentTrackId) { bar.style.width = `${amount}%` } else { bar.style.width = '0%' } }) } onPlayClick() { if (this.isPlaying()) { this.pausePlayback() } else { this.playTrack(this.selectedTrack.id, this.pausedTime) } } pausePlayback() { if (this.isPlaying()) { this.pausedTime = this.getCurrentTime() this.audioContext.suspend() if (this.audioController === 'element') { this.audioElement.pause() } this.state = 'paused' this.stopParticleSystem() this.uiDirty = true } } onCanvasMouseDown() { this.canvasMouseDown = true } onCanvasMouseLeave(offsetX) { this.mousePosition = 0 if (this.canvasMouseDown && offsetX < 0) { this.onCanvasMouseUp() } else if ( this.canvasMouseDown && offsetX > this.waveformElement.clientWidth - 10 ) { const nextTrack = this.getNextTrack(this.selectedTrack) this.playTrack(nextTrack.id, 0) } window.getSelection().removeAllRanges() this.canvasMouseDown = false } onCanvasMouseUp() { if (this.canvasMouseDown) { this.canvasMouseDown = false const element = this.waveformElement const canvasWidth = element.clientWidth const offsetX = this.mousePosition if (offsetX > canvasWidth) { const nextTrack = this.getNextTrack(this.selectedTrack) this.playTrack(nextTrack.id, 0) } else { const duration = this.getDuration() const position = Math.min( Math.max(0, (offsetX / canvasWidth) * duration), duration, ) if (this.audioController === 'element') { this.audioElement.currentTime = position } else { if (this.isPlaying()) { const loopEnd = this.getLoopEnd() if (loopEnd < position) { this.loop.count = 0 } const source = this.createSource({ buffer: this.selectedTrack.data.buffer, loop: this.loop, }) source.start(0, position) this.resetVolumes() this.startTime = this.audioContext.currentTime } } this.ellapsedTime = position this.pausedTime = position } } this.mousePosition = 0 } async onTrackClick(id) { if (this.isPlaying() && this.selectedTrack.id === id) { this.pausePlayback() } else { if (this.selectedTrack.id === id) { this.playTrack(id, this.pausedTime) } else { this.playTrack(id, 0) } } } async playTrack(id, offset) { if (isPreview) { return this.previewTrack(id) } document.dispatchEvent( new CustomEvent('mpOnPlay', { detail: { id: this.id } }), ) const currentTime = this.getCurrentTime() const isNewTrack = id !== this.selectedTrack.id || this.defaultTrack this.bindAudioContext() this.state = 'playing' this.defaultTrack = false this.crossfadeAmount = 0 if (this.source) { this.source.stop() this.source.disconnect() } this.audioElement.pause() this.resetVolumes() //reset gain nodes, etc this.uiDirty = true this.songLookaheadTrigger = true if (isNewTrack) { this.selectedTrack = this.tracks[id] const loop = this.selectedTrack?.data?.loop || {} this.loop = { ...loop } this.loadWaveform(id, false) this.addParticleSystem(this.selectedTrack) } this.startTime = this.audioContext.currentTime this.ellapsedTime = offset if (this.tracks[id]?.data?.buffer) { // Track has already been downloaded this.audioController = 'api' const source = this.createSource({ buffer: this.tracks[id].data.buffer, loop: this.loop, }) if (isNewTrack) { source.start() } else { source.start(0, currentTime) } this.onNewAudioAnalyzer(this.analyzer) this.selectedTrack.data.loading = false this.startParticleSystem() } else { // Start audio element, download buffer this.audioController = 'element' this.selectedTrack.data.loading = true if (isNewTrack) { const trackUrlData = await this.getTrackUrl(id) this.audioElement.src = trackUrlData.url setTimeout(() => this.getTrackBuffer(id, trackUrlData.url), 1000) } if (this.isSafari()) { this.audioElement.volume = volume } this.audioElement.play() this.audioElement.currentTime = offset this.onNewAudioAnalyzer(this.audioAnalyzer) } this.audioContext.resume() } resetVolumes() { const cancelTime = Math.max(0, this.audioContext.currentTime - 10) const volume = this.volume if (this.gainNode) { this.gainNode.gain.cancelScheduledValues(cancelTime) this.gainNode.gain.setValueAtTime(volume, this.audioContext.currentTime) } if (this.audioGainNode) { this.audioGainNode.gain.cancelScheduledValues(cancelTime) this.audioGainNode.gain.setValueAtTime( volume, this.audioContext.currentTime, ) } } async previewTrack(id) { this.selectedTrack = this.tracks[id] const loop = this.selectedTrack?.data?.loop || {} this.loop = { ...loop } this.loadWaveform(id, true) this.addParticleSystem(this.selectedTrack) this.uiDirty = true } getTrackBuffer(id, url) { if (!this.tracks[id].data.bufferRequested) { const request = new XMLHttpRequest() request.open('GET', url, true) request.responseType = 'arraybuffer' this.tracks[id].data.bufferRequested = true request.onload = () => { let audioData = request.response this.audioContext.decodeAudioData( audioData, (buffer) => { this.tracks[id].data.buffer = buffer if (this.selectedTrack.id === id && this.state === 'playing') { this.gainNode.gain.value = 0 const source = this.createSource({ buffer, loop: this.loop }) source.start(0, this.getStartOffset()) this.crossfadeState = 'active' } }, (e) => { console.error(`Error with decoding audio data ${e.error}`) }, ) } request.send() } } addParticleSystem(track) { const particleSystem = track.data.theme?.particleSystem?.data if (particleSystem) { this.particleSystem = createParticleSystem({ background: this.getElements('ParticleBackground')[0], foreground: this.getElements('ParticleForeground')[0], data: particleSystem, player: this, waveData: this.waveData[track.id], }) } else { this.stopParticleSystem() this.particleSystem = null } } startParticleSystem() { if (this.particleSystem) { this.particleSystem.start() } } stopParticleSystem() { if (this.particleSystem) { this.particleSystem.stop() } } async getTrackUrl(id) { if (!this.loadedTracks[id] || this.isTrackUrlExpired(id)) { const playResponse = await fetch( `${apiUrl}/player/${this.id}/${id}/play?sessionId=${sessionId}`, ) if (playResponse.ok) { const playData = await playResponse.json() this.loadedTracks[id] = { url: playData.url, expires: Date.now() + playData.expires * 1000, } } } return this.loadedTracks[id] } async loadWaveform(id, defaultTrack) { if (!this.waveData[id]) { this.setLoading(true) const url = this.tracks[id]?.data?.waveform if (url) { const waveformResponse = await fetch(url) if (waveformResponse) { const buffer = await waveformResponse.arrayBuffer() const waveformData = getWaveformData(buffer) const waveData = waveformData.data const tempData = [...waveData] .map((d) => Math.abs(d)) .sort((a, b) => b - a) const max = tempData.slice(0, 100).reduce((acc, cur) => acc + cur, 0) / 100 const min = tempData .slice(tempData.length - 100, tempData.length) .reduce((acc, cur) => acc + cur, 0) / 100 const normalized = [] for (let i = 0; i < waveData.length; i += 2) { const x = waveData[i] const y = waveData[i + 1] normalized.push([x / max, y / max]) } const average = normalized.reduce( (acc, cur) => acc + (Math.abs(cur[0]) + Math.abs(cur[1])) / 2, 0, ) / normalized.length const sumSquares = Math.sqrt( normalized.reduce((acc, cur) => { const amp = Math.max(Math.abs(cur[0]), Math.abs(cur[1])) return acc + amp * amp }, 0) / normalized.length, ) let drawableData = normalized while (drawableData.length > 2500) { drawableData = this.reduceWaveData(drawableData) } const drawableMin = Math.abs( Math.min(...drawableData.map((d) => d[0])), ) const drawableMax = Math.max(...drawableData.map((d) => d[1])) drawableData = drawableData.map((d) => [ d[0] / drawableMin, d[1] / drawableMax, ]) this.waveData[id] = { data: normalized, original: waveData, min: min / max, max, average, drawableData, sumSquares, } } } else { const waveformResponse = await fetch( `${apiUrl}/player/${this.id}/${id}/waveform`, ) if (waveformResponse.ok) { const waveformData = await waveformResponse.json() this.waveData[id] = { data: waveformData, } } else { this.waveData[id] = { error: true, } } } } const canvas = this.getElements('WaveformCanvas')[0] if (this.waveCanvas) { this.waveCanvas.state = 'complete' } this.waveCanvas = new WaveCanvas( canvas, this, this.waveData[id].drawableData, { ...this.defaultTheme, ...(this.selectedTrack?.data?.theme || {}), }, defaultTrack, ) this.onNewAudioAnalyzer(this.audioAnalyzer) } padGroups() { const groups = this.getElements('GroupChildren') const padAmount = 8 const padGroup = (group) => { const tracks = this.getChildElements(group, 'TrackNode') const childGroups = this.getChildElements(group, 'GroupAccordion') const groupsToProcess = this.getChildElements(group, 'GroupNode') tracks.forEach((track) => { const currentPad = parseInt(track.style.marginLeft, 10) || 0 const amount = `${currentPad + padAmount}px` track.style.marginLeft = amount track.style.width = `calc(100% - ${amount})` }) childGroups.forEach((childGroup) => { const currentPad = parseInt(childGroup.style.marginLeft, 10) || 0 const amount = `${currentPad + padAmount}px` childGroup.style.marginLeft = amount childGroup.style.width = `calc(100% - ${amount})` }) groupsToProcess.forEach(padGroup) } groups.forEach(padGroup) } reduceWaveData(data) { const reduced = [] for (let i = 0; i < data.length; i += 2) { if (data[i + 1]) { const x = (Number(data[i][0]) + Number(data[i + 1][0])) / 2 const y = (Number(data[i][1]) + Number(data[i + 1][1])) / 2 reduced.push([x, y]) } else { reduced.push([...data[i]]) } } return reduced } getElements(className) { return [ ...this.container.getElementsByClassName(`${this.uuid}-${className}`), ] } getChildElements(container, className) { return [...container.getElementsByClassName(`${this.uuid}-${className}`)] } updatePlayPause() { const color = this.selectedTrack?.data?.theme?.highlightColor || '#e44967' const elements = this.getElements('PlayButton') elements.forEach((element) => { if (this.isPlaying()) { if (this.selectedTrack?.data?.loading) { element.innerHTML = this.loadingSpinner() } else { element.innerHTML = this.pauseButton(color, 50, 50) } } else { element.innerHTML = this.playButton(color, 50, 50) } }) } isTrackUrlExpired(id) { return Date.now() > this?.loadedTracks[id]?.expires } getNextTrack(currentTrack) { const index = this.trackOrder.findIndex( (child) => child.id === currentTrack.id, ) if (this.trackOrder[index + 1]) { return this.trackOrder[index + 1] } return this.trackOrder[0] } getPrevTrack(currentTrack) { const index = this.trackOrder.findIndex( (child) => child.id === currentTrack.id, ) if (this.trackOrder[index - 1]) { return this.trackOrder[index - 1] } return this.trackOrder[this.trackOrder.length - 1] } getStartOffset() { return ( (this.audioElement.currentTime * 1000 + 27 + this.audioContext.baseLatency * 2) / 1000 ) } selectDefaultTrack() { let trackId = null Object.values(this.tracks).forEach((track) => { if (track?.data?.selected) { trackId = track.id } }) if (!trackId) { trackId = Object.keys(this.tracks)?.[0] } if (trackId) { this.selectedTrack = this.tracks[trackId] this.loadWaveform(trackId, true) this.openParent(this.selectedTrack.parent) this.uiDirty = true } } openParent(parentId) { const group = this.groups[parentId] if (group?.data) { group.data.open = true } if (group?.parent) { this.openParent(group.parent) } } getCurrentTime() { if (this.audioController === 'element') { return this.audioElement.currentTime } else if (this.audioController === 'api') { const currentTime = this.audioContext.currentTime return this.ellapsedTime + currentTime - this.startTime } return 0 } getDuration() { const duration = this?.selectedTrack?.data?.duration || 0 return duration / 1000 } isPlaying() { return this.state === 'playing' } isVisible() { const el = this.container return Boolean( el && (el.offsetParent || el.offsetWidth || el.offsetHeight), ) } tick() { if (!this.isVisible()) { this.pausePlayback() } this.handleUiUpdate() this.handleAudioBufferTransition() // Handles removing loop if song should end, this way onended will fire if (this.songLookaheadTrigger) { const currentTime = this.getCurrentTime() const duration = this.getDuration() if (currentTime + 2 > duration) { if ( this.loop.count === 0 && this.loop.fadeOut && this.loopState !== 'infinite' ) { this.loop.fadeOut = false this.gainNode.gain.setValueAtTime( this.gainNode.gain.value, this.audioContext.currentTime, ) this.gainNode.gain.linearRampToValueAtTime( 0, this.audioContext.currentTime + duration - currentTime, ) } } const end = this.source?.loop ? this.source.loopEnd : duration if (currentTime + 0.2 > end) { // If looping, check if inf, if not subtract and remove loop if required. if ( this.loopState === 'infinite' || (this.loop?.count > 0 && this.loopState === 'default') ) { this.ellapsedTime = this.getLoopStart(this.loop) this.startTime = this.audioContext.currentTime if (this.loopState === 'default') { this.loop.count -= 1 } } else { this.songLookaheadTrigger = false if (this.source) { this.source.loop = false } } } } setTimeout(() => { this.tick() }, this.tickMS) } getLoopStart(loop) { return Number(loop.loopTo) } getLoopEnd() { return this.loop?.loopPointType === 'custom' ? Number(this.loop.loopFrom) : this.getDuration() } handleUiUpdate() { const currentTime = this.getCurrentTime() const duration = this.getDuration() let currentDuration = currentTime if (this.canvasMouseDown) { const canvasWidth = this.waveformElement.clientWidth const offsetX = this.mousePosition currentDuration = Math.min( Math.max(0, (offsetX / canvasWidth) * duration), duration, ) } this.renderDuration(currentDuration, duration) this.renderProgressBar(currentDuration, duration) const container = this.getElements('PlayerContainer')[0] if (container.clientWidth < 600) { container.classList.remove('desktop') } else { container.classList.add('desktop') } //ui-dirty if (this.uiDirty) { this.updateTracks() this.updateGroups() this.generateSelectedTrackStyle() this.onVolumeUpdate() this.updatePlayPause() this.updateLoopButton() this.updatePrevNextButtons() this.updateMetadataToggle() this.renderMetadata(this.showMetadata, this.selectedTrack) this.uiDirty = false } } updatePrevNextButtons() { const color = this.selectedTrack?.data?.theme?.highlightColor || '#e44967' this.getElements('PrevButton').forEach( (element) => (element.innerHTML = this.prevIcon(color)), ) this.getElements('NextButton').forEach( (element) => (element.innerHTML = this.nextIcon(color)), ) } updateGroups() { const groupElements = [...this.getElements('GroupNode')] const className = `${this.uuid}-Open` groupElements.forEach((groupElement) => { const id = groupElement.dataset.id if (this.groups[id].data.open) { groupElement.classList.add(className) } else { groupElement.classList.remove(className) } }) } updateTracks() { const title = this.selectedTrack.text this.getElements('NameContainer')[0].innerHTML = `${title}` const trackElements = [...this.getElements('TrackNode')] const selectedClassName = `${this.uuid}-Selected` const playingClassName = `${this.uuid}-Playing` trackElements.forEach((trackElement) => { if (trackElement.dataset.id === this.selectedTrack.id) { trackElement.classList.add(selectedClassName) if (this.isPlaying()) { trackElement.classList.add(playingClassName) } else { trackElement.classList.remove(playingClassName) } } else { trackElement.classList.remove(selectedClassName, playingClassName) } }) } onNewAudioAnalyzer(analyzer) { if (this.waveCanvas) { this.waveCanvas.setAudioAnalyzer(analyzer) } } handleAudioBufferTransition() { const { currentTime } = this?.audioContext || {} if (this.crossfadeState === 'active') { const delta = 0.5 this.crossfadeAmount = Math.min(1, this.crossfadeAmount + delta) if (this.crossfadeAmount >= 1) { this.crossfadeState = 'pending' this.ellapsedTime = this.audioElement.currentTime this.audioElement.pause() this.onNewAudioAnalyzer(this.analyzer) this.startTime = this.audioContext.currentTime this.audioController = 'api' } const audioVolume = Math.max(0, 1 - this.crossfadeAmount) * this.volume const gainVolume = this.crossfadeAmount * this.volume if (this.audioGainNode !== undefined) { this.audioGainNode.gain.linearRampToValueAtTime( audioVolume, currentTime + this.tickMS / 1000, ) } if (this.gainNode !== undefined) { this.gainNode.gain.linearRampToValueAtTime( gainVolume, currentTime + this.tickMS / 1000, ) } } } updateLoopButton() { const color = this.styles.primaryColor const disabledColor = this.styles.secondaryColor const loopButtons = this.getElements('LoopButton') loopButtons.forEach((loopButton) => { if (this.loopState === 'default') { loopButton.innerHTML = this.loopIcon(color) } else if (this.loopState === 'infinite') { loopButton.innerHTML = this.infLoopIcon(color) } else if (this.loopState === 'disabled') { loopButton.innerHTML = this.loopIcon(disabledColor) } }) } updateMetadataToggle() { const color = this.styles.primaryColor const disabledColor = this.styles.secondaryColor const elements = this.getElements('MetadataToggle') elements.forEach((element) => { if (this.showMetadata) { element.innerHTML = this.pageIcon(color) } else { element.innerHTML = this.pageIcon(disabledColor) } }) } bindAudioContext() { if (!this.audioContext) { this.audioContext = new AudioContext() this.analyzer = this.audioContext.createAnalyser() this.analyzer.fftSize = 4096 if (!this.isSafari()) { this.audioAnalyzer = this.audioContext.createAnalyser() this.audioAnalyzer.fftSize = 4096 this.audioSource = this.audioContext.createMediaElementSource( this.audioElement, ) this.audioSource.connect(this.audioAnalyzer) this.audioGainNode = this.audioContext.createGain() this.audioGainNode.gain.value = 1 this.audioAnalyzer.connect(this.audioGainNode) this.audioGainNode.connect(this.audioContext.destination) } this.gainNode = this.audioContext.createGain() this.gainNode.gain.value = 0 this.analyzer.connect(this.gainNode) this.gainNode.connect(this.audioContext.destination) this.audioContext.resume() } } createSource({ buffer, loop }) { if (this.source) { this.source.stop() this.source.disconnect() } this.source = new AudioBufferSourceNode(this.audioContext) this.source.connect(this.analyzer) this.source.addEventListener('ended', () => { this.onTrackEnded() }) this.source.buffer = this.cloneAudioBuffer(buffer) if (loop?.count > 0) { this.source.loop = true this.source.loopStart = this.getLoopStart(loop) this.source.loopEnd = this.getLoopEnd() } else { this.source.loop = false } return this.source } isSafari() { const userAgent = navigator.userAgent const chrome = userAgent.indexOf('Chrome') > -1 const safari = userAgent.indexOf('Safari') > -1 return safari && !chrome } cloneAudioBuffer(fromAudioBuffer) { const audioBuffer = new AudioBuffer({ length: fromAudioBuffer.length, numberOfChannels: fromAudioBuffer.numberOfChannels, sampleRate: fromAudioBuffer.sampleRate, }) for ( let channelI = 0; channelI < audioBuffer.numberOfChannels; ++channelI ) { const samples = fromAudioBuffer.getChannelData(channelI) audioBuffer.copyToChannel(samples, channelI) } return audioBuffer } insertIntoDom() { const style = (name) => `${this.uuid}-${name}` const trackArtSizes = { '': '48px', small: '48px', normal: '64px', large: '84px', } const metadataArtDimensions = { small: '150px', normal: '200px', large: '250px', } const folderArtDimensions = { small: '48px', normal: '60px', large: '72px', } const waveformAreaVisible = this.styles.showWaveform || this.styles.showPlayControls || this.styles.showTrackInformation const trackNodeSize = trackArtSizes[this.styles.trackArtSize] const volumeHoverKey = `VolumeContainer:hover .${style( 'VolumeRangeContainer', )}` const uuid = this.uuid const TrackNodePlusGroupNode = `TrackNode + .${uuid}-GroupNode` const OpenGroupChildren = `GroupNode.${uuid}-Open > .${uuid}-GroupChildren` const OpenGroupChevron = `GroupNode.${uuid}-Open > .${uuid}-GroupAccordion .${uuid}-Chevron:before` const GroupChildTracks = `GroupNode .${uuid}-TrackNode` const GroupContentWithArt = `GroupArt + .${uuid}-GroupContent` const TrackPlayStateHover = `TrackNode:hover .${uuid}-TrackPlayState` const TrackNodePlayingPlayState = `TrackNode.${uuid}-Playing .${uuid}-TrackPlayState` const SelectedTrackTitle = `TrackNode.${uuid}-Selected .${uuid}-TrackTitle` const { currentlyPlayingText, durationText, trackTitleText, trackAlbumText, trackArtistText, trackDurationText, trackArtSize, trackBackground, trackBackgroundHover, primaryColor, secondaryColor, showTrackInformation, metadataLabelText, metadataValueText, metadataArtSize, folderTitleText, folderDescriptionText, folderBorderColor, folderChevronColor, folderArtSize, } = this.styles const showControlsContainer = showTrackInformation || !currentlyPlayingText?.hidden || !durationText?.hidden const largerTrackArtSize = trackArtSize === 'normal' || trackArtSize === 'large' const styles = { mobile: { PlayerContainer: ` `, WaveContainer: ` display: flex; flex-direction: column-reverse; position: relative; justify-content: center; `, PlayContainerMobile: ` display: ${this.styles.showPlayControls ? 'flex' : 'none'}; justify-content: center; align-items: center; margin: 16px 0; `, 'PlayContainerMobile > button': ` padding: 0; width: 50px; height: 50px; display: flex; justify-content: center; align-items: center; margin-bottom: 8px; `, PlayContainerDesktop: ` display: none; justify-content: center; align-items: center; padding-right: 1em; `, PlayButton: ` border: 0; background: transparent; border-radius: 500em; padding: 10px; transition: 0.2s ease-out; `, 'PlayButton:hover': ` filter: brightness(125%) contrast(75%); `, 'PlayButton:active': ` filter: brightness(75%); `, PrevButton: ` border: 0; background: transparent; border-radius: 500em; padding: 10px 5px; transition: 0.2s ease-out; `, 'PrevButton:hover': ` filter: brightness(125%) contrast(75%); `, 'PrevButton:active': ` filter: brightness(75%); `, NextButton: ` border: 0; background: transparent; border-radius: 500em; padding: 10px 5px; transition: 0.2s ease-out; `, 'NextButton:hover': ` filter: brightness(125%) contrast(75%); `, 'NextButton:active': ` filter: brightness(75%); `, Waveform: ` display: ${this.styles.showWaveform ? 'block' : 'none'}; flex: 1; `, WaveformCanvasContainer: ` position: relative; height: 150px; `, ParticleContainer: ` position: absolute; pointer-events: none; top: -50px; width: 100%; height: 205px; display: ${this.styles.showWaveform ? 'block' : 'none'}; `, ParticleBackground: ` display: block; position: absolute; `, ParticleForeground: ` display: block; position: absolute; `, WaveformCanvas: ` position: absolute; left: 0; top: 0; `, Audio: ` display: none; `, ControlsContainer: ` display: ${showControlsContainer ? 'flex' : 'none'}; flex-wrap: wrap; padding: ${this.styles.showWaveform ? '8px' : '0px'} 8px 0 8px; position: relative; `, NameContainer: ` font-size: ${currentlyPlayingText.size}; color: ${currentlyPlayingText.color}; font-weight: ${currentlyPlayingText.bold ? 'bold' : 'normal'}; text-transform: ${ currentlyPlayingText.allCaps ? 'uppercase' : 'none' }; display: block; opacity: ${currentlyPlayingText.hidden ? '0' : '1'}; padding: 12px 0 10px; flex: 1; text-align: center; `, FlexSpacer: ` flex: 0; `, MetadataToggleContainer: ` display: ${showTrackInformation ? 'flex' : 'none'}; align-items: center; margin-right: 12px; `, MetadataToggle: ` padding: 0; border: 0; background: transparent; `, VolDurContainer: ` display: none; flex-direction: row; justify-content: flex-end; `, DurationContainer: ` font-size: ${durationText.size}; color: ${durationText.color}; font-weight: ${durationText.bold ? 'bold' : 'normal'}; text-transform: ${durationText.allCaps ? 'uppercase' : 'none'}; display: ${durationText.hidden ? 'none' : 'flex'}; align-items: center; `, VolumeContainer: ` display: ${showTrackInformation ? 'flex' : 'none'}; align-items: center; `, VolumeRangeContainer: ` display: none; position: relative; height: 16px; align-items: center; font-family: Arial; font-size: 16px; line-height: 1; `, [volumeHoverKey]: ` display: flex; `, VolumeButton: ` background: transparent; border: 0; padding: 0 8px; `, LoopContainer: ` display: ${showTrackInformation ? 'flex' : 'none'}; align-items: center; margin-right: 6px; `, LoopButton: ` background: transparent; border: 0; padding: 0 8px; `, VolumeBar: ` position: absolute; left: 0px; height: 12px; top: 2px; border-radius: 100em; pointer-events: none; min-width: 20px; `, GroupAccordion: ` padding: 4px 0; margin-bottom: 8px; ${ !!folderBorderColor ? `border-bottom: 1px solid ${folderBorderColor};` : '' } `, GroupArt: ` `, 'GroupArt img': ` width: ${folderArtDimensions[folderArtSize]}; height: ${folderArtDimensions[folderArtSize]}; object-fit: cover; margin-right: 16px; `, GroupContent: ` display: flex; flex: 1; flex-direction: column; padding-right: 0.75em; justify-content: center; `, [GroupContentWithArt]: ` padding-top: 4px; `, GroupTitle: ` font-size: ${folderTitleText.size}; color: ${folderTitleText.color}; font-weight: ${folderTitleText.bold ? 'bold' : 'normal'}; text-transform: ${folderTitleText.allCaps ? 'uppercase' : 'none'}; display: ${folderTitleText.hidden ? 'none' : 'block'}; margin-bottom: 0.25em; `, LibraryContainer: ` display: flex; flex-direction: column; `, TracksContainer: ` flex: 1; padding: 0 4px 0 8px; margin-top: 12px; `, MetadataContainer: ` flex: 0; display: none; margin-top: 16px; padding: 16px; position: relative; `, 'MetadataContainer.open': ` display: flex; flex: 1; flex-wrap: wrap; `, MetadataCloseContainer: ` position: absolute; top: -16px; right: 0; `, MetadataCloseButton: ` background: transparent; padding: 16px; border: 0; `, MetadataAlbumArt: ` width: 100%; display: flex; align-items: center; justify-content: center; margin-bottom: 32px; `, MetadataItem: ` text-align: left; min-height: 55px; padding: 0 8px; `, MetadataItemDescription: ` text-align: left; min-height: 55px; padding: 0 8px; width: 600px; max-width: 100%; `, MetadataLabel: ` font-size: ${metadataLabelText.size}; color: ${metadataLabelText.color}; font-weight: ${metadataLabelText.bold ? 'bold' : 'normal'}; text-transform: ${metadataLabelText.allCaps ? 'uppercase' : 'none'}; display: ${metadataLabelText.hidden ? 'none' : 'block'}; `, MetadataValue: ` font-size: ${metadataValueText.size}; color: ${metadataValueText.color}; font-weight: ${metadataValueText.bold ? 'bold' : 'normal'}; text-transform: ${metadataValueText.allCaps ? 'uppercase' : 'none'}; display: ${metadataValueText.hidden ? 'none' : 'block'}; margin-bottom: 20px; `, AlbumArt: ` width: ${metadataArtDimensions[metadataArtSize]}; height: ${metadataArtDimensions[metadataArtSize]}; display: ${metadataArtSize === '' ? 'none' : 'block'}; `, Hide: ` display: none; `, [TrackNodePlusGroupNode]: ` margin-top: 8px; `, GroupChildren: ` padding-bottom: 8px; display: none; `, [OpenGroupChildren]: ` display: block; `, TrackNode: ` display: flex; background: none; border: 0; cursor: pointer; align-items: center; text-align: left; padding: 0 16px 0 0; transition: 0.2s ease-out; width: 100%; position: relative; filter: none; align-items: stretch; ${!!trackArtSize ? 'margin-bottom: 4px;' : ''} ${trackBackground ? `background: ${trackBackground};` : ''} `, 'TrackNode:hover': ` background: ${trackBackgroundHover}; `, [GroupChildTracks]: ` `, TrackNumber: ` margin-right: 1em; font-size: 16px; font-weight: bold; line-height: 15px; display: none; `, TrackArtContainer: ` width: 40px; height: 40px; margin-right: 1rem; background: rgba(80,80,80,0.1); `, TrackArt: ` width: 100%; `, TrackDescription: ` flex: 1; position: relative; display: flex; flex-direction: column; justify-content: center; min-height: 58px; `, TrackTitle: ` font-size: ${trackTitleText.size}; color: ${trackTitleText.color}; font-weight: ${trackTitleText.bold ? 'bold' : 'normal'}; text-transform: ${trackTitleText.allCaps ? 'uppercase' : 'none'}; display: ${trackTitleText.hidden ? 'none' : 'block'}; transition: 0.2s ease-out; `, [SelectedTrackTitle]: ` color: ${primaryColor}; `, TrackArtist: ` font-size: ${trackArtistText.size}; color: ${trackArtistText.color}; font-weight: ${trackArtistText.bold ? 'bold' : 'normal'}; text-transform: ${trackArtistText.allCaps ? 'uppercase' : 'none'}; display: ${trackArtistText.hidden ? 'none' : 'block'}; `, TrackAlbum: ` font-size: ${trackAlbumText.size}; color: ${trackAlbumText.color}; font-weight: ${trackAlbumText.bold ? 'bold' : 'normal'}; text-transform: ${trackAlbumText.allCaps ? 'uppercase' : 'none'}; display: ${trackAlbumText.hidden ? 'none' : 'block'}; `, TrackDuration: ` margin-left: 1rem; font-size: ${trackDurationText.size}; color: ${trackDurationText.color}; font-weight: ${trackDurationText.bold ? 'bold' : 'normal'}; text-transform: ${trackDurationText.allCaps ? 'uppercase' : 'none'}; display: ${trackDurationText.hidden ? 'none' : 'flex'}; align-items: center; `, TrackProgressBar: ` height: 2px; width: calc(100% + 46px); position: absolute; bottom: ${largerTrackArtSize ? '8px' : '2px'}; background: ${secondaryColor}; `, TrackProgressBarAmount: ` position: absolute; left: 0; top: 0; height: 2px; max-width: 100%; `, TrackPlayState: ` transition: 0.2s ease-out; margin-right: ${trackArtSize === '' ? '0px' : '16px'}; height: ${trackNodeSize}; width: ${trackNodeSize}; display: flex; align-items: center; justify-content: center; background-repeat: no-repeat; background-size: cover; align-self: center; `, 'TrackPlayState .PlayIcon': ` display: flex; border-radius: 500em; width: 29px; height: 30px; padding-left: 1px; justify-content: center; align-items: center; `, 'TrackPlayState.hasArt .PlayIcon': ` background: #00000088; `, 'TrackPlayState .PauseIcon': ` display: none; `, [TrackPlayStateHover]: ` `, [`${TrackNodePlayingPlayState} .PlayIcon`]: ` display: none; `, [`${TrackNodePlayingPlayState} .PauseIcon`]: ` display: inline-block; `, GroupToggle: ` border: 0; text-align: left; padding: 0; background: transparent; cursor: pointer; width: 100%; display: flex; flex-direction: row; align-items: stretch; `, GroupDescription: ` font-size: ${folderDescriptionText.size}; color: ${folderDescriptionText.color}; font-weight: ${folderDescriptionText.bold ? 'bold' : 'normal'}; text-transform: ${ folderDescriptionText.allCaps ? 'uppercase' : 'none' }; display: ${folderDescriptionText.hidden ? 'none' : 'block'}; `, ChevronContainer: ` display: flex; align-items: center; justify-content: center; width: 40px; `, Chevron: ` line-height: 1; color: ${folderChevronColor}; `, 'Chevron:before': ` font-size: 24px; border-style: solid; border-width: 0.1em 0.1em 0 0; content: ''; display: inline-block; height: 0.35em; left: 0.15em; position: relative; top: 0.15em; transform: rotate(-45deg); vertical-align: top; width: 0.35em; `, [OpenGroupChevron]: ` top: 0; transform: rotate(135deg); `, }, desktop: { PlayerContainer: ` padding: ${waveformAreaVisible ? '16px' : ''} 0; `, WaveContainer: ` flex-direction: row; `, ParticleContainer: ` height: 250px; `, PlayContainerDesktop: ` display: ${this.styles.showPlayControls ? 'flex' : 'none'}; `, PlayContainerMobile: ` display: none; `, LibraryContainer: ` `, MetadataFirstRow: ` flex-direction: row; `, FirstRowLeftSide: ` padding-bottom: 0px; `, AlbumArt: ` margin-right: 16px; `, FirstRowRightSide: ` flex-direction: row; `, MetadataSecondRow: ` flex-direction: row; padding: 32px 0 16px; `, NameContainer: ` text-align: left; align-items: center; padding: 2px 0 0 0; `, FlexSpacer: ` flex: 1; `, VolDurContainer: ` display: flex; `, MetadataContainer: ` flex: 0; display: none; margin-top: 16px; padding: 16px; position: relative; `, 'MetadataContainer.open': ` display: block; flex: 1; `, MetadataAlbumArt: ` float: left; display: block; width: auto; margin: 0; `, MetadataItem: ` text-align: left; min-height: 55px; padding: 0 8px; float: left; width: 200px; `, MetadataItemDescription: ` text-align: left; min-height: 55px; padding: 0 8px; float: left; width: 600px; max-width: 100%; `, }, } let trackNumber = 1 const renderNode = (node) => { const firstChar = node.id.slice(0, 1) let children = '' if (node?.children?.length > 0) { children = `
${node.children?.map(renderNode).join('')}
` } let groupDescription = '' if (node.data.description) { groupDescription = `
${node.data.description || ''}
` } if (firstChar === 'g') { this.groups[node.id] = node const hasArt = !!node?.data?.art const art = hasArt ? `
${node.text}
` : '' trackNumber = 1 return `
${children}
` } else if (firstChar === 't') { this.tracks[node.id] = node this.trackOrder.push(node) const hasArt = this.styles?.trackArtSize && node?.data?.art ? 'hasArt' : '' const art = hasArt ? `style="background-image: url('${node.data.art}');"` : '' const seconds = node.data.duration / 1000 const mins = Math.floor(seconds / 60) const remaining = Math.floor(seconds - mins * 60) const duration = `${mins}:${ remaining > 9 ? remaining : '0' + remaining }` const currentTrackNumber = trackNumber trackNumber = trackNumber + 1 const iconSize = 12 const trackColor = node?.data?.theme?.highlightColor const buttonColor = this.styles.trackPlayButtonColor const pauseColor = node?.data?.theme?.highlightColor || buttonColor const playButton = this.playButton(buttonColor, iconSize, iconSize) const pauseButton = this.pauseButton(pauseColor, iconSize, iconSize) const trackProgressBar = `
` return ` ` } } this.container.innerHTML = `
${data.map(renderNode).join('')}
` const { mobile, desktop } = styles let compiledStyles = this.minifyStyles( Object.keys(mobile).reduce((acc, cur) => { acc += `.${this.uuid}-${cur} {${mobile[cur]}}` return acc }, ''), ) const desktopStyles = this.minifyStyles( Object.keys(desktop).reduce((acc, cur) => { acc += `.${this.uuid}-PlayerContainer.desktop .${this.uuid}-${cur} {${desktop[cur]}}` return acc }, ''), ) compiledStyles += `${desktopStyles}` const fontFamily = this.styles.fontFamily const fontFamilyParam = fontFamily.replace(/ /g, '+') const letterSpacing = `${this.styles.letterSpacing < 10 ? '0' : ''}${ this.styles.letterSpacing }` document.head.insertAdjacentHTML( 'beforeend', ` `, ) this.selectedTrackStyleElement = document.createElement('style') this.selectedTrackStyleElement.dataset.playerUuid = this.id document.head.appendChild(this.selectedTrackStyleElement) } minifyStyles(styles) { return styles return styles .replace(/([^0-9a-zA-Z\.#])\s+/g, '$1') .replace(/\s([^0-9a-zA-Z\.#]+)/g, '$1') .replace(/;}/g, '}') .replace(/\/\*.*?\*\//g, '') } renderMetadata(shouldRender, track) { const { text: title } = track const { artist } = track?.data || {} const { album, albumArtist, composer, genre, grouping, year, releaseDate, bpm, isrc, description, } = track?.data?.metadata || {} const style = (name) => `${this.uuid}-${name}` const container = this.getElements('MetadataContainer')[0] const color = this.styles.primaryColor const art = track?.data?.art || this.groups[track?.parent]?.data?.art const hideIfEmpty = (value) => { if (!value) { return style('Hide') } return '' } if (shouldRender) { container.classList.add('open') container.innerHTML = `
Title
${title}
Artist
${artist}
Album
${album}
Composer
${composer}
Album Artist
${albumArtist}
Genre
${genre}
Grouping
${grouping || ''}
Year
${year || ''}
BPM
${bpm || ''}
Release Date
${releaseDate || ''}
ISRC
${isrc || ''}
Description
${description || ''}
` this.getElements('MetadataCloseButton')[0].addEventListener( 'click', () => { this.showMetadata = false this.uiDirty = true }, ) } else { container.classList.remove('open') container.innerHTML = '' } } generateSelectedTrackStyle() { const id = `mp-${this.id}` const uuid = this.uuid const color = this.styles.primaryColor this.selectedTrackStyleElement.innerHTML = this.minifyStyles(` /*********** Baseline, reset styles ***********/ .${id} input[type="range"] { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; width: 120px; } .${id} button[type="button"] { cursor: pointer; } /* Removes default focus */ .${id} input[type="range"]:focus { outline: none; } /******** Chrome, Safari, Opera and Edge Chromium styles ********/ /* slider track */ .${id} input[type="range"]::-webkit-slider-runnable-track { background-color: #a8a8a8; border-radius: 8px; height: 12px; } /* slider thumb */ .${id} input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; /* Override default look */ appearance: none; margin-top: -4px; /* Centers thumb on the track */ background-color: ${color}; border-radius: 500em; height: 20px; width: 20px; } /*********** Firefox styles ***********/ /* slider track */ .${id} input[type="range"]::-moz-range-track { background-color: #a8a8a8; border-radius: 0.5rem; height: 0.5rem; } /* slider thumb */ .${id} input[type="range"]::-moz-range-thumb { background-color: ${color}; border: none; /*Removes extra border that FF applies*/ border-radius: 0.5rem; height: 1rem; width: 1rem; } .${id} .${uuid}-VolumeBar { background-color: ${color}; } .${id} .${uuid}-VolumeBar { background-color: ${color}; } .${id} .${uuid}-Loader { display: inline-block; width: 50px; height: 30px; position: relative; left: 4px; top: -2px; } .${id} .${uuid}-Loader:after { content: " "; display: block; width: 24px; height: 24px; margin: 0px; border-radius: 50%; border: 6px solid ${color}; border-color: ${color} transparent ${color} transparent; animation: mp-loading-spinner 1.2s linear infinite; } @keyframes mp-loading-spinner { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } `) } pauseButton(color, height = 30, width = 30) { return ` ` } playButton(color, height = 30, width = 30) { return ` ` } volumeOne(color) { return ` ` } volumeTwo(color) { return ` ` } volumeMute(color) { return ` ` } nextIcon(color) { return ` ` } prevIcon(color) { return ` ` } loopIcon(color) { return ` ` } pageIcon(color) { return ` ` } infLoopIcon(color) { return ` ` } loadingSpinner() { return `
` } setLoading(loading) { this.loading = loading } } class WaveCanvas { constructor(element, player, data, theme, defaultTrack) { this.element = element this.data = data this.player = player this.theme = theme this.mousePosition = 0 this.bindEventHandlers() this.defaultTrack = defaultTrack this.state = 'transition-in' this.transitionTime = 0 this.isMobile = false this.volume = 1 this.previousState = element .getContext('2d') .getImageData(0, 0, 1500, 500) window.requestAnimationFrame(() => this.draw()) this.transitionComplete = () => { return new Promise((resolve, reject) => { this.resolveTransition = resolve }) } } setAudioAnalyzer(audioAnalyzer) { this.audioAnalyzer = audioAnalyzer } setVolume(vol) { this.volume = vol } draw() { const transitionDuration = 0.1 const canvas = this.element const width = canvas.parentElement.clientWidth const height = canvas.parentElement.clientHeight if (this.isMobile) { this.mousePosition = 0 } if (width && height) { const ctx = canvas.getContext('2d') const currentTime = this.player.getCurrentTime() const duration = this.player.getDuration() const timeProgress = currentTime / duration const hovered = this.mousePosition > 0 const mousePositionRelative = this.mousePosition / width const mouseAhead = mousePositionRelative > timeProgress const analyzer = this.audioAnalyzer const isPlaying = this.player.isPlaying() canvas.width = width canvas.height = height const { bgColor, fgColor, highlightColor, visualizer } = this.theme switch (this.state) { case 'transition-in': const transitionProgress = this.transitionTime / transitionDuration const transitionWidth = transitionProgress * width if (this.previousState) { ctx.putImageData(this.previousState, 0, 0) } ctx.clearRect( 0, 0, this.easeOutQuad(transitionProgress, 0, 1, 1) * width, 500, ) this.drawWaveform( canvas, 0, this.easeOutQuad(transitionProgress, 0, 1, 1), { color: this.defaultTrack ? highlightColor : bgColor, }, ) this.transitionTime += 0.002 if (this.transitionTime > transitionDuration) { this.state = 'active' } break case 'active': ctx.clearRect(0, 0, 1500, 500) if (currentTime > 0) { let dataArray = [] let visualizerArray = [] if (analyzer) { const bufferLength = analyzer.frequencyBinCount dataArray = new Uint8Array(bufferLength) analyzer.getByteFrequencyData(dataArray) visualizerArray = dataArray.slice(0, 1700) } let progressStart = 0 let backgroundStart = timeProgress let hoverStart = mousePositionRelative let progressEnd = mousePositionRelative > 0 ? mousePositionRelative : timeProgress let backgroundEnd = 1 let hoverEnd = timeProgress if (mouseAhead) { hoverStart = timeProgress hoverEnd = mousePositionRelative backgroundStart = mousePositionRelative progressEnd = timeProgress } this.drawWaveform( canvas, backgroundStart, backgroundEnd, { color: bgColor, visualizer, }, visualizerArray, ) this.drawWaveform( canvas, progressStart, progressEnd, { color: highlightColor, visualizer, }, visualizerArray, ) if (this.mousePosition > 0) { this.drawWaveform( canvas, hoverStart, hoverEnd, { color: fgColor, visualizer, }, visualizerArray, ) } } else { if (this.defaultTrack) { this.drawWaveform( canvas, Math.max(mousePositionRelative, timeProgress, 0), 1, { color: hovered ? fgColor : highlightColor, visualizer, }, ) if (this.mousePosition > 0) { this.drawWaveform(canvas, 0, mousePositionRelative, { color: highlightColor, visualizer, }) } } else { this.drawWaveform(canvas, 0, 1, { color: bgColor, visualizer, }) } } break } } window.requestAnimationFrame(() => this.draw()) } drawWaveform(canvas, startPosition, endPosition, theme, freqPoints = []) { const data = this.data const ctx = canvas.getContext('2d') const width = canvas.clientWidth const offset = 75.5 const timeFactor = width / data.length const drawStart = Math.floor(data.length * startPosition) const drawEnd = Math.ceil(data.length * endPosition) const { color, visualizer } = theme ctx.beginPath() for (let x = drawStart; x < drawEnd; x += 1) { if (data[x] !== undefined) { const val = Number(data[x]?.[1]) * getFrequencyModifier(x) const y = offset + this.clampWaveValue(Math.round(val)) ctx.lineTo(x * timeFactor, y) } } for (let x = drawEnd - 1; x >= drawStart; x -= 1) { if (data[x] !== undefined) { const val = Number(data[x]?.[0]) * getFrequencyModifier(x) const y = offset + this.clampWaveValue(Math.round(val)) ctx.lineTo(x * timeFactor, y) } } ctx.fillStyle = color ctx.strokeStyle = color ctx.closePath() ctx.stroke() ctx.fill() function getFrequencyModifier(x) { let baseMultiplier = 70 if (visualizer === 'default') { if (freqPoints.length > 0) { baseMultiplier = 35 const chunkSize = Math.ceil(data.length / freqPoints.length) return ( (1 + (freqPoints[Math.floor(x / chunkSize)] / 255) * 1.75) * baseMultiplier ) } } else if (visualizer === 'orchestral') { if (freqPoints.length > 0) { baseMultiplier = 60 const chunkSize = Math.ceil(data.length / freqPoints.length) return ( (0.6 + (freqPoints[Math.floor(x / chunkSize)] / 255) * 1.75) * baseMultiplier ) } } else if (visualizer === 'bass') { if (freqPoints.length > 0) { //freqPoints[5] is aprox 55-65hz baseMultiplier = 80 const chunkSize = Math.ceil(data.length / freqPoints.length) return ( (0.25 + freqPoints[5] / 255 + freqPoints[Math.floor(x / chunkSize)] / 255) * baseMultiplier ) } } return baseMultiplier } } bindEventHandlers() { this.element.addEventListener('mouseleave', (evt) => { this.mousePosition = 0 }) this.element.addEventListener('touchcancel', (evt) => { this.isMobile = true this.mousePosition = 0 }) this.element.addEventListener('touchend', (evt) => { this.isMobile = true this.mousePosition = 0 }) this.element.addEventListener('mousemove', (evt) => { this.mousePosition = evt.offsetX }) this.element.addEventListener('touchmove', (evt) => { this.mousePosition = Math.min( Math.max( 0, evt.touches[0].clientX - evt.target.getBoundingClientRect().left, ), evt.target.clientWidth, ) }) } easeOutQuad(t, b, c, d) { return -c * (t /= d) * (t - 2) + b } clampWaveValue(x) { const sign = Math.sign(x) const abs = Math.abs(x) / 100 if (abs > 0.7) { const value = 1 - (1 - abs) * (1 - abs) return sign * value * 74 } else { return x } } sum(arr) { return arr.reduce((acc, cur) => { return acc + cur }, 0) } } init() function getWaveformData(waveformDataBuffer) { /** * Provides access to the waveform data for a single audio channel. */ function WaveformDataChannel(waveformData, channelIndex) { this._waveformData = waveformData this._channelIndex = channelIndex } /** * Returns the waveform minimum at the given index position. */ WaveformDataChannel.prototype.min_sample = function (index) { var offset = (index * this._waveformData.channels + this._channelIndex) * 2 return this._waveformData._at(offset) } /** * Returns the waveform maximum at the given index position. */ WaveformDataChannel.prototype.max_sample = function (index) { var offset = (index * this._waveformData.channels + this._channelIndex) * 2 + 1 return this._waveformData._at(offset) } /** * Sets the waveform minimum at the given index position. */ WaveformDataChannel.prototype.set_min_sample = function (index, sample) { var offset = (index * this._waveformData.channels + this._channelIndex) * 2 return this._waveformData._set_at(offset, sample) } /** * Sets the waveform maximum at the given index position. */ WaveformDataChannel.prototype.set_max_sample = function (index, sample) { var offset = (index * this._waveformData.channels + this._channelIndex) * 2 + 1 return this._waveformData._set_at(offset, sample) } /** * Returns all the waveform minimum values as an array. */ WaveformDataChannel.prototype.min_array = function () { var length = this._waveformData.length var values = [] for (var i = 0; i < length; i++) { values.push(this.min_sample(i)) } return values } /** * Returns all the waveform maximum values as an array. */ WaveformDataChannel.prototype.max_array = function () { var length = this._waveformData.length var values = [] for (var i = 0; i < length; i++) { values.push(this.max_sample(i)) } return values } /** * Provides access to waveform data. */ function WaveformData(data) { this._data = new DataView(data) this._offset = this._version() === 2 ? 24 : 20 this._channels = [] for (var channel = 0; channel < this.channels; channel++) { this._channels[channel] = new WaveformDataChannel(this, channel) } } var defaultOptions = { scale: 512, amplitude_scale: 1.0, split_channels: false, disable_worker: false, } function getOptions(options) { var opts = { scale: options.scale || defaultOptions.scale, amplitude_scale: options.amplitude_scale || defaultOptions.amplitude_scale, split_channels: options.split_channels || defaultOptions.split_channels, disable_worker: options.disable_worker || defaultOptions.disable_worker, } return opts } function getChannelData(audio_buffer) { var channels = [] for (var i = 0; i < audio_buffer.numberOfChannels; ++i) { channels.push(audio_buffer.getChannelData(i).buffer) } return channels } /** * Creates and returns a WaveformData instance from the given waveform data. */ WaveformData.create = function create(data) { return new WaveformData(data) } WaveformData.prototype = { _getResampleOptions(options) { var opts = {} opts.scale = options.scale opts.width = options.width if ( opts.width != null && (typeof opts.width !== 'number' || opts.width <= 0) ) { throw new RangeError( 'WaveformData.resample(): width should be a positive integer value', ) } if ( opts.scale != null && (typeof opts.scale !== 'number' || opts.scale <= 0) ) { throw new RangeError( 'WaveformData.resample(): scale should be a positive integer value', ) } if (!opts.scale && !opts.width) { throw new Error( 'WaveformData.resample(): Missing scale or width option', ) } if (opts.width) { // Calculate the target scale for the resampled waveform opts.scale = Math.floor( (this.duration * this.sample_rate) / opts.width, ) } if (opts.scale < this.scale) { throw new Error( 'WaveformData.resample(): Zoom level ' + opts.scale + ' too low, minimum: ' + this.scale, ) } opts.abortSignal = options.abortSignal return opts }, resample: function (options) { options = this._getResampleOptions(options) options.waveformData = this var resampler = new WaveformResampler(options) while (!resampler.next()) { // nothing } return new WaveformData(resampler.getOutputData()) }, /** * Concatenates with one or more other waveforms, returning a new WaveformData object. */ concat: function () { var self = this var otherWaveforms = Array.prototype.slice.call(arguments) // Check that all the supplied waveforms are compatible otherWaveforms.forEach(function (otherWaveform) { if ( self.channels !== otherWaveform.channels || self.sample_rate !== otherWaveform.sample_rate || self.bits !== otherWaveform.bits || self.scale !== otherWaveform.scale ) { throw new Error('WaveformData.concat(): Waveforms are incompatible') } }) var combinedBuffer = this._concatBuffers.apply(this, otherWaveforms) return WaveformData.create(combinedBuffer) }, /** * Returns a new ArrayBuffer with the concatenated waveform. * All waveforms must have identical metadata (version, channels, etc) */ _concatBuffers: function () { var otherWaveforms = Array.prototype.slice.call(arguments) var headerSize = this._offset var totalSize = headerSize var totalDataLength = 0 var bufferCollection = [this].concat(otherWaveforms).map(function (w) { return w._data.buffer }) var i, buffer for (i = 0; i < bufferCollection.length; i++) { buffer = bufferCollection[i] var dataSize = new DataView(buffer).getInt32(16, true) totalSize += buffer.byteLength - headerSize totalDataLength += dataSize } var totalBuffer = new ArrayBuffer(totalSize) var sourceHeader = new DataView(bufferCollection[0]) var totalBufferView = new DataView(totalBuffer) // Copy the header from the first chunk for (i = 0; i < headerSize; i++) { totalBufferView.setUint8(i, sourceHeader.getUint8(i)) } // Rewrite the data-length header item to reflect all of the samples concatenated together totalBufferView.setInt32(16, totalDataLength, true) var offset = 0 var dataOfTotalBuffer = new Uint8Array(totalBuffer, headerSize) for (i = 0; i < bufferCollection.length; i++) { buffer = bufferCollection[i] dataOfTotalBuffer.set(new Uint8Array(buffer, headerSize), offset) offset += buffer.byteLength - headerSize } return totalBuffer }, /** * Returns the data format version number. */ _version: function () { return this._data.getInt32(0, true) }, /** * Returns the length of the waveform, in pixels. */ get length() { return this._data.getUint32(16, true) }, /** * Returns the number of bits per sample, either 8 or 16. */ get bits() { var bits = Boolean(this._data.getUint32(4, true)) return bits ? 8 : 16 }, /** * Returns the (approximate) duration of the audio file, in seconds. */ get duration() { return (this.length * this.scale) / this.sample_rate }, /** * Returns the number of pixels per second. */ get pixels_per_second() { return this.sample_rate / this.scale }, /** * Returns the amount of time represented by a single pixel, in seconds. */ get seconds_per_pixel() { return this.scale / this.sample_rate }, /** * Returns the number of waveform channels. */ get channels() { if (this._version() === 2) { return this._data.getInt32(20, true) } else { return 1 } }, /** * Returns a waveform channel. */ channel: function (index) { if (index >= 0 && index < this._channels.length) { return this._channels[index] } else { throw new RangeError('Invalid channel: ' + index) } }, /** * Returns the number of audio samples per second. */ get sample_rate() { return this._data.getInt32(8, true) }, /** * Returns the number of audio samples per pixel. */ get scale() { return this._data.getInt32(12, true) }, /** * Returns a waveform data value at a specific offset. */ _at: function at_sample(index) { if (this.bits === 8) { return this._data.getInt8(this._offset + index) } else { return this._data.getInt16(this._offset + index * 2, true) } }, /** * Sets a waveform data value at a specific offset. */ _set_at: function set_at(index, sample) { if (this.bits === 8) { return this._data.setInt8(this._offset + index, sample) } else { return this._data.setInt16(this._offset + index * 2, sample, true) } }, /** * Returns the waveform data index position for a given time. */ at_time: function at_time(time) { return Math.floor((time * this.sample_rate) / this.scale) }, /** * Returns the time in seconds for a given index. */ time: function time(index) { return (index * this.scale) / this.sample_rate }, /** * Returns an object containing the waveform data. */ toJSON: function () { const waveform = { version: 2, channels: this.channels, sample_rate: this.sample_rate, samples_per_pixel: this.scale, bits: this.bits, length: this.length, data: [], } for (var i = 0; i < this.length; i++) { for (var channel = 0; channel < this.channels; channel++) { waveform.data.push(this.channel(channel).min_sample(i)) waveform.data.push(this.channel(channel).max_sample(i)) } } return waveform }, /** * Returns the waveform data in binary format as an ArrayBuffer. */ toArrayBuffer: function () { return this._data.buffer }, } const wfd = new WaveformData(waveformDataBuffer) return wfd.toJSON() } const ENERGY_LOW_THRESHOLD = 0.45 const ENERGY_HIGH_THRESHOLD = 0.55 class Particle { constructor({ color, gravity, image, lifetime, rotation, rotationRate, scale, scaleRate, shape, wobble, position, velocity, emitter, trail, edgeBehaviour, svg, drag, ctx, bufferCanvas, bufferContext, }) { const scaleRender = Range.render(scale) this.position = position || new Vector(0, 0) this.velocity = velocity || new Vector(0, 0) this.gravity = gravity this.rotation = Choose.render(Range.render(rotation)) this.rotationRate = Range.render(rotationRate) this.scale = new Vector(scaleRender) this.scaleRate = new Vector(Range.render(scaleRate)) this.drag = Range.render(drag) this.lifetime = lifetime this.wobble = wobble ? Range.render(wobble) / 10000 : 0 this.shape = Choose.render(shape) this.color = Choose.render(color) this.trail = trail this.edgeBehaviour = edgeBehaviour this.ctx = ctx this.canvas = ctx.canvas this.firstDraw = Date.now() this.lastDraw = Date.now() this.timeLived = 0 this.emitter = emitter this.wobbleTimer = -Math.PI + Math.random() * Math.PI * 2 if (image) { const img = new Image() img.src = Choose.render(image) this.image = img } this.svg = svg if (shape.indexOf('waveform') !== -1) { this.path = this.getWaveformPath(shape) } this.previousPositions = [] this.bufferCanvas = bufferCanvas this.bufferContext = bufferContext this.leftEdge = Math.random() * 20 this.rightEdge = this.canvas.width - Math.random() * 60 this.topEdge = Math.random() * 20 this.bottomEdge = this.canvas.height - Math.random() * 20 } move({ delta, energy }) { const now = Date.now() const dilatedDelta = this.emitter.applyTimeDilation({ delta, energy }) const impulse = this.emitter.getImpulse(energy) const current = { position: this.position, rotation: this.rotation, scale: this.scale, } if (this.trail.length > 0) { this.storeState() } let acceleration = new Vector(0, this.gravity) acceleration = acceleration.add( this.emitter.applyGravityPoints({ position: this.position, delta, energy, }), ) this.velocity = this.velocity.add(acceleration.multiply(dilatedDelta)) this.velocity = this.velocity.add( new Vector(dilatedDelta * this.wobble * Math.sin(this.wobbleTimer), 0), ) this.velocity = this.velocity.add(impulse) this.velocity = this.velocity.multiply( new Vector(1, 1).add(this.drag.multiply(-0.01 * dilatedDelta)), ) this.velocity = this.handleReflection() this.position = this.position.add(this.velocity.multiply(dilatedDelta)) this.rotation = this.rotation + this.rotationRate * dilatedDelta this.scale = this.scale.add(this.scaleRate.multiply(dilatedDelta)) this.timeLived = this.timeLived + dilatedDelta * 10 this.lastDraw = now this.wobbleTimer += dilatedDelta / 100 this.edgeStop(current) } handleReflection() { if (this.edgeBehaviour === 'bounce') { if (this.position.y > this.canvas.height) { const normal = new Vector(0, -1) return this.velocity.subtract( normal.multiply(2 * this.velocity.dotProduct(normal)), ) } else if (this.position.x > this.canvas.width) { const normal = new Vector(1, 0) return this.velocity.subtract( normal.multiply(2 * this.velocity.dotProduct(normal)), ) } else if (this.position.y < 0) { const normal = new Vector(0, 1) return this.velocity.subtract( normal.multiply(2 * this.velocity.dotProduct(normal)), ) } else if (this.position.x < 0) { const normal = new Vector(-1, 0) return this.velocity.subtract( normal.multiply(2 * this.velocity.dotProduct(normal)), ) } } return this.velocity } edgeStop({ position, scale, rotation }) { if (this.edgeBehaviour === 'stopBottom') { if (this.position.y > this.bottomEdge) { this.position = position this.scale = scale this.rotation = rotation this.velocity = new Vector(0, 0) this.rotationRate = 0 this.wobble = 0 } } } getEdgeAlpha() { if (this.edgeBehaviour === 'fade') { const alpha = Math.min( 1, (this.bottomEdge - this.position.y) / 25, (this.rightEdge - this.position.x) / 25, (this.position.y - this.topEdge) / 25, (this.position.x - this.leftEdge) / 25, ) return alpha < 0 ? 0 : alpha } return 1 } addImpulse(impulse) { this.impulse = this.impulse.add(impulse) } preDraw(ctx) { ctx.globalAlpha = this.getEdgeAlpha() ctx.setTransform( this.scale.x, 0, 0, this.scale.y, this.position.x, this.position.y, ) ctx.rotate(this.rotation) } postDraw(ctx) { ctx.globalAlpha = 1 ctx.rotate(0) ctx.setTransform(1, 0, 0, 1, 0, 0) } draw() { const ctx = this.ctx if (this.trail.length > 0) { this.drawTrail(ctx) } this.preDraw(ctx) switch (this.shape) { case 'line': this.drawLine(ctx) break case 'square': this.drawSquare(ctx) break case 'roundSquare': this.drawRoundSquare(ctx) break case 'circle': this.drawCircle(ctx) break case 'flame': this.drawFlame(ctx) break case 'image': this.drawImage(ctx) break case 'svg': this.drawSvg(ctx) break case 'waveformExact': this.drawPath(ctx) break case 'waveformClose': case 'waveformFast': case 'waveformVeryFast': case 'waveformLoose': case 'waveformVeryLoose': this.drawPathRound(ctx) break } this.postDraw(ctx) } getLifetimeRatio() { return this.timeLived / this.lifetime return (this.lastDraw - this.firstDraw) / this.lifetime } drawPath(ctx) { const color = this.color.render(this.getLifetimeRatio()) ctx.beginPath() this.path.forEach(([x, y]) => { ctx.lineTo(x, y) }) ctx.fillStyle = `rgba(${color})` ctx.strokeStyle = `rgba(${color})` ctx.closePath() ctx.stroke() ctx.fill() } drawPathRound(ctx) { const color = this.color.render(this.getLifetimeRatio()) ctx.beginPath() ctx.moveTo(this.path[0][0], this.path[0][1]) let i = 0 for (i = 1; i < this.path.length - 2; i++) { const xc = (this.path[i][0] + this.path[i + 1][0]) / 2 const yc = (this.path[i][1] + this.path[i + 1][1]) / 2 ctx.quadraticCurveTo(this.path[i][0], this.path[i][1], xc, yc) } // curve through the last two points ctx.quadraticCurveTo( this.path[i][0], this.path[i][1], this.path[i + 1][0], this.path[i + 1][1], ) ctx.fillStyle = `rgba(${color})` ctx.strokeStyle = `rgba(${color})` ctx.closePath() ctx.stroke() ctx.fill() } drawSvg(ctx) { const color = this.color.render(this.getLifetimeRatio()) ctx.fillStyle = `rgba(${color})` ctx.strokeStyle = `rgba(${color})` const basePath = new Path2D() const path = new Path2D(this.svg) basePath.addPath(path, {}) //{ a: 1 / 40, d: 1 / 40, e: -12.5, f: -12.5 }) ctx.fill(path) } drawLine(ctx) { const lineEnd = this.velocity.multiply(4) const color = this.color.render(this.getLifetimeRatio()) ctx.strokeStyle = `rgba(${color})` ctx.beginPath() // Start a new path ctx.moveTo(-lineEnd.x / 2, -lineEnd.y / 2) ctx.lineTo(lineEnd.x / 2, lineEnd.y / 2) ctx.lineWidth = 3 ctx.stroke() // Render the path ctx.closePath() } drawSquare(ctx) { const color = this.color.render(this.getLifetimeRatio()) ctx.fillStyle = `rgba(${color})` ctx.strokeStyle = `rgba(${color})` ctx.fillRect(-0.5, -0.5, 1, 1) } drawRoundSquare(ctx) { const color = this.color.render(this.getLifetimeRatio()) ctx.fillStyle = `rgba(${color})` ctx.strokeStyle = `rgba(${color})` ctx.beginPath() ctx.roundRect(-0.5, -0.5, 1, 1, 0.1) ctx.fill() ctx.closePath() } drawFlame(ctx) { const color = this.color.render(this.getLifetimeRatio()) ctx.fillStyle = `rgba(${color})` ctx.strokeStyle = `rgba(${color})` ctx.beginPath() const path = new Path2D( 'M.5176,1.0035A.3782.3782,0,0,0,.734.7219.453.453,0,0,0,.69.4064.8332.8332,0,0,0,.5306.1911,1.4556,1.4556,0,0,0,.2986.0028C.2937,0,.2936-.0006.2932.0011l-.0017.01C.2894.0257.2841.0543.28.0743A.7921.7921,0,0,1,.1662.3568C.15.3812.136.4.1.4469A.5572.5572,0,0,0,.03.552.3045.3045,0,0,0,.0011.656a.3864.3864,0,0,0,0,.0627.3462.3462,0,0,0,.099.1968A.3392.3392,0,0,0,.25,1.01.6552.6552,0,0,0,.5176,1.0035Z', ) ctx.fill(path) ctx.closePath() } drawCircle(ctx) { const color = this.color.render(this.getLifetimeRatio()) ctx.fillStyle = `rgba(${color})` ctx.strokeStyle = `rgba(${color})` ctx.beginPath() ctx.arc(-0.5, -0.5, 1, 0, 2 * Math.PI) ctx.fill() ctx.closePath() } drawImage(ctx) { const image = this.image const color = this.color.getColor(this.getLifetimeRatio()) const colorNoAlpha = new Color(color.red, color.green, color.blue, 1) const c = this.bufferCanvas const cctx = this.bufferContext const x = -image.width / 2 const y = -image.height / 2 if (!image.height || !image.width) { return } if (color.isWhite()) { ctx.globalAlpha = ctx.globalAlpha * color.alpha return ctx.drawImage(image, x, y) } c.width = image.width c.height = image.height cctx.clearRect(0, 0, image.width, image.height) cctx.drawImage(image, 0, 0) cctx.globalCompositeOperation = 'source-atop' cctx.fillStyle = `rgba(${colorNoAlpha.render()}` cctx.fillRect(0, 0, image.width, image.height) cctx.globalCompositeOperation = 'source-over' ctx.globalAlpha = ctx.globalAlpha * color.alpha ctx.drawImage(image, x, y) ctx.globalCompositeOperation = 'color' ctx.drawImage(c, x, y) ctx.globalCompositeOperation = 'source-over' } drawTrail(ctx) { const color = this.trail.color.render(this.getLifetimeRatio()) const edgeAlpha = this.getEdgeAlpha() ctx.fillStyle = `rgba(${color})` ctx.strokeStyle = `rgba(${color})` let step = 1 / (this.previousPositions.length + 1) let modifier = 1 - step this.previousPositions.forEach(({ position, rotation }) => { ctx.globalAlpha = modifier * edgeAlpha ctx.setTransform( this.scale.x * modifier * this.trail.size, 0, 0, this.scale.y * modifier * this.trail.size, position.x, position.y, ) ctx.rotate(this.rotation) //ctx.beginPath() //ctx.arc(-0.5, -0.5, 1, 0, 2 * Math.PI) //ctx.fill() //ctx.closePath() ctx.fillRect(-0.5, -0.5, 1, 1) modifier = modifier - step }) } getWaveformPath(shape) { const container = window.lastWaveformDraw || { top: [], bottom: [] } const points = [...container.top, ...container.bottom] const filterAmounts = { waveformExact: 1, waveformClose: 2, waveformFast: 4, waveformVeryFast: 8, waveformLoose: 16, waveformVeryLoose: 32, } const pointsFiltered = points.filter( (any, index) => index % filterAmounts[shape] === 0, ) return pointsFiltered.map(([x, y]) => { return [x - this.canvas.width / 2, y - this.canvas.height / 2] }) } storeState() { this.previousPositions = [ { position: this.position, rotation: this.rotation, }, ...this.previousPositions.slice(0, this.trail.length - 1), ] } } class Emitter { constructor({ angle, position, particle, speed, rate, ctx, effects, system, }) { this.angle = angle this.position = position this.particle = particle this.rate = rate this.speed = speed this.ctx = ctx this.timer = rate - 10 this.effects = effects this.bufferCanvas = null this.bufferContext = null this.system = system } tick({ delta, energy, max }) { const rate = this.rate const dilatedDelta = this.applyTimeDilation({ delta, energy }) const spawnedParticles = [] if (dilatedDelta > 0 && max > 0) { const amountToSpawn = rate > 10 ? 1 : Math.max(1, 10 - rate * (1 / dilatedDelta)) if (this.timer > rate - 10) { for (let i = 0; i < amountToSpawn; i++) { spawnedParticles.push(this.emit(energy)) } this.timer = 0 } this.timer += dilatedDelta } return spawnedParticles } applyTimeDilation({ delta, energy }) { let value = delta const effects = this.effects.filter((effect) => effect.type === 'time') effects.forEach((effect) => { const factor = getEffectStrength(effect, 1, energy) value = value * factor }) return value } emit(energy) { const width = this.ctx.canvas.clientWidth const height = this.ctx.canvas.clientHeight const canvasDimensions = new Vector(width, height) const angle = Range.render(this.angle) let position = Range.render(this.position).multiply(canvasDimensions) const spawnOffsetEffects = this.effects.filter( (effect) => effect.type === 'spawnOffset', ) spawnOffsetEffects.forEach((effect) => { const offset = getEffectStrengthVector(effect, new Vector(0, 0), energy) position = position.add(offset.multiply(canvasDimensions)) }) const velocity = Vector.fromAngle(angle, Range.render(this.speed)) let lifetime = Range.render(this.particle.lifetime) * 1000 const lifetimeEffects = this.effects.filter( (effect) => effect.type === 'lifetime', ) lifetimeEffects.forEach((effect) => { const strength = getEffectStrength(effect, 1, energy) lifetime = lifetime * strength }) return new Particle({ ...this.particle, lifetime, velocity, position, ctx: this.ctx, emitter: this, bufferCanvas: this.bufferCanvas, bufferContext: this.bufferContext, }) } getImpulse(energy) { const effects = this.effects.filter((effect) => effect.type === 'impulse') let impulse = new Vector(0, 0) effects.forEach((effect) => { const strength = getEffectStrength(effect, 0, energy) impulse = impulse.add(Vector.fromAngle(effect.data.direction, strength)) }) return impulse } setBufferCanvas(bufferCanvas, bufferContext) { this.bufferContext = bufferContext this.bufferCanvas = bufferCanvas } applyGravityPoints({ position, delta, energy }) { let accel = new Vector(0, 0) const points = this.effects.filter((effect) => this.filterEffectsByEnergy(effect, 'gravity', energy), ) points.forEach((point) => { const { position: pointPosition, radius } = point.data const width = this.ctx.canvas.clientWidth const height = this.ctx.canvas.clientHeight const center = new Vector( pointPosition[0] * width, pointPosition[1] * height, ) if (pointInCircle(position, center, radius * width)) { const strength = getEffectStrength(point, 0, energy) const diff = new Vector(center.x - position.x, center.y - position.y) const magnitude = diff.getMagnitude() const normal = new Vector(diff.x / magnitude, diff.y / magnitude) const forceFactor = 100000 / Math.max(20000, diff.x * diff.x + diff.y * diff.y) accel = accel.add(normal.multiply(delta * strength * forceFactor)) } }) return accel } filterEffectsByEnergy(effect, type, energy) { const energyValid = effect.energy === 'all' || (effect.energy === 'below' && energy < ENERGY_LOW_THRESHOLD) || (effect.energy === 'above' && energy > ENERGY_HIGH_THRESHOLD) return effect.type === type && energyValid } } function pointInCircle(point, center, radius) { return ( Math.pow(center.x - point.x, 2) + Math.pow(center.y - point.y, 2) <= Math.pow(radius, 2) ) } class ParticleSystem { constructor({ background, foreground, emitters, player, waveData }) { this.background = background this.foreground = foreground this.maxParticles = 4000 this.particles = [] this.emitters = emitters this.energies = [] this.enabled = false this.bgCtx = this.background.getContext('2d') this.fgCtx = this.foreground.getContext('2d') this.energyIntensity = null this.lastDraw = Date.now() this.player = player this.waveData = waveData this.bufferCanvas = document.createElement('canvas') this.bufferContext = this.bufferCanvas.getContext('2d') this.emitters.forEach((emitter) => emitter.setBufferCanvas(this.bufferCanvas, this.bufferContext), ) this.resetDimensions() this.queue() } resetDimensions() { this.background.width = this.background.parentElement.clientWidth this.background.height = this.background.parentElement.clientHeight this.foreground.width = this.foreground.parentElement.clientWidth this.foreground.height = this.foreground.parentElement.clientHeight this.width = this.background.width this.height = this.background.height } start() { this.enabled = true } stop() { this.enabled = false this.clear() } tick() { this.resetDimensions() this.clear() if (this.enabled) { this.update() this.draw() } this.queue() } clear() { this.bgCtx.clearRect(0, 0, this.width, this.height) this.fgCtx.clearRect(0, 0, this.width, this.height) } update() { const now = Date.now() let delta = (now - this.lastDraw) / 10 const energy = this.getEnergy() this.spawnParticles({ delta, energy }) this.moveParticles({ delta, energy }) this.lastDraw = now } getEnergy() { const player = this.player const { data, average, min } = this.waveData || {} if (data) { try { const index = Math.ceil( (player.getCurrentTime() / player.getDuration()) * data.length, ) const energyData = data[index] const energy = (Math.abs(energyData[0]) + Math.abs(energyData[1])) / 2 if (energy > average) { const max = 1 const progress = (1 - (max - energy) / (max - average)) / 2 return Math.min(1, 0.5 + progress) } else if (energy < average) { const progress = (1 - (min - energy) / (min - average)) / 2 return Math.max(0, 0.5 - progress) } } catch (e) { console.error(e) } return 0.5 } else if (this.energyIntensity !== null) { return this.energyIntensity } return 0.5 } setEnergyIntensity(value) { this.energyIntensity = value } spawnParticles({ delta, energy }) { this.emitters.forEach((emitter) => { const max = this.maxParticles - this.particles.length const particles = emitter.tick({ delta, energy, max }) if (particles.length > 0) { particles.forEach((particle) => this.particles.push(particle)) } }) } moveParticles({ delta, energy }) { const currentParticles = [] const leftBounds = -300 const rightBounds = this.width + 100 const topBounds = -100 const bottomBounds = this.height + 100 this.particles.forEach((particle) => { const x = particle.position.x const y = particle.position.y if ( x > leftBounds && x < rightBounds && y > topBounds && y < bottomBounds && particle.timeLived < particle.lifetime ) { particle.move({ delta, energy }) currentParticles.push(particle) } }) this.particles = currentParticles } draw() { this.particles.forEach((particle) => particle.draw()) } queue() { window.requestAnimationFrame(() => { this.tick() }) } sum(arr) { return arr.reduce((acc, cur) => { return acc + cur }, 0) } } function createParticleSystem({ background, foreground, data, player, waveData, }) { const defaultEmitter = { position: { type: 'vector', value: [0, 0], }, speed: { type: 'float', value: 0, }, angle: { type: 'float', value: 0, }, rate: { type: 'float', value: 0, }, layer: { type: 'string', value: 'foreground', }, } const defaultParticle = { shape: { type: 'string', value: 'circle', }, gravity: { type: 'float', value: 0, }, drag: { type: 'vector', value: [0, 0], }, bounce: { type: 'float', value: 0, }, wobble: { type: 'float', value: 0, }, scale: { type: 'float', value: 1, }, scaleRate: { type: 'float', value: 0, }, rotation: { type: 'float', value: 0, }, rotationRate: { type: 'float', value: 0, }, lifetime: { type: 'float', value: 30, }, color: { type: 'color', value: [255, 255, 255, 1], }, edgeBehaviour: { type: 'string', value: 'fade', }, image: { type: 'string', value: '', }, svg: { type: 'string', value: '', }, } const defaultTrail = { shape: { type: 'string', value: 'square', }, length: { type: 'float', value: 0, }, color: { type: 'color', value: [255, 255, 255, 1], }, size: { type: 'float', value: 1, }, } const system = { waveformData: [], prerender: 0, emitters: [], background: background, foreground: foreground, player, waveData, } const bgCtx = background.getContext('2d') const fgCtx = foreground.getContext('2d') data?.emitters?.forEach((emitterData) => { const id = emitterData.id const emitter = Object.keys(defaultEmitter).reduce((acc, cur) => { const emitterConfig = emitterData?.[cur] || defaultEmitter[cur] acc[cur] = convertData(emitterConfig) return acc }, {}) const particle = Object.keys(defaultParticle).reduce((acc, cur) => { const particleConfig = emitterData?.particle?.[cur] || defaultParticle[cur] acc[cur] = convertData(particleConfig) acc.trail = Object.keys(defaultTrail).reduce((trailAcc, trailCur) => { const trailConfig = emitterData?.particle?.trail?.[trailCur] || defaultTrail[trailCur] trailAcc[trailCur] = convertData(trailConfig) return trailAcc }, {}) return acc }, {}) const effects = data?.effects.reduce((acc, cur) => { if (cur?.emitters?.includes(id)) { acc.push(cur) } return acc }, []) system.emitters.push( new Emitter({ ...emitter, particle, background, foreground, effects, ctx: emitter.layer === 'foreground' ? fgCtx : bgCtx, system, }), ) }) return new ParticleSystem(system) } function convertData(data) { let v = data.value let type = data.type if (type.indexOf('choose') !== -1) { return new Choose(data) } switch (type) { case 'float': return Number(v) case 'string': return String(v) case 'color': return new Color(v[0], v[1], v[2], v[3]) case 'colorOverLife': return new ColorOverLife(v) case 'vector': return new Vector(v[0], v[1]) case 'range': return new Range(Number(v[0]), Number(v[1])) case 'vectorRange': return new Range( new Vector(v[0][0], v[0][1]), new Vector(v[1][0], v[1][1]), ) } } class Choose { constructor(data) { this.data = data } static render(input) { if (input instanceof Choose) { const data = input.data const tempType = data.type.replace('choose', '') const value = data.value[Math.floor(Math.random() * data.value.length)] const type = tempType.charAt(0).toLowerCase() + tempType.slice(1) return convertData({ type, value }) } return input } } class Vector { constructor(x, y) { if (Array.isArray(x)) { this.x = x[0] this.y = x[1] } else if (typeof x === 'object') { this.x = x.x this.y = x.y } else { this.x = x || 0 this.y = y === undefined ? x : y || 0 } } get() { return { x: this.x, y: this.y } } add(vector) { return new Vector(this.x + vector.x, this.y + vector.y) } subtract(vector) { return new Vector(this.x - vector.x, this.y - vector.y) } multiply(input) { if (input instanceof Vector) { return new Vector(this.x * input.x, this.y * input.y) } return new Vector(this.x * input, this.y * input) } getMagnitude() { return Math.sqrt(this.x * this.x + this.y * this.y) } getAngle() { return Math.atan2(this.y, this.x) } dotProduct(vector) { return this.x * vector.x + this.y * vector.y } static fromAngle(angle, magnitude) { return new Vector( magnitude * Math.cos(angle), magnitude * Math.sin(angle), ) } } class Range { constructor(start, end) { this.start = start this.end = end } static render(input) { if (input instanceof Range) { if (input.start instanceof Vector) { return new Vector( input.start.x + Math.random() * (input.end.x - input.start.x), input.start.y + Math.random() * (input.end.y - input.start.y), ) } return input.start + Math.random() * (input.end - input.start) } return input } } class Color { constructor(r, g, b, a = 255) { this.red = r this.green = g this.blue = b this.alpha = a } rgba() { return `${this.red}, ${this.green}, ${this.blue}, ${this.alpha}` } render() { return this.rgba() } copy() { return new Color(this.red, this.green, this.blue, this.alpha) } getColor() { return this } isWhite() { return this.red === 255 && this.green === 255 && this.blue === 255 } } class ColorOverLife { constructor(keyframes) { this.keyframes = keyframes } getColor(lifetimeRatio) { const normalizedLife = 100 * lifetimeRatio const currentFrameIndex = this.keyframes.findLastIndex( (frame) => frame.keyframe <= normalizedLife, ) || 0 const currentFrame = this.keyframes[currentFrameIndex] const nextFrame = this.keyframes[currentFrameIndex + 1] || this.keyframes[this.keyframes.length - 1] if (nextFrame.keyframe === currentFrame.keyframe) { return new Color(...currentFrame.value) } const progress = (normalizedLife - currentFrame.keyframe) / (nextFrame.keyframe - currentFrame.keyframe) let red = currentFrame.value[0], green = currentFrame.value[1], blue = currentFrame.value[2], alpha = currentFrame.value[3] const nextRed = nextFrame.value[0], nextGreen = nextFrame.value[1], nextBlue = nextFrame.value[2], nextAlpha = nextFrame.value[3] if (red !== nextRed) { red = red * (1 - progress) + nextRed * progress } if (green !== nextGreen) { green = green * (1 - progress) + nextGreen * progress } if (blue !== nextBlue) { blue = blue * (1 - progress) + nextBlue * progress } if (alpha !== nextAlpha) { alpha = alpha * (1 - progress) + nextAlpha * progress } return new Color(red, green, blue, alpha) } render(lifetimeRatio) { const color = this.getColor(lifetimeRatio) return color.render() } } function getEffectStrength(effect, baseAmount, energy) { const fullAmount = effect.data.amount if (effect.energy === 'all') { return fullAmount } else if (effect.energy === 'above' && energy >= ENERGY_HIGH_THRESHOLD) { const eStart = ENERGY_HIGH_THRESHOLD const eEnd = 1 return ( baseAmount + (effect.data.amount - baseAmount) * ((energy - eStart) / (eEnd - eStart)) ) } else if (effect.energy === 'below' && energy <= ENERGY_LOW_THRESHOLD) { const eStart = ENERGY_LOW_THRESHOLD const eEnd = 0 return ( baseAmount + (effect.data.amount - baseAmount) * ((energy - eStart) / (eEnd - eStart)) ) } return baseAmount } function getEffectStrengthVector(effect, baseAmount, energy) { const fullAmount = new Vector(effect.data.amount) if (effect.energy === 'all') { return fullAmount } else if (effect.energy === 'above' && energy >= ENERGY_HIGH_THRESHOLD) { const eStart = ENERGY_HIGH_THRESHOLD const eEnd = 1 const modifier = (energy - eStart) / (eEnd - eStart) return new Vector( baseAmount.x + (fullAmount.x - baseAmount.x) * modifier, baseAmount.y + (fullAmount.y - baseAmount.y) * modifier, ) } else if (effect.energy === 'below' && energy <= ENERGY_LOW_THRESHOLD) { const eStart = ENERGY_LOW_THRESHOLD const eEnd = 0 const modifier = (energy - eStart) / (eEnd - eStart) return new Vector( baseAmount.x + (fullAmount.x - baseAmount.x) * modifier, baseAmount.y + (fullAmount.y - baseAmount.y) * modifier, ) } return baseAmount } })()