dotfiles/.config/spicetify/CustomApps/stats/extension.js

1066 lines
No EOL
43 KiB
JavaScript

(async function() {
while (!Spicetify.React || !Spicetify.ReactDOM) {
await new Promise(resolve => setTimeout(resolve, 10));
}
"use strict";
var stats = (() => {
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __publicField = (obj, key, value) => {
__defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
// external-global-plugin:react
var require_react = __commonJS({
"external-global-plugin:react"(exports, module) {
module.exports = Spicetify.React;
}
});
// src/extensions/extension.tsx
var import_react15 = __toESM(require_react());
// src/pages/playlist.tsx
var import_react12 = __toESM(require_react());
// src/components/cards/stat_card.tsx
var import_react = __toESM(require_react());
function formatValue(name, value) {
switch (name) {
case "tempo":
return `${Math.round(value)} bpm`;
case "popularity":
return `${Math.round(value)} %`;
default:
return `${Math.round(value * 100)} %`;
}
}
function normalizeString(inputString) {
return inputString.charAt(0).toUpperCase() + inputString.slice(1).toLowerCase();
}
function StatCard(props) {
const { TextComponent } = Spicetify.ReactComponent;
const { label, value } = props;
return /* @__PURE__ */ import_react.default.createElement("div", {
className: "main-card-card"
}, /* @__PURE__ */ import_react.default.createElement(TextComponent, {
as: "div",
semanticColor: "textBase",
variant: "alto",
children: typeof value === "number" ? formatValue(label, value) : value
}), /* @__PURE__ */ import_react.default.createElement(TextComponent, {
as: "div",
semanticColor: "textBase",
variant: "balladBold",
children: normalizeString(label)
}));
}
var stat_card_default = StatCard;
// src/components/cards/genres_card.tsx
var import_react2 = __toESM(require_react());
var genreLine = (name, value, limit, total) => {
return /* @__PURE__ */ import_react2.default.createElement("div", {
className: "stats-genreRow"
}, /* @__PURE__ */ import_react2.default.createElement("div", {
className: "stats-genreRowFill",
style: {
width: `calc(${value / limit * 100}% + ${(limit - value) / (limit - 1) * 100}px)`
}
}, /* @__PURE__ */ import_react2.default.createElement("span", {
className: "stats-genreText"
}, name)), /* @__PURE__ */ import_react2.default.createElement("span", {
className: "stats-genreValue"
}, Math.round(value / total * 100) + "%"));
};
var genreLines = (genres, total) => {
return genres.map(([genre, value]) => {
return genreLine(genre, value, genres[0][1], total);
});
};
var genresCard = ({ genres, total }) => {
const genresArray = genres.sort(([, a], [, b]) => b - a).slice(0, 10);
return /* @__PURE__ */ import_react2.default.createElement("div", {
className: `main-card-card stats-genreCard`
}, genreLines(genresArray, total));
};
var genres_card_default = genresCard;
// ../library/src/components/collection_menu.tsx
var import_react5 = __toESM(require_react());
// ../library/src/components/text_input_dialog.tsx
var import_react3 = __toESM(require_react());
var TextInputDialog = (props) => {
const { ButtonPrimary } = Spicetify.ReactComponent;
const { def, placeholder, onSave } = props;
const [value, setValue] = import_react3.default.useState(def);
const onSubmit = (e) => {
e.preventDefault();
Spicetify.PopupModal.hide();
onSave(value);
};
return /* @__PURE__ */ import_react3.default.createElement(import_react3.default.Fragment, null, /* @__PURE__ */ import_react3.default.createElement("form", {
className: "text-input-form",
onSubmit
}, /* @__PURE__ */ import_react3.default.createElement("label", {
className: "text-input-wrapper"
}, /* @__PURE__ */ import_react3.default.createElement("input", {
className: "text-input",
type: "text",
value,
placeholder,
onChange: (e) => setValue(e.target.value)
})), /* @__PURE__ */ import_react3.default.createElement("button", {
type: "submit",
"data-encore-id": "buttonPrimary",
className: "Button-sc-qlcn5g-0 Button-small-buttonPrimary"
}, /* @__PURE__ */ import_react3.default.createElement("span", {
className: "ButtonInner-sc-14ud5tc-0 ButtonInner-small encore-bright-accent-set"
}, "Save"))));
};
var text_input_dialog_default = TextInputDialog;
// ../library/src/components/leading_icon.tsx
var import_react4 = __toESM(require_react());
var LeadingIcon = ({ path }) => {
return /* @__PURE__ */ import_react4.default.createElement(Spicetify.ReactComponent.IconComponent, {
semanticColor: "textSubdued",
dangerouslySetInnerHTML: {
__html: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">${path}</svg>`
},
iconSize: 16
});
};
var leading_icon_default = LeadingIcon;
// ../library/src/components/collection_menu.tsx
var editIconPath = '<path d="M11.838.714a2.438 2.438 0 0 1 3.448 3.448l-9.841 9.841c-.358.358-.79.633-1.267.806l-3.173 1.146a.75.75 0 0 1-.96-.96l1.146-3.173c.173-.476.448-.909.806-1.267l9.84-9.84zm2.387 1.06a.938.938 0 0 0-1.327 0l-9.84 9.842a1.953 1.953 0 0 0-.456.716L2 14.002l1.669-.604a1.95 1.95 0 0 0 .716-.455l9.841-9.841a.938.938 0 0 0 0-1.327z"></path>';
var deleteIconPath = '<path d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8z"></path><path d="M12 8.75H4v-1.5h8v1.5z"></path>';
var CollectionMenu = ({ id }) => {
const { Menu, MenuItem } = Spicetify.ReactComponent;
const deleteCollection = () => {
SpicetifyLibrary.CollectionWrapper.deleteCollection(id);
};
const renameCollection = () => {
const name = SpicetifyLibrary.CollectionWrapper.getCollection(id).name;
const rename = (newName) => {
SpicetifyLibrary.CollectionWrapper.renameCollection(id, newName);
};
Spicetify.PopupModal.display({
title: "Rename Collection",
content: /* @__PURE__ */ import_react5.default.createElement(text_input_dialog_default, {
def: name,
onSave: rename
})
});
};
const image = SpicetifyLibrary.CollectionWrapper.getCollection(id).imgUrl;
const setCollectionImage = () => {
const setImg = (imgUrl) => {
SpicetifyLibrary.CollectionWrapper.setCollectionImage(id, imgUrl);
};
Spicetify.PopupModal.display({
title: "Set Collection Image",
content: /* @__PURE__ */ import_react5.default.createElement(text_input_dialog_default, {
def: image,
placeholder: "Image URL",
onSave: setImg
})
});
};
const removeImage = () => {
SpicetifyLibrary.CollectionWrapper.removeCollectionImage(id);
};
return /* @__PURE__ */ import_react5.default.createElement(Menu, null, /* @__PURE__ */ import_react5.default.createElement(MenuItem, {
leadingIcon: /* @__PURE__ */ import_react5.default.createElement(leading_icon_default, {
path: editIconPath
}),
onClick: renameCollection
}, "Rename"), /* @__PURE__ */ import_react5.default.createElement(MenuItem, {
leadingIcon: /* @__PURE__ */ import_react5.default.createElement(leading_icon_default, {
path: deleteIconPath
}),
onClick: deleteCollection
}, "Delete"), /* @__PURE__ */ import_react5.default.createElement(MenuItem, {
leadingIcon: /* @__PURE__ */ import_react5.default.createElement(leading_icon_default, {
path: editIconPath
}),
onClick: setCollectionImage
}, "Set Collection Image"), image && /* @__PURE__ */ import_react5.default.createElement(MenuItem, {
leadingIcon: /* @__PURE__ */ import_react5.default.createElement(leading_icon_default, {
path: deleteIconPath
}),
onClick: removeImage
}, "Remove Collection Image"));
};
var collection_menu_default = CollectionMenu;
// ../library/src/components/folder_menu.tsx
var import_react6 = __toESM(require_react());
var editIconPath2 = '<path d="M11.838.714a2.438 2.438 0 0 1 3.448 3.448l-9.841 9.841c-.358.358-.79.633-1.267.806l-3.173 1.146a.75.75 0 0 1-.96-.96l1.146-3.173c.173-.476.448-.909.806-1.267l9.84-9.84zm2.387 1.06a.938.938 0 0 0-1.327 0l-9.84 9.842a1.953 1.953 0 0 0-.456.716L2 14.002l1.669-.604a1.95 1.95 0 0 0 .716-.455l9.841-9.841a.938.938 0 0 0 0-1.327z"></path>';
var deleteIconPath2 = '<path d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8z"></path><path d="M12 8.75H4v-1.5h8v1.5z"></path>';
var FolderMenu = ({ uri }) => {
const { MenuItem, Menu } = Spicetify.ReactComponent;
const image = SpicetifyLibrary.FolderImageWrapper.getFolderImage(uri);
const setImage = () => {
const setNewImage = (newUrl) => {
SpicetifyLibrary.FolderImageWrapper.setFolderImage({ uri, url: newUrl });
};
Spicetify.PopupModal.display({
title: "Set Folder Image",
content: /* @__PURE__ */ import_react6.default.createElement(text_input_dialog_default, {
def: image,
onSave: setNewImage
})
});
};
const removeImage = () => {
SpicetifyLibrary.FolderImageWrapper.removeFolderImage(uri);
};
return /* @__PURE__ */ import_react6.default.createElement(Menu, null, /* @__PURE__ */ import_react6.default.createElement(MenuItem, {
leadingIcon: /* @__PURE__ */ import_react6.default.createElement(leading_icon_default, {
path: editIconPath2
}),
onClick: setImage
}, "Set Folder Image"), image && /* @__PURE__ */ import_react6.default.createElement(MenuItem, {
leadingIcon: /* @__PURE__ */ import_react6.default.createElement(leading_icon_default, {
path: deleteIconPath2
}),
onClick: removeImage
}, "Remove Folder Image"));
};
var folder_menu_default = FolderMenu;
// ../shared/components/spotify_card.tsx
var import_react8 = __toESM(require_react());
// ../shared/components/folder_fallback.tsx
var import_react7 = __toESM(require_react());
var FolderSVG = (e) => {
return /* @__PURE__ */ import_react7.default.createElement(Spicetify.ReactComponent.IconComponent, {
semanticColor: "textSubdued",
viewBox: "0 0 24 24",
size: "xxlarge",
dangerouslySetInnerHTML: {
__html: '<path d="M1 4a2 2 0 0 1 2-2h5.155a3 3 0 0 1 2.598 1.5l.866 1.5H21a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V4zm7.155 0H3v16h18V7H10.464L9.021 4.5a1 1 0 0 0-.866-.5z"/>'
},
...e
});
};
var folder_fallback_default = FolderSVG;
// ../shared/components/spotify_card.tsx
function SpotifyCard(props) {
const { Cards, TextComponent, ArtistMenu, AlbumMenu, PodcastShowMenu, PlaylistMenu, ContextMenu } = Spicetify.ReactComponent;
const { FeatureCard: Card, CardImage } = Cards;
const { createHref, push } = Spicetify.Platform.History;
const { type, header, uri, imageUrl, subheader, artistUri } = props;
const backupImageUrl = type === "folder" || type === "collection" ? "https://raw.githubusercontent.com/harbassan/spicetify-apps/main/shared/placeholders/folder_placeholder.png" : "https://raw.githubusercontent.com/harbassan/spicetify-apps/main/shared/placeholders/def_placeholder.png";
const Menu = () => {
switch (type) {
case "artist":
return /* @__PURE__ */ import_react8.default.createElement(ArtistMenu, {
uri
});
case "album":
return /* @__PURE__ */ import_react8.default.createElement(AlbumMenu, {
uri,
artistUri,
canRemove: true
});
case "playlist":
return /* @__PURE__ */ import_react8.default.createElement(PlaylistMenu, {
uri
});
case "show":
return /* @__PURE__ */ import_react8.default.createElement(PodcastShowMenu, {
uri
});
case "collection":
return /* @__PURE__ */ import_react8.default.createElement(collection_menu_default, {
id: uri
});
case "folder":
return /* @__PURE__ */ import_react8.default.createElement(folder_menu_default, {
uri
});
default:
return /* @__PURE__ */ import_react8.default.createElement(import_react8.default.Fragment, null);
}
};
const lastfmProps = type === "lastfm" ? { onClick: () => window.open(uri, "_blank"), isPlayable: false, delegateNavigation: true } : {};
const folderProps = type === "folder" ? {
delegateNavigation: true,
onClick: () => {
createHref({ pathname: `/library/folder/${uri}` });
push({ pathname: `/library/folder/${uri}` });
}
} : {};
const collectionProps = type === "collection" ? {
delegateNavigation: true,
onClick: () => {
createHref({ pathname: `/library/collection/${uri}` });
push({ pathname: `/library/collection/${uri}` });
}
} : {};
return /* @__PURE__ */ import_react8.default.createElement(ContextMenu, {
menu: Menu(),
trigger: "right-click"
}, /* @__PURE__ */ import_react8.default.createElement(Card, {
featureIdentifier: type,
headerText: header,
renderCardImage: () => /* @__PURE__ */ import_react8.default.createElement(CardImage, {
images: [
{
height: 640,
url: imageUrl,
width: 640
}
],
isCircular: type === "artist",
FallbackComponent: folder_fallback_default
}),
renderSubHeaderContent: () => /* @__PURE__ */ import_react8.default.createElement(TextComponent, {
as: "div",
variant: "mesto",
semanticColor: "textSubdued",
children: subheader
}),
uri,
...lastfmProps,
...folderProps,
...collectionProps
}));
}
var spotify_card_default = SpotifyCard;
// ../shared/components/status.tsx
var import_react9 = __toESM(require_react());
var ErrorIcon = () => {
return /* @__PURE__ */ import_react9.default.createElement("svg", {
"data-encore-id": "icon",
role: "img",
"aria-hidden": "true",
viewBox: "0 0 24 24",
className: "status-icon"
}, /* @__PURE__ */ import_react9.default.createElement("path", {
d: "M11 18v-2h2v2h-2zm0-4V6h2v8h-2z"
}), /* @__PURE__ */ import_react9.default.createElement("path", {
d: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM1 12C1 5.925 5.925 1 12 1s11 4.925 11 11-4.925 11-11 11S1 18.075 1 12z"
}));
};
var LibraryIcon = () => {
return /* @__PURE__ */ import_react9.default.createElement("svg", {
role: "img",
height: "46",
width: "46",
"aria-hidden": "true",
viewBox: "0 0 24 24",
"data-encore-id": "icon",
className: "status-icon"
}, /* @__PURE__ */ import_react9.default.createElement("path", {
d: "M14.5 2.134a1 1 0 0 1 1 0l6 3.464a1 1 0 0 1 .5.866V21a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1V3a1 1 0 0 1 .5-.866zM16 4.732V20h4V7.041l-4-2.309zM3 22a1 1 0 0 1-1-1V3a1 1 0 0 1 2 0v18a1 1 0 0 1-1 1zm6 0a1 1 0 0 1-1-1V3a1 1 0 0 1 2 0v18a1 1 0 0 1-1 1z"
}));
};
var Status = (props) => {
const [isVisible, setIsVisible] = import_react9.default.useState(false);
import_react9.default.useEffect(() => {
const to = setTimeout(() => {
setIsVisible(true);
}, 500);
return () => clearTimeout(to);
}, []);
return isVisible ? /* @__PURE__ */ import_react9.default.createElement(import_react9.default.Fragment, null, /* @__PURE__ */ import_react9.default.createElement("div", {
className: "loadingWrapper"
}, props.icon === "error" ? /* @__PURE__ */ import_react9.default.createElement(ErrorIcon, null) : /* @__PURE__ */ import_react9.default.createElement(LibraryIcon, null), /* @__PURE__ */ import_react9.default.createElement("h1", null, props.heading), /* @__PURE__ */ import_react9.default.createElement("h3", null, props.subheading))) : /* @__PURE__ */ import_react9.default.createElement(import_react9.default.Fragment, null);
};
var status_default = Status;
// src/components/inline_grid.tsx
var import_react10 = __toESM(require_react());
function scrollGrid(event) {
const { target } = event;
if (!(target instanceof HTMLElement))
return;
const grid = target.parentNode?.querySelector("div");
if (!grid)
return;
grid.scrollLeft += grid.clientWidth;
if (grid.scrollWidth - grid.clientWidth - grid.scrollLeft <= grid.clientWidth) {
grid.setAttribute("data-scroll", "end");
} else {
grid.setAttribute("data-scroll", "both");
}
}
function scrollGridLeft(event) {
const { target } = event;
if (!(target instanceof HTMLElement))
return;
const grid = target.parentNode?.querySelector("div");
if (!grid)
return;
grid.scrollLeft -= grid.clientWidth;
if (grid.scrollLeft <= grid.clientWidth) {
grid.setAttribute("data-scroll", "start");
} else {
grid.setAttribute("data-scroll", "both");
}
}
function InlineGrid(props) {
const { children, special } = props;
return /* @__PURE__ */ import_react10.default.createElement("section", {
className: "stats-gridInlineSection"
}, /* @__PURE__ */ import_react10.default.createElement("button", {
className: "stats-scrollButton",
onClick: scrollGridLeft
}, "<"), /* @__PURE__ */ import_react10.default.createElement("button", {
className: "stats-scrollButton",
onClick: scrollGrid
}, ">"), /* @__PURE__ */ import_react10.default.createElement("div", {
className: `main-gridContainer-gridContainer stats-gridInline${special ? " stats-specialGrid" : ""}`,
"data-scroll": "start"
}, children));
}
var inline_grid_default = import_react10.default.memo(InlineGrid);
// src/components/shelf.tsx
var import_react11 = __toESM(require_react());
function Shelf(props) {
const { TextComponent } = Spicetify.ReactComponent;
const { title, children } = props;
return /* @__PURE__ */ import_react11.default.createElement("section", {
className: "main-shelf-shelf Shelf"
}, /* @__PURE__ */ import_react11.default.createElement("div", {
className: "main-shelf-header"
}, /* @__PURE__ */ import_react11.default.createElement("div", {
className: "main-shelf-topRow"
}, /* @__PURE__ */ import_react11.default.createElement("div", {
className: "main-shelf-titleWrapper"
}, /* @__PURE__ */ import_react11.default.createElement(TextComponent, {
children: title,
as: "h2",
variant: "canon",
semanticColor: "textBase"
})))), /* @__PURE__ */ import_react11.default.createElement("section", null, children));
}
var shelf_default = import_react11.default.memo(Shelf);
// src/endpoints.ts
var SPOTIFY = {
toptracks: (range) => `https://api.spotify.com/v1/me/top/tracks?limit=50&offset=0&time_range=${range}`,
topartists: (range) => `https://api.spotify.com/v1/me/top/artists?limit=50&offset=0&time_range=${range}`,
artists: (artists) => `https://api.spotify.com/v1/artists?ids=${filter(artists)}`,
rootlist: "sp://core-playlist/v1/rootlist",
playlist: (uri) => `sp://core-playlist/v1/playlist/${uri}`,
search: (track, artist) => `https://api.spotify.com/v1/search?q=track:${filter(track)}+artist:${filter(artist)}&type=track`,
searchartist: (artist) => `https://api.spotify.com/v1/search?q=artist:${filter(artist)}&type=artist`,
searchalbum: (album, artist) => `https://api.spotify.com/v1/search?q=${filter(album)}+artist:${filter(artist)}&type=album`,
audiofeatures: (ids) => `https://api.spotify.com/v1/audio-features?ids=${ids}`,
queryliked: (ids) => `https://api.spotify.com/v1/me/tracks/contains?ids=${ids}`
};
var PLACEHOLDER = "https://raw.githubusercontent.com/harbassan/spicetify-apps/main/stats/src/styles/placeholder.png";
// src/funcs.ts
function filter(str) {
const normalizedStr = str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
return normalizedStr.replace(/[^a-zA-Z0-9\-._~:/?#[\]@!$&()*+,;= ]/g, "").replace(/ /g, "+");
}
var apiRequest = async (name, url, timeout = 5, log = true) => {
try {
const timeStart = window.performance.now();
const response = await Spicetify.CosmosAsync.get(url);
if (log)
console.log("stats -", name, "fetch time:", window.performance.now() - timeStart);
return response;
} catch (e) {
if (timeout === 0) {
console.log("stats -", name, "all requests failed:", e);
console.log("stats -", name, "giving up");
return null;
} else {
if (timeout === 5) {
console.log("stats -", name, "request failed:", e);
console.log("stats -", name, "retrying...");
}
await new Promise((resolve) => setTimeout(resolve, 5e3));
return apiRequest(name, url, timeout - 1);
}
}
};
var fetchAudioFeatures = async (ids) => {
const batchSize = 100;
const batches = [];
ids = ids.filter((id) => id.match(/^[a-zA-Z0-9]{22}$/));
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize);
batches.push(batch);
}
const promises = batches.map((batch, index) => {
return apiRequest(`audioFeaturesBatch${index}`, SPOTIFY.audiofeatures(batch.join(",")), 5, false);
});
const responses = await Promise.all(promises);
const data = responses.reduce((acc, response) => {
if (!response?.audio_features)
return acc;
return acc.concat(response.audio_features);
}, []);
return data;
};
var fetchTopAlbums = async (albums, cachedAlbums) => {
let album_keys = Object.keys(albums).filter((id) => id.match(/^[a-zA-Z0-9]{22}$/)).sort((a, b) => albums[b] - albums[a]).slice(0, 100);
let release_years = {};
let total_album_tracks = 0;
const cachedAlbumsSet = new Set(cachedAlbums?.map((album) => album.uri));
let top_albums = await Promise.all(
album_keys.map(async (albumID) => {
let albumMeta;
if (cachedAlbums && cachedAlbumsSet.has(`spotify:album:${albumID}`)) {
albumMeta = cachedAlbums.find((album) => album.uri === `spotify:album:${albumID}`);
}
if (!albumMeta) {
try {
albumMeta = await Spicetify.GraphQL.Request(Spicetify.GraphQL.Definitions.getAlbum, {
uri: `spotify:album:${albumID}`,
locale: "en",
offset: 0,
limit: 50
});
if (!albumMeta?.data?.albumUnion?.name)
throw new Error("Invalid URI");
} catch (e) {
console.error("stats - album metadata request failed:", e);
return;
}
}
const releaseYear = albumMeta?.release_year || albumMeta.data.albumUnion.date.isoString.slice(0, 4);
release_years[releaseYear] = (release_years[releaseYear] || 0) + albums[albumID];
total_album_tracks += albums[albumID];
return {
name: albumMeta.name || albumMeta.data.albumUnion.name,
uri: albumMeta.uri || albumMeta.data.albumUnion.uri,
image: albumMeta.image || albumMeta.data.albumUnion.coverArt.sources[0]?.url || PLACEHOLDER,
release_year: releaseYear,
freq: albums[albumID]
};
})
);
top_albums = top_albums.filter((el) => el != null).slice(0, 10);
return [top_albums, Object.entries(release_years), total_album_tracks];
};
var fetchTopArtists = async (artists) => {
if (Object.keys(artists).length === 0)
return [[], [], 0];
let artist_keys = Object.keys(artists).filter((id) => id.match(/^[a-zA-Z0-9]{22}$/)).sort((a, b) => artists[b] - artists[a]).slice(0, 50);
let genres = {};
let total_genre_tracks = 0;
const artistsMeta = await apiRequest("artistsMetadata", SPOTIFY.artists(artist_keys.join(",")));
let top_artists = artistsMeta?.artists?.map((artist) => {
if (!artist)
return null;
artist.genres.forEach((genre) => {
genres[genre] = (genres[genre] || 0) + artists[artist.id];
});
total_genre_tracks += artists[artist.id];
return {
name: artist.name,
uri: artist.uri,
image: artist.images[2]?.url || PLACEHOLDER,
freq: artists[artist.id]
};
});
top_artists = top_artists.filter((el) => el != null).slice(0, 10);
const top_genres = Object.entries(genres).sort((a, b) => b[1] - a[1]).slice(0, 10);
return [top_artists, top_genres, total_genre_tracks];
};
// src/pages/playlist.tsx
var PlaylistPage = ({ uri }) => {
const { ReactComponent, ReactQuery, Platform, _platform } = Spicetify;
const { History, ReduxStore } = Platform;
const { QueryClientProvider, QueryClient } = ReactQuery;
const { Router, Route, Routes, PlatformProvider, StoreProvider } = ReactComponent;
const [library, setLibrary] = import_react12.default.useState(100);
const fetchData = async () => {
const start = window.performance.now();
const playlistMeta = await apiRequest("playlistMeta", SPOTIFY.playlist(uri));
if (!playlistMeta) {
setLibrary(200);
return;
}
let duration = playlistMeta.playlist.duration;
let trackCount = playlistMeta.playlist.length;
let explicitCount = 0;
let trackIDs = [];
let popularity = 0;
let albums = {};
let artists = {};
playlistMeta.items.forEach((track) => {
popularity += track.popularity;
trackIDs.push(track.link.split(":")[2]);
if (track.isExplicit)
explicitCount++;
const albumID = track.album.link.split(":")[2];
albums[albumID] = albums[albumID] ? albums[albumID] + 1 : 1;
track.artists.forEach((artist) => {
const artistID = artist.link.split(":")[2];
artists[artistID] = artists[artistID] ? artists[artistID] + 1 : 1;
});
});
const [topAlbums, releaseYears, releaseYearsTotal] = await fetchTopAlbums(albums);
const [topArtists, topGenres, topGenresTotal] = await fetchTopArtists(artists);
const fetchedFeatures = await fetchAudioFeatures(trackIDs);
let audioFeatures = {
danceability: 0,
energy: 0,
valence: 0,
speechiness: 0,
acousticness: 0,
instrumentalness: 0,
liveness: 0,
tempo: 0
};
for (let i = 0; i < fetchedFeatures.length; i++) {
if (!fetchedFeatures[i])
continue;
const track = fetchedFeatures[i];
Object.keys(audioFeatures).forEach((feature) => {
audioFeatures[feature] += track[feature];
});
}
audioFeatures = {
popularity,
explicitness: explicitCount,
...audioFeatures
};
for (let key in audioFeatures) {
audioFeatures[key] /= fetchedFeatures.length;
}
const stats2 = {
audioFeatures,
trackCount,
totalDuration: duration,
artistCount: Object.keys(artists).length,
artists: topArtists,
genres: topGenres,
genresDenominator: topGenresTotal,
albums: topAlbums,
years: releaseYears,
yearsDenominator: releaseYearsTotal
};
setLibrary(stats2);
console.log("total playlist stats fetch time:", window.performance.now() - start);
};
import_react12.default.useEffect(() => {
fetchData();
}, []);
switch (library) {
case 200:
return /* @__PURE__ */ import_react12.default.createElement(status_default, {
icon: "error",
heading: "Failed to Fetch Stats",
subheading: "Make an issue on Github"
});
case 100:
return /* @__PURE__ */ import_react12.default.createElement(status_default, {
icon: "library",
heading: "Analysing the Playlist",
subheading: "This may take a while"
});
}
const statCards = Object.entries(library.audioFeatures).map(([key, value]) => {
return /* @__PURE__ */ import_react12.default.createElement(stat_card_default, {
label: key,
value
});
});
const artistCards = library.artists.map((artist) => {
return /* @__PURE__ */ import_react12.default.createElement(spotify_card_default, {
type: "artist",
uri: artist.uri,
header: artist.name,
subheader: `Appears in ${artist.freq} tracks`,
imageUrl: artist.image
});
});
const albumCards = library.albums.map((album) => {
return /* @__PURE__ */ import_react12.default.createElement(spotify_card_default, {
type: "album",
uri: album.uri,
header: album.name,
subheader: `Appears in ${album.freq} tracks`,
imageUrl: album.image
});
});
return /* @__PURE__ */ import_react12.default.createElement("div", {
id: "stats-app",
className: "page-content encore-dark-theme encore-base-set"
}, /* @__PURE__ */ import_react12.default.createElement("section", {
className: "stats-libraryOverview"
}, /* @__PURE__ */ import_react12.default.createElement(stat_card_default, {
label: "Total Tracks",
value: library.trackCount.toString()
}), /* @__PURE__ */ import_react12.default.createElement(stat_card_default, {
label: "Total Artists",
value: library.artistCount.toString()
}), /* @__PURE__ */ import_react12.default.createElement(stat_card_default, {
label: "Total Minutes",
value: Math.floor(library.totalDuration / 60).toString()
}), /* @__PURE__ */ import_react12.default.createElement(stat_card_default, {
label: "Total Hours",
value: (library.totalDuration / (60 * 60)).toFixed(1)
})), /* @__PURE__ */ import_react12.default.createElement(shelf_default, {
title: "Most Frequent Genres"
}, /* @__PURE__ */ import_react12.default.createElement(genres_card_default, {
genres: library.genres,
total: library.genresDenominator
}), /* @__PURE__ */ import_react12.default.createElement(inline_grid_default, {
special: true
}, statCards)), /* @__PURE__ */ import_react12.default.createElement(shelf_default, {
title: "Release Year Distribution"
}, /* @__PURE__ */ import_react12.default.createElement(genres_card_default, {
genres: library.years,
total: library.yearsDenominator
})));
};
var playlist_default = import_react12.default.memo(PlaylistPage);
// package.json
var version = "0.3.3";
// ../shared/config/config_wrapper.tsx
var import_react14 = __toESM(require_react());
// ../shared/config/config_modal.tsx
var import_react13 = __toESM(require_react());
var TextInput = (props) => {
const handleTextChange = (event) => {
props.callback(event.target.value);
};
return /* @__PURE__ */ import_react13.default.createElement("label", {
className: "text-input-wrapper"
}, /* @__PURE__ */ import_react13.default.createElement("input", {
className: "text-input",
type: "text",
value: props.value || "",
"data-storage-key": props.storageKey,
placeholder: props.placeholder,
id: `text-input:${props.storageKey}`,
title: `Text input for ${props.storageKey}`,
onChange: handleTextChange
}));
};
var Dropdown = (props) => {
const handleDropdownChange = (event) => {
props.callback(event.target.value);
};
return /* @__PURE__ */ import_react13.default.createElement("label", {
className: "dropdown-wrapper"
}, /* @__PURE__ */ import_react13.default.createElement("select", {
className: "dropdown-input",
value: props.value,
"data-storage-key": props.storageKey,
id: `dropdown:${props.storageKey}`,
title: `Dropdown for ${props.storageKey}`,
onChange: handleDropdownChange
}, props.options.map((option, index) => /* @__PURE__ */ import_react13.default.createElement("option", {
key: index,
value: option
}, option))));
};
var ToggleInput = (props) => {
const { Toggle } = Spicetify.ReactComponent;
const handleToggleChange = (newValue) => {
props.callback(newValue);
};
return /* @__PURE__ */ import_react13.default.createElement(Toggle, {
id: `toggle:${props.storageKey}`,
value: props.value,
onSelected: (newValue) => handleToggleChange(newValue)
});
};
var SliderInput = (props) => {
const { Slider } = Spicetify.ReactComponent;
const handleSliderChange = (newValue) => {
const calculatedValue = props.min + newValue * (props.max - props.min);
props.callback(calculatedValue);
};
const value = (props.value - props.min) / (props.max - props.min);
return /* @__PURE__ */ import_react13.default.createElement(Slider, {
id: `slider:${props.storageKey}`,
value,
min: 0,
max: 1,
step: 0.1,
onDragMove: (newValue) => handleSliderChange(newValue),
onDragStart: () => {
},
onDragEnd: () => {
}
});
};
var TooltipIcon = () => {
return /* @__PURE__ */ import_react13.default.createElement("svg", {
role: "img",
height: "16",
width: "16",
className: "Svg-sc-ytk21e-0 uPxdw nW1RKQOkzcJcX6aDCZB4",
viewBox: "0 0 16 16"
}, /* @__PURE__ */ import_react13.default.createElement("path", {
d: "M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8z"
}), /* @__PURE__ */ import_react13.default.createElement("path", {
d: "M7.25 12.026v-1.5h1.5v1.5h-1.5zm.884-7.096A1.125 1.125 0 007.06 6.39l-1.431.448a2.625 2.625 0 115.13-.784c0 .54-.156 1.015-.503 1.488-.3.408-.7.652-.973.818l-.112.068c-.185.116-.26.203-.302.283-.046.087-.097.245-.097.57h-1.5c0-.47.072-.898.274-1.277.206-.385.507-.645.827-.846l.147-.092c.285-.177.413-.257.526-.41.169-.23.213-.397.213-.602 0-.622-.503-1.125-1.125-1.125z"
}));
};
var ConfigRow = (props) => {
return /* @__PURE__ */ import_react13.default.createElement("div", {
className: "setting-row"
}, /* @__PURE__ */ import_react13.default.createElement("label", {
className: "col description"
}, props.name, props.desc && /* @__PURE__ */ import_react13.default.createElement(Spicetify.ReactComponent.TooltipWrapper, {
label: /* @__PURE__ */ import_react13.default.createElement("div", {
dangerouslySetInnerHTML: { __html: props.desc }
}),
renderInline: true,
showDelay: 10,
placement: "top",
labelClassName: "tooltip",
disabled: false
}, /* @__PURE__ */ import_react13.default.createElement("div", {
className: "tooltip-icon"
}, /* @__PURE__ */ import_react13.default.createElement(TooltipIcon, null)))), /* @__PURE__ */ import_react13.default.createElement("div", {
className: "col action"
}, props.children));
};
var ConfigModal = (props) => {
const { config, structure, appKey, updateAppConfig } = props;
const [modalConfig, setModalConfig] = import_react13.default.useState({ ...config });
const modalRows = structure.map((modalRow, index) => {
const key = modalRow.key;
const currentValue = modalConfig[key];
const updateItem = (state) => {
console.debug(`toggling ${key} to ${state}`);
localStorage.setItem(`${appKey}:config:${key}`, String(state));
if (modalRow.callback)
modalRow.callback(state);
const newConfig = { ...modalConfig };
newConfig[key] = state;
updateAppConfig(newConfig);
setModalConfig(newConfig);
};
const header = modalRow.sectionHeader;
const element = () => {
switch (modalRow.type) {
case "toggle":
return /* @__PURE__ */ import_react13.default.createElement(ToggleInput, {
storageKey: key,
value: currentValue,
callback: updateItem
});
case "text":
return /* @__PURE__ */ import_react13.default.createElement(TextInput, {
storageKey: key,
value: currentValue,
callback: updateItem
});
case "dropdown":
return /* @__PURE__ */ import_react13.default.createElement(Dropdown, {
storageKey: key,
value: currentValue,
options: modalRow.options,
callback: updateItem
});
case "slider":
return /* @__PURE__ */ import_react13.default.createElement(SliderInput, {
storageKey: key,
value: currentValue,
min: modalRow.min,
max: modalRow.max,
step: modalRow.step,
callback: updateItem
});
}
};
return /* @__PURE__ */ import_react13.default.createElement(import_react13.default.Fragment, null, header && index !== 0 && /* @__PURE__ */ import_react13.default.createElement("br", null), header && /* @__PURE__ */ import_react13.default.createElement("h2", {
className: "section-header"
}, modalRow.sectionHeader), /* @__PURE__ */ import_react13.default.createElement(ConfigRow, {
name: modalRow.name,
desc: modalRow.desc
}, element()));
});
return /* @__PURE__ */ import_react13.default.createElement("div", {
className: "config-container"
}, modalRows);
};
var config_modal_default = ConfigModal;
// ../shared/config/config_wrapper.tsx
var _ConfigWrapper = class {
Config;
launchModal;
constructor(modalStructure, key) {
const config = modalStructure.map((modalStructureRow) => {
const value = _ConfigWrapper.getLocalStorageDataFromKey(
`${key}:config:${modalStructureRow.key}`,
modalStructureRow.def
);
modalStructureRow.callback?.(value);
return { [modalStructureRow.key]: value };
});
this.Config = Object.assign({}, ...config);
this.launchModal = (callback) => {
const updateConfig = (config2) => {
this.Config = { ...config2 };
callback?.(config2);
};
Spicetify.PopupModal.display({
title: `${key.charAt(0).toUpperCase() + key.slice(1)} Settings`,
content: /* @__PURE__ */ import_react14.default.createElement(config_modal_default, {
config: this.Config,
structure: modalStructure,
appKey: key,
updateAppConfig: updateConfig
}),
isLarge: true
});
};
}
};
var ConfigWrapper = _ConfigWrapper;
__publicField(ConfigWrapper, "getLocalStorageDataFromKey", (key, fallback) => {
const data = localStorage.getItem(key);
if (data) {
try {
return JSON.parse(data);
} catch (err) {
return data;
}
} else {
return fallback;
}
});
var config_wrapper_default = ConfigWrapper;
// src/extensions/extension.tsx
var SpicetifyStats = class {
ConfigWrapper = new config_wrapper_default(
[
{
name: "Last.fm Api Key",
key: "api-key",
type: "text",
def: null,
placeholder: "Enter API Key",
desc: `You can get this by visiting www.last.fm/api/account/create and simply entering any name.<br/>You'll need to make an account first, which is a plus.`,
sectionHeader: "Last.fm Integration"
},
{
name: "Last.fm Username",
key: "lastfm-user",
type: "text",
def: null,
placeholder: "Enter Username"
},
{
name: "Use Last.fm for Stats",
key: "use-lastfm",
type: "toggle",
def: false,
desc: `Last.fm charts your stats purely based on the streaming count, whereas Spotify factors in other variables`
},
{
name: "Artists Page",
key: "show-artists",
type: "toggle",
def: true,
sectionHeader: "Pages"
},
{ name: "Tracks Page", key: "show-tracks", type: "toggle", def: true },
{
name: "Albums Page",
key: "show-albums",
type: "toggle",
def: false,
desc: `Requires Last.fm API key and username`
},
{ name: "Genres Page", key: "show-genres", type: "toggle", def: true },
{ name: "Library Page", key: "show-library", type: "toggle", def: true },
{
name: "Charts Page",
key: "show-charts",
type: "toggle",
def: true,
desc: `Requires Last.fm API key`
}
],
"stats"
);
};
window.SpicetifyStats = new SpicetifyStats();
(function stats() {
const {
PopupModal,
LocalStorage,
Topbar,
Platform: { History }
} = Spicetify;
if (!PopupModal || !LocalStorage || !Topbar || !History) {
setTimeout(stats, 300);
return;
}
const version2 = localStorage.getItem("stats:version");
if (!version2 || version2 !== version) {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith("stats:") && !key.startsWith("stats:config:")) {
localStorage.removeItem(key);
}
}
localStorage.setItem("stats:version", version);
}
LocalStorage.set("stats:cache-info", JSON.stringify([0, 0, 0, 0, 0, 0]));
const styleLink = document.createElement("link");
styleLink.rel = "stylesheet";
styleLink.href = "/spicetify-routes-stats.css";
document.head.appendChild(styleLink);
const playlistEdit = new Topbar.Button("playlist-stats", "visualizer", () => {
const playlistUri = `spotify:playlist:${History.location.pathname.split("/")[2]}`;
PopupModal.display({ title: "Playlist Stats", content: /* @__PURE__ */ import_react15.default.createElement(playlist_default, {
uri: playlistUri
}), isLarge: true });
});
playlistEdit.element.classList.toggle("hidden", true);
function setTopbarButtonVisibility(pathname) {
const [, type, uid] = pathname.split("/");
const isPlaylistPage = type === "playlist" && uid;
playlistEdit.element.classList.toggle("hidden", !isPlaylistPage);
}
setTopbarButtonVisibility(History.location.pathname);
History.listen(({ pathname }) => {
setTopbarButtonVisibility(pathname);
});
})();
})();
})();