🔧 chore(spicetify): update stats extension
This commit is contained in:
parent
132081e49a
commit
ba9748218c
8 changed files with 2502 additions and 766 deletions
|
@ -1,46 +0,0 @@
|
|||
# Spicetify Stats
|
||||
|
||||
### A custom app that shows you your top artists, tracks, genres and an analysis of your whole library.
|
||||
|
||||
---
|
||||
|
||||
### Top Artists
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Top Tracks
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Top Genres
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Library Analysis
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Manual Installation
|
||||
|
||||
Download the files in the [dist branch](https://github.com/harbassan/spicetify-stats/archive/refs/heads/dist.zip) and rename the folder to `stats`, and then place that folder into your `CustomApps` folder in the spicetify directory.
|
||||
|
||||
Then run these commands to apply:
|
||||
|
||||
```powershell
|
||||
spicetify config custom_apps stats
|
||||
spicetify apply
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
If you have any questions or issues regarding the app open an issue on this repo. Please specify your spicetify version and installation method if you do so.
|
||||
|
||||
If you really like the app i'd be grateful if you liked the repo ❤️.
|
|
@ -1,10 +1,608 @@
|
|||
"use strict";
|
||||
(async function() {
|
||||
while (!Spicetify.React || !Spicetify.ReactDOM) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
"use strict";
|
||||
var stats = (() => {
|
||||
// src/extensions/extension.tsx
|
||||
(async () => {
|
||||
while (!(Spicetify == null ? void 0 : Spicetify.LocalStorage)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __propIsEnum = Object.prototype.propertyIsEnumerable;
|
||||
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
||||
var __spreadValues = (a, b) => {
|
||||
for (var prop in b || (b = {}))
|
||||
if (__hasOwnProp.call(b, prop))
|
||||
__defNormalProp(a, prop, b[prop]);
|
||||
if (__getOwnPropSymbols)
|
||||
for (var prop of __getOwnPropSymbols(b)) {
|
||||
if (__propIsEnum.call(b, prop))
|
||||
__defNormalProp(a, prop, b[prop]);
|
||||
}
|
||||
return a;
|
||||
};
|
||||
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 });
|
||||
}
|
||||
Spicetify.LocalStorage.set("stats:cache-info", JSON.stringify([0, 0, 0, 0]));
|
||||
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
|
||||
));
|
||||
|
||||
// external-global-plugin:react
|
||||
var require_react = __commonJS({
|
||||
"external-global-plugin:react"(exports, module) {
|
||||
module.exports = Spicetify.React;
|
||||
}
|
||||
});
|
||||
|
||||
// src/extensions/extension.tsx
|
||||
var import_react7 = __toESM(require_react());
|
||||
|
||||
// src/pages/playlist.tsx
|
||||
var import_react6 = __toESM(require_react());
|
||||
|
||||
// src/components/cards/stat_card.tsx
|
||||
var import_react = __toESM(require_react());
|
||||
var StatCard = (props) => {
|
||||
return /* @__PURE__ */ import_react.default.createElement(import_react.default.Fragment, null, /* @__PURE__ */ import_react.default.createElement("div", {
|
||||
className: "main-card-card"
|
||||
}, /* @__PURE__ */ import_react.default.createElement("div", {
|
||||
className: "stats-cardValue"
|
||||
}, props.value), /* @__PURE__ */ import_react.default.createElement("div", null, /* @__PURE__ */ import_react.default.createElement("div", {
|
||||
className: "TypeElement-balladBold-textBase-4px-type main-cardHeader-text stats-cardText",
|
||||
"data-encore-id": "type"
|
||||
}, props.stat))));
|
||||
};
|
||||
var stat_card_default = import_react.default.memo(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 = import_react2.default.memo(genresCard);
|
||||
|
||||
// src/components/cards/artist_card.tsx
|
||||
var import_react3 = __toESM(require_react());
|
||||
var DraggableComponent = (props) => {
|
||||
var _a, _b;
|
||||
const dragHandler = (_b = (_a = Spicetify.ReactHook).DragHandler) == null ? void 0 : _b.call(_a, [props.uri], props.title);
|
||||
return /* @__PURE__ */ import_react3.default.createElement("div", {
|
||||
onDragStart: dragHandler,
|
||||
draggable: "true",
|
||||
className: "main-card-draggable"
|
||||
}, props.children);
|
||||
};
|
||||
var Card = ({ name, image, uri, subtext }) => {
|
||||
const goToArtist = (uriString) => {
|
||||
if (uriString.includes("last")) {
|
||||
return window.open(uriString, "_blank");
|
||||
}
|
||||
const uriObj = Spicetify.URI.fromString(uriString);
|
||||
const url = uriObj.toURLPath(true);
|
||||
Spicetify.Platform.History.push(url);
|
||||
Spicetify.Platform.History.goForward();
|
||||
};
|
||||
const isArtist = uri.includes("artist");
|
||||
const MenuWrapper = import_react3.default.memo((props) => {
|
||||
return isArtist ? /* @__PURE__ */ import_react3.default.createElement(Spicetify.ReactComponent.ArtistMenu, __spreadValues({}, props)) : /* @__PURE__ */ import_react3.default.createElement(Spicetify.ReactComponent.AlbumMenu, __spreadValues({}, props));
|
||||
});
|
||||
return /* @__PURE__ */ import_react3.default.createElement(import_react3.default.Fragment, null, /* @__PURE__ */ import_react3.default.createElement(Spicetify.ReactComponent.ContextMenu, {
|
||||
menu: /* @__PURE__ */ import_react3.default.createElement(MenuWrapper, {
|
||||
uri
|
||||
}),
|
||||
trigger: "right-click"
|
||||
}, /* @__PURE__ */ import_react3.default.createElement("div", {
|
||||
className: "main-card-card"
|
||||
}, /* @__PURE__ */ import_react3.default.createElement(DraggableComponent, {
|
||||
uri,
|
||||
title: name
|
||||
}, /* @__PURE__ */ import_react3.default.createElement("div", {
|
||||
className: "main-card-imageContainer"
|
||||
}, /* @__PURE__ */ import_react3.default.createElement("div", {
|
||||
className: `main-cardImage-imageWrapper ${isArtist ? `main-cardImage-circular` : ""}`
|
||||
}, /* @__PURE__ */ import_react3.default.createElement("div", {
|
||||
className: ""
|
||||
}, /* @__PURE__ */ import_react3.default.createElement("img", {
|
||||
"aria-hidden": "false",
|
||||
draggable: "false",
|
||||
loading: "lazy",
|
||||
src: image,
|
||||
className: `main-image-image main-cardImage-image ${isArtist ? `main-cardImage-circular` : ""} main-image-loaded`
|
||||
}))), /* @__PURE__ */ import_react3.default.createElement("div", {
|
||||
className: "main-card-PlayButtonContainer",
|
||||
onClick: () => Spicetify.Player.playUri(uri)
|
||||
}, /* @__PURE__ */ import_react3.default.createElement("div", {
|
||||
className: "main-playButton-PlayButton"
|
||||
}, /* @__PURE__ */ import_react3.default.createElement("button", {
|
||||
"data-encore-id": "buttonPrimary",
|
||||
className: "Button-md-useBrowserDefaultFocusStyle Button-md-buttonPrimary-useBrowserDefaultFocusStyle Button-medium-buttonPrimary-useBrowserDefaultFocusStyle"
|
||||
}, /* @__PURE__ */ import_react3.default.createElement("span", {
|
||||
className: "ButtonInner-md-iconOnly ButtonInner-medium-iconOnly encore-bright-accent-set"
|
||||
}, /* @__PURE__ */ import_react3.default.createElement("span", {
|
||||
"aria-hidden": "true",
|
||||
className: "Wrapper-md-24-only Wrapper-medium-medium-only"
|
||||
}, /* @__PURE__ */ import_react3.default.createElement("svg", {
|
||||
role: "img",
|
||||
height: "24",
|
||||
width: "24",
|
||||
"aria-hidden": "true",
|
||||
viewBox: "0 0 24 24",
|
||||
"data-encore-id": "icon",
|
||||
className: "Svg-img-24 Svg-img-24-icon Svg-img-icon-medium"
|
||||
}, /* @__PURE__ */ import_react3.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_react3.default.createElement("div", {
|
||||
className: "main-card-cardMetadata"
|
||||
}, /* @__PURE__ */ import_react3.default.createElement("a", {
|
||||
draggable: "false",
|
||||
className: "main-cardHeader-link",
|
||||
dir: "auto"
|
||||
}, /* @__PURE__ */ import_react3.default.createElement("div", {
|
||||
className: "TypeElement-balladBold-textBase-4px-type main-cardHeader-text",
|
||||
"data-encore-id": "type"
|
||||
}, name)), /* @__PURE__ */ import_react3.default.createElement("div", {
|
||||
className: "TypeElement-mesto-textSubdued-type main-cardSubHeader-root",
|
||||
"data-encore-id": "type"
|
||||
}, /* @__PURE__ */ import_react3.default.createElement("span", null, subtext))), /* @__PURE__ */ import_react3.default.createElement("div", {
|
||||
className: "main-card-cardLink",
|
||||
onClick: () => goToArtist(uri)
|
||||
})))));
|
||||
};
|
||||
var artist_card_default = import_react3.default.memo(Card);
|
||||
|
||||
// src/funcs.ts
|
||||
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) => {
|
||||
const url = `https://api.spotify.com/v1/audio-features?ids=${batch.join(",")}`;
|
||||
return apiRequest("audioFeaturesBatch" + index, url, 5, false);
|
||||
});
|
||||
const responses = await Promise.all(promises);
|
||||
const data = responses.reduce((acc, response) => {
|
||||
if (!(response == null ? void 0 : 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;
|
||||
let top_albums = await Promise.all(
|
||||
album_keys.map(async (albumID) => {
|
||||
var _a, _b, _c;
|
||||
let albumMeta;
|
||||
if (cachedAlbums) {
|
||||
for (let i = 0; i < cachedAlbums.length; i++) {
|
||||
if (cachedAlbums[i].uri === `spotify:album:${albumID}`) {
|
||||
albumMeta = cachedAlbums[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!albumMeta) {
|
||||
try {
|
||||
albumMeta = await Spicetify.GraphQL.Request(Spicetify.GraphQL.Definitions.getAlbum, {
|
||||
uri: `spotify:album:${albumID}`,
|
||||
locale: "en",
|
||||
offset: 0,
|
||||
limit: 50
|
||||
});
|
||||
if (!((_b = (_a = albumMeta == null ? void 0 : albumMeta.data) == null ? void 0 : _a.albumUnion) == null ? void 0 : _b.name))
|
||||
throw new Error("Invalid URI");
|
||||
} catch (e) {
|
||||
console.error("stats - album metadata request failed:", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const releaseYear = (albumMeta == null ? void 0 : 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 || ((_c = albumMeta.data.albumUnion.coverArt.sources[0]) == null ? void 0 : _c.url) || "https://commons.wikimedia.org/wiki/File:Black_square.jpg",
|
||||
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) => {
|
||||
var _a;
|
||||
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", `https://api.spotify.com/v1/artists?ids=${artist_keys.join(",")}`);
|
||||
let top_artists = (_a = artistsMeta == null ? void 0 : artistsMeta.artists) == null ? void 0 : _a.map((artist) => {
|
||||
var _a2;
|
||||
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: ((_a2 = artist.images[2]) == null ? void 0 : _a2.url) || "https://commons.wikimedia.org/wiki/File:Black_square.jpg",
|
||||
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/components/status.tsx
|
||||
var import_react4 = __toESM(require_react());
|
||||
var ErrorIcon = () => {
|
||||
return /* @__PURE__ */ import_react4.default.createElement("svg", {
|
||||
"data-encore-id": "icon",
|
||||
role: "img",
|
||||
"aria-hidden": "true",
|
||||
viewBox: "0 0 24 24",
|
||||
className: "status-icon"
|
||||
}, /* @__PURE__ */ import_react4.default.createElement("path", {
|
||||
d: "M11 18v-2h2v2h-2zm0-4V6h2v8h-2z"
|
||||
}), /* @__PURE__ */ import_react4.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_react4.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_react4.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_react4.default.useState(false);
|
||||
import_react4.default.useEffect(() => {
|
||||
const to = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, 500);
|
||||
return () => clearTimeout(to);
|
||||
}, []);
|
||||
return isVisible ? /* @__PURE__ */ import_react4.default.createElement(import_react4.default.Fragment, null, /* @__PURE__ */ import_react4.default.createElement("div", {
|
||||
className: "stats-loadingWrapper"
|
||||
}, props.icon === "error" ? /* @__PURE__ */ import_react4.default.createElement(ErrorIcon, null) : /* @__PURE__ */ import_react4.default.createElement(LibraryIcon, null), /* @__PURE__ */ import_react4.default.createElement("h1", null, props.heading), /* @__PURE__ */ import_react4.default.createElement("h3", null, props.subheading))) : /* @__PURE__ */ import_react4.default.createElement(import_react4.default.Fragment, null);
|
||||
};
|
||||
var status_default = Status;
|
||||
|
||||
// src/components/inline_grid.tsx
|
||||
var import_react5 = __toESM(require_react());
|
||||
var scrollGrid = (event) => {
|
||||
const grid = event.target.parentNode.querySelector("div");
|
||||
grid.scrollLeft += grid.clientWidth;
|
||||
if (grid.scrollWidth - grid.clientWidth - grid.scrollLeft <= grid.clientWidth) {
|
||||
grid.setAttribute("data-scroll", "end");
|
||||
} else {
|
||||
grid.setAttribute("data-scroll", "both");
|
||||
}
|
||||
};
|
||||
var scrollGridLeft = (event) => {
|
||||
const grid = event.target.parentNode.querySelector("div");
|
||||
grid.scrollLeft -= grid.clientWidth;
|
||||
if (grid.scrollLeft <= grid.clientWidth) {
|
||||
grid.setAttribute("data-scroll", "start");
|
||||
} else {
|
||||
grid.setAttribute("data-scroll", "both");
|
||||
}
|
||||
};
|
||||
var InlineGrid = (props) => {
|
||||
return /* @__PURE__ */ import_react5.default.createElement("section", {
|
||||
className: "stats-gridInlineSection"
|
||||
}, /* @__PURE__ */ import_react5.default.createElement("button", {
|
||||
className: "stats-scrollButton",
|
||||
onClick: scrollGridLeft
|
||||
}, "<"), /* @__PURE__ */ import_react5.default.createElement("button", {
|
||||
className: "stats-scrollButton",
|
||||
onClick: scrollGrid
|
||||
}, ">"), /* @__PURE__ */ import_react5.default.createElement("div", {
|
||||
className: `main-gridContainer-gridContainer stats-gridInline${props.special ? " stats-specialGrid" : ""}`,
|
||||
"data-scroll": "start"
|
||||
}, props.children));
|
||||
};
|
||||
var inline_grid_default = InlineGrid;
|
||||
|
||||
// src/pages/playlist.tsx
|
||||
var PlaylistPage = ({ uri }) => {
|
||||
const [library, setLibrary] = import_react6.default.useState(100);
|
||||
const fetchData = async () => {
|
||||
const start = window.performance.now();
|
||||
const playlistMeta = await apiRequest("playlistMeta", `sp://core-playlist/v1/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 = __spreadValues({ 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_react6.default.useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
switch (library) {
|
||||
case 200:
|
||||
return /* @__PURE__ */ import_react6.default.createElement(status_default, {
|
||||
icon: "error",
|
||||
heading: "Failed to Fetch Stats",
|
||||
subheading: "Make an issue on Github"
|
||||
});
|
||||
case 100:
|
||||
return /* @__PURE__ */ import_react6.default.createElement(status_default, {
|
||||
icon: "library",
|
||||
heading: "Analysing the Playlist",
|
||||
subheading: "This may take a while"
|
||||
});
|
||||
}
|
||||
const parseVal = (obj) => {
|
||||
switch (obj[0]) {
|
||||
case "tempo":
|
||||
return Math.round(obj[1]) + "bpm";
|
||||
case "popularity":
|
||||
return Math.round(obj[1]) + "%";
|
||||
default:
|
||||
return Math.round(obj[1] * 100) + "%";
|
||||
}
|
||||
};
|
||||
const statCards = [];
|
||||
Object.entries(library.audioFeatures).forEach((obj) => {
|
||||
statCards.push(/* @__PURE__ */ import_react6.default.createElement(stat_card_default, {
|
||||
stat: obj[0][0].toUpperCase() + obj[0].slice(1),
|
||||
value: parseVal(obj)
|
||||
}));
|
||||
});
|
||||
const artistCards = library.artists.map((artist) => /* @__PURE__ */ import_react6.default.createElement(artist_card_default, {
|
||||
name: artist.name,
|
||||
image: artist.image,
|
||||
uri: artist.uri,
|
||||
subtext: `Appears in ${artist.freq} tracks`
|
||||
}));
|
||||
const albumCards = library.albums.map((album) => {
|
||||
return /* @__PURE__ */ import_react6.default.createElement(artist_card_default, {
|
||||
name: album.name,
|
||||
image: album.image,
|
||||
uri: album.uri,
|
||||
subtext: `Appears in ${album.freq} tracks`
|
||||
});
|
||||
});
|
||||
return /* @__PURE__ */ import_react6.default.createElement("div", {
|
||||
className: "stats-page"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("section", {
|
||||
className: "stats-libraryOverview"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement(stat_card_default, {
|
||||
stat: "Total Tracks",
|
||||
value: library.trackCount
|
||||
}), /* @__PURE__ */ import_react6.default.createElement(stat_card_default, {
|
||||
stat: "Total Artists",
|
||||
value: library.artistCount
|
||||
}), /* @__PURE__ */ import_react6.default.createElement(stat_card_default, {
|
||||
stat: "Total Minutes",
|
||||
value: Math.floor(library.totalDuration / 60)
|
||||
}), /* @__PURE__ */ import_react6.default.createElement(stat_card_default, {
|
||||
stat: "Total Hours",
|
||||
value: (library.totalDuration / (60 * 60)).toFixed(1)
|
||||
})), /* @__PURE__ */ import_react6.default.createElement("section", null, /* @__PURE__ */ import_react6.default.createElement("div", {
|
||||
className: "main-shelf-header"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("div", {
|
||||
className: "main-shelf-topRow"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("div", {
|
||||
className: "main-shelf-titleWrapper"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("h2", {
|
||||
className: "Type__TypeElement-sc-goli3j-0 TypeElement-canon-textBase-type main-shelf-title"
|
||||
}, "Most Frequent Genres")))), /* @__PURE__ */ import_react6.default.createElement(genres_card_default, {
|
||||
genres: library.genres,
|
||||
total: library.genresDenominator
|
||||
}), /* @__PURE__ */ import_react6.default.createElement(inline_grid_default, {
|
||||
special: true
|
||||
}, statCards)), /* @__PURE__ */ import_react6.default.createElement("section", {
|
||||
className: "main-shelf-shelf Shelf"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("div", {
|
||||
className: "main-shelf-header"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("div", {
|
||||
className: "main-shelf-topRow"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("div", {
|
||||
className: "main-shelf-titleWrapper"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("h2", {
|
||||
className: "Type__TypeElement-sc-goli3j-0 TypeElement-canon-textBase-type main-shelf-title"
|
||||
}, "Most Frequent Artists")))), /* @__PURE__ */ import_react6.default.createElement(inline_grid_default, null, artistCards)), /* @__PURE__ */ import_react6.default.createElement("section", {
|
||||
className: "main-shelf-shelf Shelf"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("div", {
|
||||
className: "main-shelf-header"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("div", {
|
||||
className: "main-shelf-topRow"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("div", {
|
||||
className: "main-shelf-titleWrapper"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("h2", {
|
||||
className: "Type__TypeElement-sc-goli3j-0 TypeElement-canon-textBase-type main-shelf-title"
|
||||
}, "Most Frequent Albums")))), /* @__PURE__ */ import_react6.default.createElement(inline_grid_default, null, albumCards)), /* @__PURE__ */ import_react6.default.createElement("section", {
|
||||
className: "main-shelf-shelf Shelf"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("div", {
|
||||
className: "main-shelf-header"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("div", {
|
||||
className: "main-shelf-topRow"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("div", {
|
||||
className: "main-shelf-titleWrapper"
|
||||
}, /* @__PURE__ */ import_react6.default.createElement("h2", {
|
||||
className: "Type__TypeElement-sc-goli3j-0 TypeElement-canon-textBase-type main-shelf-title"
|
||||
}, "Release Year Distribution")))), /* @__PURE__ */ import_react6.default.createElement("section", null, /* @__PURE__ */ import_react6.default.createElement(genres_card_default, {
|
||||
genres: library.years,
|
||||
total: library.yearsDenominator
|
||||
}))));
|
||||
};
|
||||
var playlist_default = import_react6.default.memo(PlaylistPage);
|
||||
|
||||
// package.json
|
||||
var version = "0.3.0";
|
||||
|
||||
// src/constants.ts
|
||||
var STATS_VERSION = version;
|
||||
|
||||
// src/extensions/extension.tsx
|
||||
(async function stats() {
|
||||
if (!Spicetify.Platform) {
|
||||
setTimeout(stats, 100);
|
||||
return;
|
||||
}
|
||||
const version2 = localStorage.getItem("stats:version");
|
||||
if (!version2 || version2 !== STATS_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", STATS_VERSION);
|
||||
}
|
||||
Spicetify.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 Spicetify.Topbar.Button("playlist-stats", "visualizer", () => {
|
||||
const playlistUri = `spotify:playlist:${Spicetify.Platform.History.location.pathname.split("/")[2]}`;
|
||||
Spicetify.PopupModal.display({ title: "Playlist Stats", content: /* @__PURE__ */ import_react7.default.createElement(playlist_default, {
|
||||
uri: playlistUri
|
||||
}), isLarge: true });
|
||||
});
|
||||
playlistEdit.element.classList.toggle("hidden", true);
|
||||
Spicetify.Platform.History.listen(({ pathname }) => {
|
||||
const [, type, uid] = pathname.split("/");
|
||||
const isPlaylistPage = type === "playlist" && uid;
|
||||
playlistEdit.element.classList.toggle("hidden", !isPlaylistPage);
|
||||
});
|
||||
})();
|
||||
})();
|
||||
|
||||
})();
|
File diff suppressed because it is too large
Load diff
Binary file not shown.
Before Width: | Height: | Size: 119 KiB |
Binary file not shown.
Before Width: | Height: | Size: 719 KiB |
Binary file not shown.
Before Width: | Height: | Size: 112 KiB |
Binary file not shown.
Before Width: | Height: | Size: 166 KiB |
|
@ -1,4 +1,4 @@
|
|||
/* ../../AppData/Local/Temp/tmp-19288-CUnNgMBeJddq/18888d8cb0d1/navBar.module.css */
|
||||
/* ../../AppData/Local/Temp/tmp-6912-LESOepgMxajG/18ca59b28c22/navBar.module.css */
|
||||
.navBar-module__topBarHeaderItem___v29bR_stats {
|
||||
-webkit-app-region: no-drag;
|
||||
display: inline-block;
|
||||
|
@ -46,10 +46,10 @@ div.navBar-module__topBarHeaderItemLink___VeyBY_stats {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
/* ../../AppData/Local/Temp/tmp-19288-CUnNgMBeJddq/18888d8c9df0/app.css */
|
||||
/* ../../AppData/Local/Temp/tmp-6912-LESOepgMxajG/18ca59b275b0/app.css */
|
||||
.stats-grid {
|
||||
--grid-gap: 24px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)) !important;
|
||||
}
|
||||
.stats-refreshButton {
|
||||
width: 32px;
|
||||
|
@ -57,21 +57,35 @@ div.navBar-module__topBarHeaderItemLink___VeyBY_stats {
|
|||
}
|
||||
.collection-searchBar-searchBar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.stats-specialGrid {
|
||||
grid-template-columns: repeat(11, 180px) !important;
|
||||
}
|
||||
.stats-gridInline {
|
||||
--grid-gap: 24px;
|
||||
grid-template-columns: repeat(10, 180px);
|
||||
grid-template-columns: repeat(10, 180px) !important;
|
||||
overflow-x: hidden;
|
||||
scroll-behavior: smooth;
|
||||
margin-top: 5px;
|
||||
}
|
||||
[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);
|
||||
}
|
||||
[data-scroll=end] {
|
||||
-webkit-mask-image: linear-gradient(to right, transparent, black 10%);
|
||||
mask-image: linear-gradient(to right, transparent, black 10%);
|
||||
}
|
||||
[data-scroll=start] {
|
||||
-webkit-mask-image: linear-gradient(to right, black 90%, transparent);
|
||||
mask-image: linear-gradient(to right, black 90%, transparent);
|
||||
}
|
||||
.stats-loadingWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 75vh;
|
||||
min-height: 60vh;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
@ -79,6 +93,7 @@ div.navBar-module__topBarHeaderItemLink___VeyBY_stats {
|
|||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.stats-page {
|
||||
display: flex;
|
||||
|
@ -100,9 +115,10 @@ div.navBar-module__topBarHeaderItemLink___VeyBY_stats {
|
|||
color: var(--spice-subtext);
|
||||
}
|
||||
.stats-scrollButton:hover {
|
||||
background-color: #282828;
|
||||
background-color: var(--spice-card);
|
||||
}
|
||||
.stats-createPlaylistButton {
|
||||
margin-left: 24px;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
font-size: 0.8125rem;
|
||||
|
@ -208,3 +224,145 @@ div.navBar-module__topBarHeaderItemLink___VeyBY_stats {
|
|||
margin: 0 24px;
|
||||
border: 0px;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.status-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* ../../AppData/Local/Temp/tmp-6912-LESOepgMxajG/18ca59b28921/settings_modal.css */
|
||||
#stats-config-container {
|
||||
gap: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#stats-config-container .toggle-wrapper {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
#stats-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);
|
||||
}
|
||||
#stats-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);
|
||||
}
|
||||
#stats-config-container .disabled {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#stats-config-container .toggle-input {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
#stats-config-container .toggle-input:checked ~ .toggle-indicator-wrapper {
|
||||
background-color: var(--spice-text);
|
||||
}
|
||||
#stats-config-container .toggle-input:checked ~ .toggle-indicator-wrapper .toggle-indicator {
|
||||
background-color: #fff;
|
||||
left: auto;
|
||||
right: 2px;
|
||||
right: 3px;
|
||||
}
|
||||
#stats-config-container .toggle-input:hover ~ .toggle-indicator-wrapper {
|
||||
filter: brightness(1.3);
|
||||
}
|
||||
#stats-config-container .toggle-input:hover:checked ~ .toggle-indicator-wrapper {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
#stats-config-container .toggle-input:active:not([disabled]) ~ .toggle-indicator-wrapper .toggle-indicator {
|
||||
width: 20px;
|
||||
}
|
||||
#stats-config-container .toggle-indicator-wrapper {
|
||||
background-color: #535353;
|
||||
border-radius: 24px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
width: 42px;
|
||||
}
|
||||
#stats-config-container .toggle-indicator {
|
||||
background: #fff;
|
||||
border-radius: inherit;
|
||||
height: 20px;
|
||||
left: 2px;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
transition:
|
||||
background-color,
|
||||
left,
|
||||
right,
|
||||
width 0.1s ease;
|
||||
width: 20px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
background: var(--spice-shadow) !important;
|
||||
}
|
||||
#stats-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%;
|
||||
}
|
||||
#stats-config-container .text-input:focus {
|
||||
background-color: var(--spice-tab-active);
|
||||
border: 1px solid var(--spice-button-disabled);
|
||||
outline: none;
|
||||
}
|
||||
#stats-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%;
|
||||
}
|
||||
#stats-config-container .tooltip-icon {
|
||||
float: right;
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
fill: var(--spice-subtext);
|
||||
}
|
||||
#stats-config-container .tooltip-icon:hover {
|
||||
fill: var(--spice-text);
|
||||
}
|
||||
#stats-config-container .tooltip {
|
||||
text-align: center;
|
||||
}
|
||||
#stats-config-container .setting-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue