diff --git a/package.json b/package.json index 3f7796aa..974381bf 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "chorus", "private": true, "type": "module", - "version": "1.16.2", + "version": "1.17.0", "scripts": { "build": "rollup -c", "watch": "rollup -c -w" diff --git a/src/actions/init.js b/src/actions/init.js index 872b2ab7..0247a39d 100644 --- a/src/actions/init.js +++ b/src/actions/init.js @@ -5,10 +5,11 @@ import App from '../models/app.js' async function load() { await store.populate() - const app = new App(spotifyVideo.element) + + const app = new App({ video: spotifyVideo.element, reverb: spotifyVideo.reverb }) const enabled = JSON.parse(sessionStorage.getItem('enabled') ?? 'true') - enabled ? await app.connect() : app.disconnect() + enabled ? app.connect() : app.disconnect() spotifyVideo.element.active = enabled document.addEventListener('app.enabled', async e => { @@ -19,7 +20,7 @@ async function load() { sessionStorage.setItem('enabled', enabled.newValue) spotifyVideo.element.active = enabled.newValue - enabled.newValue ? await app.connect() : app.disconnect() + enabled.newValue ? app.connect() : app.disconnect() }) document.addEventListener('app.device_id', async e => { diff --git a/src/actions/overload.js b/src/actions/overload.js index 3e099477..f97e4358 100644 --- a/src/actions/overload.js +++ b/src/actions/overload.js @@ -1,53 +1,32 @@ +import Reverb from '../models/reverb/reverb.js' import VideoElement from '../models/video/video.js' class SpotifyVideo { - #video - #tries = 0 - #originalCreateElement = document.createElement - constructor() { - this.#overloadCreateElement() + this._video + this._reverb + this._originalCreateElement = document.createElement this.#init() } - #overloadCreateElement() { + #init() { const self = this document.createElement = function (tagName) { - const element = self.#originalCreateElement.apply(this, arguments) + const element = self._originalCreateElement.apply(this, arguments) if (tagName === 'video') { - self.#video = new VideoElement(element) - - document.createElement = self.#originalCreateElement + self._reverb = new Reverb(element) + self._video = new VideoElement({ video: element, reverb: self._reverb }) + document.createElement = self._originalCreateElement } return element } } - #init = () => { - try { - this.#tries++ - this.#checkForMainEl() - } catch { - if (this.#tries <= 20) { - setTimeout(this.#init, 500) - return - } - } - } - - #checkForMainEl() { - const mainEl = document.getElementById('main') + get element() { return this._video } - if (mainEl === null) { - throw new Error('Main container element not found') - } - } - - get element() { - return this.#video - } + get reverb() { return this._reverb } } export const spotifyVideo = new SpotifyVideo() diff --git a/src/background.js b/src/background.js index 38f198f9..62182fc0 100644 --- a/src/background.js +++ b/src/background.js @@ -1,8 +1,8 @@ import { setState, getState } from './utils/state.js' import { getActiveTab, sendMessage } from './utils/messaging.js' -import { playSharedTrack } from './services/player.js' import { createArtistDiscoPlaylist } from './services/artist-disco.js' +import { playSharedTrack, seekTrackToPosition } from './services/player.js' let ENABLED = true let popupPort = null @@ -79,20 +79,22 @@ chrome.webRequest.onBeforeSendHeaders.addListener(details => { ['requestHeaders'] ) -chrome.runtime.onMessage.addListener(({ key, data }, _, sendResponse) => { - switch (key) { - case 'artist.disco': - createArtistDiscoPlaylist(data) - .then(result => sendResponse({ state: 'completed', data: result })) - .catch(error => sendResponse({ state: 'error', error: error.message })) - return true - case 'play.shared': - playSharedTrack(data) - .then(result => sendResponse({ state: 'completed', data: result })) - .catch(error => sendResponse({ state: 'error', error: error.message })) - - return true +function promiseHandler(promise, sendResponse) { + promise.then(result => sendResponse({ state: 'completed', data: result })) + .catch(error => sendResponse({ state: 'error', error: error.message })) +} + +chrome.runtime.onMessage.addListener(({ key, values }, _, sendResponse) => { + const messageHandler = { + 'play.shared': playSharedTrack, + 'play.seek': seekTrackToPosition, + 'artist-disco': createArtistDiscoPlaylist, } + const handlerFn = messageHandler[key] + if (!handlerFn) return + + promiseHandler(handlerFn(values), sendResponse) + return true }) chrome.commands.onCommand.addListener(async command => { diff --git a/src/components/effects/effects-button.js b/src/components/effects/effects-button.js new file mode 100644 index 00000000..782750d3 --- /dev/null +++ b/src/components/effects/effects-button.js @@ -0,0 +1,8 @@ +import { createTextButton } from '../text-button.js' + +export const createEffectsButtons = () => ` +
+ ${createTextButton({ text: 'reset', id: 'effects-reset' })} + ${createTextButton({ text: 'save', id: 'effects-save' })} +
+` diff --git a/src/components/effects/effects-controls.js b/src/components/effects/effects-controls.js new file mode 100644 index 00000000..7ad8649a --- /dev/null +++ b/src/components/effects/effects-controls.js @@ -0,0 +1,20 @@ +import { createEffectsButtons } from './effects-button.js' +import { createEffectsSelector } from './effects-selector.js' +import { convolverPresets, drinkPresets } from '../../lib/reverb/presets.js' + +export const createEffectsControls = () => ` + +` diff --git a/src/components/effects/effects-selector.js b/src/components/effects/effects-selector.js new file mode 100644 index 00000000..a9de2b75 --- /dev/null +++ b/src/components/effects/effects-selector.js @@ -0,0 +1,21 @@ +const createOptions = optionNames => ( + optionNames.map(name => ``).join('') +) + +export const createEffectsSelector = ({ labelName, name, optionNames }) => ` +
+ + +
+ +
+
+` diff --git a/src/components/header.js b/src/components/header.js index dc9659d9..8fecd752 100644 --- a/src/components/header.js +++ b/src/components/header.js @@ -17,6 +17,7 @@ export const createHeader = () => `
${createHeaderButton({ role: 'snip', ariaLabel: 'Snip Controls', additionalStyles: 'background-color:green;' })} ${createHeaderButton({ role: 'speed', ariaLabel: 'Speed Controls', additionalStyles: 'margin-left:.5rem;' })} + ${createHeaderButton({ role: 'fx', ariaLabel: 'FX Controls', additionalStyles: 'margin-left:.5rem;' })} ${createHeaderButton({ role: 'seek', ariaLabel: 'Seek Controls', additionalStyles: 'margin:0 .5rem;' })}
diff --git a/src/components/text-button.js b/src/components/text-button.js index f29618fa..ade9bd1c 100644 --- a/src/components/text-button.js +++ b/src/components/text-button.js @@ -1,10 +1,14 @@ -export const createTextButton = ({ id, text, style }) => ` - -` +const classNames = { share: 'share', save: 'success', reset: 'danger', remove: 'danger' } +export const createTextButton = ({ id, text, style }) => { + const btnTypeClass = classNames[id?.split('-')?.at(-1)] + return ` + + ` +} diff --git a/src/components/track-info.js b/src/components/track-info.js index 4e6d5cb4..836f8041 100644 --- a/src/components/track-info.js +++ b/src/components/track-info.js @@ -1,6 +1,6 @@ export const createTrackInfo = () => ` -
-

-

+
+
+
` diff --git a/src/content.js b/src/content.js index b09f81de..07e79e15 100644 --- a/src/content.js +++ b/src/content.js @@ -5,17 +5,16 @@ const loadScript = filePath => { const script = document.createElement('script') script.src = chrome.runtime.getURL(filePath) script.type = 'module' - document.body.appendChild(script) + document.head.appendChild(script) + script.onload = () => { script.remove() } } loadScript('actions/init.js') +sessionStorage.setItem('soundsDir', chrome.runtime.getURL('/lib/sounds/')) +sessionStorage.setItem('reverbPath', chrome.runtime.getURL('/lib/reverb/reverb.js')) const sendEventToPage = ({ eventType, detail }) => { - window.postMessage({ - type: 'FROM_CONTENT_SCRIPT', - requestType: eventType, - payload: detail - }, window.location.origin) + window.postMessage({ type: 'FROM_CONTENT_SCRIPT', requestType: eventType, payload: detail }, window.location.origin) } window.addEventListener('message', async (event) => { @@ -23,37 +22,21 @@ window.addEventListener('message', async (event) => { if (event.data.type !== 'FROM_PAGE_SCRIPT') return const { requestType, payload } = event.data - let response - - switch (requestType) { - case 'artist.disco': - response = await sendBackgroundMessage({ key: payload.key, data: payload.values }) - sendEventToPage({ eventType: 'artist.disco.response', detail: response }) - break - case 'play.shared': - response = await sendBackgroundMessage({ key: payload.key, data: payload.values }) - sendEventToPage({ eventType: 'play.shared.response', detail: response }) - break - case 'storage.set': - const { key, values } = payload - response = await setState({ key, values }) - sendEventToPage({ eventType: 'storage.set.response', detail: response }) - break - - case 'storage.get': - response = await getState(payload?.key) - sendEventToPage({ eventType: 'storage.get.response', detail: response }) - break - - case 'storage.delete': - await removeState(payload.key) - break - - case 'storage.populate': - response = await getState(null) - sendEventToPage({ eventType: 'storage.populate.response', detail: response }) - break - } + const messageHandlers = { + 'play.seek': sendBackgroundMessage, + 'play.shared': sendBackgroundMessage, + 'artist.disco': sendBackgroundMessage, + 'storage.populate': () => getState(null), + 'storage.get': ({ key }) => getState(key), + 'storage.delete': ({ key }) => removeState(key), + 'storage.set': ({ key, values }) => setState({ key, values }), + }; + + const handlerFn = messageHandlers[requestType] + if (!handlerFn) return + + const response = await handlerFn(payload) + sendEventToPage({ eventType: `${requestType}.response`, detail: response }) }) chrome.runtime.onMessage.addListener(message => { diff --git a/src/events/listeners/action-listeners.js b/src/events/listeners/action-listeners.js index f1d04dfb..8ee5995d 100644 --- a/src/events/listeners/action-listeners.js +++ b/src/events/listeners/action-listeners.js @@ -9,63 +9,66 @@ export default class ActionListeners extends Listeners { init() { if (this._setup) return + this.#resetSeekListener() this.#saveSeekListener() - this.#saveTrackListener() - this.#saveSpeedListener() this.#shareTrackListener() this.#deleteTrackListener() + this.#saveTrackListener() + this.#resetSpeedListener() + this.#saveSpeedListener() + + this.#resetReverbListener() + this.#saveReverbListener() this._setup = true } #resetSpeedListener() { const resetButton = document.getElementById('chorus-speed-reset-button') - resetButton?.addEventListener('click', async () => { - await this._speed.reset() - }) + resetButton?.addEventListener('click', async () => await this._speed.reset()) } #deleteTrackListener() { const deleteButton = document.getElementById('chorus-snip-remove-button') deleteButton?.addEventListener('click', async () => { - await this._snip.delete() - this._hide() + await this._snip.delete(); this._hide() }) } #saveTrackListener() { const saveButton = document.getElementById('chorus-snip-save-button') - saveButton?.addEventListener('click', async () => { - await this._snip.save() - this._hide() - }) + saveButton?.addEventListener('click', async () => await this._snip.save()) } #saveSpeedListener() { const speedSaveButton = document.getElementById('chorus-speed-save-button') - speedSaveButton?.addEventListener('click', async () => { - await this._speed.save() - this._hide() - }) + speedSaveButton?.addEventListener('click', async () => await this._speed.save()) + } + + #saveReverbListener() { + const reverbSaveButton = document.getElementById('chorus-effects-save-button') + reverbSaveButton?.addEventListener('click', async () => await this._reverb.saveSelection()) + } + + #resetReverbListener() { + const reverbResetButton = document.getElementById('chorus-effects-reset-button') + reverbResetButton?.addEventListener('click', async () => await this._reverb.clearReverb()) } #saveSeekListener() { const seekSaveButton = document.getElementById('chorus-seek-save-button') - seekSaveButton?.addEventListener('click', async () => { - await this._seek.save() - this._hide() - }) + seekSaveButton?.addEventListener('click', async () => await this._seek.save()) } - #handleShare() { - this._snip.share() - this._hide() + #resetSeekListener() { + const seekResetButton = document.getElementById('chorus-seek-reset-button') + seekResetButton?.addEventListener('click', async () => await this._seek.reset()) } #shareTrackListener() { const shareButton = document.getElementById('chorus-snip-share-button') - shareButton?.addEventListener('click', () => this.#handleShare()) + shareButton?.addEventListener('click', () => this._snip.share()) } } diff --git a/src/events/listeners/header-listeners.js b/src/events/listeners/header-listeners.js index b62514c0..d91dfae2 100644 --- a/src/events/listeners/header-listeners.js +++ b/src/events/listeners/header-listeners.js @@ -6,7 +6,7 @@ export default class HeaderListeners extends Listeners { this._setup = false this._viewInFocus = null - this._VIEWS = ['snip', 'speed', 'seek'] + this._VIEWS = ['snip', 'speed', 'fx', 'seek'] } init() { @@ -15,6 +15,7 @@ export default class HeaderListeners extends Listeners { this.#snipViewToggle() this.#seekViewToggle() this.#speedViewToggle() + this.#effectsViewToggle() this.#closeModalListener() this._currentView = 'snip' @@ -22,18 +23,12 @@ export default class HeaderListeners extends Listeners { } async hide() { - if (this._viewInFocus == 'speed') { - this._speed.clearCurrentSpeed() - await this._speed.reset() - } - - this._currentView = 'snip' + if (this._viewInFocus == 'speed') this._speed.clearCurrentSpeed() this._hide() } #seekViewToggle() { const seekButton = document.getElementById('chorus-seek-button') - seekButton?.addEventListener('click', async () => { this._currentView = 'seek' await this._seek.init() @@ -42,7 +37,6 @@ export default class HeaderListeners extends Listeners { set _currentView(selectedView) { this._viewInFocus = selectedView - this._VIEWS.forEach(view => { const viewButton = document.getElementById(`chorus-${view}-button`) const viewInFocusContainer = document.getElementById(`chorus-${view}-controls`) @@ -66,6 +60,14 @@ export default class HeaderListeners extends Listeners { }) } + #effectsViewToggle() { + const effectsButton = document.getElementById('chorus-fx-button') + effectsButton?.addEventListener('click', () => { + this._currentView = 'fx' + this._reverb.init() + }) + } + #closeModalListener() { const closeButton = document.getElementById('chorus-modal-close-button') closeButton?.addEventListener('click', async () => { await this.hide() }) diff --git a/src/events/listeners/listeners.js b/src/events/listeners/listeners.js index 71b9925f..94d87d9a 100644 --- a/src/events/listeners/listeners.js +++ b/src/events/listeners/listeners.js @@ -1,6 +1,7 @@ import Seek from '../../models/seek/seek.js' import Speed from '../../models/speed/speed.js' import CurrentSnip from '../../models/snip/current-snip.js' +import ReverbController from '../../models/reverb/reverb-controller.js' import { spotifyVideo } from '../../actions/overload.js' export default class Listeners { @@ -9,6 +10,7 @@ export default class Listeners { this._seek = new Seek() this._speed = new Speed() this._snip = new CurrentSnip(songTracker) + this._reverb = new ReverbController() } _hide() { diff --git a/src/lib/reverb/presets.js b/src/lib/reverb/presets.js new file mode 100644 index 00000000..c8c0bda3 --- /dev/null +++ b/src/lib/reverb/presets.js @@ -0,0 +1,23 @@ +const PARAMS = ['preDelay','bandwidth','diffuse','decay','damping','excursion','wet','dry'] + +const PRESETS = { + // preDelay bandwidth diffuse decay damping excursion wet dry + demi: [ 1525 , 0.5683 , 0.5 , 0.3226 , 0.6446 , 0 , 0.2921 , 0.4361 ], + short: [ 596 , 0.983 , 0.9 , 0.8271 , 0.2975 , 2.8 , 0.1402 , 0.9000 ], + tall: [ 0 , 0.9999 , 1 , 0.5 , 0.005 , 16 , 0.6 , 0.3 ], + grande: [ 0 , 0.999 , 0.6 , 0.78 , 0.37 , 12 , 0.5015 , 0.5012 ], + venti: [ 0 , 0.999 , 0.6 , 0.78 , 0.37 , 12 , 0.715 , 0.394 ], + trenta: [ 0 , 0.7011 , 0.7331 , 0.88 , 0.35 , 12 , 0.7015 , 0.3012 ], + quaranta: [ 0 , 0.999 , 1 , 0.88 , 0.35 , 12 , 0.915 , 0.194 ], +} + +const drinkPresets = ['demi','short','tall','grande','venti','trenta','quaranta' ] +const convolverPresets = ['diffusor','kick','muffler','telephone'] + +const getParamsListForEffect = effect => { + const effectValues = PRESETS[effect] + const paramsList = effectValues.map((value, idx) => ({ name: PARAMS[idx], value })) + return paramsList +} + +export { drinkPresets, convolverPresets, getParamsListForEffect } diff --git a/src/lib/reverb/reverb.js b/src/lib/reverb/reverb.js new file mode 100644 index 00000000..88492c79 --- /dev/null +++ b/src/lib/reverb/reverb.js @@ -0,0 +1,230 @@ +class UXFDReverb extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [{ + name: 'preDelay', + defaultValue: 0, + minValue: 0, + maxValue: sampleRate/4-1, + automationRate: "k-rate" + },{ + name: 'bandwidth', + defaultValue: 0.9999, + minValue: 0, + maxValue: 1, + automationRate: "k-rate" + },{ + name: 'diffuse', + defaultValue: 1, + minValue: 0, + maxValue: 1, + automationRate: "k-rate" + },{ + name: 'decay', + defaultValue: 0.5, + minValue: 0, + maxValue: 1, + automationRate: "k-rate" + },{ + name: 'damping', + defaultValue: 0.005, + minValue: 0, + maxValue: 1, + automationRate: "k-rate" + },{ + name: 'excursion', + defaultValue: 16, + minValue: 0, + maxValue: 32, + automationRate: "k-rate" + },{ + name: 'wet', + defaultValue: 0.3, + minValue: 0, + maxValue: 1, + automationRate: "k-rate" + },{ + name: 'dry', + defaultValue: 0.6, + minValue: 0, + maxValue: 1, + automationRate: "k-rate" + }] + } + + constructor(options) { + super(options); + + this._Delays = []; + this._pDLength = sampleRate + (128 - sampleRate%128) + this._preDelay = new Float32Array(this._pDLength); + this._pDWrite = 0; + this._lp1 = 0.0; + this._lp2 = 0.0; + this._lp3 = 0.0; + + [ + 0.004771345, 0.003595309, 0.012734787, 0.009307483, + 0.022579886, 0.149625349, 0.060481839, 0.1249958 , + 0.030509727, 0.141695508, 0.089244313, 0.106280031 + ].forEach(x => this.makeDelay(x)); + + this._taps = Int16Array.from([ + 0.008937872, 0.099929438, 0.064278754, 0.067067639, 0.066866033, 0.006283391, 0.035818689, + 0.011861161, 0.121870905, 0.041262054, 0.08981553 , 0.070931756, 0.011256342, 0.004065724 + ], x => Math.round(x * sampleRate)); + } + + makeDelay(length) { + // len, array, write, read + let len = Math.round(length * sampleRate); + this._Delays.push([ len, new Float32Array(len), len - 1, 0 ]); + } + + writeDelay(index, data) { + this._Delays[index][1][this._Delays[index][2]] = data; + } + + readDelay(index) { + return this._Delays[index][1][this._Delays[index][3]]; + } + + readDelayAt(index, i) { + return this._Delays[index][1][(this._Delays[index][3] + i)%this._Delays[index][0]]; + } + + // readDelayAt Linear Interpolated + readDelayLAt(index, i) { + let d = this._Delays[index]; + let curr = d[1][(d[3] + ~~i)%d[0]]; + return curr + (i-~~i++) * (d[1][(d[3] + ~~i)%d[0]] - curr); + } + + readPreDelay(index) { + return this._Delays[index][1][this._Delays[index][2]]; + } + + // Only accepts one input, two channels. + // Spits one output, two channels. + process(inputs, outputs, parameters) { + let pd = ~~parameters.preDelay[0] , + bw = parameters.bandwidth[0] , + fi = parameters.diffuse[0] * 0.75 , + si = parameters.diffuse[0] * 0.625 , + dc = parameters.decay[0] , + ft = parameters.diffuse[0] * 0.76 , + st = Math.min(Math.max(dc + 0.15, 0.25), 0.5), + dp = parameters.damping[0] , + ex = parameters.excursion[0] , + we = parameters.wet[0] * 0.6 , // lo and ro are both multiplied by 0.6 anyways + dr = parameters.dry[0] ; + + let lIn = inputs[0][0], + rIn = inputs[0][1], + lOut = outputs[0][0], + rOut = outputs[0][1]; + + // write to predelay and dry output + if (inputs[0].length == 2) { + for (let i = 127; i >= 0; i--) { + this._preDelay[this._pDWrite + i] = + (inputs[0][0][i] + inputs[0][1][i]) * 0.5; + + outputs[0][0][i] = inputs[0][0][i] * dr; + outputs[0][1][i] = inputs[0][1][i] * dr; + } + } else if (inputs[0].length > 0) { + this._preDelay.set(inputs[0][0], this._pDWrite); + for (let i = 127; i >= 0; i--) + outputs[0][0][i] = outputs[0][1][i] = inputs[0][0][i] * dr; + } else { + this._preDelay.set(new Float32Array(128), this._pDWrite); + } + + + // write to predelay + if (inputs[0].length != 0) { + this._preDelay.set(inputs[0][0], this._pDWrite); + } else { + this._preDelay.fill(0, this._pDWrite, this._pDWrite + 128); + } + // this._preDelay.set(Float32Array.from(inputs[0][0], (n, i) => (n + inputs[0][1][i]) * 0.5), this._pDWrite); + + let i = 0; + while (i < 128) { + let lo = 0.0, + ro = 0.0; + + this._lp1 = this._preDelay[(this._pDLength + this._pDWrite - pd + i)%this._pDLength] * bw + (1 - bw) * this._lp1; + + // Please note: The groupings and formatting below does not bear any useful information about + // the topology of the network. I just want orderly looking text. + + // pre + this.writeDelay(0, this._lp1 - fi * this.readDelay(0) ); + this.writeDelay(1, fi * (this.readPreDelay(0) - this.readDelay(1)) + this.readDelay(0) ); + this.writeDelay(2, fi * this.readPreDelay(1) + this.readDelay(1) - si * this.readDelay(2) ); + this.writeDelay(3, si * (this.readPreDelay(2) - this.readDelay(3)) + this.readDelay(2) ); + + let split = si * this.readPreDelay(3) + this.readDelay(3); + + // 1Hz (footnote 14, pp. 665) + let excursion = ex * (1 + Math.cos(currentTime*6.28)); + + // left + this.writeDelay( 4, split + dc * this.readDelay(11) + ft * this.readDelayLAt(4, excursion) ); // tank diffuse 1 + this.writeDelay( 5, this.readDelayLAt(4, excursion)- ft * this.readPreDelay(4) ); // long delay 1 + this._lp2 = (1 - dp) * this.readDelay(5) + dp * this._lp2 ; // damp 1 + this.writeDelay( 6, dc * this._lp2 - st * this.readDelay(6) ); // tank diffuse 2 + this.writeDelay( 7, this.readDelay(6) + st * this.readPreDelay(6) ); // long delay 2 + + // right + this.writeDelay( 8, split + dc * this.readDelay(7) + ft * this.readDelayLAt(8, excursion) ); // tank diffuse 3 + this.writeDelay( 9, this.readDelayLAt(8, excursion)- ft * this.readPreDelay(8) ); // long delay 3 + this._lp3 = (1 - dp) * this.readDelay(9) + dp * this._lp3 ; // damper 2 + this.writeDelay(10, dc * this._lp3 - st * this.readDelay(10) ); // tank diffuse 4 + this.writeDelay(11, this.readDelay(10) + st * this.readPreDelay(10) ); // long delay 4 + + lo = this.readDelayAt( 9, this._taps[0]) + + this.readDelayAt( 9, this._taps[1]) + - this.readDelayAt(10, this._taps[2]) + + this.readDelayAt(11, this._taps[3]) + - this.readDelayAt( 5, this._taps[4]) + - this.readDelayAt( 6, this._taps[5]) + - this.readDelayAt( 7, this._taps[6]); + + ro = this.readDelayAt( 5, this._taps[7]) + + this.readDelayAt( 5, this._taps[8]) + - this.readDelayAt( 6, this._taps[9]) + + this.readDelayAt( 7, this._taps[10]) + - this.readDelayAt( 9, this._taps[11]) + - this.readDelayAt(10, this._taps[12]) + - this.readDelayAt(11, this._taps[13]); + + // write + lOut[i] = lo * we; + rOut[i] = ro * we; + + if (lIn) { + lOut[i] += lIn[i] * dr; + rOut[i] += lIn[i] * dr; + } + // lOut[i] = lIn[i] * dr + lo * we; + // rOut[i] = rIn[i] * dr + ro * we; + + i++; + + for (let j = 0; j < this._Delays.length; j++) { + let d = this._Delays[j]; + d[2] = (d[2] + 1) % d[0]; + d[3] = (d[3] + 1) % d[0]; + } + } + + // Update preDelay index + this._pDWrite = (this._pDWrite + 128) % this._pDLength; + + return true; + } +} + +registerProcessor('UXFDReverb', UXFDReverb); diff --git a/src/lib/sounds/diffusor.wav b/src/lib/sounds/diffusor.wav new file mode 100644 index 00000000..8cc71bc7 Binary files /dev/null and b/src/lib/sounds/diffusor.wav differ diff --git a/src/lib/sounds/kick.wav b/src/lib/sounds/kick.wav new file mode 100644 index 00000000..39dc0724 Binary files /dev/null and b/src/lib/sounds/kick.wav differ diff --git a/src/lib/sounds/muffler.wav b/src/lib/sounds/muffler.wav new file mode 100644 index 00000000..8c93aac1 Binary files /dev/null and b/src/lib/sounds/muffler.wav differ diff --git a/src/lib/sounds/telephone.wav b/src/lib/sounds/telephone.wav new file mode 100644 index 00000000..47c50e7e Binary files /dev/null and b/src/lib/sounds/telephone.wav differ diff --git a/src/manifest.chrome.json b/src/manifest.chrome.json index c972fa5c..771aceca 100644 --- a/src/manifest.chrome.json +++ b/src/manifest.chrome.json @@ -2,7 +2,7 @@ "short_name": "Chorus", "name": "Chorus - Spotify Enhancer", "description": "Enhance Spotify with controls to save favourite snips, auto-skip tracks, and set global and custom speed. More to come!", - "version": "1.16.2", + "version": "1.17.0", "manifest_version": 3, "author": "cdrani", "action": { @@ -39,7 +39,9 @@ "stores/*.js", "actions/*.js", "data/*.js", - "services/*.js" + "services/*.js", + "lib/**/*.js", + "lib/sounds/*.wav" ] } ], diff --git a/src/manifest.firefox.json b/src/manifest.firefox.json index 53340d69..3dfc7c0e 100644 --- a/src/manifest.firefox.json +++ b/src/manifest.firefox.json @@ -2,7 +2,7 @@ "short_name": "Chorus", "name": "Chorus - Spotify Enhancer", "description": "Enhance Spotify with controls to save favourite snips, auto-skip tracks, and set global and custom speed. More to come!", - "version": "1.16.2", + "version": "1.17.0", "manifest_version": 3, "author": "cdrani", "action": { @@ -39,7 +39,9 @@ "stores/*.js", "actions/*.js", "data/*.js", - "services/*.js" + "services/*.js", + "lib/**/*.js", + "lib/sounds/*.wav" ] } ], @@ -90,7 +92,7 @@ "browser_specific_settings": { "gecko": { "id": "chorus@cdrani.dev", - "strict_min_version": "109.0" + "strict_min_version": "112.0" } } } diff --git a/src/models/app.js b/src/models/app.js index fe7bde43..39139de7 100644 --- a/src/models/app.js +++ b/src/models/app.js @@ -10,9 +10,10 @@ import NowPlayingObserver from '../observers/now-playing.js' import ArtistDiscoObserver from '../observers/artist-disco.js' export default class App { - constructor(video) { + constructor({ video, reverb }) { this._store = store this._video = video + this._reverb = reverb this._active = true this._intervalId = null @@ -55,10 +56,13 @@ export default class App { this._artistDiscoObserver.disconnect() this.#resetInterval() + + navigator.userAgent.includes('Firefox') && this._reverb.setReverbEffect('none') } - async connect() { + connect() { this._active = true + this._chorus.init() this._nowPlayingIcons.placeIcons() this._trackListObserver.observe() @@ -75,10 +79,7 @@ export default class App { if (!this._intervalId) return const chorus = document.getElementById('chorus') - - if (!chorus) { - this._nowPlayingIcons.placeIcons() - } + if (!chorus) this._nowPlayingIcons.placeIcons() }, 3000) } } diff --git a/src/models/chorus.js b/src/models/chorus.js index 5a3a6a9e..ffd56ffe 100644 --- a/src/models/chorus.js +++ b/src/models/chorus.js @@ -1,6 +1,7 @@ import { createSnipControls } from '../components/snip/snip-controls.js' import { createSpeedControls } from '../components/speed/speed-controls.js' import { createSeekControls } from '../components/seek/seek-controls.js' +import { createEffectsControls } from '../components/effects/effects-controls.js' import HeaderListeners from '../events/listeners/header-listeners.js' import ActionListeners from '../events/listeners/action-listeners.js' @@ -10,9 +11,13 @@ import { spotifyVideo } from '../actions/overload.js' export default class Chorus { constructor(songTracker) { + this._songTracker = songTracker this._video = spotifyVideo.element - this.headerListeners = new HeaderListeners(songTracker) - this.actionListeners = new ActionListeners(songTracker) + } + + init() { + this.headerListeners = new HeaderListeners(this._songTracker) + this.actionListeners = new ActionListeners(this._songTracker) } get isShowing() { @@ -43,9 +48,11 @@ export default class Chorus { const snipControlsEl = parseNodeString(createSnipControls()) const speedControlsEl = parseNodeString(createSpeedControls()) const seekControlsEl = parseNodeString(createSeekControls()) + const effectsControlsEl = parseNodeString(createEffectsControls()) this.chorusControls.appendChild(snipControlsEl) this.chorusControls.appendChild(speedControlsEl) + this.chorusControls.appendChild(effectsControlsEl) this.chorusControls.appendChild(seekControlsEl) } diff --git a/src/models/reverb/reverb-controller.js b/src/models/reverb/reverb-controller.js new file mode 100644 index 00000000..bfba3d3e --- /dev/null +++ b/src/models/reverb/reverb-controller.js @@ -0,0 +1,66 @@ +import { store } from '../../stores/data.js' +import { spotifyVideo } from '../../actions/overload.js' +import { drinkPresets } from '../../lib/reverb/presets.js' + +export default class ReverbController { + constructor() { + this._store = store + this._reverb = spotifyVideo.reverb + } + + init() { + const effect = this._store.getReverb() ?? 'none' + this.#setupEvents(effect) + } + + #setupEvents(effect) { + const { drinkEffectSelect, convolverEffectSelect, presetSelection } = this.elements + + if (effect == 'none') { + this.setValuesToNone() + } else { + presetSelection.textContent = effect + const selectedElement = drinkPresets.includes(effect) ? drinkEffectSelect : convolverEffectSelect + selectedElement.value = effect + } + + drinkEffectSelect.onchange = async (e) => { await this.handleSelection(e) } + convolverEffectSelect.onchange = async (e) => { await this.handleSelection(e) } + } + + get elements() { + return { + presetSelection: document.getElementById('preset-selection'), + drinkEffectSelect: document.getElementById('drink-effect-presets'), + convolverEffectSelect: document.getElementById('convolver-effect-presets') + } + } + + async handleSelection(e) { + const { target: { value, id } } = e + await this._reverb.setReverbEffect(value) + + const { convolverEffectSelect, drinkEffectSelect } = this.elements + const nonSelectedElement = id?.startsWith('drink') ? convolverEffectSelect : drinkEffectSelect + nonSelectedElement.value = 'none' + + this.elements.presetSelection.textContent = value + } + + async saveSelection() { + await this._store.saveReverb(this.elements.presetSelection.textContent) + } + + setValuesToNone() { + const { presetSelection, drinkEffectSelect, convolverEffectSelect } = this.elements + presetSelection.textContent = 'none' + drinkEffectSelect.value = 'none' + convolverEffectSelect.value = 'none' + } + + async clearReverb() { + this.setValuesToNone() + await this.saveSelection() + await this._reverb.setReverbEffect('none') + } +} diff --git a/src/models/reverb/reverb.js b/src/models/reverb/reverb.js new file mode 100644 index 00000000..44a33f21 --- /dev/null +++ b/src/models/reverb/reverb.js @@ -0,0 +1,75 @@ +import { drinkPresets, getParamsListForEffect } from '../../lib/reverb/presets.js' + +export default class Reverb { + constructor(video) { + this._video = video + } + + #isDigital(effect) { + return drinkPresets.includes(effect) + } + + async setReverbEffect(effect) { + this.#setup() + if (effect == 'none') return this.#disconnect() + + const isDigital = this.#isDigital(effect) + await (isDigital ? this.#createDigitalReverb(effect) : this.#createImpulseReverb(effect)) + if (!isDigital) return + + this.#connect(); this.#applyReverbEffect(effect) + } + + #setup() { + this._audioContext = this._audioContext ?? new AudioContext({ latencyHint: 'playback' }) + this._source = this._source ?? this._audioContext.createMediaElementSource(this._video) + this._gain = this._gain ?? this._audioContext.createGain() + } + + #connect() { + this._source.disconnect() + this._source.connect(this._gain) + this._gain.connect(this._reverb) + this._reverb.connect(this._audioContext.destination) + } + + async #createDigitalReverb() { + const modulePath = sessionStorage.getItem('reverbPath') + await this._audioContext.audioWorklet.addModule(modulePath) + this._reverb = this._reverb ?? new AudioWorkletNode( + this._audioContext, 'UXFDReverb', { channelCountMode: 'explicit', channelCount: 1, outputChannelCount: [2] } + ) + } + + async #createImpulseReverb(effect) { + this._convolverNode = this._convolverNode ?? this._audioContext.createConvolver() + const soundsDir = sessionStorage.getItem('soundsDir') + + const response = await fetch(`${soundsDir}${effect}.wav`) + const arraybuffer = await response.arrayBuffer() + this._convolverNode.buffer = await this._audioContext.decodeAudioData(arraybuffer) + + this._source.connect(this._convolverNode) + this._convolverNode.connect(this._gain) + this._gain.connect(this._audioContext.destination) + } + + #disconnect() { + this._source?.disconnect() + this._source?.connect(this._audioContext.destination) + } + + async #applyReverbEffect(effect) { + if (effect == 'none') return this.#disconnect() + + try { this.#applyReverbEffectParams(effect) } + catch (error) { console.error({ ERROR: error })} + } + + #applyReverbEffectParams(effect) { + const paramsList = getParamsListForEffect(effect) + paramsList.forEach(({ name, value }) => { + this._reverb.parameters.get(name).linearRampToValueAtTime(value, this._audioContext.currentTime + 0.195) + }) + } +} diff --git a/src/models/seek/seek-controller.js b/src/models/seek/seek-controller.js index c5f621a9..57e237dd 100644 --- a/src/models/seek/seek-controller.js +++ b/src/models/seek/seek-controller.js @@ -1,8 +1,8 @@ export default class SeekController { - #data + constructor() { this._data = null } init(data) { - if (!this.#data) { + if (!this._data) { this.#setupEvents() } @@ -16,7 +16,7 @@ export default class SeekController { this.#setCheckedUI(seekChecked) this.#highlightSeekValue(seekChecked) - this.#data = data + this._data = data } #setupEvents() { @@ -72,7 +72,7 @@ export default class SeekController { const { checked } = seekCheckbox this.#setCheckedUI(checked) - const { shows, global } = this.#data + const { shows, global } = this._data rwInput.value = checked ? shows.rw : global.rw ffInput.value = checked ? shows.ff : global.ff diff --git a/src/models/seek/seek-icon.js b/src/models/seek/seek-icon.js index 9a4ca4a0..3f74a71f 100644 --- a/src/models/seek/seek-icon.js +++ b/src/models/seek/seek-icon.js @@ -4,6 +4,8 @@ import { currentData } from '../../data/current.js' import { songState } from '../../data/song-state.js' import { parseNodeString } from '../../utils/parser.js' import { spotifyVideo } from '../../actions/overload.js' +import { playback } from '../../utils/playback.js' +import { secondsToTime } from '../../utils/time.js' export default class SeekIcons { constructor() { @@ -49,6 +51,7 @@ export default class SeekIcons { removeIcons() { const seekBack = document.getElementById('seek-player-rw-button') const seekForward = document.getElementById('seek-player-ff-button') + if (!seekBack) return seekBack.style.display = 'none' seekForward.style.display = 'none' @@ -123,10 +126,12 @@ export default class SeekIcons { async #calculateCurrentTime({ role, seekTime }) { const { startTime, endTime } = await songState() - const currentTime = this._video.currentTime + let currentTime = parseInt(this._video.currentTime, 10) + if (currentTime !== playback.current()) currentTime = playback.current() + const newTimeFF = Math.min(parseInt(currentTime + seekTime, 10), parseInt(endTime, 10) - 0.5) const newStartTime = currentTime < parseInt(startTime) ? 0 : startTime - const newTimeRW = Math.max(parseInt(currentTime - seekTime, 10), parseInt(newStartTime, 10) - 0.5) + const newTimeRW = Math.max(parseInt(currentTime - seekTime, 10), parseInt(newStartTime, 10), 0) return role == 'ff' ? newTimeFF : newTimeRW } @@ -138,6 +143,7 @@ export default class SeekIcons { const newTime = await this.#calculateCurrentTime({ role, seekTime }) this._video.currentTime = newTime + document.querySelector('[data-testid="playback-position"]').textContent = secondsToTime(newTime) } #setupListeners() { diff --git a/src/models/seek/seek.js b/src/models/seek/seek.js index 494e8fdc..940be328 100644 --- a/src/models/seek/seek.js +++ b/src/models/seek/seek.js @@ -5,29 +5,20 @@ import { store } from '../../stores/data.js' import { currentData } from '../../data/current.js' export default class Seek { - #store - #controls - #seekIcons - constructor() { - this.#store = store - this.#seekIcons = new SeekIcons() - this.#controls = new SeekController() + this._store = store + this._seekIcons = new SeekIcons() + this._controls = new SeekController() } async init() { const data = await currentData.getSeekValues() - this.#controls.init(data) + this._controls.init(data) } get #inputValues() { - const { rwInput, ffInput, seekCheckbox } = this.#controls.elements - - return { - rw: rwInput.value, - ff: ffInput.value, - updateShows: seekCheckbox.checked - } + const { rwInput, ffInput, seekCheckbox } = this._controls.elements + return { rw: rwInput.value, ff: ffInput.value, updateShows: seekCheckbox.checked } } async save() { @@ -35,23 +26,22 @@ export default class Seek { await this.#saveSeeking({ rw, ff, updateShows }) } + async reset() { + const { updateShows } = this.#inputValues + const seekTimeValue = updateShows ? 15 : 10 + await this.#saveSeeking({ rw: seekTimeValue, ff: seekTimeValue, updateShows }) + await this.init() + } + async #saveSeeking({ rw, ff, updateShows }) { const { shows, global } = await currentData.getSeekValues() - - await this.#store.saveTrack({ + await this._store.saveTrack({ id: 'chorus-seek', value: { - shows: { - ...shows, - ...updateShows && { rw, ff } - }, - global: { - ...global, - ...!updateShows && { rw, ff } - } + shows: { ...shows, ...updateShows && { rw, ff } }, + global: { ...global, ...!updateShows && { rw, ff } } }, }) - - await this.#seekIcons.setSeekLabels() + await this._seekIcons.setSeekLabels() } } diff --git a/src/models/snip/current-snip.js b/src/models/snip/current-snip.js index 13b9e12a..4507b9e7 100644 --- a/src/models/snip/current-snip.js +++ b/src/models/snip/current-snip.js @@ -1,8 +1,9 @@ import Snip from './snip.js' +import { currentData } from '../../data/current.js' +import { spotifyVideo } from '../../actions/overload.js' +import { playback } from '../../utils/playback.js' import { currentSongInfo } from '../../utils/song.js' -import { spotifyVideo } from '../../actions/overload.js' -import { currentData } from '../../data/current.js' export default class CurrentSnip extends Snip { constructor(songTracker) { @@ -58,8 +59,11 @@ export default class CurrentSnip extends Snip { } async delete() { - await super._delete() - const updatedValues = await this.read() + const track = await this.read() + const updatedValues = await this._store.saveTrack({ + id: currentSongInfo().id, + value: { ...track, isSnip: false, startTime: 0, endTime: playback.duration() } + }) await this._songTracker.updateCurrentSongData(updatedValues) } diff --git a/src/models/snip/snip.js b/src/models/snip/snip.js index 06c3bb35..e807fe7a 100644 --- a/src/models/snip/snip.js +++ b/src/models/snip/snip.js @@ -6,6 +6,7 @@ import SliderControls from '../slider/slider-controls.js' import { timeToSeconds } from '../../utils/time.js' import { currentData } from '../../data/current.js' import { copyToClipBoard } from '../../utils/clipboard.js' +import { setTrackInfo } from '../../utils/track-info.js' export default class Snip { constructor() { @@ -88,10 +89,6 @@ export default class Snip { } _setTrackInfo({ title, artists }) { - const titleElement = document.getElementById('track-title') - const artistsElement = document.getElementById('track-artists') - - titleElement.textContent = title - artistsElement.textContent = artists + setTrackInfo({ title, artists, chorusView: true }) } } diff --git a/src/models/speed/speed.js b/src/models/speed/speed.js index 8bf0373c..18bab031 100644 --- a/src/models/speed/speed.js +++ b/src/models/speed/speed.js @@ -6,28 +6,21 @@ import { currentData } from '../../data/current.js' import { spotifyVideo } from '../../actions/overload.js' export default class Speed { - #store - #video - #controls - constructor() { - this.#store = store - this.#video = spotifyVideo.element - this.#controls = new RangeSlider(this.#video) + this._store = store + this._video = spotifyVideo.element + this._controls = new RangeSlider(this._video) } async init() { const data = await currentData.getPlaybackValues() - this.#controls.init(data) + this._controls.init(data) } - clearCurrentSpeed() { - this.#video.clearCurrentSpeed() - } + clearCurrentSpeed() { this._video.clearCurrentSpeed() } get #inputValues() { - const { input, speedCheckbox, pitchCheckbox } = this.#controls.elements - + const { input, speedCheckbox, pitchCheckbox } = this._controls.elements return { playbackRate: input.value, preservesPitch: pitchCheckbox?.checked, @@ -36,53 +29,37 @@ export default class Speed { } async save() { - const { playbackRate, settingGlobalSpeed, preservesPitch } = this.#inputValues - this.#video.currentSpeed = playbackRate - - if (settingGlobalSpeed) { - await this.saveGlobalSpeed({ playbackRate, preservesPitch }) - return - } - - await this.saveTrackSpeed({ playbackRate, preservesPitch }) + const { playbackRate, preservesPitch } = this.#inputValues + await this.#saveSelectedSpeed({ playbackRate, preservesPitch }) } async saveTrackSpeed({ playbackRate, preservesPitch }) { const trackInfo = await currentData.readTrack() + await this._store.saveTrack({ id: currentData.songId, value: { ...trackInfo, playbackRate, preservesPitch } }) - await this.#store.saveTrack({ - id: currentData.songId, - value: { - ...trackInfo, - playbackRate, - preservesPitch, - }, - }) + this.#updateVideoSpeed({ playbackRate, preservesPitch }) + } - this.#video.playbackRate = playbackRate - this.#video.preservesPitch = preservesPitch + #updateVideoSpeed({ playbackRate, preservesPitch }) { + this._video.currentSpeed = playbackRate + this._video.playbackRate = playbackRate + this._video.preservesPitch = preservesPitch } async saveGlobalSpeed({ playbackRate, preservesPitch }) { const globalsInfo = await currentData.readGlobals() const trackInfo = await currentData.readTrack() - await this.#store.saveTrack({ - id: 'globals', - value: { - ...globalsInfo, - playbackRate, - preservesPitch, - } - }) + await this._store.saveTrack({ id: 'globals', value: { ...globalsInfo, playbackRate, preservesPitch } }) + if (!trackInfo?.playbackRate) this.#updateVideoSpeed({ playbackRate, preservesPitch }) + } - if (!trackInfo?.playbackRate) { - this.#video.playbackRate = playbackRate - this.#video.preservesPitch = preservesPitch - } + async #saveSelectedSpeed(speedValues) { + await (this.#inputValues.settingGlobalSpeed ? this.saveGlobalSpeed(speedValues) : this.saveTrackSpeed(speedValues)) } async reset() { + await this.#saveSelectedSpeed({ playbackRate: 1, preservesPitch: true }) await this.init() } } diff --git a/src/models/video/video.js b/src/models/video/video.js index 53406784..515deab3 100644 --- a/src/models/video/video.js +++ b/src/models/video/video.js @@ -1,30 +1,27 @@ import VideoOverride from './video-override.js' export default class VideoElement { - constructor(video) { + constructor({ video, reverb }) { this._video = video - this._active = sessionStorage.getItem('enabled') == 'true' + this._reverb = reverb this._isEditing = false - this._video.autoplay = false - + this._video.crossOrigin = 'anonymous' this._videoOverride = new VideoOverride(this) } set active(value) { this._active = value - } + if (navigator.userAgent.includes('Firefox')) return - get active() { - return this._active + const effect = sessionStorage.getItem('reverb') ?? 'none' + this._reverb.setReverbEffect(value ? effect : 'none') } - get isEditing() { - return this._isEditing - } + get active() { return this._active } - set isEditing(editing) { - this._isEditing = editing - } + get isEditing() { return this._isEditing } + + set isEditing(editing) { this._isEditing = editing } reset() { this.clearCurrentSpeed() @@ -32,67 +29,23 @@ export default class VideoElement { this.preservesPitch = true } - get element() { - return this._video - } + get element() { return this._video } - set currentTime(value) { - if (this._video) { - this._video.currentTime = value - } - } + set currentTime(value) { if (this._video) this._video.currentTime = value } - play() { - this._video.play() - } + get currentTime() { return this._video?.currentTime } - pause() { - this._video.pause() - } + clearCurrentSpeed() { this._video.removeAttribute('currentSpeed') } - get currentTime() { - return this._video?.currentTime - } + set preservesPitch(value) { if (this._video) this._video.preservesPitch = value } - clearCurrentSpeed() { - this._video.removeAttribute('currentSpeed') - } + get currentSpeed() { return this._video.getAttribute('currentSpeed') } - set preservesPitch(value) { - if (this._video) { - this._video.preservesPitch = value - } - } + set currentSpeed(value) { this._video.setAttribute('currentSpeed', value) } - get currentSpeed() { - return this._video.getAttribute('currentSpeed') - } - - set currentSpeed(value) { - this._video.setAttribute('currentSpeed', value) - } + get preservesPitch() { return this._video ? this._video.preservesPitch : true } - get preservesPitch() { - return this._video ? this._video.preservesPitch : true - } - - set playbackRate(value) { - if (this._active) { - this._video.playbackRate = { source: 'chorus', value: value } - } else { - this._video.playbackRate = value - } - } + set playbackRate(value) { this._video.playbackRate = this._active ? { source: 'chorus', value } : value } - get playbackRate() { - return this._video ? this._video.playbackRate : 1 - } - - get volume() { - return this._video.volume - } - - set volume(value) { - this._video.volume = value - } + get playbackRate() { return this._video ? this._video.playbackRate : 1 } } diff --git a/src/observers/song-tracker.js b/src/observers/song-tracker.js index fbe8adc3..6c7b2ee8 100644 --- a/src/observers/song-tracker.js +++ b/src/observers/song-tracker.js @@ -12,7 +12,7 @@ import Dispatcher from '../events/dispatcher.js' export default class SongTracker { constructor() { this._init = true - this._playedSnip = false + this._reverbSet = false this._currentSongState = null this._video = spotifyVideo.element this._dispatcher = new Dispatcher() @@ -33,6 +33,13 @@ export default class SongTracker { }) } + async #dispatchSeekToPosition(position) { + await this._dispatcher.sendEvent({ + eventType: 'play.seek', + detail: { key: 'play.seek', values: { position } }, + }) + } + async handleShared(songStateData) { await this.#applyEffects(songStateData) const { startTime: position } = songStateData @@ -51,28 +58,40 @@ export default class SongTracker { await this.#applyEffects(this._currentSongState) } + get #isPlaying() { + const playButton = document.querySelector('[data-testid="control-button-playpause"]') + if (!playButton) return false + + return playButton.getAttribute('aria-label') == 'Pause' + } + get #isLooping() { const repeatButton = document.querySelector('[data-testid="control-button-repeat"]') return repeatButton?.getAttribute('aria-label') === 'Disable repeat' } - get #muteButton() { - return document.querySelector('[data-testid="volume-bar-toggle-mute-button"]') - } + get #muteButton() { return document.querySelector('[data-testid="volume-bar-toggle-mute-button"]') } - get #isMute() { - return this.#muteButton?.getAttribute('aria-label') == 'Unmute' - } + get #isMute() { return this.#muteButton?.getAttribute('aria-label') == 'Unmute' } #mute() { if (!this.#isMute) this.#muteButton?.click() } #unMute() { if (this.#isMute) this.#muteButton?.click() } - #setupListeners() { - this._video.element.addEventListener('timeupdate', this.#handleTimeUpdate) + get #isFirefox() { return navigator.userAgent.includes('Firefox') } + + async #setReverb () { + if (this._reverbSet) return + + const effect = sessionStorage.getItem('reverb') ?? 'none' + await spotifyVideo.reverb.setReverbEffect(effect) + this._reverbSet = true } + #setupListeners() { this._video.element.addEventListener('timeupdate', this.#handleTimeUpdate) } + clearListeners() { this._video.element.removeEventListener('timeupdate', this.#handleTimeUpdate) + this._reverbSet = false } async #applyEffects({ isShared, isSnip, playbackRate, preservesPitch }) { @@ -109,7 +128,7 @@ export default class SongTracker { const currentPositionTime = parseInt(currentPosition, 10) * 1000 if (parsedStartTime != 0 && currentPositionTime < parsedStartTime) { - this._video.currentTime = startTime + await this.#dispatchSeekToPosition(parsedStartTime) } } @@ -117,15 +136,13 @@ export default class SongTracker { this._init = false } - get #nextButton() { - return document.querySelector('[data-testid="control-button-skip-forward"]') - } + get #nextButton() { return document.querySelector('[data-testid="control-button-skip-forward"]') } - get #playbackPosition() { - return document.querySelector('[data-testid="playback-position"]') - } + get #playbackPosition() { return document.querySelector('[data-testid="playback-position"]') } + + #handleTimeUpdate = async () => { + if (this.#isPlaying && this.#isFirefox && !this._reverbSet) await this.#setReverb() - #handleTimeUpdate = () => { if (this._video.isEditing) return if (!this._currentSongState) return diff --git a/src/popup/index.html b/src/popup/index.html index fbf46cb6..321d43d2 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -3,6 +3,7 @@ + diff --git a/src/popup/index.js b/src/popup/index.js index e89c9e3a..22a04f55 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -1,6 +1,7 @@ import { extToggle } from './toggle.js' import { createRootContainer } from './ui.js' +import { setTrackInfo } from '../utils/track-info.js' import { parseNodeString } from '../utils/parser.js' import { getState, setState } from '../utils/state.js' import { getImageBackgroundAndTextColours } from '../utils/image-colours.js' @@ -20,36 +21,6 @@ PORT.onMessage.addListener(async ({ type, data }) => { await setCoverImage(data) }) -function getTextNode({ text, isShortText }) { - const shortTextHtml = `

${text}

` - const displayText = isShortText - ? shortTextHtml - : `
${shortTextHtml} • ${shortTextHtml} · 
` - - return parseNodeString(displayText) -} - -function setNowPlayingTextElement({ element, text, textColour }) { - const isShortText = text.length < 28 - const textNode = getTextNode({ isShortText, text }) - - element.replaceChildren(textNode) - element.style.color = textColour - - if (isShortText) return element.classList.remove('marquee') - - element.classList.add('marquee') -} - -function setTrackInfo({ title, artists, textColour = '#000' }) { - if (!title || !artists) return - - const { titleElement, artistsElement } = getElements() - - setNowPlayingTextElement({ element: titleElement, text: title, textColour }) - setNowPlayingTextElement({ element: artistsElement, text: artists, textColour }) -} - async function updatePopupUIState(popupState) { await setState({ key: 'popup-ui', values: popupState }) } @@ -91,9 +62,7 @@ function getElements() { return { cover: document.getElementById('cover'), double: document.getElementById('double'), - chorusPopup: document.getElementById('chorus'), - titleElement: document.getElementById('track-title'), - artistsElement: document.getElementById('track-artists'), + chorusPopup: document.getElementById('chorus') } } diff --git a/src/popup/styles.css b/src/popup/styles.css index adfdf2ac..9c298e11 100644 --- a/src/popup/styles.css +++ b/src/popup/styles.css @@ -54,34 +54,3 @@ label.chorus-common { color: #b3b3b3; align-items: center; } - -.container { - max-width: 200px; - overflow: hidden; - white-space: nowrap; -} - -.marquee { - white-space: nowrap; - overflow: hidden; - display: inline-block; - animation: marquee 10s linear infinite; -} - -.marquee p { - display: inline-block; -} - -.track-text { - font-size: 14px; - font-weight: 500; -} - -@keyframes marquee { - 0% { - transform: translate3d(0, 0, 0); - } - 100% { - transform: translate3d(-50%, 0, 0); - } -} diff --git a/src/popup/ui.js b/src/popup/ui.js index fbc4a24e..6312628b 100644 --- a/src/popup/ui.js +++ b/src/popup/ui.js @@ -6,14 +6,20 @@ const coverImage = () => ` ` +const trackText = id => ` +
+
+
+` + export const createRootContainer = () => `
${extToggle.ui}
${coverImage()}
-
-
+ ${trackText('track-title')} + ${trackText('track-artists')}
diff --git a/src/services/player.js b/src/services/player.js index f7fb2f51..aa597875 100644 --- a/src/services/player.js +++ b/src/services/player.js @@ -20,4 +20,16 @@ function playSharedTrack({ track_id, position }) { }) } -export { playSharedTrack } + +function seekTrackToPosition({ position }) { + return new Promise(async (resolve, reject) => { + try { + const options = await setOptions({ method: 'PUT' }) + const device_id = await getState('device_id') + const url = `${API_URL}/seek?position_ms=${position}&device_id=${device_id}` + const response = await request({ url, options }) + resolve(response) + } catch (error) { reject(error) } + }) +} +export { playSharedTrack, seekTrackToPosition } diff --git a/src/stores/cache.js b/src/stores/cache.js index 4d3d0a28..3d975579 100644 --- a/src/stores/cache.js +++ b/src/stores/cache.js @@ -5,7 +5,9 @@ export default class CacheStore { } getKey(key) { - return JSON.parse(this.#cache.getItem(key)) + const result = this.#cache.getItem(key) + try { return JSON.parse(result) } + catch (error) { return result } } getValue({ key, value }) { diff --git a/src/stores/data.js b/src/stores/data.js index abb066e1..86917f54 100644 --- a/src/stores/data.js +++ b/src/stores/data.js @@ -3,7 +3,7 @@ import Dispatcher from '../events/dispatcher.js' import { currentSongInfo } from '../utils/song.js' import { playback } from '../utils/playback.js' -const DO_NOT_INCLUDE = [ 'now-playing', 'device_id', 'auth_token', 'enabled', 'globals', 'chorus-seek'] +const DO_NOT_INCLUDE = ['reverb', 'now-playing', 'device_id', 'auth_token', 'enabled', 'globals', 'chorus-seek'] class DataStore { #cache @@ -28,14 +28,10 @@ class DataStore { value.isSkipped = endTime == 0 } - this.#cache.update({ key, value: JSON.stringify(value) }) + this.#cache.update({ key, value: typeof value !== 'string' ? JSON.stringify(value) : value }) }) } - removeTrack(id) { - this.#cache.removeKey(id) - } - getTrack({ id, value = {}}) { const cacheValue = this.#cache.getValue({ key: id, value }) return cacheValue @@ -55,42 +51,47 @@ class DataStore { return this.#cache.getKey(id) } - async removeTrack(id) { - await this.#dispatcher.sendEvent({ - eventType: 'storage.delete', - detail: { key: id }, - }) - } - - shouldRemoveTrack({ isSkipped, playbackRate = '1', isSnip }) { + #shouldDeleteTrack({ isSkipped, playbackRate = '1', isSnip }) { if (isSkipped || isSnip || playbackRate != '1') return false return true } + getReverb() { + const cacheValue = this.#cache.getValue({ key: 'reverb', value: 'none' }) + return cacheValue + } + + async saveReverb(effect) { + await this.#dispatcher.sendEvent({ + eventType: 'storage.set', + detail: { key: 'reverb', values: effect }, + }) + + this.#cache.update({ key: 'reverb', value: effect }) + return this.#cache.getKey(id) + } + async saveTrack({ id, value }) { let response - if (this.shouldRemoveTrack(value)) { - this.removeTrack(id) + if (this.#shouldDeleteTrack(value)) { + await this.deleteTrack(id) } else { response = await this.#dispatcher.sendEvent({ eventType: 'storage.set', detail: { key: id, values: value }, }) - } + } this.#cache.update({ key: id, value: response ?? value }) return this.#cache.getKey(id) } - async deleteTrack({ id, value }) { + async deleteTrack(id) { await this.#dispatcher.sendEvent({ eventType: 'storage.delete', detail: { key: id }, }) - - this.#cache.update({ key: id, value: { ...value, isSnip: false } }) - return this.#cache.getKey(id) } } diff --git a/src/styles.css b/src/styles.css index d9fd7240..1cfd282a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -56,36 +56,47 @@ width: 320px; } -#chorus-settings { - width: 240px; - padding: .5rem .75rem; - background-color: #171717; +#chorus button, +#chorus input { + border-width: 0 !important; + border-style: solid !important; + border-color: #e5e7eb; } -#chorus-settings input { - margin-left: .5rem; - width: 56px; - border-radius: .125rem; - padding-left: .125rem; - padding-right: .125rem; - text-align: center; - font-size: .875rem -} +.success { + background-color: green; + + &:hover { + background-color: darkgreen; + } -.chorus-text-button:hover { - background-color: #9B9B9B; - cursor: pointer; + &:active { + background-color: darkolivegreen; + } } -#chorus button:hover { - cursor: pointer; +.danger { + background-color: crimson; + + &:hover { + background-color: firebrick; + } + + &:active { + background-color: maroon; + } } -#chorus button, -#chorus input { - border-width: 0 !important; - border-style: solid !important; - border-color: #e5e7eb; +.share { + background-color: darkviolet; + + &:hover { + background-color: blueviolet; + } + + &:active { + background-color: rebeccapurple; + } } .chorus-close-button, @@ -97,10 +108,6 @@ background-color: unset; } -#chorus button:disabled { - cursor: not-allowed; -} - .chorus-icon-active, .chorus-icon-active:hover { color: #2edb64; @@ -125,7 +132,6 @@ .chorus-text-button { display: flex; align-items: center; - background-color: #535353; color: #fff; font-weight: 600; font-size: .75rem; @@ -139,6 +145,7 @@ input[type=number]::placeholder { /* Chrome, Firefox, Opera, Safari 10.1+ */ color: #fff; -moz-appearance: textfield; -webkit-appearance: textfield; + appearance: textfield; opacity: 1; /* Firefox */ } @@ -339,3 +346,45 @@ input[type=number]::-webkit-outer-spin-button { [role=artist-disco][aria-label]:hover:after { display: inline-block; } + +hr { + margin: 0; + width: 100%; +} + +.select { + background-color: transparent; + color: #fff; + border: none; +} + +.container { + max-width: 282px; + overflow: hidden; + white-space: nowrap; +} + +.marquee { + white-space: nowrap; + overflow: hidden; + display: inline-block; + animation: marquee 10s linear infinite !important; +} + +.marquee p { + display: inline-block; +} + +.track-text { + font-size: 16px; + font-weight: 500; +} + +@keyframes marquee { + 0% { + transform: translate3d(0, 0, 0); + } + 100% { + transform: translate3d(-50%, 0, 0); + } +} diff --git a/src/utils/song.js b/src/utils/song.js index ab2f552a..5ba72d83 100644 --- a/src/utils/song.js +++ b/src/utils/song.js @@ -60,15 +60,9 @@ const getTrackId = row => { } const getArtists = row => { - const artistsList = row.querySelectorAll('span > a') + const artistsList = row.querySelectorAll('span > div > a') + // Here means we are at artist page and can get name from h1 + if (!artistsList.length) return document.querySelector('span[data-testid="entityTitle"] > h1').textContent - if (!artistsList.length) { - // Here means we are at artist page and can get name from h1 - return document.querySelector('span[data-testid="entityTitle"] > h1').textContent - } - - return Array.from(artistsList) - .filter(artist => artist.href.includes('artist')) - .map(artist => artist.textContent) - .join(', ') + return Array.from(artistsList).filter(artist => artist.href.includes('artist')).map(artist => artist.textContent).join(', ') } diff --git a/src/utils/track-info.js b/src/utils/track-info.js new file mode 100644 index 00000000..00404cb9 --- /dev/null +++ b/src/utils/track-info.js @@ -0,0 +1,34 @@ +import { parseNodeString } from './parser.js' + +function getTextNode({ text, isShortText }) { + const shortTextHtml = `

${text}

` + const displayText = isShortText + ? shortTextHtml + : `
${shortTextHtml} • ${shortTextHtml} · 
` + + return parseNodeString(displayText) +} + +function setNowPlayingTextElement({ element, text, textColour, chorusView }) { + const isShortText = text.length < (chorusView ? 43 : 28) + const textNode = getTextNode({ isShortText, text }) + + element.replaceChildren(textNode) + element.style.color = textColour + + isShortText ? element.classList.remove('marquee') : element.classList.add('marquee') +} + +export function setTrackInfo({ title, artists, textColour = '#000', chorusView = false }) { + if (!title || !artists) return + + const titleElement = document.getElementById('track-title') + const artistsElement = document.getElementById('track-artists') + + setNowPlayingTextElement({ + element: titleElement, text: title, textColour: chorusView ? '#fff' : textColour, chorusView + }) + setNowPlayingTextElement({ + element: artistsElement, text: artists, textColour: chorusView ? '#b3b3b3' : textColour, chorusView + }) +}