diff --git a/package-lock.json b/package-lock.json
index d60a29f..e4289ba 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -62,12 +62,14 @@
"node_modules/@types/prop-types": {
"version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
- "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q=="
+ "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
+ "dev": true
},
"node_modules/@types/react": {
"version": "18.3.4",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.4.tgz",
"integrity": "sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==",
+ "dev": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -366,7 +368,8 @@
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
- "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "dev": true
},
"node_modules/cwd": {
"version": "0.10.0",
@@ -2115,12 +2118,14 @@
"@types/prop-types": {
"version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
- "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q=="
+ "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
+ "dev": true
},
"@types/react": {
"version": "18.3.4",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.4.tgz",
"integrity": "sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==",
+ "dev": true,
"requires": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -2311,7 +2316,8 @@
"csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
- "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "dev": true
},
"cwd": {
"version": "0.10.0",
diff --git a/src/app.tsx b/src/app.tsx
index 6a752d4..7b7f601 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -1,14 +1,15 @@
import React from "react";
-import NowPlaying from "./components/NowPlaying";
+import NowPlaying from "./components/NowPlaying";
-class App extends React.Component<{}, {}> {
- render() {
- return (
- <>
-
- >
- );
- }
+// --- React Component for the App ---
+class App extends React.Component {
+ render() {
+ return (
+ <>
+
+ >
+ );
+ }
}
export default App;
diff --git a/src/css/app.module.scss b/src/css/app.module.scss
index 077e693..663f30d 100644
--- a/src/css/app.module.scss
+++ b/src/css/app.module.scss
@@ -325,4 +325,6 @@
grid-template-rows: repeat(4, 1fr);
row-gap: 13px;
column-gap: 10px;
-}
\ No newline at end of file
+}
+
+
diff --git a/src/extensions/playlistExtension.tsx b/src/extensions/playlistExtension.tsx
new file mode 100644
index 0000000..f7ae6cf
--- /dev/null
+++ b/src/extensions/playlistExtension.tsx
@@ -0,0 +1,383 @@
+import getMultiTrackAudioFeatures from "../services/multiTrackAudioFeaturesService";
+import getPlaylistTrackIDs from "../services/playlistTrackIDService";
+import reorderPlaylist from "../services/reorderPlaylistService";
+
+// --- Global Logic to affect Spotify's UI ---
+const initPlaylistPageLogic = () => {
+ let currentPlaylistID: string | null = null;
+
+ const addStyles = () => {
+ const style = document.createElement("style");
+ style.innerHTML = `
+ .preset-buttons input, .preset-buttons button, #savePresetBtn, #loadPresetBtn, #undoBtn {
+ margin-left: 5px;
+ margin-right: 5px;
+ padding: 10px;
+ color: rgba(241, 241, 241, 0.7);
+ font-size: var(--encore-text-size-smaller);
+ height: 32px;
+ }
+ .preset-buttons, #savePresetBtn, #loadPresetBtn, #undoBtn {
+ display: flex;
+ align-items: center;
+ }
+ .preset-buttons button, #savePresetBtn, #loadPresetBtn, #undoBtn {
+ font-size: var(--encore-text-size-smaller);
+ border-radius: 5px;
+ cursor: pointer;
+ border: none;
+ margin-right: 5px;
+ }
+ .preset-buttons button:hover, #savePresetBtn:hover, #loadPresetBtn:hover, #undoBtn:hover {
+ color: white;
+ }
+ .preset-buttons input, #savePresetBtn, #loadPresetBtn, #undoBtn {
+ position: relative;
+ font-size: var(--encore-text-size-smaller);
+ color: rgba(241, 241, 241, 0.7);
+ background-color: rgba(var(--spice-rgb-shadow), 0.7);
+ border-radius: 8px;
+ padding: 5px;
+ margin-right: 10px;
+ border: none;
+ }
+ `;
+ document.head.appendChild(style);
+ };
+
+ // Call addStyles once to inject the CSS
+ addStyles();
+
+ function isPlaylistPage() {
+ const pathname = Spicetify.Platform.History.location.pathname;
+ const matches = pathname.match(/playlist\/(.*)/);
+ return matches ? matches[1] : null;
+ }
+
+ const waitForElement = (selector: string, callback: (element: Element) => void, retryCount = 0) => {
+ const element = document.querySelector(selector);
+ if (element) {
+ callback(element);
+ } else if (retryCount < 10) {
+ setTimeout(() => waitForElement(selector, callback, retryCount + 1), 500);
+ }
+ };
+
+ const svgIconMarkup = `
+
+ `;
+
+ const updateDropdownButtonText = (text: string) => {
+ const dropdownButton = document.querySelector('.x-sortBox-sortDropdown');
+ if (dropdownButton) {
+ const labelSpan = dropdownButton.querySelector('[data-sortbox-label="true"]');
+ if (labelSpan) {
+ labelSpan.textContent = text;
+ }
+ }
+ };
+
+ const injectSortingOptions = (playlistID: string) => {
+ waitForElement('.main-contextMenu-menu', (customOrderDropdown) => {
+ if (!document.querySelector('.custom-sorting')) {
+ const sortingOptions = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const insertPosition = customOrderDropdown.children[7];
+
+ if (insertPosition) {
+ insertPosition.insertAdjacentHTML("beforebegin", sortingOptions);
+ } else {
+ customOrderDropdown.insertAdjacentHTML("beforeend", sortingOptions);
+ }
+
+ const sortingFeatures = [
+ "acousticness", "danceability", "energy", "instrumentalness",
+ "liveness", "loudness", "speechiness", "tempo", "valence"
+ ];
+ document.querySelectorAll(".custom-sorting button").forEach((button, index) => {
+ const sortingFeature = sortingFeatures[index];
+ button.addEventListener("click", () => {
+ const customOrderButton = document.querySelectorAll(".main-contextMenu-menuItemButton")[0];
+ if (customOrderButton) {
+ (customOrderButton as HTMLElement).click();
+ }
+ sortPlaylistByFeature(playlistID, sortingFeature);
+ document.querySelectorAll(".main-contextMenu-menuItemButton").forEach((btn, i, btns) => {
+ if (i < btns.length - 2) {
+ const svg = btn.querySelector("svg");
+ if (svg) svg.remove();
+ }
+ });
+ document.querySelectorAll(".main-contextMenu-menuItemButton[aria-checked='true']").forEach(btn => {
+ btn.setAttribute("aria-checked", "false");
+ btn.setAttribute("tabindex", "-1");
+ btn.setAttribute("data-roving-interactive", "0");
+ });
+ button.setAttribute("aria-checked", "true");
+ button.setAttribute("tabindex", "0");
+ button.setAttribute("data-roving-interactive", "1");
+ button.insertAdjacentHTML("beforeend", svgIconMarkup);
+ updateDropdownButtonText(sortingFeature);
+ });
+ });
+ }
+ });
+ };
+
+ const sortPlaylistByFeature = async (playlistID: string, feature: string) => {
+ const ids = await getPlaylistTrackIDs(playlistID);
+ const features = await getMultiTrackAudioFeatures(ids);
+ const sortedTracks = features
+ .filter((track) => track && track[feature] !== undefined)
+ .sort((a, b) => a[feature] - b[feature]);
+ const sortedTrackURIs = sortedTracks.map((track) => `spotify:track:${track.id}`);
+ await reorderPlaylist(playlistID, sortedTrackURIs);
+ };
+
+ const getSavedPresets = (playlistID: string): string[] => {
+ const presets = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && key.startsWith(`preset-`) && key.endsWith(`-${playlistID}`)) {
+ const presetName = key.replace(`preset-`, "").replace(`-${playlistID}`, "");
+ presets.push(presetName);
+ }
+ }
+ return presets;
+ };
+
+ const setupPresetDropdown = (playlistID: string) => {
+ const inputElement = document.getElementById("presetNameInput") as HTMLInputElement;
+ let dropdownContainer = document.getElementById("presetDropdown");
+
+ if (!dropdownContainer) {
+ dropdownContainer = document.createElement("div");
+ dropdownContainer.id = "presetDropdown";
+ document.body.appendChild(dropdownContainer);
+ }
+
+ const overlayElements = document.querySelectorAll("[data-overlayscrollbars-padding], [data-overlayscrollbars-viewport], [data-overlayscrollbars]");
+
+ const applyOverlayZIndex = (zIndex: string) => {
+ overlayElements.forEach(element => {
+ (element as HTMLElement).style.zIndex = zIndex;
+ });
+ };
+
+ dropdownContainer.style.position = "fixed";
+ dropdownContainer.style.zIndex = "999";
+ dropdownContainer.style.backgroundColor = "rgba(43, 43, 43, 0.9)";
+ dropdownContainer.style.border = "1px solid rgba(255, 255, 255, 0.2)";
+ dropdownContainer.style.borderRadius = "4px";
+ dropdownContainer.style.padding = "5px";
+ dropdownContainer.style.width = `${inputElement.offsetWidth}px`;
+ dropdownContainer.style.display = "none";
+
+ const updateDropdownPosition = () => {
+ const rect = inputElement.getBoundingClientRect();
+ dropdownContainer.style.top = `${rect.bottom + window.scrollY}px`;
+ dropdownContainer.style.left = `${rect.left + window.scrollX}px`;
+ };
+
+ inputElement.addEventListener("focus", () => {
+ dropdownContainer.innerHTML = "";
+ updateDropdownPosition();
+ applyOverlayZIndex("999");
+
+ const savedPresets = getSavedPresets(playlistID);
+ savedPresets.forEach(preset => {
+ const presetItem = document.createElement("div");
+ presetItem.textContent = preset;
+ presetItem.style.padding = "5px";
+ presetItem.style.cursor = "pointer";
+ presetItem.style.color = "white";
+ presetItem.addEventListener("click", () => {
+ inputElement.value = preset;
+ dropdownContainer.style.display = "none";
+ applyOverlayZIndex("");
+ });
+ dropdownContainer.appendChild(presetItem);
+ });
+
+ dropdownContainer.style.display = savedPresets.length > 0 ? "block" : "none";
+ });
+
+ inputElement.addEventListener("blur", () => {
+ setTimeout(() => {
+ dropdownContainer.style.display = "none";
+ applyOverlayZIndex("");
+ }, 150);
+ });
+
+ window.addEventListener("resize", updateDropdownPosition);
+ window.addEventListener("scroll", updateDropdownPosition);
+ };
+
+ const injectPresetButtons = (playlistID: string) => {
+ waitForElement(".main-actionBar-ActionBarRow", (container) => {
+ const customOrderButton = container.querySelector('.x-sortBox-sortDropdown');
+
+ if (customOrderButton && !document.querySelector(".preset-buttons")) {
+ const buttonHTML = `
+
+ `;
+ customOrderButton.insertAdjacentHTML('beforebegin', buttonHTML);
+
+ setupPresetDropdown(playlistID);
+ document.getElementById("savePresetBtn")?.addEventListener("click", () => {
+ const presetName = (document.getElementById("presetNameInput") as HTMLInputElement).value;
+ savePreset(presetName, playlistID);
+ });
+
+ document.getElementById("loadPresetBtn")?.addEventListener("click", () => {
+ const presetName = (document.getElementById("presetNameInput") as HTMLInputElement).value;
+ loadPreset(presetName, playlistID);
+ });
+ }
+ });
+ };
+
+ const savePreset = async (name: string, playlistID: string) => {
+ const ids = await getPlaylistTrackIDs(playlistID);
+ const prefixedTrackOrder = ids.map(id => `spotify:track:${id}`);
+ const preset = {
+ name,
+ trackOrder: prefixedTrackOrder,
+ };
+ localStorage.setItem(`preset-${name}-${playlistID}`, JSON.stringify(preset));
+ Spicetify.showNotification(`Preset '${name}' saved.`);
+ };
+
+ const loadPreset = async (name: string, playlistID: string) => {
+ const preset = JSON.parse(localStorage.getItem(`preset-${name}-${playlistID}`) || "{}");
+
+ if (preset && preset.trackOrder) {
+ // Find and click the dropdown toggle button to open the menu
+ const dropdownToggleButton = document.querySelector('.x-sortBox-sortDropdown');
+
+ if (dropdownToggleButton) {
+ (dropdownToggleButton as HTMLElement).click();
+
+ // Wait for the dropdown options to render
+ setTimeout(() => {
+ const dropdownButtons = document.querySelectorAll('.main-contextMenu-menuItemButton');
+
+ // Assume "Custom order" is the second button (update if this changes)
+ if (dropdownButtons[0]) {
+ (dropdownButtons[0] as HTMLElement).click(); // Click "Custom order"
+ document.querySelectorAll(".main-contextMenu-menuItemButton").forEach((btn, i, btns) => {
+ if (i < btns.length - 2) {
+ const svg = btn.querySelector("svg");
+ if (svg) svg.remove();
+ }
+ });
+ document.querySelectorAll(".main-contextMenu-menuItemButton[aria-checked='true']").forEach(btn => {
+ btn.setAttribute("aria-checked", "false");
+ btn.setAttribute("tabindex", "-1");
+ btn.setAttribute("data-roving-interactive", "0");
+ });
+ dropdownButtons[0].setAttribute("aria-checked", "true");
+ dropdownButtons[0].setAttribute("tabindex", "0");
+ dropdownButtons[0].setAttribute("data-roving-interactive", "1");
+ dropdownButtons[0].insertAdjacentHTML("beforeend", svgIconMarkup);
+ } else {
+ console.error("Custom order option not found");
+ }
+
+ // Proceed with the preset reordering after setting to "Custom order"
+ reorderPlaylist(playlistID, preset.trackOrder);
+ }, 10); // Adjust delay as needed based on render time
+ } else {
+ console.error("Dropdown toggle button not found");
+ }
+ } else {
+ Spicetify.showNotification(`Preset '${name}' not found.`);
+ }
+ };
+
+ const observeDropdownOpen = () => {
+ waitForElement('.x-sortBox-sortDropdown', (dropdownButton) => {
+ const dropdownObserver = new MutationObserver(() => {
+ const isExpanded = dropdownButton.getAttribute('aria-expanded') === 'true';
+ const isContextMenuOpen = dropdownButton.getAttribute('data-context-menu-open') === 'true';
+
+ if (isExpanded && isContextMenuOpen && currentPlaylistID) {
+ injectSortingOptions(currentPlaylistID);
+ }
+ });
+
+ dropdownObserver.observe(dropdownButton, {
+ attributes: true,
+ attributeFilter: ['aria-expanded', 'data-context-menu-open'],
+ });
+ });
+ };
+
+ const observer = new MutationObserver(() => {
+ const playlistID = isPlaylistPage();
+ if (playlistID && playlistID !== currentPlaylistID) {
+ currentPlaylistID = playlistID;
+ observeDropdownOpen();
+ injectPresetButtons(playlistID);
+ }
+ });
+
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ });
+};
+
+initPlaylistPageLogic();
\ No newline at end of file
diff --git a/src/services/multiTrackAudioFeaturesService.tsx b/src/services/multiTrackAudioFeaturesService.tsx
new file mode 100644
index 0000000..c368f32
--- /dev/null
+++ b/src/services/multiTrackAudioFeaturesService.tsx
@@ -0,0 +1,44 @@
+import type { AudioFeaturesResponse } from "../types/spotify-web-api";
+
+// Accept an array of songIDs directly
+async function getMultiTrackAudioFeatures(songIDs: string[]): Promise {
+ if (!songIDs || songIDs.length === 0) {
+ return [];
+ }
+
+ const accessToken = Spicetify.Platform.Session.accessToken;
+ let allAudioFeatures: AudioFeaturesResponse[] = [];
+
+ // Split the songIDs array into chunks of up to 100 IDs each
+ const chunks = [];
+ const chunkSize = 100;
+ for (let i = 0; i < songIDs.length; i += chunkSize) {
+ chunks.push(songIDs.slice(i, i + chunkSize));
+ }
+
+ // Fetch audio features for each chunk of songIDs
+ for (const chunk of chunks) {
+ const idsString = chunk.join(',');
+ const response = await fetch(
+ `https://api.spotify.com/v1/audio-features?ids=${idsString}`,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+
+ if (response.status === 200) {
+ const data = await response.json();
+ if (data && data.audio_features) {
+ allAudioFeatures = allAudioFeatures.concat(data.audio_features.filter(Boolean));
+ }
+ } else {
+ console.error("Failed to fetch audio features for chunk:", chunk);
+ }
+ }
+
+ return allAudioFeatures;
+}
+
+export default getMultiTrackAudioFeatures;
diff --git a/src/services/playlistTrackIDService.tsx b/src/services/playlistTrackIDService.tsx
new file mode 100644
index 0000000..eb8987c
--- /dev/null
+++ b/src/services/playlistTrackIDService.tsx
@@ -0,0 +1,42 @@
+async function getPlaylistTrackIDs(playlistID: string): Promise {
+ // If playlistID is undefined or empty, return an empty array
+ if (!playlistID) {
+ return [];
+ }
+
+ // Retrieve access token from Spicetify Platform
+ const accessToken = Spicetify.Platform.Session.accessToken;
+
+ // Initialize an empty array to store track IDs
+ let trackIDs: string[] = [];
+ let nextURL: string | null = `https://api.spotify.com/v1/playlists/${playlistID}/tracks`;
+
+ // Loop to handle pagination in case of playlists with more than 100 tracks
+ while (nextURL) {
+ // Fetch the playlist tracks data
+ const response = await fetch(nextURL, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ if (response.status !== 200) {
+ // If the response fails, return the trackIDs collected so far (or empty array if first call)
+ return [];
+ }
+
+ const data = await response.json();
+
+ // Extract the track IDs from the fetched data
+ const ids = data.items
+ .filter((item: any) => item.track && item.track.id) // Ensure track is not null
+ .map((item: any) => item.track.id);
+
+ trackIDs = trackIDs.concat(ids);
+ nextURL = data.next;
+ }
+
+ return trackIDs;
+}
+
+export default getPlaylistTrackIDs;
diff --git a/src/services/reorderPlaylistService.tsx b/src/services/reorderPlaylistService.tsx
new file mode 100644
index 0000000..544a45e
--- /dev/null
+++ b/src/services/reorderPlaylistService.tsx
@@ -0,0 +1,28 @@
+export default async function reorderPlaylist(playlistID: string, sortedTrackURIs: string[]): Promise {
+ if (!playlistID || !sortedTrackURIs.length) {
+ console.error('No playlist ID or sorted tracks provided.');
+ return;
+ }
+
+ const accessToken = Spicetify.Platform.Session.accessToken;
+ const uri = `https://api.spotify.com/v1/playlists/${playlistID}/tracks`;
+
+ // Make the PUT request to reorder the playlist
+ const response = await fetch(uri, {
+ method: 'PUT',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ uris: sortedTrackURIs,
+ }),
+ });
+
+ if (response.ok) {
+ console.log('Playlist successfully reordered!');
+ return response.json();
+ } else {
+ console.error('Failed to reorder playlist:', response.status, response.statusText);
+ }
+}