add: profile connections, spotify connection

This commit is contained in:
trisua 2025-04-26 16:27:18 -04:00
parent a5c2356940
commit 33ba576d4a
31 changed files with 931 additions and 19 deletions

View file

@ -0,0 +1,40 @@
{% extends "auth/base.html" %} {% block head %}
<title>Connection</title>
{% endblock %} {% block title %}Connection{% endblock %} {% block content %}
<div class="w-full flex-col gap-2" id="status"><b>Working...</b></div>
{% if connection_type == "Spotify" and user and user.connections.Spotify and
config.connections.spotify_client_id %}
<script>
setTimeout(async () => {
const code = new URLSearchParams(window.location.search).get("code");
const client_id = "{{ config.connections.spotify_client_id }}";
const verifier = "{{ user.connections.Spotify[0].data.verifier }}";
if (!code) {
alert("Connection failed (did not get code from Spotify)");
return;
}
const [token, refresh_token, expires_in] = await trigger(
"spotify::get_token",
[client_id, verifier, code],
);
const profile = await trigger("spotify::profile", [token]);
const { message } = await trigger("connections::push_con_data", [
"Spotify",
{
token,
refresh_token,
expires_in: expires_in.toString(),
name: profile.display_name,
},
]);
document.getElementById("status").innerHTML =
`<b>${message}.</b> You can now close this tab.`;
}, 150);
</script>
{% endif %} {% endblock %}

View file

@ -801,4 +801,50 @@ secondary=false, show_community=true) -%}
</div>
</div>
</div>
{%- endmacro %}
{%- endmacro %} {% macro spotify_playing(state, size="60px") -%} {% if state and
state.data %}
<div class="card-nest">
<div class="card flex items-center justify-between gap-2 small">
<div class="flex items-center gap-2">
<b>Listening on</b>
{{ icon "spotify" }}
</div>
<span class="fade date short">{{ state.data.timestamp }}</span>
</div>
<div class="card secondary flex gap-2">
<a href="{{ state.external_urls.album }}">
<img
src="{{ state.external_urls.album_img }}"
alt="Album cover"
loading="lazy"
class="avatar"
style="--size: {{ size }}"
/>
</a>
<div class="flex flex-col">
<h5 class="w-full">
<a href="{{ state.external_urls.track }}" class="flush"
>{{ state.data.track }}</a
>
</h5>
<span class="fade"
><a href="{{ state.external_urls.artist }}" class="flush"
>{{ state.data.artist }}</a
></span
>
<span
hook="spotify_time_text"
hook-arg:updated="{{ state.data.timestamp }}"
hook-arg:progress="{{ state.data.progress_ms }}"
hook-arg:duration="{{ state.data.duration_ms }}"
hook-arg:display="full"
></span>
</div>
</div>
</div>
{% endif %} {%- endmacro %}

View file

@ -105,6 +105,13 @@
</div>
<div class="card flex flex-col gap-2">
<!-- prettier-ignore -->
<div style="display: contents;">
{% if profile.connections.Spotify and profile.connections.Spotify[0].data.name and profile.connections.Spotify[0].show_on_profile %}
{{ components::spotify_playing(state=profile.connections.Spotify[1]) }}
{% endif %}
</div>
<div class="w-full flex justify-between items-center">
<span class="notification chip">ID</span>
<button

View file

@ -29,6 +29,11 @@
{{ icon "cookie" }}
<span>{{ text "settings:tab.sessions" }}</span>
</a>
<a data-tab-button="connections" href="#/connections">
{{ icon "cable" }}
<span>{{ text "settings:tab.connections" }}</span>
</a>
</div>
<div class="w-full flex flex-col gap-2" data-tab="account">
@ -496,6 +501,46 @@
</button>
</div>
<div
class="card w-full tertiary hidden flex flex-col gap-2"
data-tab="connections"
>
<div class="card w-full flex flex-wrap gap-2">
{% if config.connections.spotify_client_id and not
user.connections.Spotify %}
<button
class="quaternary"
onclick="trigger('spotify::create_connection', ['{{ config.connections.spotify_client_id }}'])"
>
{{ icon "spotify" }}
<span>Spotify</span>
</button>
{% endif %}
</div>
{% for key, value in user.connections %}
<div class="card-nest">
<div class="card small flex items-center gap-2">
{% if key == "Spotify" %} {{ icon "spotify" }} {% endif %}
<b>
{% if value[0].data.name %} {{ value[0].data.name }} {% else
%} {{ key }} {% endif %}
</b>
</div>
<div class="card flex items-center gap-2">
<button
class="quaternary red small"
onclick="trigger('connections::delete', ['{{ key }}'])"
>
{{ text "general:action.delete" }}
</button>
</div>
</div>
{% endfor %}
</div>
<!-- prettier-ignore -->
<script type="application/json" id="settings_json">{{ user_settings_serde|safe }}</script>

