Skip to content

Commit bca1fc6

Browse files
committed
[play-on-spotify] add plugin
1 parent cca01e5 commit bca1fc6

File tree

9 files changed

+372
-0
lines changed

9 files changed

+372
-0
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ https://ioj4.github.io/shelter-plugins/open-profile-images/
3030
https://ioj4.github.io/shelter-plugins/channel-typing-indicators/
3131
```
3232

33+
### [Play On Spotify](https://github.com/ioj4/shelter-plugins/tree/master/plugins/play-on-spotify/)
34+
35+
##### Play, queue or open songs (and other links) directly in the Spotify app
36+
37+
<img src="static/play-on-spotify.jpg" width="480">
38+
39+
```
40+
https://ioj4.github.io/shelter-plugins/play-on-spotify/
41+
```
42+
3343
### [Developer Options](https://github.com/ioj4/shelter-plugins/tree/master/plugins/developer-options/) (credits to link for the initial snippet)
3444

3545
##### Enables Discord's developer options, experiments and more - use at your own risk
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
const {
2+
plugin: { store },
3+
ui: { tooltip, showToast, ToastColors },
4+
solid: { Show },
5+
util: { log }
6+
} = shelter;
7+
8+
import { BUTTONS } from "..";
9+
import classes from "../styles.jsx.scss";
10+
11+
false || tooltip;
12+
13+
export default function ({ item }) {
14+
const rows = [];
15+
for (const button of BUTTONS) {
16+
rows.push(
17+
<Show
18+
when={
19+
store[button.storeKey] &&
20+
(!button.allowedTypes ||
21+
button.allowedTypes.includes(item.type))
22+
}
23+
>
24+
<button
25+
use:tooltip={button.tooltip}
26+
onClick={(e) => {
27+
const el = e.currentTarget;
28+
el.style.color = "var(--interactive-active, #fbfbfb)";
29+
button.action(item).then(
30+
() => {
31+
el.style.color = "#1bc357";
32+
setTimeout(() => (el.style.color = ""), 800);
33+
},
34+
(err) => {
35+
el.style.color = "#da3e44";
36+
setTimeout(() => (el.style.color = ""), 800);
37+
log(
38+
`[play-on-spotify] ${err.message}`,
39+
"error"
40+
);
41+
showToast({
42+
title: "Couldn't perform action",
43+
content: err.message,
44+
duration: 8_000,
45+
class: ToastColors.CRITICAL
46+
});
47+
}
48+
);
49+
}}
50+
>
51+
<button.icon />
52+
</button>
53+
</Show>
54+
);
55+
}
56+
return (
57+
<div className={`ioj4-pos-buttons ${classes.container}`}>{rows}</div>
58+
);
59+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
export const Open = () => (
2+
<svg
3+
width="16"
4+
height="16"
5+
viewBox="0 0 24 24"
6+
fill="none"
7+
xmlns="http://www.w3.org/2000/svg"
8+
>
9+
<path
10+
d="M11.6438 2.15465C11.4612 2.19322 11.2874 2.28356 11.1453 2.42565L7.59106 5.97985C7.4563 6.36608 7.54272 6.81285 7.85181 7.12194C8.17065 7.44078 8.63647 7.52281 9.03101 7.36803L10.8528 5.54674V13.0223C10.8528 13.5746 11.3 14.0223 11.8528 14.0223C12.405 14.0223 12.8528 13.5746 12.8528 13.0223V5.54674L14.6746 7.36901C15.0691 7.52281 15.5344 7.44078 15.8528 7.12194C16.1624 6.81285 16.2488 6.36559 16.113 5.97887L12.5598 2.42565C12.3113 2.17711 11.9641 2.08678 11.6438 2.15465Z"
11+
fill="currentColor"
12+
/>
13+
<path
14+
d="M4.18774 8.0775C3.6355 8.0775 3.18774 8.52526 3.18774 9.0775V20.8671C3.18774 21.4193 3.6355 21.8671 4.18774 21.8671H19.8123C20.3645 21.8671 20.8123 21.4193 20.8123 20.8671V9.0775C20.8123 8.52526 20.3645 8.0775 19.8123 8.0775H16.8743V10.0775H18.8123V19.8671H5.18774V10.0775H6.83032V8.0775H4.18774Z"
15+
fill="currentColor"
16+
/>
17+
</svg>
18+
);
19+
20+
export const Queue = () => (
21+
<svg
22+
width="16"
23+
height="16"
24+
viewBox="0 0 24 24"
25+
fill="none"
26+
xmlns="http://www.w3.org/2000/svg"
27+
>
28+
<path
29+
d="M4.02637 1.04321H6.02637V4.04321H9.02637V6.04321H6.02637V9.04321H4.02637V6.04321H1.02637V4.04321H4.02637V1.04321Z"
30+
fill="currentColor"
31+
/>
32+
<path
33+
d="M12.0776 5.53198C12.0493 5.76099 12.0103 5.98657 11.9604 6.20825H19.1411C19.7939 6.20825 20.3237 6.73804 20.3237 7.39087C20.3237 8.0437 19.7939 8.57349 19.1411 8.57349H10.9424C10.48 9.26099 9.89844 9.86206 9.22754 10.3474C8.92041 10.5696 8.59473 10.7673 8.25293 10.9382H19.4263C21.3853 10.9382 22.9736 9.34985 22.9736 7.39087C22.9736 5.43188 21.3853 3.84351 19.4263 3.84351H12.0815C12.1133 4.11499 12.1299 4.39136 12.1299 4.67163C12.1299 4.96265 12.1123 5.24976 12.0776 5.53198Z"
34+
fill="currentColor"
35+
/>
36+
<path
37+
d="M22.0928 14.8972H4.05469V17.262H22.0928V14.8972Z"
38+
fill="currentColor"
39+
/>
40+
<path
41+
d="M4.05469 20.592H22.0928V22.9568H4.05469V20.592Z"
42+
fill="currentColor"
43+
/>
44+
</svg>
45+
);
46+
47+
export const Play = () => (
48+
<svg
49+
width="16"
50+
height="16"
51+
viewBox="0 0 24 24"
52+
fill="none"
53+
xmlns="http://www.w3.org/2000/svg"
54+
>
55+
<path
56+
fill-rule="evenodd"
57+
clip-rule="evenodd"
58+
d="M12 24C18.6274 24 24 18.6274 24 12C24 5.37258 18.6274 0 12 0C5.37258 0 0 5.37258 0 12C0 18.6274 5.37258 24 12 24ZM9.04596 8.19684V15.8123C9.04596 16.1973 9.4628 16.4378 9.79614 16.2452L16.3167 12.477C16.6481 12.2855 16.6502 11.8079 16.3206 11.6134L9.80004 7.76621C9.46673 7.56955 9.04596 7.80984 9.04596 8.19684Z"
59+
fill="currentColor"
60+
/>
61+
</svg>
62+
);

plugins/play-on-spotify/index.jsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
const {
2+
plugin: { store, scoped },
3+
solidWeb: { render }
4+
} = shelter;
5+
6+
import ControlButtons from "./components/control-buttons";
7+
import * as icons from "./components/icons";
8+
import * as spotify from "./spotify";
9+
10+
import classes from "./styles.jsx.scss";
11+
12+
const ANCHOR_QUERY = `a[href^="https://open.spotify.com/"]:not([data-ioj4_pos])`;
13+
const PATCHED_ANCHOR_QUERY = `a[href^="https://open.spotify.com/"][data-ioj4_pos]`;
14+
15+
export const BUTTONS = [
16+
{
17+
storeKey: "showOpen",
18+
settingsDescription: "Show open in Spotify button",
19+
tooltip: "Open in Spotify",
20+
icon: icons.Open,
21+
action: spotify.open
22+
},
23+
{
24+
storeKey: "showQueue",
25+
settingsDescription: "Show queue button",
26+
tooltip: "Add to queue",
27+
icon: icons.Queue,
28+
action: spotify.queue,
29+
allowedTypes: ["track", "episode"]
30+
},
31+
{
32+
storeKey: "showPlay",
33+
settingsDescription: "Show play button",
34+
tooltip: "Play in Spotify",
35+
icon: icons.Play,
36+
action: spotify.play,
37+
allowedTypes: ["track", "playlist", "album", "artist"]
38+
}
39+
];
40+
41+
function blockAnchorClick(e) {
42+
if (e.target.closest(`.${classes.container}`)) {
43+
e.preventDefault();
44+
}
45+
}
46+
47+
function patchAnchor(anchor) {
48+
if (anchor.dataset.ioj4_pos) return;
49+
anchor.dataset.ioj4_pos = true;
50+
51+
const parsedURL = spotify.extractTypeAndId(anchor.href);
52+
53+
anchor.addEventListener("click", blockAnchorClick);
54+
55+
const item = {
56+
url: anchor.href,
57+
type: parsedURL?.[0]?.toLowerCase(),
58+
id: parsedURL?.[1]
59+
};
60+
render(() => <ControlButtons item={item} />, anchor);
61+
}
62+
63+
function startObservingDom() {
64+
scoped.observeDom(ANCHOR_QUERY, (anchor) => {
65+
patchAnchor(anchor);
66+
});
67+
}
68+
69+
export function onLoad() {
70+
BUTTONS.forEach((b) => (store[b.storeKey] ??= true));
71+
document.querySelectorAll(ANCHOR_QUERY).forEach(patchAnchor);
72+
startObservingDom();
73+
}
74+
75+
export function onUnload() {
76+
document.querySelectorAll(PATCHED_ANCHOR_QUERY).forEach((anchor) => {
77+
anchor.querySelector(`.${classes.container}`)?.remove();
78+
anchor.removeEventListener("click", blockAnchorClick);
79+
delete anchor.dataset.ioj4_pos;
80+
});
81+
}
82+
83+
export { default as settings } from "./settings";
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "Play On Spotify",
3+
"description": "Play, queue or open songs (and other links) directly in the Spotify app",
4+
"author": "ioj4",
5+
"hash": "<HASH_PLACEHOLDER>"
6+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { BUTTONS } from ".";
2+
3+
const {
4+
plugin: { store },
5+
ui: { SwitchItem }
6+
} = shelter;
7+
8+
export default () => {
9+
const rows = [];
10+
for (const button of BUTTONS) {
11+
rows.push(
12+
<SwitchItem
13+
value={store[button.storeKey]}
14+
onChange={(value) => {
15+
store[button.storeKey] = value;
16+
}}
17+
>
18+
{button.settingsDescription}
19+
</SwitchItem>
20+
);
21+
}
22+
return <>{rows}</>;
23+
};

plugins/play-on-spotify/spotify.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
const {
2+
flux: {
3+
stores: { SpotifyStore }
4+
},
5+
util: { awaitDispatch }
6+
} = shelter;
7+
8+
export const URL_REGEX =
9+
/^https:\/\/open\.spotify\.com\/(track|album|playlist|episode|show|artist|user)(?:\/)([a-z0-9]+).*$/i;
10+
11+
function getDeviceAndSocket() {
12+
if (!SpotifyStore.hasConnectedAccount()) {
13+
throw new Error(
14+
"No account found. Have you connected your Spotify account yet?"
15+
);
16+
}
17+
18+
const deviceAndSocket = SpotifyStore.getActiveSocketAndDevice();
19+
if (!deviceAndSocket) {
20+
throw new Error("No device found. Start Spotify and try again..");
21+
}
22+
23+
return deviceAndSocket;
24+
}
25+
26+
async function refreshAccessToken() {
27+
getDeviceAndSocket().socket.handleDeviceStateChange();
28+
return Promise.race([
29+
awaitDispatch("SPOTIFY_SET_DEVICES"),
30+
new Promise((_, reject) =>
31+
setTimeout(
32+
() => reject(new Error("Couldn't refresh Access Token!")),
33+
3_000
34+
)
35+
)
36+
]);
37+
}
38+
39+
async function spotifyRequest(
40+
method,
41+
path,
42+
searchParams,
43+
body,
44+
isRetry = false
45+
) {
46+
return new Promise((resolve, reject) => {
47+
const { device, socket } = getDeviceAndSocket();
48+
49+
const token = socket.accessToken;
50+
51+
const url = new URL(`https://api.spotify.com/v1/me/player/${path}`);
52+
53+
url.search = new URLSearchParams({
54+
device_id: device.id,
55+
...searchParams
56+
});
57+
58+
const options = {
59+
method,
60+
headers: {
61+
"Content-Type": "application/json",
62+
Authorization: `Bearer ${token}`
63+
},
64+
body: JSON.stringify(body)
65+
};
66+
fetch(url, options).then(async (res) => {
67+
if (res.ok) return resolve();
68+
if (res.status === 401 && !isRetry) {
69+
refreshAccessToken()
70+
.then(() => spotifyRequest(...arguments, true))
71+
.then(resolve, reject);
72+
return;
73+
}
74+
reject(new Error(`Spotify API request failed with ${res.status}!`));
75+
});
76+
});
77+
}
78+
79+
export function extractTypeAndId(url) {
80+
const match = URL_REGEX.exec(url);
81+
if (!match) return;
82+
return match.slice(1);
83+
}
84+
85+
export async function open({ url: urlString }) {
86+
const url = new URL(urlString);
87+
url.searchParams.delete("si"); // remove tracking parameter
88+
window.open("spotify:/" + url.pathname + url.search);
89+
}
90+
91+
export async function play({ type, id }) {
92+
const body =
93+
type === "track"
94+
? { uris: [`spotify:${type}:${id}`] }
95+
: { context_uri: `spotify:${type}:${id}` }; // playlist, album, artist
96+
97+
body.position_ms = 0;
98+
return spotifyRequest("PUT", "play", {}, body);
99+
}
100+
101+
export async function queue({ type, id }) {
102+
return spotifyRequest("POST", "queue", {
103+
uri: `spotify:${type}:${id}`
104+
});
105+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
.container {
2+
margin: 0 0.5rem 0rem 0.25rem;
3+
display: inline-flex;
4+
gap: 4px;
5+
vertical-align: bottom;
6+
background-color: var(--background-surface-higher, #28282d);
7+
border-radius: var(--radius-sm, 8px);
8+
padding: 4px;
9+
10+
> button {
11+
padding: 0;
12+
height: 16px;
13+
background-color: transparent;
14+
color: var(--interactive-normal, #aaaab1);
15+
transition: color ease 0.2s;
16+
&:hover {
17+
color: var(--interactive-hover, #fbfbfb);
18+
}
19+
}
20+
21+
&:empty {
22+
display: none;
23+
}
24+
}

static/play-on-spotify.jpg

39.2 KB
Loading

0 commit comments

Comments
 (0)