From bfebe53e1d832594d2c783a08eaeaf9dfd6bc049 Mon Sep 17 00:00:00 2001 From: charles Date: Mon, 30 Oct 2023 15:20:25 -0600 Subject: [PATCH] fix: song tracker and shared snip playback issues (#170) --- src/background.js | 23 +++++--- src/data/current.js | 43 ++++----------- src/data/song-state.js | 6 +-- src/manifest.chrome.json | 4 +- src/models/snip/current-snip.js | 32 +++++------ src/models/snip/snip.js | 15 +++--- src/models/snip/track-snip.js | 23 ++++---- src/models/tracklist/track-list.js | 2 +- src/models/tracklist/tracklist-icon.js | 8 ++- src/observers/now-playing.js | 40 +++++++++----- src/observers/song-tracker.js | 74 ++++++++++++-------------- src/observers/track-list.js | 2 - src/popup/index.js | 9 +++- src/services/player.js | 22 ++++++++ src/stores/cache.js | 7 ++- src/stores/data.js | 6 +-- src/utils/request.js | 22 ++------ src/utils/song.js | 16 +++--- 18 files changed, 177 insertions(+), 177 deletions(-) create mode 100644 src/services/player.js diff --git a/src/background.js b/src/background.js index fbd1f754..ae6513df 100644 --- a/src/background.js +++ b/src/background.js @@ -45,22 +45,29 @@ function setState({ key, value = {} }) { )) } +function updateBadgeState({ changes, changedKey }) { + if (changedKey != 'enabled') return + + const { newValue, oldValue } = changes.enabled + ENABLED = newValue ?? !oldValue + setBadgeInfo(ENABLED) +} + chrome.storage.onChanged.addListener(async changes => { const keys = Object.keys(changes) const changedKey = keys.find(key => ( - key == 'now-playing' || key == 'enabled' || key == 'auth_token' || key == 'device_id' + ['now-playing', 'enabled', 'auth_token', 'device_id'].includes(key) )) if (!changedKey) return - if (changedKey == 'now-playing' && ENABLED) { - return popupPort?.postMessage({ type: changedKey, data: changes[changedKey].newValue }) - } + updateBadgeState({ changes, changedKey }) + + if (['now-playing', 'enabled'].includes(changedKey)) { + if (changedKey == 'now-playing' && !ENABLED) return - if (changedKey == 'enabled') { - const { newValue } = changes.enabled - ENABLED = newValue - setBadgeInfo(newValue) + popupPort?.postMessage({ type: changedKey, data: changes[changedKey].newValue }) + if (changedKey == 'now-playing') return } await sendMessage({ message: { [changedKey]: changes[changedKey].newValue }}) diff --git a/src/data/current.js b/src/data/current.js index a90514e5..85f2f5b6 100644 --- a/src/data/current.js +++ b/src/data/current.js @@ -4,32 +4,28 @@ import { playback } from '../utils/playback.js' import { currentSongInfo } from '../utils/song.js' class CurrentData { - #store - constructor(store) { - this.#store = store + this._store = store } get #isShowingModal() { const mainElement = document.getElementById('chorus-main') - if (!mainElement) return false return mainElement.style.display == 'block' } get songId() { - if (this.#isShowingModal) { - const title = document.getElementById('track-title')?.textContent - const artists = document.getElementById('track-artists')?.textContent - return `${title} by ${artists}` - } + if (!this.#isShowingModal) return currentSongInfo().id - return currentSongInfo().id + const title = document.getElementById('track-title')?.textContent + const artists = document.getElementById('track-artists')?.textContent + return `${title} by ${artists}` } get #trackDefaults() { return { + ...currentSongInfo(), startTime: 0, isSnip: false, isSkipped: false, @@ -61,41 +57,24 @@ class CurrentData { } async getSeekValues() { - const seekValues = await this.#store.getTrack({ + return await this._store.getTrack({ id: 'chorus-seek', value: { - shows: { ff: 15, rw: 15 }, // audiobooks, podcasts, (longform audio) + shows: { ff: 15, rw: 15 }, // audiobooks, podcasts, (longform audio) global: { ff: 10, rw: 10 }, // albums, playlists, tracks (shortform audio) } }) - - return seekValues } async readTrack() { - const { cover, id } = currentSongInfo() - const track = await this.#store.getTrack({ - id, - value: { - id, - cover, - ...this.#trackDefaults, - } - }) - - return track + return await this._store.getTrack({ id: currentSongInfo().id, value: this.#trackDefaults }) } async readGlobals() { - const globals = await this.#store.getTrack({ + return await this._store.getTrack({ id: 'globals', - value: { - playbackRate: 1, - preservesPitch: true - } + value: { playbackRate: 1, preservesPitch: true } }) - - return globals } } diff --git a/src/data/song-state.js b/src/data/song-state.js index 7d3ee18b..e8be8f0a 100644 --- a/src/data/song-state.js +++ b/src/data/song-state.js @@ -29,9 +29,9 @@ export const songState = async () => { return { id, - trackId: trackId ?? trackIdFromURL, - isShared: !!trackIdFromURL, + isShared: true, ...state, - ...sharedSnipState + ...sharedSnipState, + trackId: trackIdFromURL } } diff --git a/src/manifest.chrome.json b/src/manifest.chrome.json index 3809aa95..11a6dac3 100644 --- a/src/manifest.chrome.json +++ b/src/manifest.chrome.json @@ -6,7 +6,9 @@ "manifest_version": 3, "author": "cdrani", "action": { - "default_icon": "icons/logo.png", + "default_icon": { + "32": "icons/logo.png" + }, "default_popup": "popup/index.html" }, "icons": { diff --git a/src/models/snip/current-snip.js b/src/models/snip/current-snip.js index 4183cd3f..594f802c 100644 --- a/src/models/snip/current-snip.js +++ b/src/models/snip/current-snip.js @@ -1,8 +1,8 @@ import Snip from './snip.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) { @@ -12,12 +12,13 @@ export default class CurrentSnip extends Snip { this._video = spotifyVideo.element } - init() { + async init() { super.init() this._controls.init() this.#displayTrackInfo() - this._controls.setInitialValues(this.read()) + const track = await this.read() + this._controls.setInitialValues(track) } #displayTrackInfo() { @@ -27,15 +28,7 @@ export default class CurrentSnip extends Snip { } get _defaultTrack() { - return { - id: currentSongInfo().id, - value: { - startTime: 0, - isSnip: false, - isSkipped: false, - endTime: playback.duration(), - }, - } + return currentData.readTrack() } updateView() { @@ -72,11 +65,13 @@ export default class CurrentSnip extends Snip { async save() { const { inputLeft, inputRight } = this._elements - const { isSkipped, endTime: prevEndTime } = await this.read() + const track = await this.read() + const { id, isSkipped, endTime: prevEndTime } = track - await this._store.saveTrack({ - id: currentSongInfo().id, + const result = await this._store.saveTrack({ + id, value: { + ...track, isSnip: true, startTime: inputLeft.value, endTime: inputRight.value, @@ -85,10 +80,9 @@ export default class CurrentSnip extends Snip { }) this.updateView() + this.skipTrackOnSave(result) + this.setCurrentTime({ prevEndTime, endTime: result.endTime }) - const updatedValues = await this.read() - this.skipTrackOnSave(updatedValues) - this.setCurrentTime({ prevEndTime, endTime: updatedValues.endTime }) - await this._songTracker.updateCurrentSongData(updatedValues) + await this._songTracker.updateCurrentSongData(result) } } diff --git a/src/models/snip/snip.js b/src/models/snip/snip.js index ccfc70c4..e285fe86 100644 --- a/src/models/snip/snip.js +++ b/src/models/snip/snip.js @@ -3,8 +3,9 @@ import { store } from '../../stores/data.js' import Alert from '../alert.js' import SliderControls from '../slider/slider-controls.js' -import { copyToClipBoard } from '../../utils/clipboard.js' import { timeToSeconds } from '../../utils/time.js' +import { currentData } from '../../data/current.js' +import { copyToClipBoard } from '../../utils/clipboard.js' export default class Snip { constructor() { @@ -17,8 +18,8 @@ export default class Snip { this._controls.init() } - read() { - return this._store.getTrack(this._defaultTrack) + async read() { + return (await currentData.readTrack()) } reset() { @@ -30,8 +31,8 @@ export default class Snip { this._updateView() } - _updateView() { - const response = this.read() + async _updateView(initData = null) { + const response = initData ?? await this.read() const { isSnip, isSkip } = response this.#setUpdateControls(response) @@ -48,8 +49,8 @@ export default class Snip { } } - _share() { - const { startTime, endTime, playbackRate = '1.00', preservesPitch = true } = this.read() + async _share() { + const { startTime, endTime, playbackRate = '1.00', preservesPitch = true } = await this.read() const pitch = preservesPitch ? 1 : 0 const rate = parseFloat(playbackRate) * 100 diff --git a/src/models/snip/track-snip.js b/src/models/snip/track-snip.js index ff52338a..5f9e9319 100644 --- a/src/models/snip/track-snip.js +++ b/src/models/snip/track-snip.js @@ -10,14 +10,15 @@ export default class TrackSnip extends Snip { this._row = null } - init(row) { + async init(row) { super.init() this._row = row this._controls.init() this.#displayTrackInfo() const { id, endTime: duration } = trackSongInfo(row) - this._controls.setInitialValues({ ...this.read(), id, duration }) + const track = await this.read() + this._controls.setInitialValues({ ...track, id, duration }) } #displayTrackInfo() { @@ -27,11 +28,12 @@ export default class TrackSnip extends Snip { } get _defaultTrack() { - const { id, endTime } = trackSongInfo(this._row) + const track = trackSongInfo(this._row) return { - id, + id: track.id, value: { + ...track, endTime, startTime: 0, isSnip: false, @@ -40,9 +42,9 @@ export default class TrackSnip extends Snip { } } - updateView() { + updateView(songStateData) { super._updateView() - this.highlightSnip() + this.highlightSnip(songStateData) } toggleIconVisibility({ isSnip }) { @@ -50,8 +52,7 @@ export default class TrackSnip extends Snip { icon.style.visibility = isSnip ? 'visible' : 'hidden' } - highlightSnip() { - const songStateData = this.read() + highlightSnip(songStateData) { highlightElement({ songStateData, property: 'color', @@ -77,9 +78,9 @@ export default class TrackSnip extends Snip { async save() { const { inputLeft, inputRight } = this._elements - const { isSkipped } = this.read() + const { isSkipped } = await this.read() - await this._store.saveTrack({ + const songStateData = await this._store.saveTrack({ id: trackSongInfo(this._row).id, value: { isSnip: true, @@ -89,6 +90,6 @@ export default class TrackSnip extends Snip { }, }) - this.updateView() + this.updateView(songStateData) } } diff --git a/src/models/tracklist/track-list.js b/src/models/tracklist/track-list.js index 1f3ffe04..53e4cd8d 100644 --- a/src/models/tracklist/track-list.js +++ b/src/models/tracklist/track-list.js @@ -92,7 +92,7 @@ export default class TrackList { if (role == 'snip') { if (!this._previousRowNum || (currentIndex != this._previousRowNum)) { this._chorus.show() - this._trackSnip.init(row) + await this._trackSnip.init(row) } else if (currentIndex == this._previousRowNum) { this._chorus.toggle() } diff --git a/src/models/tracklist/tracklist-icon.js b/src/models/tracklist/tracklist-icon.js index e5d87644..5c20b2bd 100644 --- a/src/models/tracklist/tracklist-icon.js +++ b/src/models/tracklist/tracklist-icon.js @@ -6,6 +6,7 @@ export default class TrackListIcon { this._key = key this._store = store this._selector = selector + this._seen = new Set() } #getIcon(row) { @@ -37,11 +38,14 @@ export default class TrackListIcon { async #initializeTrack(row) { const song = trackSongInfo(row) + if (!song) return + if (this._seen.has(song.id)) return - return await this._store.getTrack({ + this._seen.add(song.id) + await this._store.getTrack({ id: song.id, - value: { isSkipped: false, isSnip: false, startTime: 0, endTime: song.endTime }, + value: { ...song, isSkipped: false, isSnip: false, startTime: 0, endTime: song.endTime } }) } diff --git a/src/observers/now-playing.js b/src/observers/now-playing.js index ad60a360..b04f2fe9 100644 --- a/src/observers/now-playing.js +++ b/src/observers/now-playing.js @@ -2,25 +2,24 @@ import { store } from '../stores/data.js' import { currentData } from '../data/current.js' import SeekIcons from '../models/seek/seek-icon.js' +import { currentSongInfo } from '../utils/song.js' export default class NowPlayingObserver { constructor({ snip, chorus, songTracker }) { this._snip = snip this._observer = null + this._currentSongId = null this._songTracker = songTracker this._chorus = chorus this._seekIcons = new SeekIcons() - - this.observe() } async observe() { - const config = { subtree: true, childList: true, attributes: true } const target = document.querySelector('[data-testid="now-playing-widget"]') - this._observer = new MutationObserver(this.#mutationHandler) - this._observer.observe(target, config) + this._observer.observe(target, { attributes: true }) + this.#toggleSnipUI() this._seekIcons.init() await this.setNowPlayingData() @@ -30,8 +29,8 @@ export default class NowPlayingObserver { #isAnchor(mutation) { return ( mutation.type === 'attributes' && - mutation.target.localName == 'a' && - mutation.attributeName === 'href' + mutation.target.localName == 'div' && + mutation.attributeName === 'aria-label' ) } @@ -40,16 +39,28 @@ export default class NowPlayingObserver { await store.setNowPlaying(track) } + get #songId() { + return currentSongInfo().id + } + + get #songChanged() { + if (this._currentSongId == null) return true + + return this.#songId !== this._currentSongId + } #mutationHandler = async mutationsList => { for (const mutation of mutationsList) { - if (this.#isAnchor(mutation)) { - if (this._chorus.isShowing) this._snip.init() - await this.setNowPlayingData() - await this._songTracker.songChange() - this._snip.updateView() - await this._seekIcons.setSeekLabels() - } + if (!this.#isAnchor(mutation)) return + if (!this.#songChanged) return + + this._currentSongId = this.#songId + if (this._chorus.isShowing) this._snip.init() + + await this.setNowPlayingData() + await this._songTracker.songChange() + this._snip.updateView() + await this._seekIcons.setSeekLabels() } } @@ -66,6 +77,7 @@ export default class NowPlayingObserver { disconnect() { this._observer?.disconnect() + this._currentSongId = null this._seekIcons.removeIcons() this._songTracker.clearListeners() this._observer = null diff --git a/src/observers/song-tracker.js b/src/observers/song-tracker.js index 40c84a97..a7c13b37 100644 --- a/src/observers/song-tracker.js +++ b/src/observers/song-tracker.js @@ -2,13 +2,17 @@ import { currentData } from '../data/current.js' import { songState } from '../data/song-state.js' import { spotifyVideo } from '../actions/overload.js' -import { request } from '../utils/request.js' import { playback } from '../utils/playback.js' import { timeToSeconds } from '../utils/time.js' +import { currentSongInfo } from '../utils/song.js' import { highlightElement } from '../utils/higlight.js' +import { PlayerService } from '../services/player.js' + export default class SongTracker { constructor() { + this._init = true + this._playedSnip = false this._currentSongState = null this._video = spotifyVideo.element } @@ -21,6 +25,15 @@ export default class SongTracker { : await this.songChange(songStateData) } + async handleShared(songStateData) { + await this.#applyEffects(songStateData) + const { startTime: position } = songStateData + const trackId = location.pathname?.split('/')?.at(-1) + this._video.currentTime = position + this.#mute() + await PlayerService.play({ trackId, position, cb: () => this.#requestCallback(500) }) + } + async updateCurrentSongData(values) { if (!values) return @@ -33,12 +46,9 @@ export default class SongTracker { return repeatButton?.getAttribute('aria-label') === 'Disable repeat' } - async #seekTo(position) { - await request({ - type: 'seek', - value: Math.max(parseInt(position, 10) - 1, 1) * 1000, - cb: () => { this.#mute(); setTimeout(() => this.#unMute(), 1000) } - }) + #requestCallback = (duration = 1000) => { + this.#mute() + setTimeout(() => this.#unMute(), duration) } get #muteButton() { @@ -74,46 +84,34 @@ export default class SongTracker { async #setCurrentSongData() { const songStateData = await songState() - this._currentSongState = songStateData - return songStateData - } - - async handleShared(songStateData) { - await this.#applyEffects(songStateData) - const { startTime, trackId } = songStateData - - await request({ - type: 'play', - body: { - uris: [`spotify:track:${trackId}`], - position_ms: Math.max(parseInt(startTime, 10), 0) * 1000, - }, - cb: () => { this.#mute(); setTimeout(() => this.#unMute(), 250) } - }) + const songInfo = { ...songStateData, ...currentSongInfo() } + this._currentSongState = songInfo + return songInfo } async songChange(initialData = null) { this.#mute() + + const songStateData = initialData ?? await this.#setCurrentSongData() await this.#applyEffects(songStateData) - const { isSnip, isSkipped, startTime, isShared } = songStateData + const { isShared, isSnip, isSkipped, startTime } = songStateData + + if (isShared && location?.search) history.pushState(null, '', location.pathname) if (isSkipped) { - this.#nextButton.click() - return - } else if (isSnip || isShared) { + return this.#nextButton.click() + } else if (isSnip) { const parsedStartTime = parseInt(startTime, 10) * 1000 const currentPosition = timeToSeconds(this.#playbackPosition?.textContent || '0:00') const currentPositionTime = parseInt(currentPosition, 10) * 1000 if (parsedStartTime != 0 && currentPositionTime < parsedStartTime) { - await this.#seekTo(startTime) - } else { - this.#unMute() - } - } else { - this.#unMute() + this._video.currentTime = startTime + } } + + this.#unMute() } get #nextButton() { @@ -128,8 +126,8 @@ export default class SongTracker { if (this._video.isEditing) return if (!this._currentSongState) return - setTimeout(() => { - const { startTime, endTime } = this._currentSongState + setTimeout(async () => { + const { isShared, startTime, endTime } = this._currentSongState const currentTimeMS = parseInt(this._video.currentTime * 1000, 10) const isSongEnd = endTime == playback.duration() @@ -137,12 +135,10 @@ export default class SongTracker { const atSongEnd = currentTimeMS >= endTimeMS if (!atSongEnd) return - if (this.#isLooping) { - this._video.currentTime = startTime - return + if (this.#isLooping || isShared) { + return (this._video.currentTime = startTime) } - if (location?.search) history.pushState(null, '', location.pathname) this.#nextButton.click() }, 1000) } diff --git a/src/observers/track-list.js b/src/observers/track-list.js index 2098e40d..e81f9cc6 100644 --- a/src/observers/track-list.js +++ b/src/observers/track-list.js @@ -3,8 +3,6 @@ export default class TrackListObserver { this._observer = null this._isHidden = true this._trackList = trackList - - this.observe() } observe() { diff --git a/src/popup/index.js b/src/popup/index.js index b13dad56..123b65d8 100644 --- a/src/popup/index.js +++ b/src/popup/index.js @@ -409,6 +409,11 @@ loadInitialData() PORT.onMessage.addListener(async ({ type, data }) => { if (!['enabled', 'now-playing'].includes(type)) return - if (type == 'enabled') await extToggle.initialize(data, loadExtOffState) - if (type == 'now-playing') (data && await setCoverImage(data)) + if (type == 'enabled') { + const enabled = data == {} ? false : data + await extToggle.initialize(enabled, loadExtOffState) + } + + if (type == 'now-playing' && (!data || data == {})) return + await setCoverImage(data) }) diff --git a/src/services/player.js b/src/services/player.js new file mode 100644 index 00000000..41207842 --- /dev/null +++ b/src/services/player.js @@ -0,0 +1,22 @@ +import { setOptions, request } from '../utils/request.js' + +const API_URL = 'https://api.spotify.com/v1/me/player/' +const QUERY = { play: 'play' } + +const generateURL = ({ type, value = '' }) => { + const queryString = `${QUERY[type]}${type == 'play' ? '' : value}` + const deviceId = JSON.parse(sessionStorage.getItem('device_id')) + const deviceIdString = !deviceId ? '' : `${value && type != 'play' ? '&' : '?'}device_id=${deviceId}` + return `${API_URL}${queryString}` + deviceIdString +} + +export const PlayerService = { + play: async ({ position, trackId, cb }) => { + const body = { + uris: [`spotify:track:${trackId}`], + position_ms: Math.max(parseInt(position, 10), 0) * 1000, + } + const options = setOptions({ method: 'PUT', body }) + await request({ url: generateURL({ type: 'play'}), options, cb }) + } +} diff --git a/src/stores/cache.js b/src/stores/cache.js index 062e0b21..4d3d0a28 100644 --- a/src/stores/cache.js +++ b/src/stores/cache.js @@ -10,11 +10,9 @@ export default class CacheStore { getValue({ key, value }) { const result = this.getKey(key) - if (!result) { - this.update({ key, value }) - } + if (result) return result - return this.getKey(key) + return this.update({ key, value }) } update({ key, value }) { @@ -22,6 +20,7 @@ export default class CacheStore { const parsedValue = typeof value !== 'string' ? JSON.stringify(value) : value this.#cache.setItem(key, parsedValue || {}) + return this.getKey(key) } removeKey(key) { diff --git a/src/stores/data.js b/src/stores/data.js index aa82bee0..245915e5 100644 --- a/src/stores/data.js +++ b/src/stores/data.js @@ -37,10 +37,8 @@ class DataStore { } getTrack({ id, value = {}}) { - const result = this.#cache.getKey(id) - if (result && Object.hasOwn(result, 'endTime')) return result - - return this.#cache.getValue({ key: id, value }) + const cacheValue = this.#cache.getValue({ key: id, value }) + return cacheValue } async setNowPlaying(track) { diff --git a/src/utils/request.js b/src/utils/request.js index c3ca9016..ec988f22 100644 --- a/src/utils/request.js +++ b/src/utils/request.js @@ -1,6 +1,4 @@ -const API_URL = 'https://api.spotify.com/v1/me/player/' - -const setOptions = ({ method = 'PUT', body = null }) => ({ +export const setOptions = ({ method = 'GET', body = null }) => ({ method, headers: { 'Content-Type': 'application/json', @@ -9,23 +7,9 @@ const setOptions = ({ method = 'PUT', body = null }) => ({ body: body ? JSON.stringify(body) : null }) -const QUERY = { - play: 'play', - seek: 'seek?position_ms=', -} - -const generateURL = ({ type, value }) => { - const queryString = `${QUERY[type]}${type == 'play' ? '' : value}` - const deviceId = JSON.parse(sessionStorage.getItem('device_id')) - const deviceIdString = !deviceId ? '' : `${value && type != 'play' ? '&' : '?'}device_id=${deviceId}` - return `${API_URL}${queryString}` + deviceIdString -} - -export const request = async ({ type = 'seek', value = '', body = null, cb = null }) => { - const URL = generateURL({ type, value }) - +export const request = async ({ url, options, cb = null }) => { try { - const response = await fetch(URL, setOptions({ body })) + const response = await fetch(url, options || setOptions()) if (!response.ok) { throw new Error('Network response was not ok') diff --git a/src/utils/song.js b/src/utils/song.js index 64830a01..ab2f552a 100644 --- a/src/utils/song.js +++ b/src/utils/song.js @@ -2,12 +2,8 @@ import { timeToSeconds } from './time.js' export const currentSongInfo = () => { const songLabel = document.querySelector('[data-testid="now-playing-widget"]')?.getAttribute('aria-label') - const context = document.querySelectorAll([ - '[data-testid="CoverSlotCollapsed__container"] a', - '[data-testid="CoverSlotCollapsed__container"] img' - ]) - const image = context?.[1] - const anchor = context?.[0] + const image = document.querySelector('[data-testid="CoverSlotCollapsed__container"] img') + const anchor = document.querySelector('[data-testid="CoverSlotCollapsed__container"] a') const contextType = anchor?.getAttribute('data-context-item-type') // Remove 'Now playing: ' prefix @@ -24,14 +20,14 @@ export const currentSongInfo = () => { ...contextType && { type: contextType, trackId, - url: `${location.origin}/${contextType}/${trackId}` + url: `${location.origin}/${contextType}/${trackId}`, } } } export const trackSongInfo = row => { - const song = row?.querySelector('a > div')?.textContent || + const title = row?.querySelector('a > div')?.textContent || row?.querySelector('div[data-encore-id="type"]')?.textContent const songLength = row?.querySelector('button[data-testid="add-button"] + div')?.textContent const image = row?.querySelector('img') @@ -42,9 +38,11 @@ export const trackSongInfo = row => { const trackInfo = getTrackId(row) return { + title, startTime: 0, cover: image?.src, - id: `${song} by ${artists}`, + artists: getArtists(row), + id: `${title} by ${artists}`, endTime: timeToSeconds(songLength), ...trackInfo && {...trackInfo } }