Skip to content

Commit 781dc82

Browse files
committed
a lot of changes to adhere to the latest obscure Spotify API changes, a lot of guess work here, but I think it's working good :D
1 parent d4b7d80 commit 781dc82

15 files changed

Lines changed: 185 additions & 216 deletions

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
# Essentials for Spotify
44

55
Effortlessly control your [Spotify](https://www.spotify.com/) through your [Elgato Stream Deck](https://www.elgato.com/us/en/s/welcome-to-stream-deck).\
6-
A **[Spotify Premium](https://www.spotify.com/premium/)** account is required to use the full functionality of this plugin.
6+
A **[Spotify Premium](https://www.spotify.com/premium/)** account is required to use this plugin.\
7+
This is due to Spotify Web API limitations.
78

89
## Features
910

@@ -100,7 +101,7 @@ You are able to configure the behavior of some of them via their settings.
100101
Map this button to any supported Spotify URL and press it to start playing.
101102

102103
- **Add to Playlist**\
103-
Adds the currently playing song to a selected playlist of yours.
104+
Adds the currently playing song to a selected playlist of yours non-exclusively.
104105

105106
### Dials
106107
- **Playback Control**\

com.ntanis.essentials-for-spotify.sdPlugin/manifest.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"Name": "Essentials for Spotify",
3-
"Version": "1.1.0.4",
3+
"Version": "1.1.0.5",
44
"Author": "Ntanis",
55
"Actions": [
66
{
@@ -232,10 +232,9 @@
232232
"Name": "Add to Playlist",
233233
"UUID": "com.ntanis.essentials-for-spotify.add-to-playlist-button",
234234
"Icon": "images/actions/add-to-playlist",
235-
"Tooltip": "Adds the currently playing song to a selected playlist of yours.",
235+
"Tooltip": "Adds the currently playing song to a selected playlist of yours non-exclusively.",
236236
"PropertyInspectorPath": "pi/add-to-playlist-button.html",
237237
"DisableAutomaticStates": true,
238-
"UserTitleEnabled": false,
239238
"Controllers": [
240239
"Keypad"
241240
],

package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "essentials-for-spotify",
3-
"version": "1.1.0.4",
3+
"version": "1.1.0.5",
44
"description": "Effortlessly control your Spotify through your Elgato Stream Deck.",
55
"author": "https://github.com/ntanis-dev",
66
"license": "ISC",
@@ -21,14 +21,18 @@
2121
"tslib": "^2.6.2",
2222
"typescript": "^5.9.3"
2323
},
24+
"overrides": {
25+
"brace-expansion": "^2.0.1"
26+
},
2427
"dependencies": {
2528
"@elgato/streamdeck": "^2.0.1",
2629
"@rollup/plugin-json": "^6.1.0",
2730
"@tsconfig/node20": "^20.1.9",
2831
"cacheable-lookup": "^7.0.0",
2932
"express": "^5.2.1",
33+
"https-proxy-agent": "^7.0.6",
34+
"node-fetch": "^3.3.2",
3035
"rollup-plugin-copy": "^3.5.0",
31-
"undici": "^7.21.0",
3236
"uuid": "^13.0.0"
3337
}
3438
}

rollup.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const config = {
1414

1515
output: {
1616
file: 'com.ntanis.essentials-for-spotify.sdPlugin/bin/plugin.js',
17+
inlineDynamicImports: true,
1718
sourcemap: isWatching,
1819

1920
sourcemapPathTransform: (relativeSourcePath, sourcemapPath) => {

src/actions/add-to-playlist-button.ts

Lines changed: 87 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
1-
import StreamDeck, {
2-
action,
3-
SendToPluginEvent,
4-
WillAppearEvent
1+
import {
2+
action
53
} from '@elgato/streamdeck'
64

75
import {
86
Button
97
} from './button.js'
108

119
import constants from '../library/constants.js'
10+
import images from '../library/images.js'
1211
import wrapper from './../library/wrapper.js'
13-
import connector from '../library/connector.js'
1412

1513
@action({ UUID: 'com.ntanis.essentials-for-spotify.add-to-playlist-button' })
1614
export default class AddToPlaylistButton extends Button {
1715
static readonly STATABLE = true
1816

17+
#cachedPlaylist: {
18+
[context: string]: any
19+
} = {}
20+
1921
constructor() {
2022
super()
2123
this.setStatelessImage('images/states/add-to-playlist-unknown')
@@ -30,12 +32,11 @@ export default class AddToPlaylistButton extends Button {
3032
if (!song || pending) {
3133
this.clearMarquee(context)
3234
await this.setTitle(context, '')
33-
await this.setImage(context, 'images/states/add-to-playlist-unknown')
35+
await this.#updateImage(context)
3436
this.setUnpressable(context, true)
3537
} else {
36-
await this.setImage(context, 'images/states/add-to-playlist')
3738
this.setUnpressable(context, false)
38-
await this.#updateDisplay(context, song)
39+
await this.#updateDisplay(context)
3940
}
4041

4142
resolve(true)
@@ -44,94 +45,90 @@ export default class AddToPlaylistButton extends Button {
4445
await Promise.allSettled(promises)
4546
}
4647

47-
async #updateDisplay(context: string, song: any = wrapper.song) {
48-
const show = this.settings[context].show || ['playlist']
48+
#processImagePlus(iconDataUrl: string): string {
49+
const iconSize = 120
50+
const badgeSize = 36
51+
const badgeX = iconSize - badgeSize - 6
52+
const badgeY = iconSize - badgeSize - 6
53+
54+
const svg = `
55+
<svg width="${iconSize}" height="${iconSize}" viewBox="0 0 ${iconSize} ${iconSize}" xmlns="http://www.w3.org/2000/svg">
56+
57+
<defs>
58+
<pattern id="iconPattern" patternUnits="userSpaceOnUse" width="${iconSize}" height="${iconSize}">
59+
<image href="${iconDataUrl}" x="0" y="0" width="${iconSize}" height="${iconSize}"/>
60+
</pattern>
61+
</defs>
62+
63+
<rect width="${iconSize}" height="${iconSize}" fill="url(#iconPattern)"/>
64+
<circle cx="${badgeX + badgeSize / 2}" cy="${badgeY + badgeSize / 2}" r="${badgeSize / 2}" fill="#1db954" stroke="#191414" stroke-width="2"/>
65+
<line x1="${badgeX + badgeSize / 2}" y1="${badgeY + 9}" x2="${badgeX + badgeSize / 2}" y2="${badgeY + badgeSize - 9}" stroke="#191414" stroke-width="3" stroke-linecap="round"/>
66+
<line x1="${badgeX + 9}" y1="${badgeY + badgeSize / 2}" x2="${badgeX + badgeSize - 9}" y2="${badgeY + badgeSize / 2}" stroke="#191414" stroke-width="3" stroke-linecap="round"/>
67+
68+
</svg>
69+
`
70+
71+
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
72+
}
73+
74+
async #updateImage(context: string) {
75+
if (!this.#cachedPlaylist[context]) {
76+
await this.setImage(context, 'images/states/add-to-playlist-unknown')
77+
return
78+
}
79+
80+
if (!images.isItemCached(this.#cachedPlaylist[context]))
81+
await this.setImage(context, 'images/states/pending')
82+
83+
const image = await images.getForItem(this.#cachedPlaylist[context])
84+
85+
if (image)
86+
await this.setImage(context, this.#processImagePlus(`data:image/jpeg;base64,${image}`))
87+
else
88+
await this.setImage(context, 'images/states/add-to-playlist')
89+
}
90+
91+
async #updateDisplay(context: string) {
92+
const show = this.settings[context].show || ['title']
4993
const data: any = []
5094

5195
let needsRestart = !this.marquees[context]
5296

53-
for (const item of show)
54-
if (item === 'playlist' && this.settings[context].playlist_name)
55-
data.push({
56-
key: 'playlist',
57-
value: this.settings[context].playlist_name
58-
})
59-
else if (item === 'name' && song?.item?.name) {
60-
data.push({
61-
key: 'name',
62-
value: song.item.name
63-
})
64-
65-
if (this.marquees[context]?.entries?.name && this.marquees[context].entries.name.original !== song.item.name)
66-
this.updateMarqueeEntry(context, 'name', song.item.name)
67-
}
97+
if (show.includes('title') && this.#cachedPlaylist[context]?.title)
98+
data.push({
99+
key: 'title',
100+
value: this.#cachedPlaylist[context].title
101+
})
68102

69103
if (data.length === 0) {
70104
this.clearMarquee(context)
71105
await this.setTitle(context, '')
72106
} else if (needsRestart || (!this.marquees[context]))
73107
await this.marqueeTitle('add-to-playlist', data, context)
74-
}
75-
76-
async #updatePlaylists(contexts = this.contexts) {
77-
const items: any = []
78-
79-
if (connector.set)
80-
try {
81-
let page = 1
82-
83-
while (true) {
84-
const playlistsResponse = await wrapper.getUserPlaylists(page)
85-
86-
if (playlistsResponse && playlistsResponse.status === constants.WRAPPER_RESPONSE_SUCCESS) {
87-
for (const playlist of playlistsResponse.items)
88-
if (playlist) {
89-
const canAddTracks = playlist.type === 'collection' || playlist.owner?.id === wrapper.user?.id || playlist.collaborative === true
90108

91-
if (canAddTracks)
92-
items.push({
93-
value: playlist.id,
94-
label: playlist.name
95-
})
96-
}
97-
98-
if ((page * constants.WRAPPER_ITEMS_PER_PAGE) >= playlistsResponse.total)
99-
break
100-
101-
page++
102-
} else
103-
break
104-
}
105-
} catch (e) { }
106-
107-
for (const context of contexts) {
108-
await StreamDeck.ui.sendToPropertyInspector({
109-
event: 'getPlaylists',
110-
items
111-
}).catch(() => {})
109+
await this.#updateImage(context)
110+
}
112111

113-
if (connector.set && this.settings[context].playlist_id && items.length > 0) {
114-
const playlist = items.find((p: any) => p?.value === this.settings[context].playlist_id)
112+
async #resolvePlaylist(context: string) {
113+
const spotify_url = this.settings[context].spotify_url
114+
const badUrl = !/^https?:\/\/open\.spotify\.com\/(?:intl-[a-z]{2}\/)?playlist\/[A-Za-z0-9]{22}(?:\/)?(?:\?.*)?$/.test(spotify_url)
115115

116-
if (playlist) {
117-
const oldPlaylistName = this.settings[context].playlist_name
116+
if ((!spotify_url) || badUrl) {
117+
this.#cachedPlaylist[context] = null
118+
return
119+
}
118120

119-
await this.setSettings(context, {
120-
playlist_name: playlist.label
121-
})
121+
if (this.#cachedPlaylist[context]?.url === spotify_url)
122+
return
122123

123-
if (oldPlaylistName !== playlist.label)
124-
this.updateMarqueeEntry(context, 'playlist', playlist.label)
125-
}
126-
}
127-
}
124+
this.#cachedPlaylist[context] = await wrapper.getInformationOnUrl(spotify_url)
128125
}
129126

130127
async invokeWrapperAction(context: string, type: symbol) {
131128
if (type === Button.TYPES.RELEASED)
132129
return
133130

134-
if (!this.settings[context].playlist_id)
131+
if (!this.#cachedPlaylist[context]?.id)
135132
return constants.WRAPPER_RESPONSE_NOT_AVAILABLE
136133

137134
const currentTrack = await wrapper.getCurrentTrack()
@@ -140,62 +137,53 @@ export default class AddToPlaylistButton extends Button {
140137
if (!wrapper.song?.item?.id)
141138
return constants.WRAPPER_RESPONSE_NOT_AVAILABLE
142139

143-
const response = await wrapper.addSongToPlaylist(this.settings[context].playlist_id, wrapper.song.item.uri)
140+
const response = await wrapper.addSongToPlaylist(this.#cachedPlaylist[context].id, wrapper.song.item.uri)
144141

145142
if (response === constants.WRAPPER_RESPONSE_SUCCESS)
146143
return constants.WRAPPER_RESPONSE_SUCCESS_INDICATIVE
147144
else
148145
return response
149146
}
150147

151-
const response = await wrapper.addSongToPlaylist(this.settings[context].playlist_id, currentTrack.uri)
148+
const response = await wrapper.addSongToPlaylist(this.#cachedPlaylist[context].id, currentTrack.uri)
152149

153150
if (response === constants.WRAPPER_RESPONSE_SUCCESS)
154151
return constants.WRAPPER_RESPONSE_SUCCESS_INDICATIVE
155152
else
156153
return response
157154
}
158155

159-
async onSendToPlugin(ev: SendToPluginEvent<any, any>): Promise<void> {
160-
if (ev.payload?.event === 'getPlaylists')
161-
await this.#updatePlaylists([ev.action.id])
162-
}
163-
164156
async onSettingsUpdated(context: string, oldSettings: any) {
165157
await super.onSettingsUpdated(context, oldSettings)
166158

167159
if (!this.settings[context].show)
168160
await this.setSettings(context, {
169-
show: ['playlist']
161+
show: ['title']
170162
})
171163

172-
if (oldSettings.playlist_id !== this.settings[context].playlist_id)
173-
await this.#updatePlaylists([context])
174-
164+
const urlChanged = oldSettings.spotify_url !== this.settings[context].spotify_url
175165
const showChanged = oldSettings.show?.length !== this.settings[context].show?.length || (oldSettings.show && this.settings[context].show && (!oldSettings.show.every((value: any, index: number) => value === this.settings[context].show[index])))
176166

177-
if (showChanged) {
167+
if (urlChanged) {
178168
this.clearMarquee(context)
169+
await this.#resolvePlaylist(context)
170+
}
179171

180-
if (wrapper.song)
181-
await this.#updateDisplay(context, wrapper.song)
182-
else
183-
await this.#updateDisplay(context, null)
184-
} else if (oldSettings.playlist_id !== this.settings[context].playlist_id)
185-
if (wrapper.song)
186-
await this.#onSongChanged(wrapper.song, false)
187-
else
188-
await this.#onSongChanged(null, false)
172+
if (urlChanged || showChanged) {
173+
if (showChanged)
174+
this.clearMarquee(context)
175+
176+
await this.#updateDisplay(context)
177+
}
189178
}
190179

191180
async onStateSettled(context: string) {
192181
await super.onStateSettled(context, true)
193-
await this.#updatePlaylists([context])
182+
await this.#resolvePlaylist(context)
194183

195184
if (wrapper.song) {
196-
await this.setImage(context, 'images/states/add-to-playlist')
197185
this.setUnpressable(context, false)
198-
await this.#updateDisplay(context, wrapper.song)
186+
await this.#updateDisplay(context)
199187
} else
200188
await this.#onSongChanged(null, false)
201189
}

src/actions/items-dial.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ export default class ItemsDial extends Dial {
253253

254254
if (this.#currentItems[context] !== undefined)
255255
if (this.#items.items[this.#currentItems[context]]) {
256-
const apiCall = await wrapper.playItem(this.#items.items[this.#currentItems[context]])
256+
const apiCall = await this.playSelectedItem(this.#items.items[this.#currentItems[context]])
257257

258258
if (apiCall !== constants.WRAPPER_RESPONSE_SUCCESS && apiCall !== constants.WRAPPER_RESPONSE_SUCCESS_INDICATIVE)
259259
await this.#refreshLayout(true, context)
@@ -273,6 +273,10 @@ export default class ItemsDial extends Dial {
273273
}, feedback))
274274
}
275275

276+
async playSelectedItem(item: any): Promise<any> {
277+
return wrapper.playItem(item)
278+
}
279+
276280
async fetchItems(page: number): Promise<any> {
277281
throw new Error('The fetchItems method must be implemented in a subclass.')
278282
}

src/actions/my-liked-songs-dial.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ export default class MyLikedSongs extends ItemsDial {
1212
super('layouts/items-layout.json', 'images/icons/items.png')
1313
}
1414

15+
async playSelectedItem(item: any) {
16+
return wrapper.playItem({
17+
type: 'user',
18+
id: `${wrapper.user?.id}:collection`
19+
}, {
20+
uri: `spotify:track:${item.id}`
21+
})
22+
}
23+
1524
async fetchItems(page: number) {
1625
return await wrapper.getUserLikedSongs(page)
1726
}

src/actions/my-playlists-dial.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ export default class MyPlaylistsDial extends ItemsDial {
1313
}
1414

1515
async fetchItems(page: number) {
16-
return await wrapper.getPlaylists(page)
16+
return await wrapper.getUserPlaylists(page)
1717
}
1818
}

0 commit comments

Comments
 (0)