View file

@ -9,7 +9,7 @@ macros -%}
<meta
http-equiv="content-security-policy"
content="default-src 'self' blob:; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' blob: *; script-src 'self' 'unsafe-inline' blob: *; object-src 'self' blob: *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: data: *"
content="default-src 'self' blob: *.spotify.com; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' blob: *; script-src 'self' 'unsafe-inline' blob: *; object-src 'self' blob: *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: data: *"
/>
<link rel="icon" href="/public/favicon.svg" />
@ -111,6 +111,7 @@ macros -%}
atto["hooks::check_reactions"]();
atto["hooks::tabs"]();
atto["hooks::partial_embeds"]();
atto["hooks::spotify_time_text"](); // spotify durations
if (document.getElementById("tokens")) {
trigger("me::render_token_picker", [
@ -325,6 +326,61 @@ macros -%}
trigger("atto::use_theme_preference");
}, 150);
</script>
{% endif %} {% if user and user.connections.Spotify and
config.connections.spotify_client_id and
user.connections.Spotify[0].data.token and
user.connections.Spotify[0].data.refresh_token %}
<script>
setTimeout(async () => {
if (window.spotify_init) {
return;
}
window.spotify_init = true;
const client_id = "{{ config.connections.spotify_client_id }}";
let token = "{{ user.connections.Spotify[0].data.token }}";
let refresh_token =
"{{ user.connections.Spotify[0].data.refresh_token }}";
if (token) {
// we already have a token
const pull_playing = async () => {
const playing = await trigger("spotify::get_playing", [
token,
]);
if (playing.error) {
// refresh token
const [new_token, new_refresh_token, expires_in] =
await trigger("spotify::refresh_token", [
client_id,
refresh_token,
]);
await trigger("connections::push_con_data", [
"Spotify",
{
token: new_token,
expires_in: expires_in.toString(),
name: profile.display_name,
},
]);
token = new_token;
refresh_token = new_refresh_token;
return;
}
await trigger("spotify::publish_playing", [playing]);
};
await pull_playing();
setInterval(pull_playing, 30_000);
} else {
window.spotify_needs_token = true;
}
}, 150);
</script>
{% endif %}
</body>
</html>

View file

@ -0,0 +1,2 @@
<!-- https://developer.spotify.com/documentation/design#using-our-logo -->
<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 236.05 225.25"><defs><style>.cls-1{fill:#1ed760;stroke-width:0px;}</style></defs><path class="cls-1" d="m122.37,3.31C61.99.91,11.1,47.91,8.71,108.29c-2.4,60.38,44.61,111.26,104.98,113.66,60.38,2.4,111.26-44.6,113.66-104.98C229.74,56.59,182.74,5.7,122.37,3.31Zm46.18,160.28c-1.36,2.4-4.01,3.6-6.59,3.24-.79-.11-1.58-.37-2.32-.79-14.46-8.23-30.22-13.59-46.84-15.93-16.62-2.34-33.25-1.53-49.42,2.4-3.51.85-7.04-1.3-7.89-4.81-.85-3.51,1.3-7.04,4.81-7.89,17.78-4.32,36.06-5.21,54.32-2.64,18.26,2.57,35.58,8.46,51.49,17.51,3.13,1.79,4.23,5.77,2.45,8.91Zm14.38-28.72c-2.23,4.12-7.39,5.66-11.51,3.43-16.92-9.15-35.24-15.16-54.45-17.86-19.21-2.7-38.47-1.97-57.26,2.16-1.02.22-2.03.26-3.01.12-3.41-.48-6.33-3.02-7.11-6.59-1.01-4.58,1.89-9.11,6.47-10.12,20.77-4.57,42.06-5.38,63.28-2.4,21.21,2.98,41.46,9.62,60.16,19.74,4.13,2.23,5.66,7.38,3.43,11.51Zm15.94-32.38c-2.1,4.04-6.47,6.13-10.73,5.53-1.15-.16-2.28-.52-3.37-1.08-19.7-10.25-40.92-17.02-63.07-20.13-22.15-3.11-44.42-2.45-66.18,1.97-5.66,1.15-11.17-2.51-12.32-8.16-1.15-5.66,2.51-11.17,8.16-12.32,24.1-4.89,48.74-5.62,73.25-2.18,24.51,3.44,47.99,10.94,69.81,22.29,5.12,2.66,7.11,8.97,4.45,14.09Z"/></svg>

