;(function () {
const id = 'ea40d1a7-f880-4d64-ace1-317beac215b8'
const sessionId = 'adfed27c-69b1-4290-90ab-9859bffb576a'
const apiUrl = 'https://sharefol.io/api'
const data = [{"id":"t49","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":139857,"waveform":"https://sharefolio-public.s3.amazonaws.com/enormoushandsomelyToad/af9665b2-a84f-4bab-b6d3-912c59a1095d.dat","metadata":{"album":null,"albumArtist":null,"composer":"Jordan Michael Reed","genre":"Various","grouping":null,"releaseDate":null,"bpm":null,"isrc":null,"description":"General demo reel of some recent work!"}},"text":"Demo Reel (2024)","parent":"p5"}]
const styles = {"bgColor":null,"fgColor":null,"durationText":{"bold":false,"size":"14px","color":"#fff","hidden":false,"allCaps":false},"primaryColor":"#1A7A3E","showWaveform":1,"trackArtSize":"normal","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()
})
const silence = this.getElements('Silence')[0]
silence.addEventListener('ended', () => {
silence.play()
})
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 || document.visibilityState == 'hidden') {
// 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)
}
if (!this.isSafari()) {
const silence = this.getElements('Silence')[0]
silence.play()
}
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
if (!this.isSafari()) {
setTimeout(() => this.getTrackBuffer(id, trackUrlData.url), 1000)
}
}
if (this.isSafari()) {
this.audioElement.volume = this.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
? `
`
: ''
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 = `
`
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
}
})()