summaryrefslogtreecommitdiff
path: root/dot-config/spicetify/CustomApps/stats
diff options
context:
space:
mode:
authorlittleblack111 <littleblack11111@gmail.com>2024-06-03 19:56:39 +0800
committerlittleblack111 <littleblack11111@gmail.com>2024-06-03 19:56:39 +0800
commitca968c4e5040f1ff4b5b5e12d9312360d807dcc1 (patch)
tree9900920e8f566794560c1519678e344351d2803b /dot-config/spicetify/CustomApps/stats
parent5f3deaff0bf006a1a353e57b82ad48c0e8f143bc (diff)
added submodules, change . -> dot (see man stow --dotfiles)
Diffstat (limited to 'dot-config/spicetify/CustomApps/stats')
-rw-r--r--dot-config/spicetify/CustomApps/stats/extension.css76
-rw-r--r--dot-config/spicetify/CustomApps/stats/extension.js1066
-rw-r--r--dot-config/spicetify/CustomApps/stats/index.js2484
-rw-r--r--dot-config/spicetify/CustomApps/stats/manifest.json9
-rw-r--r--dot-config/spicetify/CustomApps/stats/style.css276
5 files changed, 3911 insertions, 0 deletions
diff --git a/dot-config/spicetify/CustomApps/stats/extension.css b/dot-config/spicetify/CustomApps/stats/extension.css
new file mode 100644
index 00000000..7534af3f
--- /dev/null
+++ b/dot-config/spicetify/CustomApps/stats/extension.css
@@ -0,0 +1,76 @@
+/* ../../../AppData/Local/Temp/tmp-15744-866d0PjWRxih/18d53b11b111/config_modal.css */
+#config-container {
+ gap: 10px;
+ display: flex;
+ flex-direction: column;
+}
+#config-container .section-header {
+ box-sizing: border-box;
+ -webkit-tap-highlight-color: transparent;
+ margin-block: 0px;
+ font-size: 1.125rem;
+ font-weight: 700;
+ color: var(--spice-text);
+}
+#config-container .col.description {
+ box-sizing: border-box;
+ -webkit-tap-highlight-color: transparent;
+ margin-block: 0px;
+ font-size: 0.875rem;
+ font-weight: 400;
+ color: var(--spice-subtext);
+}
+#config-container .disabled {
+ opacity: 0;
+ pointer-events: none;
+}
+#config-container .text-input {
+ background: rgba(var(--spice-rgb-selected-row), 0.1);
+ border: 1px solid transparent;
+ border-radius: 4px;
+ color: var(--spice-text);
+ font-family: inherit;
+ font-size: 14px;
+ height: 32px;
+ padding: 0 12px;
+ width: 100%;
+}
+#config-container .text-input:focus {
+ background-color: var(--spice-tab-active);
+ border: 1px solid var(--spice-button-disabled);
+ outline: none;
+}
+#config-container .dropdown-input {
+ background-color: var(--spice-tab-active);
+ border: 0;
+ border-radius: 4px;
+ color: rgba(var(--spice-rgb-selected-row), 0.7);
+ font-size: 14px;
+ font-weight: 400;
+ height: 32px;
+ letter-spacing: 0.24px;
+ line-height: 20px;
+ padding: 0 32px 0 12px;
+ width: 100%;
+}
+#config-container .tooltip-icon {
+ float: right;
+ margin-left: 10px;
+ display: flex;
+ align-items: center;
+ height: 22px;
+ fill: var(--spice-subtext);
+}
+#config-container .tooltip-icon:hover {
+ fill: var(--spice-text);
+}
+#config-container .tooltip {
+ text-align: center;
+}
+#config-container .setting-row {
+ display: flex;
+ justify-content: space-between;
+}
+#config-container .playback-progressbar {
+ width: 200px;
+}
diff --git a/dot-config/spicetify/CustomApps/stats/extension.js b/dot-config/spicetify/CustomApps/stats/extension.js
new file mode 100644
index 00000000..ef6499cd
--- /dev/null
+++ b/dot-config/spicetify/CustomApps/stats/extension.js
@@ -0,0 +1,1066 @@
+(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);
+ });
+ })();
+})();
+
+ })(); \ No newline at end of file
diff --git a/dot-config/spicetify/CustomApps/stats/index.js b/dot-config/spicetify/CustomApps/stats/index.js
new file mode 100644
index 00000000..56fcfc30
--- /dev/null
+++ b/dot-config/spicetify/CustomApps/stats/index.js
@@ -0,0 +1,2484 @@
+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 __commonJS = (cb, mod) => function __require() {
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
+ };
+ var __export = (target, all) => {
+ for (var name in all)
+ __defProp(target, name, { get: all[name], enumerable: true });
+ };
+ 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 __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
+
+ // external-global-plugin:react
+ var require_react = __commonJS({
+ "external-global-plugin:react"(exports, module) {
+ module.exports = Spicetify.React;
+ }
+ });
+
+ // external-global-plugin:react-dom
+ var require_react_dom = __commonJS({
+ "external-global-plugin:react-dom"(exports, module) {
+ module.exports = Spicetify.ReactDOM;
+ }
+ });
+
+ // ../../../AppData/Local/Temp/spicetify-creator/index.jsx
+ var spicetify_creator_exports = {};
+ __export(spicetify_creator_exports, {
+ default: () => render
+ });
+
+ // src/app.tsx
+ var import_react29 = __toESM(require_react());
+
+ // ../node_modules/spcr-navigation-bar/useNavigationBar.tsx
+ var import_react3 = __toESM(require_react());
+
+ // ../node_modules/spcr-navigation-bar/navBar.tsx
+ var import_react2 = __toESM(require_react());
+ var import_react_dom = __toESM(require_react_dom());
+
+ // ../node_modules/spcr-navigation-bar/optionsMenu.tsx
+ var import_react = __toESM(require_react());
+ var OptionsMenuItemIcon = /* @__PURE__ */ import_react.default.createElement("svg", {
+ width: 16,
+ height: 16,
+ viewBox: "0 0 16 16",
+ fill: "currentColor"
+ }, /* @__PURE__ */ import_react.default.createElement("path", {
+ d: "M13.985 2.383L5.127 12.754 1.388 8.375l-.658.77 4.397 5.149 9.618-11.262z"
+ }));
+ var OptionsMenuItem = import_react.default.memo((props) => {
+ return /* @__PURE__ */ import_react.default.createElement(Spicetify.ReactComponent.MenuItem, {
+ onClick: props.onSelect,
+ icon: props.isSelected ? OptionsMenuItemIcon : null
+ }, props.value);
+ });
+ var OptionsMenu = import_react.default.memo((props) => {
+ const menuRef = import_react.default.useRef(null);
+ const menu = /* @__PURE__ */ import_react.default.createElement(Spicetify.ReactComponent.Menu, null, props.options.map(
+ (option) => /* @__PURE__ */ import_react.default.createElement(OptionsMenuItem, {
+ value: option.link,
+ isSelected: option.isActive,
+ onSelect: () => {
+ props.onSelect(option.link);
+ menuRef.current?.click();
+ }
+ })
+ ));
+ return /* @__PURE__ */ import_react.default.createElement(Spicetify.ReactComponent.ContextMenu, {
+ menu,
+ trigger: "click",
+ action: "toggle",
+ renderInLine: true
+ }, /* @__PURE__ */ import_react.default.createElement("button", {
+ className: navBar_module_default.optionsMenuDropBox,
+ ref: menuRef
+ }, /* @__PURE__ */ import_react.default.createElement("span", {
+ className: props.bold ? "main-type-mestoBold" : "main-type-mesto"
+ }, props.options.find((o) => o.isActive)?.link || props.defaultValue), /* @__PURE__ */ import_react.default.createElement("svg", {
+ width: 16,
+ height: 16,
+ viewBox: "0 0 16 16",
+ fill: "currentColor"
+ }, /* @__PURE__ */ import_react.default.createElement("path", {
+ d: "M3 6l5 5.794L13 6z"
+ }))));
+ });
+ var optionsMenu_default = OptionsMenu;
+
+ // postcss-module:C:\Users\user\AppData\Local\Temp\tmp-17120-n3iWRzissBjg\18f3cbbb4bc3\navBar.module.css
+ var navBar_module_default = { "topBarHeaderItem": "navBar-module__topBarHeaderItem___piw4C_stats", "topBarHeaderItemLink": "navBar-module__topBarHeaderItemLink___xA4uv_stats", "topBarActive": "navBar-module__topBarActive___XhWpm_stats", "topBarNav": "navBar-module__topBarNav___qWGeZ_stats", "optionsMenuDropBox": "navBar-module__optionsMenuDropBox___pzfNI_stats" };
+
+ // ../node_modules/spcr-navigation-bar/navBar.tsx
+ var NavbarItem2 = class {
+ constructor(link, isActive) {
+ this.link = link;
+ this.isActive = isActive;
+ }
+ };
+ var NavbarItemComponent = (props) => {
+ return /* @__PURE__ */ import_react2.default.createElement("li", {
+ className: navBar_module_default.topBarHeaderItem,
+ onClick: (e) => {
+ e.preventDefault();
+ props.switchTo(props.item.link);
+ }
+ }, /* @__PURE__ */ import_react2.default.createElement("a", {
+ className: `${navBar_module_default.topBarHeaderItemLink} ${props.item.isActive ? navBar_module_default.topBarActive : ""}`,
+ "aria-current": "page",
+ draggable: false,
+ href: ""
+ }, /* @__PURE__ */ import_react2.default.createElement("span", {
+ className: "main-type-mestoBold"
+ }, props.item.link)));
+ };
+ var NavbarMore = import_react2.default.memo(({ items, switchTo }) => {
+ return /* @__PURE__ */ import_react2.default.createElement("li", {
+ className: `${navBar_module_default.topBarHeaderItem} ${items.find((item) => item.isActive) ? navBar_module_default.topBarActive : ""}`
+ }, /* @__PURE__ */ import_react2.default.createElement(optionsMenu_default, {
+ options: items,
+ onSelect: switchTo,
+ defaultValue: "More",
+ bold: true
+ }));
+ });
+ var NavbarContent = (props) => {
+ const resizeHost = document.querySelector(".Root__main-view .os-resize-observer-host") ?? document.querySelector(".Root__main-view .os-size-observer");
+ const [windowSize, setWindowSize] = (0, import_react2.useState)(resizeHost.clientWidth);
+ const resizeHandler = () => setWindowSize(resizeHost.clientWidth);
+ (0, import_react2.useEffect)(() => {
+ const observer = new ResizeObserver(resizeHandler);
+ observer.observe(resizeHost);
+ return () => {
+ observer.disconnect();
+ };
+ }, [resizeHandler]);
+ return /* @__PURE__ */ import_react2.default.createElement(NavbarContext, null, /* @__PURE__ */ import_react2.default.createElement(Navbar, {
+ ...props,
+ windowSize
+ }));
+ };
+ var NavbarContext = (props) => {
+ return import_react_dom.default.createPortal(
+ /* @__PURE__ */ import_react2.default.createElement("div", {
+ className: "main-topbar-topbarContent"
+ }, props.children),
+ document.querySelector(".main-topBar-topbarContentWrapper")
+ );
+ };
+ var Navbar = (props) => {
+ const navBarListRef = import_react2.default.useRef(null);
+ const [childrenSizes, setChildrenSizes] = (0, import_react2.useState)([]);
+ const [availableSpace, setAvailableSpace] = (0, import_react2.useState)(0);
+ const [outOfRangeItemIndexes, setOutOfRangeItemIndexes] = (0, import_react2.useState)([]);
+ let items = props.links.map((link) => new NavbarItem2(link, link === props.activeLink));
+ (0, import_react2.useEffect)(() => {
+ if (!navBarListRef.current)
+ return;
+ const children = Array.from(navBarListRef.current.children);
+ const navBarItemSizes = children.map((child) => child.clientWidth);
+ setChildrenSizes(navBarItemSizes);
+ }, []);
+ (0, import_react2.useEffect)(() => {
+ if (!navBarListRef.current)
+ return;
+ setAvailableSpace(navBarListRef.current.clientWidth);
+ }, [props.windowSize]);
+ (0, import_react2.useEffect)(() => {
+ if (!navBarListRef.current)
+ return;
+ let totalSize = childrenSizes.reduce((a, b) => a + b, 0);
+ if (totalSize <= availableSpace) {
+ setOutOfRangeItemIndexes([]);
+ return;
+ }
+ const viewMoreButtonSize = Math.max(...childrenSizes);
+ const itemsToHide = [];
+ let stopWidth = viewMoreButtonSize;
+ childrenSizes.forEach((childWidth, i) => {
+ if (availableSpace >= stopWidth + childWidth) {
+ stopWidth += childWidth;
+ } else if (i !== items.length) {
+ itemsToHide.push(i);
+ }
+ });
+ setOutOfRangeItemIndexes(itemsToHide);
+ }, [availableSpace, childrenSizes]);
+ return /* @__PURE__ */ import_react2.default.createElement("nav", {
+ className: navBar_module_default.topBarNav
+ }, /* @__PURE__ */ import_react2.default.createElement("ul", {
+ className: navBar_module_default.topBarHeader,
+ ref: navBarListRef
+ }, items.filter((_, id) => !outOfRangeItemIndexes.includes(id)).map(
+ (item) => /* @__PURE__ */ import_react2.default.createElement(NavbarItemComponent, {
+ item,
+ switchTo: props.switchCallback
+ })
+ ), outOfRangeItemIndexes.length ? /* @__PURE__ */ import_react2.default.createElement(NavbarMore, {
+ items: outOfRangeItemIndexes.map((i) => items[i]),
+ switchTo: props.switchCallback
+ }) : null));
+ };
+ var navBar_default = NavbarContent;
+
+ // ../node_modules/spcr-navigation-bar/useNavigationBar.tsx
+ var useNavigationBar = (links) => {
+ const [activeLink, setActiveLink] = (0, import_react3.useState)(links[0]);
+ const navbar = /* @__PURE__ */ import_react3.default.createElement(navBar_default, {
+ links,
+ activeLink,
+ switchCallback: (link) => setActiveLink(link)
+ });
+ return [navbar, activeLink, setActiveLink];
+ };
+ var useNavigationBar_default = useNavigationBar;
+
+ // src/pages/top_artists.tsx
+ var import_react17 = __toESM(require_react());
+
+ // ../shared/dropdown/useDropdownMenu.tsx
+ var import_react5 = __toESM(require_react());
+
+ // ../shared/dropdown/dropdown.tsx
+ var import_react4 = __toESM(require_react());
+ function CheckIcon() {
+ return /* @__PURE__ */ import_react4.default.createElement(Spicetify.ReactComponent.IconComponent, {
+ iconSize: 16,
+ semanticColor: "textBase",
+ dangerouslySetInnerHTML: {
+ __html: '<svg xmlns="http://www.w3.org/2000/svg"><path d="M15.53 2.47a.75.75 0 0 1 0 1.06L4.907 14.153.47 9.716a.75.75 0 0 1 1.06-1.06l3.377 3.376L14.47 2.47a.75.75 0 0 1 1.06 0z"/></svg>'
+ }
+ });
+ }
+ var MenuItem = (props) => {
+ const { ReactComponent } = Spicetify;
+ const { option, isActive, switchCallback } = props;
+ const activeStyle = {
+ backgroundColor: "rgba(var(--spice-rgb-selected-row),.1)"
+ };
+ return /* @__PURE__ */ import_react4.default.createElement(ReactComponent.MenuItem, {
+ trigger: "click",
+ onClick: () => switchCallback(option),
+ "data-checked": isActive,
+ trailingIcon: isActive ? /* @__PURE__ */ import_react4.default.createElement(CheckIcon, null) : void 0,
+ style: isActive ? activeStyle : void 0
+ }, option.name);
+ };
+ var DropdownMenu = (props) => {
+ const { ContextMenu, Menu, TextComponent } = Spicetify.ReactComponent;
+ const { options, activeOption, switchCallback } = props;
+ const optionItems = options.map((option) => {
+ return /* @__PURE__ */ import_react4.default.createElement(MenuItem, {
+ option,
+ isActive: option === activeOption,
+ switchCallback
+ });
+ });
+ const MenuWrapper2 = (props2) => {
+ return /* @__PURE__ */ import_react4.default.createElement(Menu, {
+ ...props2
+ }, optionItems);
+ };
+ return /* @__PURE__ */ import_react4.default.createElement(ContextMenu, {
+ menu: /* @__PURE__ */ import_react4.default.createElement(MenuWrapper2, null),
+ trigger: "click"
+ }, /* @__PURE__ */ import_react4.default.createElement("button", {
+ className: "x-sortBox-sortDropdown",
+ type: "button",
+ role: "combobox",
+ "aria-expanded": "false"
+ }, /* @__PURE__ */ import_react4.default.createElement(TextComponent, {
+ variant: "mesto",
+ semanticColor: "textSubdued"
+ }, activeOption.name), /* @__PURE__ */ import_react4.default.createElement("svg", {
+ role: "img",
+ height: "16",
+ width: "16",
+ "aria-hidden": "true",
+ className: "Svg-img-16 Svg-img-16-icon Svg-img-icon Svg-img-icon-small",
+ viewBox: "0 0 16 16",
+ "data-encore-id": "icon"
+ }, /* @__PURE__ */ import_react4.default.createElement("path", {
+ d: "m14 6-6 6-6-6h12z"
+ }))));
+ };
+ var dropdown_default = DropdownMenu;
+
+ // ../shared/dropdown/useDropdownMenu.tsx
+ var useDropdownMenu = (options, storageVariable) => {
+ const initialOptionID = storageVariable && Spicetify.LocalStorage.get(`${storageVariable}:active-option`);
+ const initialOption = initialOptionID && options.find((e) => e.id === initialOptionID);
+ const [activeOption, setActiveOption] = (0, import_react5.useState)(initialOption || options[0]);
+ const [availableOptions, setAvailableOptions] = (0, import_react5.useState)(options);
+ const dropdown = /* @__PURE__ */ import_react5.default.createElement(dropdown_default, {
+ options: availableOptions,
+ activeOption,
+ switchCallback: (option) => {
+ setActiveOption(option);
+ if (storageVariable)
+ Spicetify.LocalStorage.set(`${storageVariable}:active-option`, option.id);
+ }
+ });
+ return [dropdown, activeOption, setActiveOption, setAvailableOptions];
+ };
+ var useDropdownMenu_default = useDropdownMenu;
+
+ // ../library/src/components/collection_menu.tsx
+ var import_react8 = __toESM(require_react());
+
+ // ../library/src/components/text_input_dialog.tsx
+ var import_react6 = __toESM(require_react());
+ var TextInputDialog = (props) => {
+ const { ButtonPrimary } = Spicetify.ReactComponent;
+ const { def, placeholder, onSave } = props;
+ const [value, setValue] = import_react6.default.useState(def);
+ const onSubmit = (e) => {
+ e.preventDefault();
+ Spicetify.PopupModal.hide();
+ onSave(value);
+ };
+ return /* @__PURE__ */ import_react6.default.createElement(import_react6.default.Fragment, null, /* @__PURE__ */ import_react6.default.createElement("form", {
+ className: "text-input-form",
+ onSubmit
+ }, /* @__PURE__ */ import_react6.default.createElement("label", {
+ className: "text-input-wrapper"
+ }, /* @__PURE__ */ import_react6.default.createElement("input", {
+ className: "text-input",
+ type: "text",
+ value,
+ placeholder,
+ onChange: (e) => setValue(e.target.value)
+ })), /* @__PURE__ */ import_react6.default.createElement("button", {
+ type: "submit",
+ "data-encore-id": "buttonPrimary",
+ className: "Button-sc-qlcn5g-0 Button-small-buttonPrimary"
+ }, /* @__PURE__ */ import_react6.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_react7 = __toESM(require_react());
+ var LeadingIcon = ({ path }) => {
+ return /* @__PURE__ */ import_react7.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: MenuItem2 } = 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_react8.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_react8.default.createElement(text_input_dialog_default, {
+ def: image,
+ placeholder: "Image URL",
+ onSave: setImg
+ })
+ });
+ };
+ const removeImage = () => {
+ SpicetifyLibrary.CollectionWrapper.removeCollectionImage(id);
+ };
+ return /* @__PURE__ */ import_react8.default.createElement(Menu, null, /* @__PURE__ */ import_react8.default.createElement(MenuItem2, {
+ leadingIcon: /* @__PURE__ */ import_react8.default.createElement(leading_icon_default, {
+ path: editIconPath
+ }),
+ onClick: renameCollection
+ }, "Rename"), /* @__PURE__ */ import_react8.default.createElement(MenuItem2, {
+ leadingIcon: /* @__PURE__ */ import_react8.default.createElement(leading_icon_default, {
+ path: deleteIconPath
+ }),
+ onClick: deleteCollection
+ }, "Delete"), /* @__PURE__ */ import_react8.default.createElement(MenuItem2, {
+ leadingIcon: /* @__PURE__ */ import_react8.default.createElement(leading_icon_default, {
+ path: editIconPath
+ }),
+ onClick: setCollectionImage
+ }, "Set Collection Image"), image && /* @__PURE__ */ import_react8.default.createElement(MenuItem2, {
+ leadingIcon: /* @__PURE__ */ import_react8.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_react9 = __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: MenuItem2, 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_react9.default.createElement(text_input_dialog_default, {
+ def: image,
+ onSave: setNewImage
+ })
+ });
+ };
+ const removeImage = () => {
+ SpicetifyLibrary.FolderImageWrapper.removeFolderImage(uri);
+ };
+ return /* @__PURE__ */ import_react9.default.createElement(Menu, null, /* @__PURE__ */ import_react9.default.createElement(MenuItem2, {
+ leadingIcon: /* @__PURE__ */ import_react9.default.createElement(leading_icon_default, {
+ path: editIconPath2
+ }),
+ onClick: setImage
+ }, "Set Folder Image"), image && /* @__PURE__ */ import_react9.default.createElement(MenuItem2, {
+ leadingIcon: /* @__PURE__ */ import_react9.default.createElement(leading_icon_default, {
+ path: deleteIconPath2
+ }),
+ onClick: removeImage
+ }, "Remove Folder Image"));
+ };
+ var folder_menu_default = FolderMenu;
+
+ // ../shared/components/spotify_card.tsx
+ var import_react11 = __toESM(require_react());
+
+ // ../shared/components/folder_fallback.tsx
+ var import_react10 = __toESM(require_react());
+ var FolderSVG = (e) => {
+ return /* @__PURE__ */ import_react10.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_react11.default.createElement(ArtistMenu, {
+ uri
+ });
+ case "album":
+ return /* @__PURE__ */ import_react11.default.createElement(AlbumMenu, {
+ uri,
+ artistUri,
+ canRemove: true
+ });
+ case "playlist":
+ return /* @__PURE__ */ import_react11.default.createElement(PlaylistMenu, {
+ uri
+ });
+ case "show":
+ return /* @__PURE__ */ import_react11.default.createElement(PodcastShowMenu, {
+ uri
+ });
+ case "collection":
+ return /* @__PURE__ */ import_react11.default.createElement(collection_menu_default, {
+ id: uri
+ });
+ case "folder":
+ return /* @__PURE__ */ import_react11.default.createElement(folder_menu_default, {
+ uri
+ });
+ default:
+ return /* @__PURE__ */ import_react11.default.createElement(import_react11.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_react11.default.createElement(ContextMenu, {
+ menu: Menu(),
+ trigger: "right-click"
+ }, /* @__PURE__ */ import_react11.default.createElement(Card, {
+ featureIdentifier: type,
+ headerText: header,
+ renderCardImage: () => /* @__PURE__ */ import_react11.default.createElement(CardImage, {
+ images: [
+ {
+ height: 640,
+ url: imageUrl,
+ width: 640
+ }
+ ],
+ isCircular: type === "artist",
+ FallbackComponent: folder_fallback_default
+ }),
+ renderSubHeaderContent: () => /* @__PURE__ */ import_react11.default.createElement(TextComponent, {
+ as: "div",
+ variant: "mesto",
+ semanticColor: "textSubdued",
+ children: subheader
+ }),
+ uri,
+ ...lastfmProps,
+ ...folderProps,
+ ...collectionProps
+ }));
+ }
+ var spotify_card_default = SpotifyCard;
+
+ // src/endpoints.ts
+ var lfmperiods = {
+ short_term: "1month",
+ medium_term: "6month",
+ long_term: "overall"
+ };
+ var LASTFM = {
+ toptracks: (user, key, range) => `http://ws.audioscrobbler.com/2.0/?method=user.gettoptracks&user=${user}&api_key=${key}&format=json&period=${lfmperiods[range]}`,
+ topalbums: (user, key, range) => `https://ws.audioscrobbler.com/2.0/?method=user.gettopalbums&user=${user}&api_key=${key}&format=json&period=${lfmperiods[range]}`,
+ topartists: (user, key, range) => `https://ws.audioscrobbler.com/2.0/?method=user.gettopartists&user=${user}&api_key=${key}&format=json&period=${lfmperiods[range]}`,
+ charts: (key, type) => `http://ws.audioscrobbler.com/2.0/?method=chart.gettop${type}&api_key=${key}&format=json`
+ };
+ 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 updatePageCache = (i, callback, activeOption, lib = false) => {
+ let cacheInfo = Spicetify.LocalStorage.get("stats:cache-info");
+ if (!cacheInfo)
+ return;
+ let cacheInfoArray = JSON.parse(cacheInfo);
+ if (!cacheInfoArray[i]) {
+ if (!lib) {
+ ["short_term", "medium_term", "long_term"].filter((option) => option !== activeOption).forEach((option) => callback(option, true, false));
+ }
+ if (lib === "charts") {
+ ["artists", "tracks"].filter((option) => option !== activeOption).forEach((option) => callback(option, true, false));
+ }
+ callback(activeOption, true);
+ cacheInfoArray[i] = true;
+ Spicetify.LocalStorage.set("stats:cache-info", JSON.stringify(cacheInfoArray));
+ }
+ };
+ 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];
+ };
+ var convertTrackData = async (data) => {
+ return await Promise.all(
+ data.map(async (item) => {
+ const spotifyItem = await Spicetify.CosmosAsync.get(SPOTIFY.search(item.name, item.artist.name)).then(
+ (res) => res.tracks?.items[0]
+ );
+ if (!spotifyItem) {
+ console.log(`couldn't find track: ${item.name} by ${item.artist.name}`);
+ return {
+ name: item.name,
+ image: PLACEHOLDER,
+ uri: item.url,
+ artists: [{ name: item.artist.name, uri: item.artist.url }],
+ duration: 0,
+ album: "N/A",
+ popularity: 0,
+ explicit: false,
+ album_uri: item.url
+ };
+ }
+ return {
+ name: item.name,
+ image: spotifyItem.album.images[0].url,
+ uri: spotifyItem.uri,
+ id: spotifyItem.id,
+ artists: spotifyItem.artists.map((artist) => ({ name: artist.name, uri: artist.uri })),
+ duration: spotifyItem.duration_ms,
+ album: spotifyItem.album.name,
+ popularity: spotifyItem.popularity,
+ explicit: spotifyItem.explicit,
+ album_uri: spotifyItem.album.uri,
+ release_year: spotifyItem.album.release_date.slice(0, 4)
+ };
+ })
+ );
+ };
+ var convertAlbumData = async (data) => {
+ return await Promise.all(
+ data.map(async (item) => {
+ const spotifyItem = await Spicetify.CosmosAsync.get(SPOTIFY.searchalbum(item.name, item.artist.name)).then(
+ (res) => res.albums?.items[0]
+ );
+ if (!spotifyItem) {
+ console.log(`couldn't find album: ${item.name} by ${item.artist.name}`);
+ return {
+ name: item.name,
+ image: PLACEHOLDER,
+ uri: item.url,
+ id: item.mbid
+ };
+ }
+ return {
+ name: item.name,
+ image: spotifyItem.images[0].url,
+ uri: spotifyItem.uri,
+ id: spotifyItem.id
+ };
+ })
+ );
+ };
+ var convertArtistData = async (data) => {
+ return await Promise.all(
+ data.map(async (item) => {
+ const spotifyItem = await Spicetify.CosmosAsync.get(SPOTIFY.searchartist(item.name)).then(
+ (res) => res.artists?.items[0]
+ );
+ if (!spotifyItem) {
+ console.log(`couldn't find artist: ${item.name}`);
+ return {
+ name: item.name,
+ image: PLACEHOLDER,
+ uri: item.url,
+ id: item.mbid
+ };
+ }
+ return {
+ name: item.name,
+ image: spotifyItem.images[0].url,
+ uri: spotifyItem.uri,
+ id: spotifyItem.id,
+ genres: spotifyItem.genres
+ };
+ })
+ );
+ };
+ var checkLiked = async (tracks) => {
+ const nullIndexes = [];
+ tracks.forEach((track, index) => {
+ if (track === null) {
+ nullIndexes.push(index);
+ }
+ });
+ const apiResponse = await apiRequest("checkLiked", SPOTIFY.queryliked(tracks.filter((e) => e).join(",")));
+ if (!apiResponse)
+ return;
+ const response = [];
+ let nullIndexesIndex = 0;
+ for (let i = 0; i < tracks.length; i++) {
+ if (nullIndexes.includes(i)) {
+ response.push(false);
+ } else {
+ response.push(apiResponse[nullIndexesIndex]);
+ nullIndexesIndex++;
+ }
+ }
+ return response;
+ };
+ async function queue(list, context = null) {
+ list.push("spotify:delimiter");
+ const { _queue, _client } = Spicetify.Platform.PlayerAPI._queue;
+ const { prevTracks, queueRevision } = _queue;
+ const nextTracks = list.map((uri) => ({
+ contextTrack: {
+ uri,
+ uid: "",
+ metadata: {
+ is_queued: "false"
+ }
+ },
+ removed: [],
+ blocked: [],
+ provider: "context"
+ }));
+ _client.setQueue({
+ nextTracks,
+ prevTracks,
+ queueRevision
+ });
+ if (context) {
+ const { sessionId } = Spicetify.Platform.PlayerAPI.getState();
+ Spicetify.Platform.PlayerAPI.updateContext(sessionId, {
+ uri: `spotify:user:${Spicetify.Platform.LibraryAPI._currentUsername}:top:tracks`,
+ url: ""
+ });
+ }
+ Spicetify.Player.next();
+ }
+
+ // ../shared/components/status.tsx
+ var import_react12 = __toESM(require_react());
+ var ErrorIcon = () => {
+ return /* @__PURE__ */ import_react12.default.createElement("svg", {
+ "data-encore-id": "icon",
+ role: "img",
+ "aria-hidden": "true",
+ viewBox: "0 0 24 24",
+ className: "status-icon"
+ }, /* @__PURE__ */ import_react12.default.createElement("path", {
+ d: "M11 18v-2h2v2h-2zm0-4V6h2v8h-2z"
+ }), /* @__PURE__ */ import_react12.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_react12.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_react12.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_react12.default.useState(false);
+ import_react12.default.useEffect(() => {
+ const to = setTimeout(() => {
+ setIsVisible(true);
+ }, 500);
+ return () => clearTimeout(to);
+ }, []);
+ return isVisible ? /* @__PURE__ */ import_react12.default.createElement(import_react12.default.Fragment, null, /* @__PURE__ */ import_react12.default.createElement("div", {
+ className: "loadingWrapper"
+ }, props.icon === "error" ? /* @__PURE__ */ import_react12.default.createElement(ErrorIcon, null) : /* @__PURE__ */ import_react12.default.createElement(LibraryIcon, null), /* @__PURE__ */ import_react12.default.createElement("h1", null, props.heading), /* @__PURE__ */ import_react12.default.createElement("h3", null, props.subheading))) : /* @__PURE__ */ import_react12.default.createElement(import_react12.default.Fragment, null);
+ };
+ var status_default = Status;
+
+ // ../shared/components/page_container.tsx
+ var import_react14 = __toESM(require_react());
+
+ // src/components/buttons/create_playlist_button.tsx
+ var import_react13 = __toESM(require_react());
+ async function createPlaylistAsync(infoToCreatePlaylist) {
+ const { Platform, showNotification } = Spicetify;
+ const { RootlistAPI, PlaylistAPI } = Platform;
+ try {
+ const { playlistName, itemsUris } = infoToCreatePlaylist;
+ const playlistUri = await RootlistAPI.createPlaylist(playlistName, { before: "start" });
+ await PlaylistAPI.add(playlistUri, itemsUris, { before: "start" });
+ } catch (error) {
+ console.error(error);
+ showNotification("Failed to create playlist", true, 1e3);
+ }
+ }
+ function CreatePlaylistButton(props) {
+ const { TooltipWrapper, ButtonSecondary } = Spicetify.ReactComponent;
+ const { infoToCreatePlaylist } = props;
+ return /* @__PURE__ */ import_react13.default.createElement(TooltipWrapper, {
+ label: "Turn Into Playlist",
+ renderInline: true,
+ placement: "top"
+ }, /* @__PURE__ */ import_react13.default.createElement(ButtonSecondary, {
+ "aria-label": "Turn Into Playlist",
+ children: "Turn Into Playlist",
+ semanticColor: "textBase",
+ buttonSize: "sm",
+ onClick: () => createPlaylistAsync(infoToCreatePlaylist),
+ className: "stats-make-playlist-button"
+ }));
+ }
+ var create_playlist_button_default = CreatePlaylistButton;
+
+ // ../shared/components/page_container.tsx
+ var PageContainer = (props) => {
+ const { title, infoToCreatePlaylist, headerEls, children } = props;
+ const { TextComponent } = Spicetify.ReactComponent;
+ return /* @__PURE__ */ import_react14.default.createElement("section", {
+ className: "contentSpacing"
+ }, /* @__PURE__ */ import_react14.default.createElement("div", {
+ className: "page-header"
+ }, /* @__PURE__ */ import_react14.default.createElement("div", {
+ className: "header-left"
+ }, /* @__PURE__ */ import_react14.default.createElement(TextComponent, {
+ children: title,
+ as: "h1",
+ variant: "canon",
+ semanticColor: "textBase"
+ }), infoToCreatePlaylist ? /* @__PURE__ */ import_react14.default.createElement(create_playlist_button_default, {
+ infoToCreatePlaylist
+ }) : null), /* @__PURE__ */ import_react14.default.createElement("div", {
+ className: "header-right"
+ }, headerEls)), /* @__PURE__ */ import_react14.default.createElement("div", {
+ className: "page-content"
+ }, children));
+ };
+ var page_container_default = PageContainer;
+
+ // ../shared/components/settings_button.tsx
+ var import_react15 = __toESM(require_react());
+ function SettingsIcon() {
+ return /* @__PURE__ */ import_react15.default.createElement(Spicetify.ReactComponent.IconComponent, {
+ semanticColor: "textSubdued",
+ dangerouslySetInnerHTML: {
+ __html: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M24 13.616v-3.232c-1.651-.587-2.694-.752-3.219-2.019v-.001c-.527-1.271.1-2.134.847-3.707l-2.285-2.285c-1.561.742-2.433 1.375-3.707.847h-.001c-1.269-.526-1.435-1.576-2.019-3.219h-3.232c-.582 1.635-.749 2.692-2.019 3.219h-.001c-1.271.528-2.132-.098-3.707-.847l-2.285 2.285c.745 1.568 1.375 2.434.847 3.707-.527 1.271-1.584 1.438-3.219 2.02v3.232c1.632.58 2.692.749 3.219 2.019.53 1.282-.114 2.166-.847 3.707l2.285 2.286c1.562-.743 2.434-1.375 3.707-.847h.001c1.27.526 1.436 1.579 2.019 3.219h3.232c.582-1.636.75-2.69 2.027-3.222h.001c1.262-.524 2.12.101 3.698.851l2.285-2.286c-.744-1.563-1.375-2.433-.848-3.706.527-1.271 1.588-1.44 3.221-2.021zm-12 2.384c-2.209 0-4-1.791-4-4s1.791-4 4-4 4 1.791 4 4-1.791 4-4 4z"/></svg>'
+ },
+ iconSize: 16
+ });
+ }
+ function SettingsButton(props) {
+ const { TooltipWrapper, ButtonTertiary } = Spicetify.ReactComponent;
+ const { configWrapper } = props;
+ return /* @__PURE__ */ import_react15.default.createElement(TooltipWrapper, {
+ label: "Settings",
+ renderInline: true,
+ placement: "top"
+ }, /* @__PURE__ */ import_react15.default.createElement(ButtonTertiary, {
+ buttonSize: "sm",
+ onClick: configWrapper.launchModal,
+ "aria-label": "Settings",
+ iconOnly: SettingsIcon
+ }));
+ }
+ var settings_button_default = SettingsButton;
+
+ // src/components/buttons/refresh_button.tsx
+ var import_react16 = __toESM(require_react());
+ function RefreshIcon() {
+ return /* @__PURE__ */ import_react16.default.createElement(Spicetify.ReactComponent.IconComponent, {
+ semanticColor: "textSubdued",
+ iconSize: 16,
+ dangerouslySetInnerHTML: {
+ __html: '<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 4.75A3.75 3.75 0 0 1 3.75 1h8.5A3.75 3.75 0 0 1 16 4.75v5a3.75 3.75 0 0 1-3.75 3.75H9.81l1.018 1.018a.75.75 0 1 1-1.06 1.06L6.939 12.75l2.829-2.828a.75.75 0 1 1 1.06 1.06L9.811 12h2.439a2.25 2.25 0 0 0 2.25-2.25v-5a2.25 2.25 0 0 0-2.25-2.25h-8.5A2.25 2.25 0 0 0 1.5 4.75v5A2.25 2.25 0 0 0 3.75 12H5v1.5H3.75A3.75 3.75 0 0 1 0 9.75v-5z"/></svg>'
+ }
+ });
+ }
+ function RefreshButton(props) {
+ const { ButtonTertiary, TooltipWrapper } = Spicetify.ReactComponent;
+ const { callback } = props;
+ return /* @__PURE__ */ import_react16.default.createElement(TooltipWrapper, {
+ label: "Refresh",
+ renderInline: true,
+ placement: "top"
+ }, /* @__PURE__ */ import_react16.default.createElement(ButtonTertiary, {
+ buttonSize: "sm",
+ onClick: callback,
+ "aria-label": "Refresh",
+ iconOnly: RefreshIcon
+ }));
+ }
+ var refresh_button_default = RefreshButton;
+
+ // src/pages/top_artists.tsx
+ var topArtistsReq = async (time_range, configWrapper) => {
+ const { config } = configWrapper;
+ if (config["use-lastfm"] === true) {
+ if (!config["api-key"] || !config["lastfm-user"])
+ return 300;
+ const { ["lastfm-user"]: user, ["api-key"]: key } = config;
+ const response = await apiRequest("lastfm", LASTFM.topartists(user, key, time_range));
+ if (!response)
+ return 200;
+ return await convertArtistData(response.topartists.artist);
+ } else {
+ const response = await apiRequest("topArtists", SPOTIFY.topartists(time_range));
+ if (!response)
+ return 200;
+ return response.items.map((artist) => {
+ const image = artist.images[2]?.url || artist.images[1]?.url || PLACEHOLDER;
+ return {
+ id: artist.id,
+ name: artist.name,
+ image,
+ uri: artist.uri,
+ genres: artist.genres
+ };
+ });
+ }
+ };
+ var DropdownOptions = [
+ { id: "short_term", name: "Past Month" },
+ { id: "medium_term", name: "Past 6 Months" },
+ { id: "long_term", name: "All Time" }
+ ];
+ var ArtistsPage = ({ configWrapper }) => {
+ const [topArtists, setTopArtists] = import_react17.default.useState(100);
+ const [dropdown, activeOption, setActiveOption] = useDropdownMenu_default(DropdownOptions, "stats:top-artists");
+ const fetchTopArtists2 = async (time_range, force, set = true) => {
+ if (!force) {
+ let storedData = Spicetify.LocalStorage.get(`stats:top-artists:${time_range}`);
+ if (storedData)
+ return setTopArtists(JSON.parse(storedData));
+ }
+ const start = window.performance.now();
+ const topArtists2 = await topArtistsReq(time_range, configWrapper);
+ if (set)
+ setTopArtists(topArtists2);
+ Spicetify.LocalStorage.set(`stats:top-artists:${time_range}`, JSON.stringify(topArtists2));
+ console.log("total artists fetch time:", window.performance.now() - start);
+ };
+ import_react17.default.useEffect(() => {
+ updatePageCache(0, fetchTopArtists2, activeOption.id);
+ }, []);
+ import_react17.default.useEffect(() => {
+ fetchTopArtists2(activeOption.id);
+ }, [activeOption]);
+ const refresh = () => {
+ fetchTopArtists2(activeOption.id, true);
+ };
+ const props = {
+ title: "Top Artists",
+ headerEls: [dropdown, /* @__PURE__ */ import_react17.default.createElement(refresh_button_default, {
+ callback: refresh
+ }), /* @__PURE__ */ import_react17.default.createElement(settings_button_default, {
+ configWrapper
+ })]
+ };
+ switch (topArtists) {
+ case 300:
+ return /* @__PURE__ */ import_react17.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react17.default.createElement(status_default, {
+ icon: "error",
+ heading: "No API Key or Username",
+ subheading: "Please enter these in the settings menu"
+ }));
+ case 200:
+ return /* @__PURE__ */ import_react17.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react17.default.createElement(status_default, {
+ icon: "error",
+ heading: "Failed to Fetch Top Artists",
+ subheading: "An error occurred while fetching the data"
+ }));
+ case 100:
+ return /* @__PURE__ */ import_react17.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react17.default.createElement(status_default, {
+ icon: "library",
+ heading: "Loading",
+ subheading: "Fetching data..."
+ }));
+ }
+ const artistCards = topArtists.map((artist, index) => /* @__PURE__ */ import_react17.default.createElement(spotify_card_default, {
+ type: artist.uri.includes("last") ? "lastfm" : "artist",
+ uri: artist.uri,
+ header: artist.name,
+ subheader: `#${index + 1} Artist`,
+ imageUrl: artist.image
+ }));
+ return /* @__PURE__ */ import_react17.default.createElement(import_react17.default.Fragment, null, /* @__PURE__ */ import_react17.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react17.default.createElement("div", {
+ className: `main-gridContainer-gridContainer grid`
+ }, artistCards)));
+ };
+ var top_artists_default = import_react17.default.memo(ArtistsPage);
+
+ // src/pages/top_tracks.tsx
+ var import_react20 = __toESM(require_react());
+
+ // src/components/track_row.tsx
+ var import_react18 = __toESM(require_react());
+ var ArtistLink = ({ name, uri, index, length }) => {
+ return /* @__PURE__ */ import_react18.default.createElement(import_react18.default.Fragment, null, /* @__PURE__ */ import_react18.default.createElement("a", {
+ draggable: "true",
+ dir: "auto",
+ href: uri,
+ tabIndex: -1
+ }, name), index === length ? null : ", ");
+ };
+ var ExplicitBadge = import_react18.default.memo(() => {
+ return /* @__PURE__ */ import_react18.default.createElement(import_react18.default.Fragment, null, /* @__PURE__ */ import_react18.default.createElement("span", {
+ className: "TypeElement-ballad-textSubdued-type main-trackList-rowBadges",
+ "data-encore-id": "type"
+ }, /* @__PURE__ */ import_react18.default.createElement("span", {
+ "aria-label": "Explicit",
+ className: "main-tag-container",
+ title: "Explicit"
+ }, "E")));
+ });
+ var LikedIcon = ({ active, uri }) => {
+ const [liked, setLiked] = import_react18.default.useState(active);
+ const toggleLike = () => {
+ if (liked) {
+ Spicetify.Platform.LibraryAPI.remove(uri);
+ } else {
+ Spicetify.Platform.LibraryAPI.add(uri);
+ }
+ setLiked(!liked);
+ };
+ import_react18.default.useEffect(() => {
+ setLiked(active);
+ }, [active]);
+ return /* @__PURE__ */ import_react18.default.createElement(Spicetify.ReactComponent.TooltipWrapper, {
+ label: liked ? `Remove from Your Library` : "Save to Your Library",
+ placement: "top"
+ }, /* @__PURE__ */ import_react18.default.createElement("button", {
+ type: "button",
+ role: "switch",
+ "aria-checked": liked,
+ "aria-label": "Remove from Your Library",
+ onClick: toggleLike,
+ className: liked ? "main-addButton-button main-trackList-rowHeartButton main-addButton-active" : "main-addButton-button main-trackList-rowHeartButton",
+ tabIndex: -1
+ }, /* @__PURE__ */ import_react18.default.createElement("svg", {
+ role: "img",
+ height: "16",
+ width: "16",
+ "aria-hidden": "true",
+ viewBox: "0 0 16 16",
+ "data-encore-id": "icon",
+ className: "Svg-img-16 Svg-img-16-icon Svg-img-icon Svg-img-icon-small"
+ }, /* @__PURE__ */ import_react18.default.createElement("path", {
+ d: liked ? "M15.724 4.22A4.313 4.313 0 0 0 12.192.814a4.269 4.269 0 0 0-3.622 1.13.837.837 0 0 1-1.14 0 4.272 4.272 0 0 0-6.21 5.855l5.916 7.05a1.128 1.128 0 0 0 1.727 0l5.916-7.05a4.228 4.228 0 0 0 .945-3.577z" : "M1.69 2A4.582 4.582 0 0 1 8 2.023 4.583 4.583 0 0 1 11.88.817h.002a4.618 4.618 0 0 1 3.782 3.65v.003a4.543 4.543 0 0 1-1.011 3.84L9.35 14.629a1.765 1.765 0 0 1-2.093.464 1.762 1.762 0 0 1-.605-.463L1.348 8.309A4.582 4.582 0 0 1 1.689 2zm3.158.252A3.082 3.082 0 0 0 2.49 7.337l.005.005L7.8 13.664a.264.264 0 0 0 .311.069.262.262 0 0 0 .09-.069l5.312-6.33a3.043 3.043 0 0 0 .68-2.573 3.118 3.118 0 0 0-2.551-2.463 3.079 3.079 0 0 0-2.612.816l-.007.007a1.501 1.501 0 0 1-2.045 0l-.009-.008a3.082 3.082 0 0 0-2.121-.861z"
+ }))));
+ };
+ var DraggableComponent = ({
+ uri,
+ title,
+ ...props
+ }) => {
+ const dragHandler = Spicetify.ReactHook.DragHandler?.([uri], title);
+ return /* @__PURE__ */ import_react18.default.createElement("div", {
+ onDragStart: dragHandler,
+ draggable: "true",
+ ...props
+ }, props.children);
+ };
+ function playAndQueue(uri, uris) {
+ uris = uris.filter((u) => !u.includes("last"));
+ uris = uris.concat(uris.splice(0, uris.indexOf(uri)));
+ queue(uris);
+ }
+ var MenuWrapper = import_react18.default.memo((props) => /* @__PURE__ */ import_react18.default.createElement(Spicetify.ReactComponent.AlbumMenu, {
+ ...props
+ }));
+ var TrackRow = (props) => {
+ const ArtistLinks = props.artists.map((artist, index) => {
+ return /* @__PURE__ */ import_react18.default.createElement(ArtistLink, {
+ index,
+ length: props.artists.length - 1,
+ name: artist.name,
+ uri: artist.uri
+ });
+ });
+ return /* @__PURE__ */ import_react18.default.createElement(import_react18.default.Fragment, null, /* @__PURE__ */ import_react18.default.createElement(Spicetify.ReactComponent.ContextMenu, {
+ menu: /* @__PURE__ */ import_react18.default.createElement(MenuWrapper, {
+ uri: props.uri
+ }),
+ trigger: "right-click"
+ }, /* @__PURE__ */ import_react18.default.createElement("div", {
+ role: "row",
+ "aria-rowindex": 2,
+ "aria-selected": "false"
+ }, /* @__PURE__ */ import_react18.default.createElement(DraggableComponent, {
+ uri: props.uri,
+ title: `${props.name} \u2022 ${props.artists.map((artist) => artist.name).join(", ")}`,
+ className: "main-trackList-trackListRow main-trackList-trackListRowGrid",
+ role: "presentation",
+ onClick: (event) => event.detail === 2 && playAndQueue(props.uri, props.uris),
+ style: { height: 56 }
+ }, /* @__PURE__ */ import_react18.default.createElement("div", {
+ className: "main-trackList-rowSectionIndex",
+ role: "gridcell",
+ "aria-colindex": 1,
+ tabIndex: -1
+ }, /* @__PURE__ */ import_react18.default.createElement("div", {
+ uri: props.uri,
+ className: "main-trackList-rowMarker"
+ }, /* @__PURE__ */ import_react18.default.createElement("span", {
+ className: "TypeElement-ballad-type main-trackList-number",
+ "data-encore-id": "type"
+ }, props.index), /* @__PURE__ */ import_react18.default.createElement(Spicetify.ReactComponent.TooltipWrapper, {
+ label: `Play ${props.name} by ${props.artists.map((artist) => artist.name).join(", ")}`,
+ placement: "top"
+ }, /* @__PURE__ */ import_react18.default.createElement("button", {
+ className: "main-trackList-rowImagePlayButton",
+ "aria-label": `Play ${props.name}`,
+ tabIndex: -1,
+ onClick: () => playAndQueue(props.uri, props.uris)
+ }, /* @__PURE__ */ import_react18.default.createElement("svg", {
+ role: "img",
+ height: "24",
+ width: "24",
+ "aria-hidden": "true",
+ className: "Svg-img-24 Svg-img-24-icon main-trackList-rowPlayPauseIcon",
+ viewBox: "0 0 24 24",
+ "data-encore-id": "icon"
+ }, /* @__PURE__ */ import_react18.default.createElement("path", {
+ d: "m7.05 3.606 13.49 7.788a.7.7 0 0 1 0 1.212L7.05 20.394A.7.7 0 0 1 6 19.788V4.212a.7.7 0 0 1 1.05-.606z"
+ })))))), /* @__PURE__ */ import_react18.default.createElement("div", {
+ className: "main-trackList-rowSectionStart",
+ role: "gridcell",
+ "aria-colindex": 2,
+ tabIndex: -1
+ }, /* @__PURE__ */ import_react18.default.createElement("img", {
+ "aria-hidden": "false",
+ draggable: "false",
+ loading: "eager",
+ src: props.image,
+ alt: "",
+ className: "main-image-image main-trackList-rowImage main-image-loaded",
+ width: "40",
+ height: "40"
+ }), /* @__PURE__ */ import_react18.default.createElement("div", {
+ className: "main-trackList-rowMainContent"
+ }, /* @__PURE__ */ import_react18.default.createElement("div", {
+ dir: "auto",
+ className: "TypeElement-ballad-textBase TypeElement-ballad-textBase-type main-trackList-rowTitle standalone-ellipsis-one-line",
+ "data-encore-id": "type"
+ }, props.name), props.explicit && /* @__PURE__ */ import_react18.default.createElement(ExplicitBadge, null), /* @__PURE__ */ import_react18.default.createElement("span", {
+ className: "TypeElement-mesto-textSubdued TypeElement-mesto-textSubdued-type main-trackList-rowSubTitle standalone-ellipsis-one-line",
+ "data-encore-id": "type"
+ }, ArtistLinks))), /* @__PURE__ */ import_react18.default.createElement("div", {
+ className: "main-trackList-rowSectionVariable",
+ role: "gridcell",
+ "aria-colindex": 3,
+ tabIndex: -1
+ }, /* @__PURE__ */ import_react18.default.createElement("span", {
+ "data-encore-id": "type",
+ className: "TypeElement-mesto TypeElement-mesto-type"
+ }, /* @__PURE__ */ import_react18.default.createElement("a", {
+ draggable: "true",
+ className: "standalone-ellipsis-one-line",
+ dir: "auto",
+ href: props.album_uri,
+ tabIndex: -1
+ }, props.album))), /* @__PURE__ */ import_react18.default.createElement("div", {
+ className: "main-trackList-rowSectionEnd",
+ role: "gridcell",
+ "aria-colindex": 5,
+ tabIndex: -1
+ }, /* @__PURE__ */ import_react18.default.createElement(LikedIcon, {
+ active: props.liked || false,
+ uri: props.uri
+ }), /* @__PURE__ */ import_react18.default.createElement("div", {
+ className: "TypeElement-mesto-textSubdued TypeElement-mesto-textSubdued-type main-trackList-rowDuration",
+ "data-encore-id": "type"
+ }, Spicetify.Player.formatTime(props.duration)), /* @__PURE__ */ import_react18.default.createElement(Spicetify.ReactComponent.ContextMenu, {
+ menu: /* @__PURE__ */ import_react18.default.createElement(MenuWrapper, {
+ uri: props.uri
+ }),
+ trigger: "click"
+ }, /* @__PURE__ */ import_react18.default.createElement("button", {
+ type: "button",
+ "aria-haspopup": "menu",
+ "aria-label": `More options for ${props.name}`,
+ className: "main-moreButton-button Button-sm-16-buttonTertiary-iconOnly-condensed-useBrowserDefaultFocusStyle Button-small-small-buttonTertiary-iconOnly-condensed-useBrowserDefaultFocusStyle main-trackList-rowMoreButton",
+ tabIndex: -1
+ }, /* @__PURE__ */ import_react18.default.createElement(Spicetify.ReactComponent.TooltipWrapper, {
+ label: `More options for ${props.name} by ${props.artists.map((artist) => artist.name).join(", ")}`,
+ placement: "top"
+ }, /* @__PURE__ */ import_react18.default.createElement("span", null, /* @__PURE__ */ import_react18.default.createElement("svg", {
+ role: "img",
+ height: "16",
+ width: "16",
+ "aria-hidden": "true",
+ viewBox: "0 0 16 16",
+ "data-encore-id": "icon",
+ className: "Svg-img-16 Svg-img-16-icon Svg-img-icon Svg-img-icon-small"
+ }, /* @__PURE__ */ import_react18.default.createElement("path", {
+ d: "M3 8a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm6.5 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zM16 8a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"
+ })))))))))));
+ };
+ var track_row_default = import_react18.default.memo(TrackRow);
+
+ // src/components/tracklist.tsx
+ var import_react19 = __toESM(require_react());
+ var Tracklist = ({ minified = false, children }) => {
+ return /* @__PURE__ */ import_react19.default.createElement("div", {
+ role: "grid",
+ "aria-rowcount": minified ? 5 : 50,
+ "aria-colcount": 4,
+ className: "main-trackList-trackList main-trackList-indexable",
+ tabIndex: 0
+ }, !minified && /* @__PURE__ */ import_react19.default.createElement("div", {
+ className: "main-trackList-trackListHeader",
+ role: "presentation"
+ }, /* @__PURE__ */ import_react19.default.createElement("div", {
+ className: "main-trackList-trackListHeaderRow main-trackList-trackListRowGrid",
+ role: "row",
+ "aria-rowindex": 1
+ }, /* @__PURE__ */ import_react19.default.createElement("div", {
+ className: "main-trackList-rowSectionIndex",
+ role: "columnheader",
+ "aria-colindex": 1,
+ "aria-sort": "none",
+ tabIndex: -1
+ }, "#"), /* @__PURE__ */ import_react19.default.createElement("div", {
+ className: "main-trackList-rowSectionStart",
+ role: "columnheader",
+ "aria-colindex": 2,
+ "aria-sort": "none",
+ tabIndex: -1
+ }, /* @__PURE__ */ import_react19.default.createElement("button", {
+ className: "main-trackList-column main-trackList-sortable",
+ tabIndex: -1
+ }, /* @__PURE__ */ import_react19.default.createElement("span", {
+ className: "TypeElement-mesto-type standalone-ellipsis-one-line",
+ "data-encore-id": "type"
+ }, "Title"))), /* @__PURE__ */ import_react19.default.createElement("div", {
+ className: "main-trackList-rowSectionVariable",
+ role: "columnheader",
+ "aria-colindex": 3,
+ "aria-sort": "none",
+ tabIndex: -1
+ }, /* @__PURE__ */ import_react19.default.createElement("button", {
+ className: "main-trackList-column main-trackList-sortable",
+ tabIndex: -1
+ }, /* @__PURE__ */ import_react19.default.createElement("span", {
+ className: "TypeElement-mesto-type standalone-ellipsis-one-line",
+ "data-encore-id": "type"
+ }, "Album"))), /* @__PURE__ */ import_react19.default.createElement("div", {
+ className: "main-trackList-rowSectionEnd",
+ role: "columnheader",
+ "aria-colindex": 5,
+ "aria-sort": "none",
+ tabIndex: -1
+ }, /* @__PURE__ */ import_react19.default.createElement(Spicetify.ReactComponent.TooltipWrapper, {
+ label: "Duration",
+ placement: "top"
+ }, /* @__PURE__ */ import_react19.default.createElement("button", {
+ "aria-label": "Duration",
+ className: "main-trackList-column main-trackList-durationHeader main-trackList-sortable",
+ tabIndex: -1
+ }, /* @__PURE__ */ import_react19.default.createElement("svg", {
+ role: "img",
+ height: "16",
+ width: "16",
+ "aria-hidden": "true",
+ viewBox: "0 0 16 16",
+ "data-encore-id": "icon",
+ className: "Svg-img-16 Svg-img-16-icon Svg-img-icon Svg-img-icon-small"
+ }, /* @__PURE__ */ import_react19.default.createElement("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"
+ }), /* @__PURE__ */ import_react19.default.createElement("path", {
+ d: "M8 3.25a.75.75 0 0 1 .75.75v3.25H11a.75.75 0 0 1 0 1.5H7.25V4A.75.75 0 0 1 8 3.25z"
+ }))))))), /* @__PURE__ */ import_react19.default.createElement("div", {
+ className: "main-rootlist-wrapper",
+ role: "presentation",
+ style: { height: (minified ? 5 : 50) * 56 }
+ }, /* @__PURE__ */ import_react19.default.createElement("div", {
+ role: "presentation"
+ }, children)));
+ };
+ var tracklist_default = Tracklist;
+
+ // src/pages/top_tracks.tsx
+ var topTracksReq = async (time_range, configWrapper) => {
+ const { config } = configWrapper;
+ if (config["use-lastfm"] === true) {
+ if (!config["api-key"] || !config["lastfm-user"])
+ return 300;
+ const { ["lastfm-user"]: user, ["api-key"]: key } = config;
+ const lastfmData = await apiRequest("lastfm", LASTFM.toptracks(user, key, time_range));
+ if (!lastfmData)
+ return 200;
+ const spotifyData = await convertTrackData(lastfmData.toptracks.track);
+ const likedArray2 = await checkLiked(spotifyData.map((track) => track.id));
+ if (!likedArray2)
+ return 200;
+ spotifyData.forEach((track, index) => {
+ track.liked = likedArray2[index];
+ });
+ return spotifyData;
+ }
+ const response = await apiRequest("topTracks", SPOTIFY.toptracks(time_range));
+ if (!response)
+ return 200;
+ const likedArray = await checkLiked(response.items.map((track) => track.id));
+ if (!likedArray)
+ return 200;
+ return response.items.map((track, index) => {
+ const images = track.album.images;
+ const image = images[2]?.url || images[1]?.url || PLACEHOLDER;
+ return {
+ liked: likedArray[index],
+ name: track.name,
+ image,
+ uri: track.uri,
+ id: track.id,
+ artists: track.artists.map((artist) => ({
+ name: artist.name,
+ uri: artist.uri
+ })),
+ duration: track.duration_ms,
+ album: track.album.name,
+ album_uri: track.album.uri,
+ popularity: track.popularity,
+ explicit: track.explicit,
+ release_year: track.album.release_date.slice(0, 4)
+ };
+ });
+ };
+ var DropdownOptions2 = [
+ { id: "short_term", name: "Past Month" },
+ { id: "medium_term", name: "Past 6 Months" },
+ { id: "long_term", name: "All Time" }
+ ];
+ var TracksPage = ({ configWrapper }) => {
+ const { LocalStorage } = Spicetify;
+ const [topTracks, setTopTracks] = import_react20.default.useState(100);
+ const [dropdown, activeOption] = useDropdownMenu_default(DropdownOptions2, "stats:top-tracks");
+ const fetchTopTracks = async (time_range, force, set = true) => {
+ if (!force) {
+ const storedData = LocalStorage.get(`stats:top-tracks:${time_range}`);
+ if (storedData)
+ return setTopTracks(JSON.parse(storedData));
+ }
+ const start = window.performance.now();
+ const topTracks2 = await topTracksReq(time_range, configWrapper);
+ if (set)
+ setTopTracks(topTracks2);
+ LocalStorage.set(`stats:top-tracks:${time_range}`, JSON.stringify(topTracks2));
+ console.log("total tracks fetch time:", window.performance.now() - start);
+ };
+ import_react20.default.useEffect(() => {
+ updatePageCache(1, fetchTopTracks, activeOption.id);
+ }, []);
+ import_react20.default.useEffect(() => {
+ fetchTopTracks(activeOption.id);
+ }, [activeOption]);
+ const refresh = () => {
+ fetchTopTracks(activeOption.id, true);
+ };
+ const props = {
+ title: "Top Tracks",
+ headerEls: [dropdown, /* @__PURE__ */ import_react20.default.createElement(refresh_button_default, {
+ callback: refresh
+ }), /* @__PURE__ */ import_react20.default.createElement(settings_button_default, {
+ configWrapper
+ })]
+ };
+ switch (topTracks) {
+ case 300:
+ return /* @__PURE__ */ import_react20.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react20.default.createElement(status_default, {
+ icon: "error",
+ heading: "No API Key or Username",
+ subheading: "Please enter these in the settings menu"
+ }));
+ case 200:
+ return /* @__PURE__ */ import_react20.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react20.default.createElement(status_default, {
+ icon: "error",
+ heading: "Failed to Fetch Top Tracks",
+ subheading: "An error occurred while fetching the data"
+ }));
+ case 100:
+ return /* @__PURE__ */ import_react20.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react20.default.createElement(status_default, {
+ icon: "library",
+ heading: "Loading",
+ subheading: "Fetching data..."
+ }));
+ }
+ const infoToCreatePlaylist = {
+ playlistName: `Top Songs - ${activeOption}`,
+ itemsUris: topTracks.map((track) => track.uri)
+ };
+ const trackRows = topTracks.map((track, index) => /* @__PURE__ */ import_react20.default.createElement(track_row_default, {
+ index: index + 1,
+ ...track,
+ uris: topTracks.map((track2) => track2.uri)
+ }));
+ return /* @__PURE__ */ import_react20.default.createElement(page_container_default, {
+ ...props,
+ infoToCreatePlaylist
+ }, /* @__PURE__ */ import_react20.default.createElement(tracklist_default, null, trackRows));
+ };
+ var top_tracks_default = import_react20.default.memo(TracksPage);
+
+ // src/pages/top_genres.tsx
+ var import_react25 = __toESM(require_react());
+
+ // src/components/cards/stat_card.tsx
+ var import_react21 = __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_react21.default.createElement("div", {
+ className: "main-card-card"
+ }, /* @__PURE__ */ import_react21.default.createElement(TextComponent, {
+ as: "div",
+ semanticColor: "textBase",
+ variant: "alto",
+ children: typeof value === "number" ? formatValue(label, value) : value
+ }), /* @__PURE__ */ import_react21.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_react22 = __toESM(require_react());
+ var genreLine = (name, value, limit, total) => {
+ return /* @__PURE__ */ import_react22.default.createElement("div", {
+ className: "stats-genreRow"
+ }, /* @__PURE__ */ import_react22.default.createElement("div", {
+ className: "stats-genreRowFill",
+ style: {
+ width: `calc(${value / limit * 100}% + ${(limit - value) / (limit - 1) * 100}px)`
+ }
+ }, /* @__PURE__ */ import_react22.default.createElement("span", {
+ className: "stats-genreText"
+ }, name)), /* @__PURE__ */ import_react22.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_react22.default.createElement("div", {
+ className: `main-card-card stats-genreCard`
+ }, genreLines(genresArray, total));
+ };
+ var genres_card_default = genresCard;
+
+ // src/components/inline_grid.tsx
+ var import_react23 = __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_react23.default.createElement("section", {
+ className: "stats-gridInlineSection"
+ }, /* @__PURE__ */ import_react23.default.createElement("button", {
+ className: "stats-scrollButton",
+ onClick: scrollGridLeft
+ }, "<"), /* @__PURE__ */ import_react23.default.createElement("button", {
+ className: "stats-scrollButton",
+ onClick: scrollGrid
+ }, ">"), /* @__PURE__ */ import_react23.default.createElement("div", {
+ className: `main-gridContainer-gridContainer stats-gridInline${special ? " stats-specialGrid" : ""}`,
+ "data-scroll": "start"
+ }, children));
+ }
+ var inline_grid_default = import_react23.default.memo(InlineGrid);
+
+ // src/components/shelf.tsx
+ var import_react24 = __toESM(require_react());
+ function Shelf(props) {
+ const { TextComponent } = Spicetify.ReactComponent;
+ const { title, children } = props;
+ return /* @__PURE__ */ import_react24.default.createElement("section", {
+ className: "main-shelf-shelf Shelf"
+ }, /* @__PURE__ */ import_react24.default.createElement("div", {
+ className: "main-shelf-header"
+ }, /* @__PURE__ */ import_react24.default.createElement("div", {
+ className: "main-shelf-topRow"
+ }, /* @__PURE__ */ import_react24.default.createElement("div", {
+ className: "main-shelf-titleWrapper"
+ }, /* @__PURE__ */ import_react24.default.createElement(TextComponent, {
+ children: title,
+ as: "h2",
+ variant: "canon",
+ semanticColor: "textBase"
+ })))), /* @__PURE__ */ import_react24.default.createElement("section", null, children));
+ }
+ var shelf_default = import_react24.default.memo(Shelf);
+
+ // src/pages/top_genres.tsx
+ var DropdownOptions3 = [
+ { id: "short_term", name: "Past Month" },
+ { id: "medium_term", name: "Past 6 Months" },
+ { id: "long_term", name: "All Time" }
+ ];
+ var GenresPage = ({ configWrapper }) => {
+ const { LocalStorage } = Spicetify;
+ const [topGenres, setTopGenres] = import_react25.default.useState(100);
+ const [dropdown, activeOption] = useDropdownMenu_default(DropdownOptions3, "stats:top-genres");
+ const fetchTopGenres = async (time_range, force, set = true, force_refetch) => {
+ if (!force) {
+ let storedData = LocalStorage.get(`stats:top-genres:${time_range}`);
+ if (storedData)
+ return setTopGenres(JSON.parse(storedData));
+ }
+ const start = window.performance.now();
+ const cacheInfo = JSON.parse(LocalStorage.get("stats:cache-info"));
+ const fetchedItems = await Promise.all(
+ ["artists", "tracks"].map(async (type, index) => {
+ if (cacheInfo[index] === true && !force_refetch) {
+ return await JSON.parse(LocalStorage.get(`stats:top-${type}:${time_range}`));
+ }
+ const fetchedItems2 = await (type === "artists" ? topArtistsReq(time_range, configWrapper) : topTracksReq(time_range, configWrapper));
+ cacheInfo[index] = true;
+ cacheInfo[2] = true;
+ LocalStorage.set(`stats:top-${type}:${time_range}`, JSON.stringify(fetchedItems2));
+ LocalStorage.set("stats:cache-info", JSON.stringify(cacheInfo));
+ return fetchedItems2;
+ })
+ );
+ for (let i = 0; i < 2; i++) {
+ if (fetchedItems[i] === 200 || fetchedItems[i] === 300)
+ return setTopGenres(fetchedItems[i]);
+ }
+ const fetchedArtists = fetchedItems[0].filter((artist) => artist?.genres);
+ const fetchedTracks = fetchedItems[1].filter((track) => track?.id);
+ const genres = fetchedArtists.reduce((acc, artist) => {
+ artist.genres.forEach((genre) => {
+ const index = acc.findIndex(([g]) => g === genre);
+ if (index !== -1) {
+ acc[index][1] += 1 * Math.abs(fetchedArtists.indexOf(artist) - 50);
+ } else {
+ acc.push([genre, 1 * Math.abs(fetchedArtists.indexOf(artist) - 50)]);
+ }
+ });
+ return acc;
+ }, []);
+ let trackPopularity = 0;
+ let explicitness = 0;
+ let releaseData = [];
+ const topTracks = fetchedTracks.map((track) => {
+ trackPopularity += track.popularity;
+ if (track.explicit)
+ explicitness++;
+ if (track.release_year) {
+ const year = track.release_year;
+ const index = releaseData.findIndex(([y]) => y === year);
+ if (index !== -1) {
+ releaseData[index][1] += 1;
+ } else {
+ releaseData.push([year, 1]);
+ }
+ }
+ return track.id;
+ });
+ async function testDupe(track) {
+ const spotifyItem = await apiRequest(
+ "track",
+ SPOTIFY.search(track.name, track.artists[0].name),
+ 1,
+ false
+ ).then((res) => res.tracks?.items);
+ if (!spotifyItem)
+ return false;
+ return spotifyItem.some((item) => {
+ return item.name === track.name && item.popularity > track.popularity;
+ });
+ }
+ let obscureTracks2 = [];
+ for (let i = 0; i < fetchedTracks.length; i++) {
+ let track = fetchedTracks[i];
+ if (!track?.popularity)
+ continue;
+ if (obscureTracks2.length < 5) {
+ const dupe = await testDupe(track);
+ if (dupe)
+ continue;
+ obscureTracks2.push(track);
+ obscureTracks2.sort((a, b) => b.popularity - a.popularity);
+ continue;
+ }
+ for (let j = 0; j < 5; j++) {
+ if (track.popularity < obscureTracks2[j].popularity) {
+ const dupe = await testDupe(track);
+ if (dupe)
+ break;
+ obscureTracks2.splice(j, 0, track);
+ obscureTracks2 = obscureTracks2.slice(0, 5);
+ break;
+ }
+ }
+ }
+ const fetchedFeatures = await fetchAudioFeatures(topTracks);
+ 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: trackPopularity,
+ explicitness,
+ ...audioFeatures
+ };
+ for (let key in audioFeatures) {
+ audioFeatures[key] = audioFeatures[key] / 50;
+ }
+ console.log("total genres fetch time:", window.performance.now() - start);
+ if (set)
+ setTopGenres({
+ genres,
+ features: audioFeatures,
+ years: releaseData,
+ obscureTracks: obscureTracks2
+ });
+ LocalStorage.set(
+ `stats:top-genres:${time_range}`,
+ JSON.stringify({
+ genres,
+ features: audioFeatures,
+ years: releaseData,
+ obscureTracks: obscureTracks2
+ })
+ );
+ };
+ import_react25.default.useEffect(() => {
+ updatePageCache(2, fetchTopGenres, activeOption.id);
+ }, []);
+ import_react25.default.useEffect(() => {
+ fetchTopGenres(activeOption.id);
+ }, [activeOption]);
+ const refresh = () => {
+ fetchTopGenres(activeOption.id, true);
+ };
+ const props = {
+ title: "Top Genres",
+ headerEls: [dropdown, /* @__PURE__ */ import_react25.default.createElement(refresh_button_default, {
+ callback: refresh
+ }), /* @__PURE__ */ import_react25.default.createElement(settings_button_default, {
+ configWrapper
+ })]
+ };
+ switch (topGenres) {
+ case 300:
+ return /* @__PURE__ */ import_react25.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react25.default.createElement(status_default, {
+ icon: "error",
+ heading: "No API Key or Username",
+ subheading: "Please enter these in the settings menu"
+ }));
+ case 200:
+ return /* @__PURE__ */ import_react25.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react25.default.createElement(status_default, {
+ icon: "error",
+ heading: "Failed to Fetch Top Genres",
+ subheading: "An error occurred while fetching the data"
+ }));
+ case 100:
+ return /* @__PURE__ */ import_react25.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react25.default.createElement(status_default, {
+ icon: "library",
+ heading: "Loading",
+ subheading: "Fetching data..."
+ }));
+ }
+ const statCards = Object.entries(topGenres.features).map(([key, value]) => {
+ return /* @__PURE__ */ import_react25.default.createElement(stat_card_default, {
+ label: key,
+ value
+ });
+ });
+ const obscureTracks = topGenres.obscureTracks.map((track, index) => /* @__PURE__ */ import_react25.default.createElement(track_row_default, {
+ index: index + 1,
+ ...track,
+ uris: topGenres.obscureTracks.map((track2) => track2.uri)
+ }));
+ return /* @__PURE__ */ import_react25.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react25.default.createElement("section", {
+ className: "main-shelf-shelf Shelf"
+ }, /* @__PURE__ */ import_react25.default.createElement(genres_card_default, {
+ genres: topGenres.genres,
+ total: 1275
+ }), /* @__PURE__ */ import_react25.default.createElement(inline_grid_default, {
+ special: true
+ }, statCards)), /* @__PURE__ */ import_react25.default.createElement(shelf_default, {
+ title: "Release Year Distribution"
+ }, /* @__PURE__ */ import_react25.default.createElement(genres_card_default, {
+ genres: topGenres.years,
+ total: 50
+ })), /* @__PURE__ */ import_react25.default.createElement(shelf_default, {
+ title: "Most Obscure Tracks"
+ }, /* @__PURE__ */ import_react25.default.createElement(tracklist_default, {
+ minified: true
+ }, obscureTracks)));
+ };
+ var top_genres_default = import_react25.default.memo(GenresPage);
+
+ // src/pages/library.tsx
+ var import_react26 = __toESM(require_react());
+ var DropdownOptions4 = [
+ { id: "owned", name: "My Playlists" },
+ { id: "all", name: "All Playlists" }
+ ];
+ var LibraryPage = ({ configWrapper }) => {
+ const [library, setLibrary] = import_react26.default.useState(100);
+ const [dropdown, activeOption, setActiveOption] = useDropdownMenu_default(DropdownOptions4, "stats:library");
+ const fetchData = async (option, force, set = true) => {
+ try {
+ if (!force) {
+ let storedData = Spicetify.LocalStorage.get(`stats:library:${option}`);
+ if (storedData)
+ return setLibrary(JSON.parse(storedData));
+ }
+ const start = window.performance.now();
+ const rootlistItems = await apiRequest("rootlist", SPOTIFY.rootlist);
+ const flattenPlaylists = (items) => {
+ const playlists2 = [];
+ items.forEach((row) => {
+ if (row.type === "playlist") {
+ playlists2.push(row);
+ } else if (row.type === "folder") {
+ if (!row.rows)
+ return;
+ const folderPlaylists = flattenPlaylists(row.rows);
+ playlists2.push(...folderPlaylists);
+ }
+ });
+ return playlists2;
+ };
+ let playlists = flattenPlaylists(rootlistItems?.rows);
+ playlists = playlists.sort((a, b) => a.ownedBySelf === b.ownedBySelf ? 0 : a.ownedBySelf ? -1 : 1);
+ let indexOfFirstNotOwned = -1;
+ let playlistUris = [];
+ let trackCount = 0;
+ let ownedTrackCount = 0;
+ playlists.forEach((playlist) => {
+ if (playlist.totalLength === 0)
+ return;
+ if (!playlist.ownedBySelf && indexOfFirstNotOwned === -1)
+ indexOfFirstNotOwned = playlistUris.length;
+ playlistUris.push(playlist.link);
+ trackCount += playlist.totalLength;
+ if (playlist.ownedBySelf)
+ ownedTrackCount += playlist.totalLength;
+ }, 0);
+ const playlistsMeta = await Promise.all(
+ playlistUris.map((uri) => {
+ return apiRequest("playlistsMetadata", SPOTIFY.playlist(uri), 5, false);
+ })
+ );
+ let duration = 0;
+ let trackIDs = [];
+ let popularity = 0;
+ let albums = {};
+ let artists = {};
+ let explicitCount = 0;
+ let ownedDuration = 0;
+ let ownedArtists = {};
+ let ownedPopularity = 0;
+ let ownedAlbums = {};
+ let ownedExplicitCount = 0;
+ for (let i = 0; i < playlistsMeta.length; i++) {
+ const playlist = playlistsMeta[i];
+ if (!playlist)
+ continue;
+ if (i === indexOfFirstNotOwned) {
+ ownedDuration = duration;
+ ownedArtists = Object.assign({}, artists);
+ ownedPopularity = popularity;
+ ownedExplicitCount = explicitCount;
+ ownedAlbums = Object.assign({}, albums);
+ }
+ duration += playlist.playlist.duration;
+ playlist.items.forEach((track) => {
+ if (!track?.album)
+ return;
+ if (track.link.includes("local"))
+ return;
+ trackIDs.push(track.link.split(":")[2]);
+ if (track.isExplicit)
+ explicitCount++;
+ popularity += track.popularity;
+ 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 [topArtists, topGenres, topGenresTotal] = await fetchTopArtists(artists);
+ const [ownedTopArtists, ownedTopGenres, ownedTopGenresTotal] = await fetchTopArtists(ownedArtists);
+ const [topAlbums, releaseYears, releaseYearsTotal] = await fetchTopAlbums(albums);
+ const [ownedTopAlbums, ownedReleaseYears, ownedReleaseYearsTotal] = await fetchTopAlbums(
+ ownedAlbums,
+ topAlbums
+ );
+ const fetchedFeatures = await fetchAudioFeatures(trackIDs);
+ const audioFeatures = {
+ danceability: 0,
+ energy: 0,
+ valence: 0,
+ speechiness: 0,
+ acousticness: 0,
+ instrumentalness: 0,
+ liveness: 0,
+ tempo: 0
+ };
+ let ownedAudioFeatures = {};
+ for (let i = 0; i < fetchedFeatures.length; i++) {
+ if (i === ownedTrackCount) {
+ ownedAudioFeatures = {
+ popularity: ownedPopularity,
+ explicitness: ownedExplicitCount,
+ ...audioFeatures
+ };
+ }
+ if (!fetchedFeatures[i])
+ continue;
+ const track = fetchedFeatures[i];
+ Object.keys(audioFeatures).forEach((feature) => {
+ audioFeatures[feature] += track[feature];
+ });
+ }
+ const allAudioFeatures = {
+ popularity,
+ explicitness: explicitCount,
+ ...audioFeatures
+ };
+ for (let key in allAudioFeatures) {
+ allAudioFeatures[key] /= fetchedFeatures.length;
+ }
+ for (let key in ownedAudioFeatures) {
+ ownedAudioFeatures[key] /= ownedTrackCount;
+ }
+ const ownedStats = {
+ audioFeatures: ownedAudioFeatures,
+ trackCount: ownedTrackCount,
+ totalDuration: ownedDuration,
+ artists: ownedTopArtists,
+ artistCount: Object.keys(ownedArtists).length,
+ genres: ownedTopGenres,
+ genresDenominator: ownedTopGenresTotal,
+ playlistCount: indexOfFirstNotOwned > 0 ? indexOfFirstNotOwned : 0,
+ albums: ownedTopAlbums,
+ years: ownedReleaseYears,
+ yearsDenominator: ownedReleaseYearsTotal
+ };
+ const allStats = {
+ playlistCount: playlists.length,
+ audioFeatures: allAudioFeatures,
+ trackCount,
+ totalDuration: duration,
+ artistCount: Object.keys(artists).length,
+ artists: topArtists,
+ genres: topGenres,
+ genresDenominator: topGenresTotal,
+ albums: topAlbums,
+ years: releaseYears,
+ yearsDenominator: releaseYearsTotal
+ };
+ if (set) {
+ if (option === "all" && allStats.playlistCount)
+ setLibrary(allStats);
+ else if (option === "owned" && ownedStats.playlistCount)
+ setLibrary(ownedStats);
+ else
+ return setLibrary(300);
+ }
+ Spicetify.LocalStorage.set(`stats:library:all`, JSON.stringify(allStats));
+ Spicetify.LocalStorage.set(`stats:library:owned`, JSON.stringify(ownedStats));
+ console.log("total library fetch time:", window.performance.now() - start);
+ } catch (e) {
+ console.error(e);
+ setLibrary(200);
+ }
+ };
+ import_react26.default.useEffect(() => {
+ updatePageCache(3, fetchData, activeOption.id, true);
+ }, []);
+ import_react26.default.useEffect(() => {
+ fetchData(activeOption.id);
+ }, [activeOption]);
+ const refresh = () => {
+ fetchData(activeOption.id, true);
+ };
+ const props = {
+ title: "Library Analysis",
+ headerEls: [dropdown, /* @__PURE__ */ import_react26.default.createElement(refresh_button_default, {
+ callback: refresh
+ }), /* @__PURE__ */ import_react26.default.createElement(settings_button_default, {
+ configWrapper
+ })]
+ };
+ switch (library) {
+ case 300:
+ return /* @__PURE__ */ import_react26.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react26.default.createElement(status_default, {
+ icon: "error",
+ heading: "No Playlists In Your Library",
+ subheading: "Try adding some playlists first"
+ }));
+ case 200:
+ return /* @__PURE__ */ import_react26.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react26.default.createElement(status_default, {
+ icon: "error",
+ heading: "Failed to Fetch Stats",
+ subheading: "Make an issue on Github"
+ }));
+ case 100:
+ return /* @__PURE__ */ import_react26.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react26.default.createElement(status_default, {
+ icon: "library",
+ heading: "Analysing your Library",
+ subheading: "This may take a while"
+ }));
+ }
+ const statCards = Object.entries(library.audioFeatures).map(([key, value]) => {
+ return /* @__PURE__ */ import_react26.default.createElement(stat_card_default, {
+ label: key,
+ value
+ });
+ });
+ const artistCards = library.artists.slice(0, 10).map((artist) => {
+ return /* @__PURE__ */ import_react26.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_react26.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_react26.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react26.default.createElement("section", {
+ className: "stats-libraryOverview"
+ }, /* @__PURE__ */ import_react26.default.createElement(stat_card_default, {
+ label: "Total Playlists",
+ value: library.playlistCount.toString()
+ }), /* @__PURE__ */ import_react26.default.createElement(stat_card_default, {
+ label: "Total Tracks",
+ value: library.trackCount.toString()
+ }), /* @__PURE__ */ import_react26.default.createElement(stat_card_default, {
+ label: "Total Artists",
+ value: library.artistCount.toString()
+ }), /* @__PURE__ */ import_react26.default.createElement(stat_card_default, {
+ label: "Total Minutes",
+ value: Math.floor(library.totalDuration / 60).toString()
+ }), /* @__PURE__ */ import_react26.default.createElement(stat_card_default, {
+ label: "Total Hours",
+ value: (library.totalDuration / (60 * 60)).toFixed(1)
+ })), /* @__PURE__ */ import_react26.default.createElement(shelf_default, {
+ title: "Most Frequent Genres"
+ }, /* @__PURE__ */ import_react26.default.createElement(genres_card_default, {
+ genres: library.genres,
+ total: library.genresDenominator
+ }), /* @__PURE__ */ import_react26.default.createElement(inline_grid_default, {
+ special: true
+ }, statCards)), /* @__PURE__ */ import_react26.default.createElement(shelf_default, {
+ title: "Most Frequent Artists"
+ }, /* @__PURE__ */ import_react26.default.createElement(inline_grid_default, null, artistCards)), /* @__PURE__ */ import_react26.default.createElement(shelf_default, {
+ title: "Most Frequent Albums"
+ }, /* @__PURE__ */ import_react26.default.createElement(inline_grid_default, null, albumCards)), /* @__PURE__ */ import_react26.default.createElement(shelf_default, {
+ title: "Release Year Distribution"
+ }, /* @__PURE__ */ import_react26.default.createElement(genres_card_default, {
+ genres: library.years,
+ total: library.yearsDenominator
+ })));
+ };
+ var library_default = import_react26.default.memo(LibraryPage);
+
+ // src/pages/charts.tsx
+ var import_react27 = __toESM(require_react());
+ var DropdownOptions5 = [
+ { id: "artists", name: "Top Artists" },
+ { id: "tracks", name: "Top Tracks" }
+ ];
+ var ChartsPage = ({ configWrapper }) => {
+ const [chartData, setChartData] = import_react27.default.useState(100);
+ const [dropdown, activeOption, setActiveOption] = useDropdownMenu_default(DropdownOptions5, "stats:charts");
+ async function fetchChartData(type, force, set = true) {
+ if (!force) {
+ let storedData = Spicetify.LocalStorage.get(`stats:charts:${type}`);
+ if (storedData)
+ return setChartData(JSON.parse(storedData));
+ }
+ const api_key = configWrapper.config["api-key"];
+ if (!api_key)
+ return setChartData(200);
+ const response = await apiRequest("charts", LASTFM.charts(api_key, type));
+ if (!response)
+ return setChartData(500);
+ const data = response[type].track || response[type].artist;
+ const cardData = await (type == "artists" ? convertArtistData(data) : convertTrackData(data));
+ if (type === "tracks") {
+ const likedArray = await checkLiked(cardData.map((track) => track.id));
+ if (!likedArray)
+ return setChartData(200);
+ cardData.forEach((track, index) => {
+ track.liked = likedArray[index];
+ });
+ }
+ if (set)
+ setChartData(cardData);
+ Spicetify.LocalStorage.set(`stats:charts:${type}`, JSON.stringify(cardData));
+ }
+ import_react27.default.useEffect(() => {
+ updatePageCache(4, fetchChartData, activeOption.id, "charts");
+ }, []);
+ import_react27.default.useEffect(() => {
+ fetchChartData(activeOption.id);
+ }, [activeOption]);
+ const refresh = () => {
+ fetchChartData(activeOption.id, true);
+ };
+ const props = {
+ title: "Top Albums",
+ headerEls: [dropdown, /* @__PURE__ */ import_react27.default.createElement(refresh_button_default, {
+ callback: refresh
+ }), /* @__PURE__ */ import_react27.default.createElement(settings_button_default, {
+ configWrapper
+ })]
+ };
+ switch (chartData) {
+ case 200:
+ return /* @__PURE__ */ import_react27.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react27.default.createElement(status_default, {
+ icon: "error",
+ heading: "No API Key",
+ subheading: "Please enter your Last.fm API key in the settings menu."
+ }));
+ case 500:
+ return /* @__PURE__ */ import_react27.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react27.default.createElement(status_default, {
+ icon: "error",
+ heading: "Error",
+ subheading: "An error occurred while fetching the data."
+ }));
+ case 100:
+ return /* @__PURE__ */ import_react27.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react27.default.createElement(status_default, {
+ icon: "library",
+ heading: "Loading",
+ subheading: "Fetching data from Last.fm..."
+ }));
+ }
+ if (!chartData[0]?.album) {
+ const artistCards = chartData.map((artist, index) => {
+ const type = artist.uri.startsWith("https") ? "lastfm" : "artist";
+ return /* @__PURE__ */ import_react27.default.createElement(spotify_card_default, {
+ type,
+ uri: artist.uri,
+ header: artist.name,
+ subheader: `#${index + 1} Artist`,
+ imageUrl: artist.image
+ });
+ });
+ props.title = `Charts - Top Artists`;
+ return /* @__PURE__ */ import_react27.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react27.default.createElement("div", {
+ className: `main-gridContainer-gridContainer grid`
+ }, artistCards));
+ } else {
+ const date = new Date().toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit"
+ });
+ const infoToCreatePlaylist = {
+ playlistName: `Charts - Top Tracks - ${date}`,
+ itemsUris: chartData.map((track) => track.uri)
+ };
+ const trackRows = chartData.map((track, index) => /* @__PURE__ */ import_react27.default.createElement(track_row_default, {
+ index: index + 1,
+ ...track,
+ uris: chartData.map((track2) => track2.uri)
+ }));
+ props.title = `Charts - Top Tracks`;
+ return /* @__PURE__ */ import_react27.default.createElement(page_container_default, {
+ ...props,
+ infoToCreatePlaylist
+ }, /* @__PURE__ */ import_react27.default.createElement(tracklist_default, null, trackRows));
+ }
+ };
+ var charts_default = import_react27.default.memo(ChartsPage);
+
+ // src/pages/top_albums.tsx
+ var import_react28 = __toESM(require_react());
+ var topAlbumsReq = async (time_range, configWrapper) => {
+ const { config } = configWrapper;
+ if (!config["api-key"] || !config["lastfm-user"])
+ return 300;
+ const { ["lastfm-user"]: user, ["api-key"]: key } = config;
+ const response = await apiRequest("lastfm", LASTFM.topalbums(user, key, time_range));
+ if (!response)
+ return 200;
+ return await convertAlbumData(response.topalbums.album);
+ };
+ var DropdownOptions6 = [
+ { id: "short_term", name: "Past Month" },
+ { id: "medium_term", name: "Past 6 Months" },
+ { id: "long_term", name: "All Time" }
+ ];
+ var AlbumsPage = ({ configWrapper }) => {
+ const { LocalStorage } = Spicetify;
+ const [topAlbums, setTopAlbums] = import_react28.default.useState(100);
+ const [dropdown, activeOption] = useDropdownMenu_default(DropdownOptions6, "stats:top-albums");
+ const fetchTopAlbums2 = async (time_range, force, set = true) => {
+ if (!force) {
+ let storedData = LocalStorage.get(`stats:top-albums:${time_range}`);
+ if (storedData)
+ return setTopAlbums(JSON.parse(storedData));
+ }
+ const start = window.performance.now();
+ const topAlbums2 = await topAlbumsReq(time_range, configWrapper);
+ if (set)
+ setTopAlbums(topAlbums2);
+ LocalStorage.set(`stats:top-albums:${time_range}`, JSON.stringify(topAlbums2));
+ console.log("total albums fetch time:", window.performance.now() - start);
+ };
+ import_react28.default.useEffect(() => {
+ updatePageCache(5, fetchTopAlbums2, activeOption.id);
+ }, []);
+ import_react28.default.useEffect(() => {
+ fetchTopAlbums2(activeOption.id);
+ }, [activeOption]);
+ const refresh = () => {
+ fetchTopAlbums2(activeOption.id, true);
+ };
+ const props = {
+ title: "Top Albums",
+ headerEls: [dropdown, /* @__PURE__ */ import_react28.default.createElement(refresh_button_default, {
+ callback: refresh
+ }), /* @__PURE__ */ import_react28.default.createElement(settings_button_default, {
+ configWrapper
+ })]
+ };
+ switch (topAlbums) {
+ case 300:
+ return /* @__PURE__ */ import_react28.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react28.default.createElement(status_default, {
+ icon: "error",
+ heading: "No API Key or Username",
+ subheading: "Please enter these in the settings menu"
+ }));
+ case 200:
+ return /* @__PURE__ */ import_react28.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react28.default.createElement(status_default, {
+ icon: "error",
+ heading: "Failed to Fetch Top Artists",
+ subheading: "An error occurred while fetching the data"
+ }));
+ case 100:
+ return /* @__PURE__ */ import_react28.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react28.default.createElement(status_default, {
+ icon: "library",
+ heading: "Loading",
+ subheading: "Fetching data..."
+ }));
+ }
+ const albumCards = topAlbums.map((album, index) => {
+ const type = album.uri.startsWith("https") ? "lastfm" : "album";
+ return /* @__PURE__ */ import_react28.default.createElement(spotify_card_default, {
+ type,
+ uri: album.uri,
+ header: album.name,
+ subheader: `#${index + 1} Album`,
+ imageUrl: album.image
+ });
+ });
+ return /* @__PURE__ */ import_react28.default.createElement(page_container_default, {
+ ...props
+ }, /* @__PURE__ */ import_react28.default.createElement("div", {
+ className: `main-gridContainer-gridContainer grid`
+ }, albumCards));
+ };
+ var top_albums_default = import_react28.default.memo(AlbumsPage);
+
+ // package.json
+ var version = "0.3.3";
+
+ // src/app.tsx
+ var checkForUpdates = (setNewUpdate) => {
+ fetch("https://api.github.com/repos/harbassan/spicetify-apps/releases").then((res) => res.json()).then(
+ (result) => {
+ const releases = result.filter((release) => release.name.startsWith("stats"));
+ setNewUpdate(releases[0].name.slice(7) !== version);
+ },
+ (error) => {
+ console.log("Failed to check for updates", error);
+ }
+ );
+ };
+ var App = () => {
+ const [config, setConfig] = import_react29.default.useState({ ...SpicetifyStats.ConfigWrapper.Config });
+ const launchModal = () => {
+ SpicetifyStats.ConfigWrapper.launchModal(setConfig);
+ };
+ const configWrapper = {
+ config,
+ launchModal
+ };
+ const pages = {
+ ["Artists"]: /* @__PURE__ */ import_react29.default.createElement(top_artists_default, {
+ configWrapper
+ }),
+ ["Tracks"]: /* @__PURE__ */ import_react29.default.createElement(top_tracks_default, {
+ configWrapper
+ }),
+ ["Albums"]: /* @__PURE__ */ import_react29.default.createElement(top_albums_default, {
+ configWrapper
+ }),
+ ["Genres"]: /* @__PURE__ */ import_react29.default.createElement(top_genres_default, {
+ configWrapper
+ }),
+ ["Library"]: /* @__PURE__ */ import_react29.default.createElement(library_default, {
+ configWrapper
+ }),
+ ["Charts"]: /* @__PURE__ */ import_react29.default.createElement(charts_default, {
+ configWrapper
+ })
+ };
+ const tabPages = ["Artists", "Tracks", "Albums", "Genres", "Library", "Charts"].filter(
+ (page) => configWrapper.config[`show-${page.toLowerCase()}`]
+ );
+ const [navBar, activeLink, setActiveLink] = useNavigationBar_default(tabPages);
+ const [hasPageSwitched, setHasPageSwitched] = import_react29.default.useState(false);
+ const [newUpdate, setNewUpdate] = import_react29.default.useState(false);
+ import_react29.default.useEffect(() => {
+ setActiveLink(Spicetify.LocalStorage.get("stats:active-link") || "Artists");
+ checkForUpdates(setNewUpdate);
+ setHasPageSwitched(true);
+ }, []);
+ import_react29.default.useEffect(() => {
+ Spicetify.LocalStorage.set("stats:active-link", activeLink);
+ }, [activeLink]);
+ if (!hasPageSwitched) {
+ return /* @__PURE__ */ import_react29.default.createElement(import_react29.default.Fragment, null);
+ }
+ return /* @__PURE__ */ import_react29.default.createElement("div", {
+ id: "stats-app"
+ }, navBar, newUpdate && /* @__PURE__ */ import_react29.default.createElement("div", {
+ className: "new-update"
+ }, "New app update available! Visit", " ", /* @__PURE__ */ import_react29.default.createElement("a", {
+ href: "https://github.com/harbassan/spicetify-apps/releases"
+ }, "harbassan/spicetify-apps"), " to install."), pages[activeLink]);
+ };
+ var app_default = App;
+
+ // ../../../AppData/Local/Temp/spicetify-creator/index.jsx
+ var import_react30 = __toESM(require_react());
+ function render() {
+ return /* @__PURE__ */ import_react30.default.createElement(app_default, null);
+ }
+ return __toCommonJS(spicetify_creator_exports);
+})();
+const render=()=>stats.default();
diff --git a/dot-config/spicetify/CustomApps/stats/manifest.json b/dot-config/spicetify/CustomApps/stats/manifest.json
new file mode 100644
index 00000000..2a652239
--- /dev/null
+++ b/dot-config/spicetify/CustomApps/stats/manifest.json
@@ -0,0 +1,9 @@
+{
+ "name": "Statistics",
+ "icon": "<svg xmlns=\"http://www.w3.org/2000/svg\"\r\n\t width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" style=\"fill:currentColor\" >\r\n<path d=\"M3,23L3,23c-0.55,0-1-0.45-1-1v-9c0-0.55,0.45-1,1-1h0c0.55,0,1,0.45,1,1v9C4,22.55,3.55,23,3,23z\"/>\r\n<path d=\"M9,23L9,23c-0.55,0-1-0.45-1-1V9c0-0.55,0.45-1,1-1h0c0.55,0,1,0.45,1,1v13C10,22.55,9.55,23,9,23z\"/>\r\n<path d=\"M15,23L15,23c-0.55,0-1-0.45-1-1V11c0-0.55,0.45-1,1-1h0c0.55,0,1,0.45,1,1v11C16,22.55,15.55,23,15,23z\"/>\r\n<path d=\"M21,23L21,23c-0.55,0-1-0.45-1-1V8c0-0.55,0.45-1,1-1h0c0.55,0,1,0.45,1,1v14C22,22.55,21.55,23,21,23z\"/>\r\n<path d=\"M22.86,1.5c-0.28-0.48-0.89-0.64-1.37-0.37l-6.54,3.74l-6-2.33C8.52,2.38,8.22,2.41,7.88,2.73L1.29,7.71\r\n\tC0.86,8.05,0.8,8.68,1.15,9.11s0.98,0.49,1.41,0.14l6.2-4.65l5.69,2.2C15,6.99,15.3,6.96,15.83,6.68l6.67-3.82\r\n\tC22.98,2.59,23.14,1.97,22.86,1.5z\"/>\r\n</svg>\r\n",
+ "active-icon": "<svg xmlns=\"http://www.w3.org/2000/svg\"\r\n\t width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" style=\"fill:currentColor\" >\r\n<path d=\"M3,23L3,23c-0.55,0-1-0.45-1-1v-9c0-0.55,0.45-1,1-1h0c0.55,0,1,0.45,1,1v9C4,22.55,3.55,23,3,23z\"/>\r\n<path d=\"M9,23L9,23c-0.55,0-1-0.45-1-1V9c0-0.55,0.45-1,1-1h0c0.55,0,1,0.45,1,1v13C10,22.55,9.55,23,9,23z\"/>\r\n<path d=\"M15,23L15,23c-0.55,0-1-0.45-1-1V11c0-0.55,0.45-1,1-1h0c0.55,0,1,0.45,1,1v11C16,22.55,15.55,23,15,23z\"/>\r\n<path d=\"M21,23L21,23c-0.55,0-1-0.45-1-1V8c0-0.55,0.45-1,1-1h0c0.55,0,1,0.45,1,1v14C22,22.55,21.55,23,21,23z\"/>\r\n<path d=\"M22.86,1.5c-0.28-0.48-0.89-0.64-1.37-0.37l-6.54,3.74l-6-2.33C8.52,2.38,8.22,2.41,7.88,2.73L1.29,7.71\r\n\tC0.86,8.05,0.8,8.68,1.15,9.11s0.98,0.49,1.41,0.14l6.2-4.65l5.69,2.2C15,6.99,15.3,6.96,15.83,6.68l6.67-3.82\r\n\tC22.98,2.59,23.14,1.97,22.86,1.5z\"/>\r\n</svg>\r\n",
+ "subfiles": [],
+ "subfiles_extension": [
+ "extension.js"
+ ]
+} \ No newline at end of file
diff --git a/dot-config/spicetify/CustomApps/stats/style.css b/dot-config/spicetify/CustomApps/stats/style.css
new file mode 100644
index 00000000..ba798f30
--- /dev/null
+++ b/dot-config/spicetify/CustomApps/stats/style.css
@@ -0,0 +1,276 @@
+/* ../../../AppData/Local/Temp/tmp-17120-n3iWRzissBjg/18f3cbbb4bc3/navBar.module.css */
+.navBar-module__topBarHeaderItem___piw4C_stats {
+ -webkit-app-region: no-drag;
+ display: inline-block;
+ pointer-events: auto;
+}
+.navBar-module__topBarHeaderItemLink___xA4uv_stats {
+ margin: 0 8px 0 0;
+}
+.navBar-module__topBarActive___XhWpm_stats {
+ background-color: var(--spice-tab-active);
+ border-radius: 4px;
+}
+.navBar-module__topBarHeaderItemLink___xA4uv_stats {
+ border-radius: 4px;
+ color: var(--spice-text);
+ display: inline-block;
+ margin: 0 8px;
+ padding: 8px 16px;
+ position: relative;
+ text-decoration: none !important;
+ cursor: pointer;
+}
+.navBar-module__topBarNav___qWGeZ_stats {
+ -webkit-app-region: drag;
+ pointer-events: none;
+ width: 100%;
+}
+.navBar-module__topBarHeaderItem___piw4C_stats .navBar-module__optionsMenuDropBox___pzfNI_stats {
+ color: var(--spice-text);
+ border: 0;
+ max-width: 150px;
+ height: 42px;
+ padding: 0 30px 0 12px;
+ background-color: initial;
+ cursor: pointer;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+}
+.navBar-module__topBarHeaderItem___piw4C_stats .navBar-module__optionsMenuDropBox___pzfNI_stats svg {
+ position: absolute;
+ margin-left: 8px;
+}
+div.navBar-module__topBarHeaderItemLink___xA4uv_stats {
+ padding: 0;
+}
+
+/* ../../../AppData/Local/Temp/tmp-17120-n3iWRzissBjg/18f3cbbb33e0/app.css */
+#stats-app .stats-gridInline {
+ --grid-gap: 24px;
+ grid-template-columns: repeat(10, 180px) !important;
+ overflow-x: hidden;
+ scroll-behavior: smooth;
+ margin-top: 5px;
+}
+#stats-app [data-scroll=both] {
+ -webkit-mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);
+ mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);
+}
+#stats-app [data-scroll=end] {
+ -webkit-mask-image: linear-gradient(to right, transparent, black 10%);
+ mask-image: linear-gradient(to right, transparent, black 10%);
+}
+#stats-app [data-scroll=start] {
+ -webkit-mask-image: linear-gradient(to right, black 90%, transparent);
+ mask-image: linear-gradient(to right, black 90%, transparent);
+}
+#stats-app .stats-libraryOverview {
+ display: flex;
+ gap: 24px;
+ align-items: center;
+}
+#stats-app .stats-trackPageTitle {
+ display: flex;
+ gap: 24px;
+ align-items: center;
+}
+#stats-app .stats-scrollButton {
+ width: 40px;
+ border-radius: 8px;
+ border: none;
+ padding: 0;
+ margin-right: 5px;
+ background-color: var(--spice-player);
+ color: var(--spice-subtext);
+}
+#stats-app .stats-scrollButton:hover {
+ background-color: var(--spice-card);
+ color: var(--spice-text);
+}
+#stats-app .stats-tracklistHeader > div {
+ display: flex;
+ -webkit-app-region: no-drag;
+ gap: 20px;
+ align-items: center;
+}
+#stats-app .stats-make-playlist-button {
+ margin-inline-start: 12px;
+}
+#stats-app .stats-genreCard {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 16px;
+ border-radius: 8px;
+ background: var(--spice-player);
+ position: relative;
+}
+#stats-app .stats-genreRow {
+ width: 100%;
+ height: 20px;
+ display: flex;
+ gap: 10px;
+}
+#stats-app .stats-genreRowFill {
+ background: var(--spice-button);
+ height: 100%;
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+}
+#stats-app .stats-genreText {
+ color: var(--spice-player);
+ font-size: 0.875rem;
+ margin-left: 7px;
+ font-weight: bold;
+}
+#stats-app .stats-genreValue {
+ color: var(--spice-text);
+ font-size: 0.875rem;
+}
+#stats-app .stats-genreCard + .stats-gridInlineSection {
+ margin-top: 3px;
+}
+#stats-app .main-trackList-rowHeartButton,
+#stats-app .main-trackList-rowMoreButton {
+ background-color: transparent;
+ border: none;
+}
+.GenericModal[aria-label="Playlist Stats"] .main-embedWidgetGenerator-container {
+ width: 80vw;
+ height: 80vh;
+ background-color: var(--spice-main);
+}
+.GenericModal[aria-label="Playlist Stats"] .main-shelf-title {
+ color: var(--spice-text);
+}
+
+/* ../../../AppData/Local/Temp/tmp-17120-n3iWRzissBjg/18f3cbbb48b1/config_modal.css */
+.config-container {
+ gap: 10px;
+ display: flex;
+ flex-direction: column;
+}
+.config-container .section-header {
+ box-sizing: border-box;
+ -webkit-tap-highlight-color: transparent;
+ margin-block: 0px;
+ font-size: 1.125rem;
+ font-weight: 700;
+ color: var(--spice-text);
+}
+.config-container .col.description {
+ box-sizing: border-box;
+ -webkit-tap-highlight-color: transparent;
+ margin-block: 0px;
+ font-size: 0.875rem;
+ font-weight: 400;
+ color: var(--spice-subtext);
+}
+.config-container .disabled {
+ opacity: 0;
+ pointer-events: none;
+}
+.config-container .text-input {
+ background: rgba(var(--spice-rgb-selected-row), 0.1);
+ border: 1px solid transparent;
+ border-radius: 4px;
+ color: var(--spice-text);
+ font-family: inherit;
+ font-size: 14px;
+ height: 32px;
+ padding: 0 12px;
+ width: 100%;
+}
+.config-container .text-input:focus {
+ background-color: var(--spice-tab-active);
+ border: 1px solid var(--spice-button-disabled);
+ outline: none;
+}
+.config-container .dropdown-input {
+ background-color: var(--spice-tab-active);
+ border: 0;
+ border-radius: 4px;
+ color: rgba(var(--spice-rgb-selected-row), 0.7);
+ font-size: 14px;
+ font-weight: 400;
+ height: 32px;
+ letter-spacing: 0.24px;
+ line-height: 20px;
+ padding: 0 32px 0 12px;
+ width: 100%;
+}
+.config-container .tooltip-icon {
+ float: right;
+ margin-left: 10px;
+ display: flex;
+ align-items: center;
+ height: 22px;
+ fill: var(--spice-subtext);
+}
+.config-container .tooltip-icon:hover {
+ fill: var(--spice-text);
+}
+.config-container .tooltip {
+ text-align: center;
+}
+.config-container .setting-row {
+ display: flex;
+ justify-content: space-between;
+}
+.config-container .playback-progressbar {
+ width: 200px;
+}
+
+/* ../../../AppData/Local/Temp/tmp-17120-n3iWRzissBjg/18f3cbbb49f2/shared.css */
+.grid {
+ --grid-gap: 24px;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)) !important;
+}
+.loadingWrapper {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 60vh;
+ flex-direction: column;
+ gap: 16px;
+}
+.loadingWrapper .status-icon {
+ width: 40px;
+ height: 40px;
+ fill: currentColor;
+}
+.page-content {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+.page-header {
+ align-content: space-between;
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ margin: 16px 0;
+}
+.page-header .header-right,
+.page-header .header-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.page-header .header-right {
+ justify-content: flex-end;
+}
+.page-header .header-left {
+ justify-content: flex-start;
+}
+.new-update {
+ background-color: var(--spice-player);
+ color: var(--spice-text);
+ border-radius: 8px;
+ padding: 2px 12px;
+ margin: 0 24px;
+ border: 0px;
+}