diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39bb8d36..fb647181 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,14 @@ jobs: - name: Update Version if: steps.semver.outputs.version != '' - run: jq --arg version "${{ steps.semver.outputs.version }}" '.version = $version' manifest.json > tmp && mv tmp manifest.json + run: | + jq --arg version "${{ steps.semver.outputs.version }}" \ + '.version = $version' chrome/manifest.json > chrome/tmp && \ + mv chrome/tmp chrome/manifest.json + + jq --arg version "${{ steps.semver.outputs.version }}" \ + '.version = $version' firefox/manifest.json > firefox/tmp && \ + mv firefox/tmp firefox/manifest.json - name: Commit Changes if: steps.semver.outputs.version != '' @@ -51,23 +58,28 @@ jobs: - name: Archive Extension Files if: steps.semver.outputs.version != '' - run: zip -r Chorus.zip . -x "*.git*" "*.*rc" "*.md" + run: | + (cd src && zip -r ../Chorus-Chrome.zip .) + (cd chrome && zip -j ../Chorus-Chrome.zip manifest.json) + + (cd src && zip -r ../Chorus-FireFox.zip .) + (cd firefox && zip -j ../Chorus-FireFox.zip manifest.json) - name: Create Release Archive & Notes if: steps.semver.outputs.version != '' uses: ncipollo/release-action@v1.12.0 with: - artifacts: 'Chorus.zip' tag: ${{ steps.semver.outputs.git_tag }} name: ${{ steps.semver.outputs.git_tag }} body: ${{ steps.semver.outputs.notes }} + artifacts: 'Chorus-Chrome.zip,Chorus-FireFox.zip' - name: Upload & Publish if: steps.semver.outputs.version != '' uses: cdrani/chrome-extension-upload@ci/silent-update-fail with: silent-fail: true - file-path: Chorus.zip + file-path: Chorus-Chrome.zip client-id: ${{ secrets.CLIENT_ID }} extension-id: ${{ secrets.EXTENSION_ID }} client-secret: ${{ secrets.CLIENT_SECRET }} diff --git a/.gitignore b/.gitignore index c4c4ffc6..6751878a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ *.zip +*.DS_STORE +Chorus-Chrome +Chorus-FireFox diff --git a/manifest.json b/chrome/manifest.json similarity index 100% rename from manifest.json rename to chrome/manifest.json diff --git a/content-script.js b/content-script.js deleted file mode 100644 index 12cd9ba2..00000000 --- a/content-script.js +++ /dev/null @@ -1,37 +0,0 @@ -const loadScript = filePath => { - const script = document.createElement('script') - script.src = chrome.runtime.getURL(filePath) - script.type = 'module' - document.body.appendChild(script) -} - -loadScript('actions/init.js') - -const sendEvent = ({ eventType, detail }) => { - document.dispatchEvent(new CustomEvent(eventType, { detail })) -} - -document.addEventListener('storage.set', async e => { - const { key, values } = e.detail - const response = await setState({ key, values }) - sendEvent({ eventType: 'storage.set.response', detail: response }) -}) - -document.addEventListener('storage.get', async e => { - const response = await getState(e.detail) - sendEvent({ eventType: 'storage.get.response', detail: response }) -}) - -document.addEventListener('storage.delete', async e => { - const response = await removeState(e.detail.key) - sendEvent({ eventType: 'storage.delete.response', detail: response }) -}) - -document.addEventListener('storage.populate', async () => { - const response = await getState(null) - sendEvent({ eventType: 'storage.populate.response', detail: response }) -}) - -chrome.runtime.onMessage.addListener(message => { - sendEvent({ eventType: 'app.enabled', detail: { enabled: message.enabled } }) -}) diff --git a/events/dispatcher.js b/events/dispatcher.js deleted file mode 100644 index cb1afbaf..00000000 --- a/events/dispatcher.js +++ /dev/null @@ -1,20 +0,0 @@ -export default class Dispatcher { - constructor() {} - - #responsePromise(eventType) { - return new Promise(resolve => { - const resultListener = e => { - resolve(e.detail) - document.removeEventListener(eventType, resultListener) - } - - document.addEventListener(eventType, resultListener) - }) - } - - async sendEvent({ eventType, detail = {} }) { - document.dispatchEvent(new CustomEvent(eventType, { detail })) - - return await this.#responsePromise(`${eventType}.response`) - } -} diff --git a/firefox/manifest.json b/firefox/manifest.json new file mode 100644 index 00000000..1a2da775 --- /dev/null +++ b/firefox/manifest.json @@ -0,0 +1,63 @@ +{ + "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.5.1", + "manifest_version": 3, + "author": "cdrani", + "action": {}, + "icons": { + "16": "icons/icon16.png", + "24": "icons/icon24.png", + "48": "icons/icon48.png", + "64": "icons/icon64.png", + "128": "icons/icon128.png" + }, + "content_scripts": [ + { + "run_at": "document_idle", + "js": [ + "utils/state.js", + "content-script.js" + ], + "css": [ + "styles.css" + ], + "matches": [ + "*://open.spotify.com/*" + ] + } + ], + "web_accessible_resources": [ + { + "matches": [ + "*://open.spotify.com/*" + ], + "resources": [ + "utils/*.js", + "models/*.js", + "events/*.js", + "components/*.js", + "observers/*.js", + "stores/*.js", + "actions/*.js", + "data/*.js" + ] + } + ], + "permissions": [ + "activeTab", + "tabs", + "storage", + "unlimitedStorage" + ], + "background": { + "scripts": ["background.js"] + }, + "browser_specific_settings": { + "gecko": { + "id": "chorus@cdrani.dev", + "strict_min_version": "109.0" + } + } +} diff --git a/actions/init.js b/src/actions/init.js similarity index 94% rename from actions/init.js rename to src/actions/init.js index a3d4e246..099c2a53 100644 --- a/actions/init.js +++ b/src/actions/init.js @@ -91,11 +91,17 @@ class App { } } +let loaded = false +const video = spotifyVideo.element + const setup = setInterval(async () => { const nowPlayingWidget = document.querySelector('[data-testid="now-playing-widget"]') - if (!nowPlayingWidget) return + if (!video && !nowPlayingWidget) return - await load() + if (!loaded) { + await load() + loaded = true + } clearInterval(setup) }, 500) diff --git a/actions/main.js b/src/actions/main.js similarity index 73% rename from actions/main.js rename to src/actions/main.js index 8ee42fbf..119800eb 100644 --- a/actions/main.js +++ b/src/actions/main.js @@ -3,6 +3,8 @@ import Chorus from '../models/chorus.js' import { createAlert } from '../components/alert.js' +import { parseNodeString } from '../utils/parser.js' + export default class Main { #icon #snip @@ -29,9 +31,10 @@ export default class Main { if (!iconListContainer) return - iconListContainer.insertAdjacentHTML('beforeend', root) - this.#setIconListener() + const rootEl = parseNodeString(root) + iconListContainer.appendChild(rootEl) + this.#setIconListener() clearInterval(interval) }, 50) } @@ -43,10 +46,13 @@ export default class Main { } #setupAlert() { - document.body.insertAdjacentHTML('beforeend', createAlert()) + const alertEl = parseNodeString(createAlert()) + document.body.appendChild(alertEl) - const closeAlert = document.getElementById('chorus-alert-close-button') - closeAlert?.addEventListener('click', (e) => this.#handleAlert({ e, target: closeAlert })) + const closeAlertButton = document.getElementById('chorus-alert-close-button') + closeAlertButton?.addEventListener('click', (e) => { + this.#handleAlert({ e, target: closeAlertButton }) + }) } get element() { diff --git a/actions/overload.js b/src/actions/overload.js similarity index 100% rename from actions/overload.js rename to src/actions/overload.js diff --git a/background.js b/src/background.js similarity index 100% rename from background.js rename to src/background.js diff --git a/components/alert.js b/src/components/alert.js similarity index 100% rename from components/alert.js rename to src/components/alert.js diff --git a/components/controls.js b/src/components/controls.js similarity index 100% rename from components/controls.js rename to src/components/controls.js diff --git a/components/header.js b/src/components/header.js similarity index 100% rename from components/header.js rename to src/components/header.js diff --git a/components/snip/snip-buttons.js b/src/components/snip/snip-buttons.js similarity index 100% rename from components/snip/snip-buttons.js rename to src/components/snip/snip-buttons.js diff --git a/components/snip/snip-controls.js b/src/components/snip/snip-controls.js similarity index 100% rename from components/snip/snip-controls.js rename to src/components/snip/snip-controls.js diff --git a/components/snip/snip-labels.js b/src/components/snip/snip-labels.js similarity index 100% rename from components/snip/snip-labels.js rename to src/components/snip/snip-labels.js diff --git a/components/snip/snip-slider.js b/src/components/snip/snip-slider.js similarity index 100% rename from components/snip/snip-slider.js rename to src/components/snip/snip-slider.js diff --git a/components/speed/speed-buttons.js b/src/components/speed/speed-buttons.js similarity index 100% rename from components/speed/speed-buttons.js rename to src/components/speed/speed-buttons.js diff --git a/components/speed/speed-controls.js b/src/components/speed/speed-controls.js similarity index 100% rename from components/speed/speed-controls.js rename to src/components/speed/speed-controls.js diff --git a/components/speed/speed-range.js b/src/components/speed/speed-range.js similarity index 100% rename from components/speed/speed-range.js rename to src/components/speed/speed-range.js diff --git a/components/speed/speed-toggler.js b/src/components/speed/speed-toggler.js similarity index 100% rename from components/speed/speed-toggler.js rename to src/components/speed/speed-toggler.js diff --git a/components/toggle-button.js b/src/components/toggle-button.js similarity index 100% rename from components/toggle-button.js rename to src/components/toggle-button.js diff --git a/components/track-info.js b/src/components/track-info.js similarity index 100% rename from components/track-info.js rename to src/components/track-info.js diff --git a/src/content-script.js b/src/content-script.js new file mode 100644 index 00000000..92dd3583 --- /dev/null +++ b/src/content-script.js @@ -0,0 +1,52 @@ +const loadScript = filePath => { + const script = document.createElement('script') + script.src = chrome.runtime.getURL(filePath) + script.type = 'module' + document.body.appendChild(script) +} + +loadScript('actions/init.js') + + +const sendEventToPage = ({ eventType, detail }) => { + window.postMessage({ + type: 'FROM_CONTENT_SCRIPT', + requestType: eventType, + payload: detail + }, window.location.origin) +} + +window.addEventListener('message', async (event) => { + if (event.origin !== window.location.origin) return + if (event.data.type !== 'FROM_PAGE_SCRIPT') return + + const { requestType, payload } = event.data + let response + + switch (requestType) { + 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) + sendEventToPage({ eventType: 'storage.get.response', detail: response }) + break + + case 'storage.delete': + response = await removeState(payload.key) + sendEventToPage({ eventType: 'storage.delete.response', detail: response }) + break + + case 'storage.populate': + response = await getState(null) + sendEventToPage({ eventType: 'storage.populate.response', detail: response }) + break + } +}) + +chrome.runtime.onMessage.addListener(message => { + sendEventToPage({ eventType: 'app.enabled', detail: { enabled: message.enabled } }) +}) diff --git a/data/current.js b/src/data/current.js similarity index 100% rename from data/current.js rename to src/data/current.js diff --git a/src/events/dispatcher.js b/src/events/dispatcher.js new file mode 100644 index 00000000..02de25d2 --- /dev/null +++ b/src/events/dispatcher.js @@ -0,0 +1,34 @@ +export default class Dispatcher { + constructor() { + this.#initListener() + } + + #initListener() { + window.addEventListener('message', (event) => { + if (event.origin !== window.location.origin) return + + if (event?.data?.type === 'FROM_CONTENT_SCRIPT') { + document.dispatchEvent( + new CustomEvent(event.data.requestType, { detail: event.data.payload }) + ) + } + }) + } + + sendEvent({ eventType, detail = {} }) { + window.postMessage({ + type: 'FROM_PAGE_SCRIPT', + requestType: eventType, + payload: detail + }, window.location.origin) + + return new Promise((resolve) => { + const resultListener = (e) => { + resolve(e.detail) + document.removeEventListener(`${eventType}.response`, resultListener) + } + + document.addEventListener(`${eventType}.response`, resultListener) + }) + } +} diff --git a/events/listeners.js b/src/events/listeners.js similarity index 100% rename from events/listeners.js rename to src/events/listeners.js diff --git a/icons/icon128.png b/src/icons/icon128.png similarity index 100% rename from icons/icon128.png rename to src/icons/icon128.png diff --git a/icons/icon16.png b/src/icons/icon16.png similarity index 100% rename from icons/icon16.png rename to src/icons/icon16.png diff --git a/icons/icon24.png b/src/icons/icon24.png similarity index 100% rename from icons/icon24.png rename to src/icons/icon24.png diff --git a/icons/icon48.png b/src/icons/icon48.png similarity index 100% rename from icons/icon48.png rename to src/icons/icon48.png diff --git a/icons/icon64.png b/src/icons/icon64.png similarity index 100% rename from icons/icon64.png rename to src/icons/icon64.png diff --git a/models/chorus.js b/src/models/chorus.js similarity index 75% rename from models/chorus.js rename to src/models/chorus.js index 93d67088..1b40feb7 100644 --- a/models/chorus.js +++ b/src/models/chorus.js @@ -1,6 +1,8 @@ import { createSnipControls } from '../components/snip/snip-controls.js' import { createSpeedControls } from '../components/speed/speed-controls.js' +import { parseNodeString } from '../utils/parser.js' + export default class Chorus { get isShowing() { if (!this.mainElement) return false @@ -27,11 +29,11 @@ export default class Chorus { #insertIntoDOM() { if (this.#hasSnipControls) return - const snipControls = createSnipControls() - const speedControls = createSpeedControls() + const snipControlsEl = parseNodeString(createSnipControls()) + const speedControlsEl = parseNodeString(createSpeedControls()) - this.chorusControls.insertAdjacentHTML('beforeend', snipControls) - this.chorusControls.insertAdjacentHTML('beforeend', speedControls) + this.chorusControls.appendChild(snipControlsEl) + this.chorusControls.appendChild(speedControlsEl) } hide() { diff --git a/models/icon.js b/src/models/icon.js similarity index 100% rename from models/icon.js rename to src/models/icon.js diff --git a/models/range/range.js b/src/models/range/range.js similarity index 100% rename from models/range/range.js rename to src/models/range/range.js diff --git a/models/slider/slider-controls.js b/src/models/slider/slider-controls.js similarity index 100% rename from models/slider/slider-controls.js rename to src/models/slider/slider-controls.js diff --git a/models/slider/slider.js b/src/models/slider/slider.js similarity index 100% rename from models/slider/slider.js rename to src/models/slider/slider.js diff --git a/models/snip/current-snip.js b/src/models/snip/current-snip.js similarity index 100% rename from models/snip/current-snip.js rename to src/models/snip/current-snip.js diff --git a/models/snip/snip.js b/src/models/snip/snip.js similarity index 100% rename from models/snip/snip.js rename to src/models/snip/snip.js diff --git a/models/snip/track-snip.js b/src/models/snip/track-snip.js similarity index 100% rename from models/snip/track-snip.js rename to src/models/snip/track-snip.js diff --git a/models/speed/speed.js b/src/models/speed/speed.js similarity index 100% rename from models/speed/speed.js rename to src/models/speed/speed.js diff --git a/models/tracklist/skip-icon.js b/src/models/tracklist/skip-icon.js similarity index 100% rename from models/tracklist/skip-icon.js rename to src/models/tracklist/skip-icon.js diff --git a/models/tracklist/snip-icon.js b/src/models/tracklist/snip-icon.js similarity index 100% rename from models/tracklist/snip-icon.js rename to src/models/tracklist/snip-icon.js diff --git a/models/tracklist/track-list.js b/src/models/tracklist/track-list.js similarity index 90% rename from models/tracklist/track-list.js rename to src/models/tracklist/track-list.js index 48cb3131..5f4e7337 100644 --- a/models/tracklist/track-list.js +++ b/src/models/tracklist/track-list.js @@ -66,15 +66,16 @@ export default class TrackList { if (!song) return this.#events.forEach(event => { - row?.addEventListener(event, () => { - const snipInfo = this.#snipIcon.getTrack(song.id) + row?.addEventListener(event, async () => { + const snipInfo = await this.#snipIcon.getTrack(song.id) const icons = this.#getRowIcons(row) const keys = { snip: 'isSnip', skip: 'isSkipped' } icons.forEach(icon => { icon.style.visibility = this.#visibleEvents.includes(event) ? 'visible' : 'hidden' - this.#snipIcon._burn({ icon, burn: snipInfo[keys[icon.role]] }) - this.#snipIcon._glow({ icon, glow: snipInfo[keys[icon.role]] }) + const role = icon.getAttribute('role') + this.#snipIcon._burn({ icon, burn: snipInfo[keys[role]] }) + this.#snipIcon._glow({ icon, glow: snipInfo[keys[role]] }) }) }) }) @@ -82,16 +83,17 @@ export default class TrackList { #handleClick = async e => { const target = e.target + const role = target?.getAttribute('role') - if (['snip', 'skip'].includes(target?.role)) { + if (['snip', 'skip'].includes(role)) { let row = target.parentElement do { row = row.parentElement } while (row.dataset.testid != 'tracklist-row') - const currentIndex = row.parentElement.ariaRowIndex + const currentIndex = row.parentElement.getAttribute('aria-row-index') - if (target.role == 'snip') { + if (role == 'snip') { if (!this.#previousRowNum || (currentIndex != this.#previousRowNum)) { this.#chorus.show() this.#trackSnip.init(row) diff --git a/models/tracklist/tracklist-icon.js b/src/models/tracklist/tracklist-icon.js similarity index 80% rename from models/tracklist/tracklist-icon.js rename to src/models/tracklist/tracklist-icon.js index 4f85b2ba..e5e1280c 100644 --- a/models/tracklist/tracklist-icon.js +++ b/src/models/tracklist/tracklist-icon.js @@ -1,4 +1,5 @@ import { trackSongInfo } from '../../utils/song.js' +import { parseNodeString } from '../../utils/parser.js' export default class TrackListIcon { #key @@ -20,11 +21,15 @@ export default class TrackListIcon { if (!this.#getIcon(row)) { const heartIcon = row.querySelector('button[data-testid="add-button"]') - heartIcon.insertAdjacentHTML('beforebegin', this._iconUI) + const iconEl = parseNodeString(this._iconUI) + heartIcon?.parentNode?.insertBefore(iconEl, heartIcon) } const icon = this.#getIcon(row) - icon.style.display = 'flex' + + if (icon) { + icon.style.display = 'flex' + } } _setInitialState(row) { @@ -34,25 +39,25 @@ export default class TrackListIcon { this._animate(icon) } - #initializeTrack(row) { + async #initializeTrack(row) { const song = trackSongInfo(row) if (!song) return - return this.#store.getTrack({ + return await this.#store.getTrack({ id: song.id, value: { isSkipped: false, isSnip: false, startTime: 0, endTime: song.endTime }, }) } - getTrack(id) { - return this.#store.getTrack({ id }) + async getTrack(id) { + return await this.#store.getTrack({ id }) } async _saveTrack(row) { const song = trackSongInfo(row) if (!song) return - const snipInfo = this.getTrack(song.id) + const snipInfo = await this.getTrack(song.id) await this.#store.saveTrack({ id: song.id, @@ -61,16 +66,16 @@ export default class TrackListIcon { } #getRow(icon) { - return icon.parentElement.parentElement + return icon?.parentElement?.parentElement } - _animate(icon) { + async _animate(icon) { const row = this.#getRow(icon) const song = trackSongInfo(row) if (!song) return - const snipInfo = this.getTrack(song.id) + const snipInfo = await this.getTrack(song.id) this._burn({ icon, burn: snipInfo[this.#key] }) this._glow({ icon, glow: snipInfo[this.#key] }) diff --git a/models/video.js b/src/models/video.js similarity index 100% rename from models/video.js rename to src/models/video.js diff --git a/observers/current-time.js b/src/observers/current-time.js similarity index 97% rename from observers/current-time.js rename to src/observers/current-time.js index 6272b313..ce9ae24b 100644 --- a/observers/current-time.js +++ b/src/observers/current-time.js @@ -68,7 +68,7 @@ export default class CurrentTimeObserver { } get #muted() { - return this.#muteButton?.ariaLabel == 'Unmute' + return this.#muteButton?.getAttribute('aria-label') == 'Unmute' } get #sharedSnipValues() { @@ -111,7 +111,7 @@ export default class CurrentTimeObserver { get #isLooping() { const repeatButton = document.querySelector('[data-testid="control-button-repeat"]') - return repeatButton?.ariaLabel === 'Disable repeat' + return repeatButton?.getAttribute('aria-label') === 'Disable repeat' } get #atSongStart() { diff --git a/observers/now-playing.js b/src/observers/now-playing.js similarity index 100% rename from observers/now-playing.js rename to src/observers/now-playing.js diff --git a/observers/track-list.js b/src/observers/track-list.js similarity index 100% rename from observers/track-list.js rename to src/observers/track-list.js diff --git a/stores/cache.js b/src/stores/cache.js similarity index 100% rename from stores/cache.js rename to src/stores/cache.js diff --git a/stores/data.js b/src/stores/data.js similarity index 100% rename from stores/data.js rename to src/stores/data.js diff --git a/styles.css b/src/styles.css similarity index 96% rename from styles.css rename to src/styles.css index cc1c6bb4..08e3a0d8 100644 --- a/styles.css +++ b/src/styles.css @@ -216,6 +216,7 @@ position: absolute; pointer-events: none; -webkit-appearance: none; + -moz-appearance: none; appearance: none; z-index: 2; margin: 0; @@ -224,6 +225,14 @@ opacity: 0; } +.input[type="range"]::-moz-range-thumb { + pointer-events: all; + background-color: #fff; + width: 12px; + height: 12px; + border-radius: 50%; +} + .input[type="range"]::-webkit-slider-thumb { pointer-events: all; background-color: #fff; diff --git a/utils/clipboard.js b/src/utils/clipboard.js similarity index 100% rename from utils/clipboard.js rename to src/utils/clipboard.js diff --git a/src/utils/parser.js b/src/utils/parser.js new file mode 100644 index 00000000..40e1dc66 --- /dev/null +++ b/src/utils/parser.js @@ -0,0 +1,5 @@ +export const parseNodeString = htmlString => { + const parser = new DOMParser() + const doc = parser.parseFromString(htmlString, 'text/html') + return doc.body.firstChild +} diff --git a/utils/playback.js b/src/utils/playback.js similarity index 100% rename from utils/playback.js rename to src/utils/playback.js diff --git a/utils/song.js b/src/utils/song.js similarity index 97% rename from utils/song.js rename to src/utils/song.js index 8ac06220..9e80330b 100644 --- a/utils/song.js +++ b/src/utils/song.js @@ -1,7 +1,7 @@ import { timeToSeconds } from './time.js' export const currentSongInfo = () => { - const songLabel = document.querySelector('[data-testid="now-playing-widget"]')?.ariaLabel + const songLabel = document.querySelector('[data-testid="now-playing-widget"]')?.getAttribute('aria-label') const trackURL = document.querySelector('[data-testid="CoverSlotCollapsed__container"] > div > a')?.href // Remove 'Now playing: ' prefix diff --git a/utils/state.js b/src/utils/state.js similarity index 100% rename from utils/state.js rename to src/utils/state.js diff --git a/utils/time.js b/src/utils/time.js similarity index 100% rename from utils/time.js rename to src/utils/time.js