Skip to content

Commit

Permalink
feat: re-introduce like/unlike ui (#214)
Browse files Browse the repository at this point in the history
  • Loading branch information
cdrani authored May 28, 2024
1 parent 6e68f8d commit ec22795
Show file tree
Hide file tree
Showing 24 changed files with 615 additions and 95 deletions.
5 changes: 5 additions & 0 deletions src/actions/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ async function load() {
sessionStorage.setItem('connection_id', connection_id)
})

document.addEventListener('app.now-playing', async (e) => {
const now_playing = e.detail['now-playing']
sessionStorage.setItem('now-playing', now_playing)
})

document.addEventListener('app.auth_token', async (e) => {
const { auth_token } = e.detail
sessionStorage.setItem('auth_token', auth_token)
Expand Down
63 changes: 48 additions & 15 deletions src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,26 @@ import { activeOpenTab, sendMessage } from './utils/messaging.js'
import { getQueueList, setQueueList } from './services/queue.js'
import { createArtistDiscoPlaylist } from './services/artist-disco.js'
import { playSharedTrack, seekTrackToPosition } from './services/player.js'
import { checkIfTracksInCollection, updateLikedTracks } from './services/track.js'

let ENABLED = true
let popupPort = null

async function getUIState({ selector, tabId }) {
const result = await chrome.scripting.executeScript({
args: [selector],
async function getUIState({ selector, tabId, delay = 0 }) {
const [result] = await chrome.scripting.executeScript({
args: [selector, delay],
target: { tabId },
func: (selector) =>
document.querySelector(selector)?.getAttribute('aria-label').toLowerCase()
func: (selector, delay) =>
new Promise((resolve) => {
setTimeout(() => {
resolve(
document.querySelector(selector)?.getAttribute('aria-label').toLowerCase()
)
}, delay)
})
})

return result?.at(0).result
return result?.result
}

async function getMediaControlsState(tabId) {
Expand All @@ -36,8 +43,9 @@ async function getMediaControlsState(tabId) {
const promises = selectors.map(
(selector) =>
new Promise((resolve) => {
if (selector.search(/(loop)|(add-button)/g) < 0)
if (selector.search(/(loop)|(heart)/g) < 0) {
return resolve(getUIState({ selector, tabId }))
}
return setTimeout(() => resolve(getUIState({ selector, tabId })), 500)
})
)
Expand Down Expand Up @@ -67,7 +75,10 @@ chrome.runtime.onConnect.addListener(async (port) => {
if (message?.type !== 'controls') return

const { selector, tabId } = await executeButtonClick({ command: message.key })
const result = await getUIState({ selector, tabId })

const delay = selector.includes('heart') ? 250 : 0
const result = await getUIState({ selector, tabId, delay })

port.postMessage({ type: 'controls', data: { key: message.key, result } })
})

Expand Down Expand Up @@ -104,11 +115,12 @@ chrome.storage.onChanged.addListener(async (changes) => {
updateBadgeState({ changes, changedKey })

if (changedKey == 'now-playing' && ENABLED) {
if (!popupPort) return

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

const messageValue =
Expand All @@ -132,8 +144,25 @@ chrome.webRequest.onBeforeRequest.addListener(
['requestBody']
)

function getTrackId(url) {
const query = new URL(url)
const params = new URLSearchParams(query.search)
const variables = params.get('variables')
const uris = JSON.parse(decodeURIComponent(variables)).uris.at(0)
return uris.split('track:').at(-1)
}

chrome.webRequest.onBeforeSendHeaders.addListener(
(details) => {
async (details) => {
if (details.url.includes('areEntitiesInLibrary')) {
const nowPlaying = await getState('now-playing')
if (!nowPlaying) return
if (nowPlaying?.trackId) return

nowPlaying.trackId = getTrackId(details.url)
chrome.storage.local.set({ 'now-playing': nowPlaying })
}

const authHeader = details?.requestHeaders?.find(
(header) => header?.name == 'authorization'
)
Expand All @@ -144,7 +173,8 @@ chrome.webRequest.onBeforeSendHeaders.addListener(
{
urls: [
'https://api.spotify.com/*',
'https://guc3-spclient.spotify.com/track-playback/v1/devices'
'https://guc3-spclient.spotify.com/track-playback/v1/devices',
'https://api-partner.spotify.com/pathfinder/v1/query?operationName=areEntitiesInLibrary*'
]
},
['requestHeaders']
Expand All @@ -162,8 +192,11 @@ chrome.runtime.onMessage.addListener(({ key, values }, _, sendResponse) => {
'queue.get': getQueueList,
'play.shared': playSharedTrack,
'play.seek': seekTrackToPosition,
'tracks.update': updateLikedTracks,
'tracks.liked': checkIfTracksInCollection,
'artist.disco': createArtistDiscoPlaylist
}

const handlerFn = messageHandler[key]
handlerFn
? promiseHandler(handlerFn(values), sendResponse)
Expand Down
37 changes: 32 additions & 5 deletions src/components/icons/icon.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,24 @@ export const NOW_PLAYING_SKIP_ICON = {
id: 'chorus-skip'
}

export const TRACK_HEART = {
lw: 22,
role: 'heart',
ariaLabel: 'Like Song',
stroke: 'currentColor',
fill: 'none',
viewBox: '-5 -4 24 24'
}

export const HEART_ICON = {
id: 'chorus-heart',
...TRACK_HEART
}

const SVG_PATHS = {
heart: `
<path role="heart" d="M15.724 4.22A4.313 4.313 0 0 0 12.192.814a4.269 4.269 0 0 0-3.622 1.13.837.837 0 0 1-1.14 0 4.272 4.272 0 0 0-6.21 5.855l5.916 7.05a1.128 1.128 0 0 0 1.727 0l5.916-7.05a4.228 4.228 0 0 0 .945-3.577z"/>
`,
skip: `
<path role="skip" fill-rule="evenodd"
d="M5.965 4.904l9.131 9.131a6.5 6.5 0 00-9.131-9.131zm8.07 10.192L4.904 5.965a6.5 6.5 0 009.131 9.131zM4.343 4.343a8 8 0 1111.314 11.314A8 8 0 014.343 4.343z" clip-rule="evenodd"
Expand All @@ -43,10 +60,20 @@ const BUTTON_STYLES = {
'visibility:hidden;border:none;background:unset;display:flex;align-items:center;cursor:pointer;'
}

export const createIcon = ({ role, viewBox, id, ariaLabel, strokeWidth, stroke }) => {
export const createIcon = ({
role,
viewBox,
id,
ariaLabel,
strokeWidth,
stroke,
fill = 'currentColor',
lw = '1.25rem'
}) => {
const svgPath = SVG_PATHS[role] || SVG_PATHS.default
const buttonStylesKey = id ? 'settings' : 'default'
const buttonStyles = BUTTON_STYLES[buttonStylesKey]
let buttonStyles = BUTTON_STYLES[buttonStylesKey]
if (role == 'skip' && !id) buttonStyles += 'padding-right: 4px;'

return `
<button
Expand All @@ -59,9 +86,9 @@ export const createIcon = ({ role, viewBox, id, ariaLabel, strokeWidth, stroke }
>
<svg
role="${role}"
width="1.25rem"
height="1.25rem"
fill="currentColor"
width="${lw}"
height="${lw}"
fill="${fill}"
stroke="${stroke || ''}"
stroke-width="${strokeWidth || 1.5}"
xmlns="http://www.w3.org/2000/svg"
Expand Down
4 changes: 3 additions & 1 deletion src/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ window.addEventListener('message', async (event) => {
'queue.set': sendBackgroundMessage,
'queue.get': sendBackgroundMessage,
'artist.disco': sendBackgroundMessage,
'tracks.liked': sendBackgroundMessage,
'tracks.update': sendBackgroundMessage,
'storage.populate': () => getState(null),
'storage.get': ({ key }) => getState(key),
'storage.delete': ({ key }) => removeState(key),
Expand All @@ -49,7 +51,7 @@ window.addEventListener('message', async (event) => {
chrome.runtime.onMessage.addListener((message) => {
const messageKey = Object.keys(message)
const changedKey = messageKey.find((key) =>
['connection_id', 'enabled', 'auth_token', 'device_id'].includes(key)
['now-playing', 'connection_id', 'enabled', 'auth_token', 'device_id'].includes(key)
)

if (!changedKey) return
Expand Down
162 changes: 162 additions & 0 deletions src/models/heart-icon.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { parseNodeString } from '../utils/parser.js'
import { highlightIconTimer } from '../utils/highlight.js'

import { store } from '../stores/data.js'
import Dispatcher from '../events/dispatcher.js'
import { currentData } from '../data/current.js'
import { createIcon, HEART_ICON } from '../components/icons/icon.js'

export default class HeartIcon {
constructor() {
this._id = null
this._dispatcher = new Dispatcher()
}

init() {
this.#placeIcon()
this.#setupListener()
}

removeIcon() {
this.#heartIcon?.remove()
this.#toggleNowPlayingButton(true)
}

#placeIcon() {
const heartButton = parseNodeString(this.#createHeartIcon)
const refNode = this.#nowPlayingButton
refNode.parentElement.insertBefore(heartButton, refNode)
this.#toggleNowPlayingButton(false)
}

#toggleNowPlayingButton(show) {
const button = this.#nowPlayingButton
button.style.visibility = show ? 'visible' : 'hidden'
button.style.padding = show ? '0.5rem' : 0
button.style.width = show ? '1rem' : 0
}

get #createHeartIcon() {
return createIcon(HEART_ICON)
}

get #heartIcon() {
return document.querySelector('#chorus-heart')
}

#setupListener() {
this.#heartIcon?.addEventListener('click', async () => this.#handleClick())
}

async #dispatchIsInCollection(ids) {
return await this._dispatcher.sendEvent({
eventType: 'tracks.liked',
detail: { key: 'tracks.liked', values: { ids } }
})
}

async #dispatchLikedTracks() {
const method = (await this.#isHeartIconHighlighted()) ? 'DELETE' : 'PUT'
const { trackId: id } = await currentData.readTrack()

await this._dispatcher.sendEvent({
eventType: 'tracks.update',
detail: { key: 'tracks.update', values: { id, method } }
})

return method == 'PUT'
}

async #handleClick() {
const highlight = await this.#dispatchLikedTracks()
this.highlightIcon(highlight)
}

get #nowPlayingButton() {
return document.querySelector(
'div[data-testid="now-playing-widget"] > button[data-encore-id="buttonTertiary"]'
)
}

get #isSpotifyHighlighted() {
const button = this.#nowPlayingButton
if (!button) return false

return JSON.parse(button.getAttribute('aria-checked'))
}

async #isHeartIconHighlighted() {
const trackState = store.checkInCollection(this._id)
if (trackState !== null) return trackState

// If Spotify does not mark is 'curated', then it's not in ANY of user's playlists
if (!this.#isSpotifyHighlighted) {
store.saveInCollection({ id: this._id, saved: false })
return false
}

return this.#heartIcon.firstElementChild.getAttribute('fill') != 'unset'
}

async #getIsTrackLiked() {
if (!this._id) return false

const trackState = store.checkInCollection(this._id)
if (trackState !== null) return trackState

const response = await this.#dispatchIsInCollection(this._id)

const saved = response?.data?.at(0)
store.saveInCollection({ id: this._id, saved })
return saved
}

async highlightIcon(highlight) {
this._id = null
const { trackId } = await currentData.readTrack()
this._id = trackId

const shouldUpdate = highlight ?? (await this.#getIsTrackLiked())

highlightIconTimer({
fill: true,
highlight: shouldUpdate,
selector: '#chorus-heart > svg'
})

this.#updateIconLabel(shouldUpdate)
store.saveInCollection({ id: this._id, saved: shouldUpdate })
this.#highlightInTracklist(shouldUpdate)
}

#highlightInTracklist(highlight) {
if (!this._id) return

const anchors = document.querySelectorAll(
`a[data-testid="internal-track-link"][href="/track/${this._id}"]`
)
if (!anchors?.length) return

anchors.forEach((anchor) => {
const trackRow = anchor?.parentElement?.parentElement?.parentElement
if (!trackRow) return

const heartIcon = trackRow.querySelector('button[role="heart"]')
if (!heartIcon) return

const svg = heartIcon.querySelector('svg')
if (!svg) return

heartIcon.style.visibility = highlight ? 'visible' : 'hidden'

heartIcon.setAttribute('aria-label', `${highlight ? 'Remove from' : 'Save to'} Liked`)
svg.style.fill = highlight ? '#1ed760' : 'transparent'
svg.style.stroke = highlight ? '#1ed760' : 'currentColor'
})
}

#updateIconLabel(highlight) {
const text = `${highlight ? 'Remove from' : 'Save to'} Liked`
this.#heartIcon.setAttribute('aria-label', text)
}
}
Loading

0 comments on commit ec22795

Please sign in to comment.