Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: re-introduce like/unlike ui #214

Merged
merged 39 commits into from
May 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c7f1893
feat: add heart button to now playing ui
cdrani May 18, 2024
693bdf1
feat: stub out heart button click listener
cdrani May 18, 2024
53dde5d
refactor: highlightIcon function arguments
cdrani May 18, 2024
b731d68
styles: display heart icon tooltip
cdrani May 18, 2024
3a03e4b
chore: rename file highlight util due to mispell
cdrani May 19, 2024
163103f
chore: update highlight util import path
cdrani May 19, 2024
5578802
feat: add delay timer to highlight icon function
cdrani May 19, 2024
496ca24
feat: add HeartIcon model to set track liked state
cdrani May 19, 2024
be1eede
feat: update heart icon ui on track change
cdrani May 19, 2024
e6ad8c6
feat: add service to like/unlike track
cdrani May 19, 2024
0d7db5b
refactor: move heart icon create + set to model
cdrani May 19, 2024
03208fd
feat: listen for tracks.update event & call service
cdrani May 19, 2024
6d56ecf
feat: connect heart icon to service + sync state
cdrani May 19, 2024
cecfe13
fix: set save/unsave selector to chorus-heart
cdrani May 20, 2024
b315a19
fix: add tiny delay when getting ui state
cdrani May 20, 2024
bb16c74
fix: set delay on heart icon ui state getter to 250ms
cdrani May 22, 2024
9fff48f
feat: create TRACK_HEART icon const
cdrani May 22, 2024
64416e2
feat: create tracklist HeartIcon model
cdrani May 22, 2024
07813b2
feat: render heart icon in tracklist row
cdrani May 22, 2024
38ddd76
fix: reduce delay in tracklist mutation actions
cdrani May 22, 2024
533bdac
refactor: house heart icon ui updates to model
cdrani May 22, 2024
d859dae
feat: set trackId on now-playing if not present
cdrani May 23, 2024
c39efe6
feat: update store so save user-curated tracks
cdrani May 23, 2024
403eb79
feat: connect tracks.like event to service
cdrani May 23, 2024
7c1c923
feat: load track liked state from store or api
cdrani May 23, 2024
b3ab901
fix: add guard on animate for rows w/o icon
cdrani May 23, 2024
af6c1e6
fix: update heart icon size + set role on path
cdrani May 25, 2024
f13e47a
refactor: export getTrackId util function
cdrani May 25, 2024
dc16c2e
fix: update request util to handle empty response
cdrani May 25, 2024
1aacd96
refactor: set id as search param in track service
cdrani May 25, 2024
4978e05
feat: update store to set & get track collections
cdrani May 25, 2024
2074807
feat: heart icon like/unlike in tracklist
cdrani May 25, 2024
5acb7f7
feat: current heart icon click updates tracklist ui
cdrani May 25, 2024
1a427e9
feat: sync liked state of tracklist on ui update
cdrani May 25, 2024
e131c93
feat: toggle plus-circle ui on observer disconnect
cdrani May 25, 2024
64bd995
fix: set default start & end times for track seek
cdrani May 25, 2024
75c570c
fix: update getTrackId for recommended tracklist
cdrani May 28, 2024
255dd1d
refactor: early return result in getValue
cdrani May 28, 2024
5aa3c99
refactor: misc. seek & tracklist models cleanup
cdrani May 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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