Skip to content

Commit

Permalink
feat: popup media controls (#195)
Browse files Browse the repository at this point in the history
  • Loading branch information
cdrani authored Dec 9, 2023
1 parent 9ef8ef2 commit a658572
Show file tree
Hide file tree
Showing 14 changed files with 425 additions and 87 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "chorus",
"private": true,
"type": "module",
"version": "1.18.0",
"version": "1.19.0",
"scripts": {
"build": "rollup -c",
"watch": "rollup -c -w"
Expand Down
103 changes: 71 additions & 32 deletions src/background.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,66 @@
import { setState, getState } from './utils/state.js'
import { getActiveTab, sendMessage } from './utils/messaging.js'
import { mediaKeys, chorusKeys } from './utils/selectors.js'
import { activeOpenTab, sendMessage } from './utils/messaging.js'

import { createArtistDiscoPlaylist } from './services/artist-disco.js'
import { playSharedTrack, seekTrackToPosition } from './services/player.js'

let ENABLED = true
let popupPort = null

chrome.runtime.onConnect.addListener(port => {
async function getUIState({ selector, tabId }) {
const result = await chrome.scripting.executeScript({
args: [selector],
target: { tabId },
func: selector => document.querySelector(selector)?.getAttribute('aria-label').toLowerCase()
})

return result?.at(0).result
}

async function getMediaControlsState(tabId) {
const requiredKeys = ['repeat', 'shuffle', 'play/pause', 'save/unsave', 'seek-rewind', 'seek-fastforward']
const selectorKeys = { ...mediaKeys, ...chorusKeys }
const selectors = Object.keys(selectorKeys).map(key => (
requiredKeys.includes(key) ? selectorKeys[key] : undefined
)).filter(Boolean)

const promises = selectors.map(selector => (
new Promise(resolve => {
if (!selector.includes('add-button')) return resolve(getUIState({ selector, tabId }))
return setTimeout(() => resolve(getUIState({ selector, tabId })), 500)
})
))

const results = await Promise.allSettled(promises)
const state = results.map((item, idx) => ({ data: item.value, key: requiredKeys[idx] }))
return state
}

async function setMediaState({ active, tabId }) {
popupPort.postMessage({ type: 'ui-state', data: { active } })

if (!active) return

const state = await getMediaControlsState(tabId)
return popupPort.postMessage({ type: 'state', data: state })
}

chrome.runtime.onConnect.addListener(async port => {
if (port.name !== 'popup') return

popupPort = port
const { active, tabId } = await activeOpenTab()
await setMediaState({ active, tabId })

port.onMessage.addListener(async message => {
if (message?.type !== 'controls') return

const { selector, tabId } = await executeButtonClick({ command: message.key })
const result = await getUIState({ selector, tabId })
port.postMessage({ type: 'controls', data: { key: message.key, result } })
})

port.onDisconnect.addListener(() => (popupPort = null))
})

Expand All @@ -33,7 +83,7 @@ function updateBadgeState({ changes, changedKey }) {
setBadgeInfo(ENABLED)
}

chrome.storage.onChanged.addListener(changes => {
chrome.storage.onChanged.addListener(async changes => {
const keys = Object.keys(changes)
const changedKey = keys.find(key => (
['now-playing', 'enabled', 'auth_token', 'device_id'].includes(key)
Expand All @@ -44,7 +94,11 @@ chrome.storage.onChanged.addListener(changes => {
updateBadgeState({ changes, changedKey })

if (changedKey == 'now-playing' && ENABLED) {
return popupPort?.postMessage({ type: changedKey, data: changes[changedKey].newValue })
if (!popupPort) return

const { active, tabId } = await activeOpenTab()
active && popupPort.postMessage({ type: changedKey, data: changes[changedKey].newValue })
return await setMediaState({ active, tabId })
}

const messageValue = changedKey == 'enabled' ? changes[changedKey] : changes[changedKey].newValue
Expand All @@ -55,7 +109,6 @@ chrome.webRequest.onBeforeRequest.addListener(details => {
const rawBody = details?.requestBody?.raw?.at(0)?.bytes
if (!rawBody) return

// Decoding the ArrayBuffer to a UTF-8 string
const text = new TextDecoder('utf-8').decode(new Uint8Array(rawBody))
const data = JSON.parse(text)
chrome.storage.local.set({ device_id: data.device.device_id.toString() })
Expand Down Expand Up @@ -96,41 +149,27 @@ chrome.runtime.onMessage.addListener(({ key, values }, _, sendResponse) => {
return true
})

chrome.commands.onCommand.addListener(async command => {
const tab = await getActiveTab()
if (!tab) return

const chorusMap = {
'settings': '#chorus-icon',
'block-track': '#chorus-skip',
'seek-rewind': '#seek-player-rw-button',
'seek-fastforward': '#seek-player-ff-button',
}

const mediaMap = {
'repeat': '[data-testid="control-button-repeat"]',
'shuffle': '[data-testid="control-button-shuffle"]',
'next': '[data-testid="control-button-skip-forward"]',
'previous': '[data-testid="control-button-skip-back"]',
'play/pause': '[data-testid="control-button-playpause"]',
'mute/unmute': '[data-testid="volume-bar-toggle-mute-button"]',
'save/unsave': '[data-testid="now-playing-widget"] > [data-testid="add-button"]',
}
async function executeButtonClick({ command, isShortCutKey = false }) {
const { active, tabId } = await activeOpenTab()
if (!active) return

if (command == 'on/off') {
const enabled = await getState('enabled')
chrome.storage.local.set({ enabled: !enabled })
return
return chrome.storage.local.set({ enabled: !enabled })
}

const selector = chorusMap[command] || mediaMap[command]
const isChorusCommand = Object.keys(chorusMap).includes(command)
const selector = chorusKeys[command] || mediaKeys[command]
const isChorusCommand = Object.keys(chorusKeys).includes(command)

if (!ENABLED && isChorusCommand) return
if (isShortCutKey && !ENABLED && isChorusCommand) return

await chrome.scripting.executeScript({
args: [selector],
target: { tabId: tab.id },
target: { tabId },
func: selector => document.querySelector(selector)?.click(),
})
})

if (!isShortCutKey) return { selector, tabId }
}

chrome.commands.onCommand.addListener(async command => await executeButtonClick({ command, isShortCutKey: true }))
2 changes: 1 addition & 1 deletion src/manifest.chrome.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.18.0",
"version": "1.19.0",
"manifest_version": 3,
"author": "cdrani",
"action": {
Expand Down
2 changes: 1 addition & 1 deletion src/manifest.firefox.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.18.0",
"version": "1.19.0",
"manifest_version": 3,
"author": "cdrani",
"action": {
Expand Down
2 changes: 1 addition & 1 deletion src/models/now-playing-icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default class NowPlayingIcons {
const settingsIcon = document.getElementById('chorus-icon')
settingsIcon?.addEventListener('click', () => {
this.chorus.toggle()
if (this.chorus.isShowing) this.snip.init()
if (this.chorus.isShowing) { this.snip.init(); this.snip.updateView() }
})

const skipIcon = document.getElementById('chorus-skip')
Expand Down
4 changes: 2 additions & 2 deletions src/models/snip/snip.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ export default class Snip {

async _updateView(initData = null) {
const response = initData ?? await this.read()
const { isSnip, isSkip } = response
const { isSnip, isSkipped } = response

this.#setUpdateControls(response)
this.#toggleRemoveButton(isSnip || isSkip)
this.#toggleRemoveButton(isSnip || isSkipped)
}

get tempShareTimes() {
Expand Down
183 changes: 183 additions & 0 deletions src/popup/controls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { SVG_PATHS } from './ui.js'
import { parseNodeString } from '../utils/parser.js'

class ExtControls {
constructor() {
this._port = null
this._colours = {}
this._eventsSet = false
}

initialize(port) {
this._port = port
this.setupEvents()
}

setupEvents() {
if (this._eventsSet) return

Object.values(this.btns).forEach(btn => {
btn.onclick = () => { this._port?.postMessage({ type: 'controls', key: btn.getAttribute('role') }) }
btn.onmouseover = () => { btn.style.scale = 1.125 }
btn.onmouseout = () => { btn.style.scale = 1 }
})

this._eventsSet = true
}

updateControlsState(active) {
document.body.style.height = active ? '118px' : '88px'

const mediaContainer = document.getElementById('media-controls')
mediaContainer.style.display = active ? 'flex' : 'none'
}

#setColours({ textColour, backgroundColour }) {
if (textColour) this._colours.textColour = textColour
if (backgroundColour) this._colours.backgroundColour = backgroundColour
}

setFill({ textColour, backgroundColour }) {
this.#setColours({ textColour, backgroundColour })
const { playBtn } = this.btns
const { rwSpan, ffSpan } = this.spans
const {
playIcon, heartIcon, repeatIcon, shuffleIcon, blockIcon, nextIcon, previousIcon, rwIcon, ffIcon
} = this.icons

playBtn.style.backgroundColor = textColour
playIcon.style.fill = backgroundColour
playIcon.style.stroke = backgroundColour

shuffleIcon.style.stroke = textColour
shuffleIcon.style.fill = textColour

blockIcon.style.stroke = textColour
blockIcon.style.fill = textColour
blockIcon.style.strokeWidth = 0.8

heartIcon.style.stroke = textColour
heartIcon.style.strokeWidth = 2

previousIcon.style.fill = textColour
previousIcon.style.stroke = textColour

nextIcon.style.fill = textColour
nextIcon.style.stroke = textColour

repeatIcon.style.fill = textColour
repeatIcon.style.stroke = textColour
repeatIcon.style.strokeWidth = 0.5

rwIcon.style.stroke = textColour
rwIcon.style.strokeWidth = 7
rwSpan.style.color = textColour

ffIcon.style.stroke = textColour
ffIcon.style.strokeWidth = 7
ffSpan.style.color = textColour
}

get spans() {
return { rwSpan: document.getElementById('seek-rw'), ffSpan: document.getElementById('seek-ff') }
}

#getPathKey({ type, key, result }) {
if (key == 'play/pause') return (type == 'state') ? result : (result == 'play' ? 'pause' : 'play')

if (type == 'controls') return result?.includes('one') ? 'repeat1' : 'repeat'
return result?.includes('disable') ? 'repeat1' : 'repeat'
}

#updateHeart({ svg, state }) {
const hearted = state?.includes('remove')
svg.style.fill = hearted ? this._colours.textColour : 'none'
}

#updateShuffle({ type, key, svg, state }) {
const { textColour } = this._colours
svg.style.stroke = textColour
svg.style.fill = textColour

const span = document.getElementById(`${key}-dot`)
const enabled = type == 'state' ? state?.includes('disable') : state?.includes('enable')
this.#updateSpan({ span, enabled, textColour })
}

#updateSpan({ span, enabled, textColour }) {
span.style.display = enabled ? 'inline' : 'none'
span.style.color = textColour
}

#updateRepeat({ type, key, svg, state }) {
const { textColour } = this._colours
svg.style.stroke = textColour
svg.style.fill = textColour

const span = document.getElementById(`${key}-dot`)
const enabled = type == 'state' ? state?.search(/(one)|(disable)/g) >= 0 : state?.includes('enable')
this.#updateSpan({ span, enabled, textColour })
}

#updateSeek({ key, state }) {
const isRewind = key.endsWith('rewind')
const span = isRewind ? this.spans.rwSpan : this.spans.ffSpan
const seekValue = state?.split(' ')?.at(-1) ?? 10
span.textContent = parseInt(seekValue, 10)
}

updateIcons({ type, key, result }) {
const btn = document.querySelector(`[role="${key}"]`)
const svg = btn.lastElementChild

if (key.startsWith('seek')) return this.#updateSeek({ key, state: result })
if (key == 'save/unsave') return this.#updateHeart({ svg, state: result })
if (key == 'shuffle') return this.#updateShuffle({ type, key, svg, state: result })

if (['repeat', 'play/pause'].includes(key)) {
const pathKey = this.#getPathKey({ type, key, result })
const newPath = parseNodeString(`<svg xmlns="http://www.w3.org/2000/svg">${SVG_PATHS[pathKey]}</svg>`)
svg.replaceChildren(...newPath.childNodes)
}

if (key == 'repeat') return this.#updateRepeat({ type, key, svg, state: result })

const { textColour, backgroundColour } = this._colours
svg.style.fill = key == 'play/pause' ? backgroundColour : textColour
key != 'play/pause' && (svg.style.stroke = textColour)
}

updateUIState({ type, data }) {
data.forEach(({ key, data }) => this.updateIcons({ type, key, result: data }))
}

get btns() {
return {
ffBtn: document.getElementById('ff-btn'),
rwBtn: document.getElementById('rw-btn'),
nextBtn: document.getElementById('next-btn'),
playBtn: document.getElementById('play-btn'),
heartBtn: document.getElementById('heart-btn'),
blockBtn: document.getElementById('block-btn'),
repeatBtn: document.getElementById('repeat-btn'),
shuffleBtn: document.getElementById('shuffle-btn'),
previousBtn: document.getElementById('previous-btn'),
}
}

get icons() {
return {
ffIcon: document.getElementById('ff-icon'),
rwIcon: document.getElementById('rw-icon'),
playIcon: document.getElementById('play-icon'),
nextIcon: document.getElementById('next-icon'),
heartIcon: document.getElementById('heart-icon'),
blockIcon: document.getElementById('block-icon'),
repeatIcon: document.getElementById('repeat-icon'),
shuffleIcon: document.getElementById('shuffle-icon'),
previousIcon: document.getElementById('previous-icon'),
}
}
}

export const extControls = new ExtControls()
Loading

0 comments on commit a658572

Please sign in to comment.