View file

@ -130,7 +130,10 @@ media_theme_pref();
let pretty = $.rel_date(then);
if (screen.width < 900 && pretty !== undefined) {
if (
(screen.width < 900 && pretty !== undefined) |
element.classList.contains("short")
) {
// shorten dates even more for mobile
pretty = pretty
.replaceAll(" minutes ago", "m")
@ -381,6 +384,45 @@ media_theme_pref();
}
});
self.define("hooks::spotify_time_text", (_) => {
for (const element of Array.from(
document.querySelectorAll("[hook=spotify_time_text]") || [],
)) {
function render() {
const updated = element.getAttribute("hook-arg:updated");
const progress = element.getAttribute("hook-arg:progress");
const duration = element.getAttribute("hook-arg:duration");
const display =
element.getAttribute("hook-arg:display") || "full";
element.innerHTML = trigger("spotify::timestamp", [
updated,
progress,
duration,
display,
]);
}
setInterval(() => {
element.setAttribute(
"hook-arg:updated",
Number.parseInt(element.getAttribute("hook-arg:updated")) +
1000,
);
element.setAttribute(
"hook-arg:progress",
Number.parseInt(element.getAttribute("hook-arg:progress")) +
1000,
);
render();
}, 1000);
render();
}
});
self.define("last_seen_just_now", (_, last_seen) => {
const now = new Date().getTime();
const maximum_time_to_be_considered_online = 60000 * 2; // 2 minutes
@ -999,7 +1041,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
// open page
if (warning_page !== "") {
window.location.href = warning_page;
Turbo.visit(warning_page);
return;
}
},

View file

@ -279,3 +279,298 @@
document.getElementById("tokens_dialog").showModal();
});
})();
(() => {
const self = reg_ns("connections");
self.define("pkce_verifier", async (_, length) => {
let text = "";
const possible =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (let i = 0; i < length; i++) {
text += possible.charAt(
Math.floor(Math.random() * possible.length),
);
}
return text;
});
self.define("pkce_challenge", async (_, verifier) => {
const data = new TextEncoder().encode(verifier);
const digest = await window.crypto.subtle.digest("SHA-256", data);
return btoa(
String.fromCharCode.apply(null, [...new Uint8Array(digest)]),
)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
});
self.define("delete", async (_, connection) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you want to do this?",
]))
) {
return;
}
fetch(`/api/v1/auth/user/connections/${connection}`, {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
});
self.define("push_con_data", async (_, connection, data) => {
return await (
await fetch("/api/v1/auth/user/connections/_data", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
connection,
data,
}),
})
).json();
});
self.define("push_con_state", async (_, connection, data) => {
return await (
await fetch("/api/v1/auth/user/connections/_state", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
connection,
data,
}),
})
).json();
});
self.define("push_con_shown", async (_, connection, shown) => {
return await (
await fetch("/api/v1/auth/user/connections/_shown", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
connection,
shown,
}),
})
).json();
});
})();
(() => {
const self = reg_ns("spotify");
self.define("create_connection", (_, client_id) => {
fetch("/api/v1/auth/user/connections/spotify", {
method: "POST",
})
.then((res) => res.json())
.then(async (res) => {
// create challenge and store
const verifier = await trigger("connections::pkce_verifier", [
128,
]);
const challenge = await trigger("connections::pkce_challenge", [
verifier,
]);
await trigger("connections::push_con_data", [
"Spotify",
{
verifier,
challenge,
},
]);
// ...
const params = new URLSearchParams();
params.append("client_id", client_id);
params.append("response_type", "code");
params.append(
"redirect_uri",
`${window.location.origin}/auth/connections_link/Spotify`,
);
params.append(
"scope",
"user-read-recently-played user-modify-playback-state user-read-playback-state user-read-email",
);
params.append("code_challenge_method", "S256");
params.append("code_challenge", challenge);
window.open(
`https://accounts.spotify.com/authorize?${params.toString()}`,
);
window.location.reload();
});
});
self.define("get_token", async (_, client_id, verifier, code) => {
const params = new URLSearchParams();
params.append("client_id", client_id);
params.append("grant_type", "authorization_code");
params.append("code", code);
params.append(
"redirect_uri",
`${window.location.origin}/auth/connections_link/Spotify`,
);
params.append("code_verifier", verifier);
const result = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params,
});
const { access_token, refresh_token, expires_in } = await result.json();
return [access_token, refresh_token, expires_in];
});
self.define("refresh_token", async (_, client_id, token) => {
const params = new URLSearchParams();
params.append("client_id", client_id);
params.append("grant_type", "refresh_token");
params.append("refresh_token", token);
const result = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params,
});
const { access_token, refresh_token, expires_in } = await result.json();
return [access_token, refresh_token, expires_in];
});
self.define("profile", async (_, token) => {
return await (
await fetch("https://api.spotify.com/v1/me", {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
})
).json();
});
self.define("get_playing", async (_, token) => {
// <https://developer.spotify.com/documentation/web-api/reference/get-information-about-the-users-current-playback>
return await (
await fetch("https://api.spotify.com/v1/me/player", {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
})
).json();
});
self.define("publish_playing", async (_, playing) => {
if (!playing.is_playing) {
return await trigger("connections::push_con_state", [
"Spotify",
{
external_urls: {},
data: {},
},
]);
}
if (playing.item.is_local) {
return;
}
if (
window.localStorage.getItem("atto:connections.spotify/id") ===
playing.item.id
) {
// item already pushed to connection, no need right now
return;
}
window.localStorage.setItem(
"atto:connections.spotify/id",
playing.item.id,
);
return await trigger("connections::push_con_state", [
"Spotify",
{
external_urls: {
track: playing.item.external_urls.spotify,
artist: playing.item.artists[0].external_urls.spotify,
album: playing.item.album.external_urls.spotify,
album_img: playing.item.album.images[0].url,
},
data: {
id: playing.item.id,
// track
track: playing.item.name,
artist: playing.item.artists[0].name,
album: playing.item.album.name,
// image
// img_w: playing.item.album.images[0].width.toString(),
// img_h: playing.item.album.images[0].width.toString(),
// times
timestamp: playing.timestamp.toString(),
progress_ms: playing.progress_ms.toString(),
duration_ms: playing.item.duration_ms.toString(),
},
},
]);
});
self.define("ms_time_text", (_, ms) => {
const minutes = Math.floor(ms / 60000);
const seconds = ((ms % 60000) / 1000).toFixed(0);
return `${minutes}:${(seconds < 10 ? "0" : "") + seconds}`;
});
self.define(
"timestamp",
({ $ }, updated_, progress_ms_, duration_ms_, display = "full") => {
const now = new Date().getTime();
const updated = Number.parseInt(updated_) + 8000;
const elapsed_since_update = now - updated;
const progress_ms =
Number.parseInt(progress_ms_) + elapsed_since_update;
const duration_ms = Number.parseInt(duration_ms_);
if (progress_ms > duration_ms) {
// song is over
return "";
}
if (display === "full") {
return `${$.ms_time_text(progress_ms)}/${$.ms_time_text(duration_ms)} <span class="fade">(${Math.floor((progress_ms / duration_ms) * 100)}%)</span>`;
}
if (display === "left") {
return $.ms_time_text(progress_ms);
}
return $.ms_time_text(duration_ms);
},
);
})();