From 637cf7c86a9eaaa7b4ff7dee3ac6c86c8de9630a Mon Sep 17 00:00:00 2001 From: Anish Sudini Date: Thu, 5 Sep 2024 16:01:36 -0400 Subject: [PATCH 1/6] Initial Logic For Playlist Manager Implemented getting and locally cache/storing a playlist's tracks' songIDs and those tracks' audio features as well. Created three files so far, 2 in the services folder for handling API requests and response structure, and 1 in the components folder handling getting the playlist ID of an owned playlist on a user's screen. --- src/components/playlistManager.tsx | 97 +++++++++++++++++++ .../multiTrackAudioFeaturesService.tsx | 32 ++++++ src/services/playlistTrackIDService.tsx | 41 ++++++++ 3 files changed, 170 insertions(+) create mode 100644 src/components/playlistManager.tsx create mode 100644 src/services/multiTrackAudioFeaturesService.tsx create mode 100644 src/services/playlistTrackIDService.tsx diff --git a/src/components/playlistManager.tsx b/src/components/playlistManager.tsx new file mode 100644 index 0000000..ba70a2b --- /dev/null +++ b/src/components/playlistManager.tsx @@ -0,0 +1,97 @@ +import styles from "../css/app.module.scss"; +import React from "react"; +import getMultiTrackAudioFeatures from "../services/multiTrackAudioFeaturesService"; +import getPlaylistTrackIDs from "../services/playlistTrackIDService"; +import { AudioFeaturesResponse } from "../types/spotify-web-api"; + +class PlaylistManager extends React.Component<{}, {audioFeatures: AudioFeaturesResponse[], songIDs: string[]}> { + state = { + audioFeatures: [], + songIDs: [], + } + + componentDidMount = () => { + // Add listener for playlist change + Spicetify.Player.addEventListener("songchange", this.handlePlaylistChange); + } + + componentWillUnmount = () => { + // Remove the event listener when the component is unmounted + Spicetify.Player.removeEventListener("songchange", this.handlePlaylistChange); + } + + handlePlaylistChange = async () => { + const playlistID = this.getCurrentPlaylistID(); + if (!playlistID) return; + + const cachedSongIDs = this.getCachedData(playlistID, "songIDs"); + const cachedAudioFeatures = this.getCachedData(playlistID, "audioFeatures"); + + // If song IDs or audio features are cached, use the cached data + if (cachedSongIDs && cachedAudioFeatures) { + this.setState({ + songIDs: cachedSongIDs, + audioFeatures: cachedAudioFeatures, + }); + return; + } + + // Fetch song IDs if not cached + let songIDs = cachedSongIDs || await getPlaylistTrackIDs(playlistID); + this.cacheData(playlistID, "songIDs", songIDs); + + // Fetch audio features if not cached + let audioFeatures = cachedAudioFeatures || await getMultiTrackAudioFeatures(songIDs); + this.cacheData(playlistID, "audioFeatures", audioFeatures); + + this.setState({ + songIDs, + audioFeatures, + }); + } + + // Helper function to get current playlist ID + getCurrentPlaylistID = (): string | null => { + if (!Spicetify.Player.data || !Spicetify.Player.data.context.uri) { + return null; + } + + const contextURI = Spicetify.Player.data.context.uri; + + // Check if the URI is a playlist URI + if (contextURI && contextURI.startsWith("spotify:playlist:")) { + return contextURI.split(":")[2]; // Extract playlistID + } + + return null; + } + + // Helper function to get cached data + getCachedData = (playlistID: string, dataType: string) => { + const cacheKey = `${playlistID}_${dataType}.json`; + const cachedData = localStorage.getItem(cacheKey); + return cachedData ? JSON.parse(cachedData) : null; + } + + // Helper function to cache data + cacheData = (playlistID: string, dataType: string, data: any) => { + const cacheKey = `${playlistID}_${dataType}.json`; + localStorage.setItem(cacheKey, JSON.stringify(data)); + } + + render() { + return ( + <> +
+

Song IDs

+

{JSON.stringify(this.state.songIDs)}

+ +

Audio Features

+

{JSON.stringify(this.state.audioFeatures)}

+
+ + ); + } +} + +export default PlaylistManager; \ No newline at end of file diff --git a/src/services/multiTrackAudioFeaturesService.tsx b/src/services/multiTrackAudioFeaturesService.tsx new file mode 100644 index 0000000..e6f834e --- /dev/null +++ b/src/services/multiTrackAudioFeaturesService.tsx @@ -0,0 +1,32 @@ +import type { AudioFeaturesResponse } from "../types/spotify-web-api"; + +// Accept an array of songIDs directly +async function getMultiTrackAudioFeatures(songIDs: string[] | undefined): Promise { + if (!songIDs || songIDs.length === 0) { + return []; + } + + const accessToken = Spicetify.Platform.Session.accessToken; + + // Make multiple API requests using Promise.all to fetch audio features for each songID + const responses = await Promise.all( + songIDs.map(async (songID) => { + let response = await fetch ( + "https://api.spotify.com/v1/audio-features/" + songID, + { + headers: { + Authorization: "Bearer " + accessToken, + }, + } + ); + + // Return the JSON response if the status is 200, else return null + return response.status === 200 ? await response.json() : null; + }) + ); + + // Filter out any null values in case of failed requests + return responses.filter((response) => response !== null) as AudioFeaturesResponse[]; +} + +export default getMultiTrackAudioFeatures; \ No newline at end of file diff --git a/src/services/playlistTrackIDService.tsx b/src/services/playlistTrackIDService.tsx new file mode 100644 index 0000000..dba0656 --- /dev/null +++ b/src/services/playlistTrackIDService.tsx @@ -0,0 +1,41 @@ +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.map((item: any) => item.track.id); + trackIDs = trackIDs.concat(ids); + + // Check if there's another page of results, otherwise set nextURL to null to end the loop + nextURL = data.next; + } + + return trackIDs; +} + +export default getPlaylistTrackIDs; From 6ffbba5f971c3a1b3cdfbad7c37bdcafb7a57483 Mon Sep 17 00:00:00 2001 From: Anish Sudini Date: Sun, 13 Oct 2024 12:12:38 -0400 Subject: [PATCH 2/6] Playlist Manager Feature Preset creation logic, and playlist dynamic reordering logic complete and tested. Delay issue fixed. Only issue left pertains to UI of the feature and permanently effecting the Spotify App instead of for just one instance on load. --- package-lock.json | 14 +- src/app.tsx | 19 +- src/components/playlistManager.tsx | 97 -------- src/css/app.module.scss | 39 +++- src/extensions/playlistExtension.tsx | 212 ++++++++++++++++++ .../multiTrackAudioFeaturesService.tsx | 50 +++-- src/services/playlistTrackIDService.tsx | 7 +- src/services/reorderPlaylistService.tsx | 28 +++ 8 files changed, 333 insertions(+), 133 deletions(-) delete mode 100644 src/components/playlistManager.tsx create mode 100644 src/extensions/playlistExtension.tsx create mode 100644 src/services/reorderPlaylistService.tsx 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/components/playlistManager.tsx b/src/components/playlistManager.tsx deleted file mode 100644 index ba70a2b..0000000 --- a/src/components/playlistManager.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import styles from "../css/app.module.scss"; -import React from "react"; -import getMultiTrackAudioFeatures from "../services/multiTrackAudioFeaturesService"; -import getPlaylistTrackIDs from "../services/playlistTrackIDService"; -import { AudioFeaturesResponse } from "../types/spotify-web-api"; - -class PlaylistManager extends React.Component<{}, {audioFeatures: AudioFeaturesResponse[], songIDs: string[]}> { - state = { - audioFeatures: [], - songIDs: [], - } - - componentDidMount = () => { - // Add listener for playlist change - Spicetify.Player.addEventListener("songchange", this.handlePlaylistChange); - } - - componentWillUnmount = () => { - // Remove the event listener when the component is unmounted - Spicetify.Player.removeEventListener("songchange", this.handlePlaylistChange); - } - - handlePlaylistChange = async () => { - const playlistID = this.getCurrentPlaylistID(); - if (!playlistID) return; - - const cachedSongIDs = this.getCachedData(playlistID, "songIDs"); - const cachedAudioFeatures = this.getCachedData(playlistID, "audioFeatures"); - - // If song IDs or audio features are cached, use the cached data - if (cachedSongIDs && cachedAudioFeatures) { - this.setState({ - songIDs: cachedSongIDs, - audioFeatures: cachedAudioFeatures, - }); - return; - } - - // Fetch song IDs if not cached - let songIDs = cachedSongIDs || await getPlaylistTrackIDs(playlistID); - this.cacheData(playlistID, "songIDs", songIDs); - - // Fetch audio features if not cached - let audioFeatures = cachedAudioFeatures || await getMultiTrackAudioFeatures(songIDs); - this.cacheData(playlistID, "audioFeatures", audioFeatures); - - this.setState({ - songIDs, - audioFeatures, - }); - } - - // Helper function to get current playlist ID - getCurrentPlaylistID = (): string | null => { - if (!Spicetify.Player.data || !Spicetify.Player.data.context.uri) { - return null; - } - - const contextURI = Spicetify.Player.data.context.uri; - - // Check if the URI is a playlist URI - if (contextURI && contextURI.startsWith("spotify:playlist:")) { - return contextURI.split(":")[2]; // Extract playlistID - } - - return null; - } - - // Helper function to get cached data - getCachedData = (playlistID: string, dataType: string) => { - const cacheKey = `${playlistID}_${dataType}.json`; - const cachedData = localStorage.getItem(cacheKey); - return cachedData ? JSON.parse(cachedData) : null; - } - - // Helper function to cache data - cacheData = (playlistID: string, dataType: string, data: any) => { - const cacheKey = `${playlistID}_${dataType}.json`; - localStorage.setItem(cacheKey, JSON.stringify(data)); - } - - render() { - return ( - <> -
-

Song IDs

-

{JSON.stringify(this.state.songIDs)}

- -

Audio Features

-

{JSON.stringify(this.state.audioFeatures)}

-
- - ); - } -} - -export default PlaylistManager; \ No newline at end of file diff --git a/src/css/app.module.scss b/src/css/app.module.scss index 077e693..48d5a27 100644 --- a/src/css/app.module.scss +++ b/src/css/app.module.scss @@ -325,4 +325,41 @@ grid-template-rows: repeat(4, 1fr); row-gap: 13px; column-gap: 10px; -} \ No newline at end of file +} + +.preset-buttons input, .preset-buttons button, #savePresetBtn, #loadPresetBtn, #undoBtn { + font-size: 16px; + margin-left: 5px; + margin-right: 5px; + margin-top: 10px; + padding: 10px; + color: white; +} + +.preset-buttons, #savePresetBtn, #loadPresetBtn, #undoBtn { + display: flex; + align-items: center; +} + +.preset-buttons button, #savePresetBtn, #loadPresetBtn, #undoBtn { + background-color: rgb(81, 126, 97); + border-radius: 5px; + cursor: pointer; + color: white; + border: none; + margin-right: 5px; +} + +.preset-buttons button:hover, #savePresetBtn, #loadPresetBtn, #undoBtn { + background-color: rgb(99, 155, 119); +} + +.preset-buttons input, #savePresetBtn, #loadPresetBtn, #undoBtn { + color: white; + background-color: rgb(43, 43, 43); + border-radius: 5px; + padding: 5px; + margin-right: 10px; + border: none; +} + diff --git a/src/extensions/playlistExtension.tsx b/src/extensions/playlistExtension.tsx new file mode 100644 index 0000000..9fa2c44 --- /dev/null +++ b/src/extensions/playlistExtension.tsx @@ -0,0 +1,212 @@ +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; + + // Wait for Spicetify to be ready + const waitForSpicetify = (callback: () => void, retryCount = 0) => { + if (retryCount > 10) { + console.error("Spicetify is not ready after multiple attempts."); + return; + } + if (typeof Spicetify !== "undefined" && Spicetify.Player && Spicetify.Player.data) { + callback(); + } else { + console.log(`Spicetify not ready, retrying... (${retryCount + 1})`); + setTimeout(() => waitForSpicetify(callback, retryCount + 1), 500); // Retry after 500ms + } + }; + + // Function to detect if the current page is a playlist page + function isPlaylistPage() { + const pathname = Spicetify.Platform.History.location.pathname; + const matches = pathname.match(/playlist\/(.*)/); + if (!matches) return null; + return matches[1]; // Returns the playlist ID if it's a playlist page + } + + // Retry logic for DOM element selection + const waitForElement = (selector: string, callback: (element: Element) => void, retryCount = 0) => { + const element = document.querySelector(selector); + if (element) { + callback(element); + } else if (retryCount < 10) { + console.log(`Element ${selector} not found, retrying... (${retryCount + 1})`); + setTimeout(() => waitForElement(selector, callback, retryCount + 1), 500); + } else { + console.error(`Element ${selector} not found after multiple attempts.`); + } + }; + + // Programmatically click the custom order button and inject sorting options + const clickCustomOrderButtonAndInject = (playlistID: string) => { + console.log("Attempting to click custom order button..."); + waitForElement('.x-sortBox-sortDropdown', (customOrderButton) => { + console.log("Custom order button found:", customOrderButton); + + // Simulate a click to open the dropdown + (customOrderButton as HTMLElement).click(); + + // Inject sorting options after the dropdown opens + setTimeout(() => injectSortingOptions(playlistID), 500); // Delay to allow dropdown to expand + }); + }; + + // Add sorting options to the custom order dropdown + const injectSortingOptions = (playlistID: string) => { + console.log("Injecting sorting options..."); + waitForElement('.main-contextMenu-menu', (customOrderDropdown) => { + console.log("Custom order dropdown element:", customOrderDropdown); + + if (!document.querySelector('.custom-sorting')) { + const newSortingOptions = ` + + + + `; + + customOrderDropdown.insertAdjacentHTML("beforeend", newSortingOptions); + + document.querySelectorAll(".custom-sorting button").forEach((button, index) => { + const sortingFeature = ["tempo", "danceability", "energy"][index]; + button.addEventListener("click", () => sortPlaylistByFeature(playlistID, sortingFeature)); + }); + } else { + console.log("Sorting options already injected."); + } + }); + }; + + // Sorting functionality for selected feature + const sortPlaylistByFeature = async (playlistID: string, feature: string) => { + console.log(`Sorting playlist by feature: ${feature}`); + + // Fetch the track IDs from the playlist + const ids = await getPlaylistTrackIDs(playlistID); + console.log(`Track IDs: ${ids}`); + + // Fetch the audio features for each track + const features = await getMultiTrackAudioFeatures(ids); + console.log(`Audio Features:`, features); + + // Ensure the feature exists and sort by the feature value + const sortedTracks = features + .filter((track) => track && track[feature] !== undefined) // Filter tracks that have the desired feature + .sort((a, b) => a[feature] - b[feature]); // Sort based on the feature value + + console.log(`Sorted Tracks:`, sortedTracks); + + // Extract the URIs for the sorted tracks + const sortedTrackURIs = sortedTracks.map((track) => `spotify:track:${track.id}`); + console.log(`Sorted Track URIs: ${sortedTrackURIs}`); + + // Call reorderPlaylist to apply the new order + const result = await reorderPlaylist(playlistID, sortedTrackURIs); + console.log(`Reorder result:`, result); + }; + + // Inject buttons for preset management and undo into the specified action bar + const injectPresetButtons = (playlistID: string) => { + console.log("Injecting preset buttons..."); + waitForElement(".main-actionBar-ActionBarRow", (container) => { + console.log("Action bar element:", container); + + // Find the custom order button to insert the preset buttons before it + const customOrderButton = container.querySelector('.x-sortBox-sortDropdown'); + console.log("Custom order button element:", customOrderButton); + + if (customOrderButton && !document.querySelector(".preset-buttons")) { + const buttonHTML = ` +
+ + + + +
+ `; + + // Insert the preset buttons before the Custom Order button + customOrderButton.insertAdjacentHTML('beforebegin', buttonHTML); + + let presetName = ""; + document.getElementById("savePresetBtn").addEventListener("click", () => savePreset(presetName, playlistID)); + document.getElementById("loadPresetBtn").addEventListener("click", () => loadPreset(presetName, playlistID)); + document.getElementById("undoBtn").addEventListener("click", () => undoOrder(playlistID)); + document.getElementById("presetNameInput").addEventListener("input", (e) => { + presetName = (e.target as HTMLInputElement).value; + }); + } else { + console.log("Preset buttons already injected or Custom order button not found."); + } + }); + }; + + // Preset saving/loading + const savePreset = (name: string, playlistID: string) => { + const preset = { + name, + trackOrder: [], // We will add the real track order when we save it + }; + localStorage.setItem(`preset-${name}`, JSON.stringify(preset)); + alert(`Preset '${name}' saved.`); + }; + + const loadPreset = (name: string, playlistID: string) => { + const preset = JSON.parse(localStorage.getItem(`preset-${name}`) || "{}"); + if (preset && preset.trackOrder) { + reorderPlaylist(playlistID, preset.trackOrder); + } else { + alert(`Preset '${name}' not found.`); + } + }; + + const undoOrder = (playlistID: string) => { + // Restore the original order here (assuming we stored it earlier) + alert(`Undoing the order for playlist ID: ${playlistID}`); + }; + + // Initialization Logic + waitForSpicetify(() => { + console.log("Spicetify is ready, checking if it's a playlist page..."); + + const playlistID = isPlaylistPage(); + if (playlistID) { + console.log("Valid playlist page detected, playlist ID:", playlistID); + + clickCustomOrderButtonAndInject(playlistID); + injectPresetButtons(playlistID); + } else { + console.log("Not a playlist page."); + } + + // Listen for page changes to detect when a playlist page is loaded + Spicetify.Platform.History.listen(() => { + const newPlaylistID = isPlaylistPage(); + if (newPlaylistID && newPlaylistID !== currentPlaylistID) { + console.log("Navigated to a new playlist page, playlist ID:", newPlaylistID); + currentPlaylistID = newPlaylistID; + clickCustomOrderButtonAndInject(newPlaylistID); + injectPresetButtons(newPlaylistID); + } + }); + }); +}; + +// Execute the logic on load +initPlaylistPageLogic(); + diff --git a/src/services/multiTrackAudioFeaturesService.tsx b/src/services/multiTrackAudioFeaturesService.tsx index e6f834e..c368f32 100644 --- a/src/services/multiTrackAudioFeaturesService.tsx +++ b/src/services/multiTrackAudioFeaturesService.tsx @@ -1,32 +1,44 @@ import type { AudioFeaturesResponse } from "../types/spotify-web-api"; // Accept an array of songIDs directly -async function getMultiTrackAudioFeatures(songIDs: string[] | undefined): Promise { +async function getMultiTrackAudioFeatures(songIDs: string[]): Promise { if (!songIDs || songIDs.length === 0) { return []; } const accessToken = Spicetify.Platform.Session.accessToken; + let allAudioFeatures: AudioFeaturesResponse[] = []; - // Make multiple API requests using Promise.all to fetch audio features for each songID - const responses = await Promise.all( - songIDs.map(async (songID) => { - let response = await fetch ( - "https://api.spotify.com/v1/audio-features/" + songID, - { - headers: { - Authorization: "Bearer " + accessToken, - }, - } - ); + // 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}`, + }, + } + ); - // Return the JSON response if the status is 200, else return null - return response.status === 200 ? await response.json() : null; - }) - ); + 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); + } + } - // Filter out any null values in case of failed requests - return responses.filter((response) => response !== null) as AudioFeaturesResponse[]; + return allAudioFeatures; } -export default getMultiTrackAudioFeatures; \ No newline at end of file +export default getMultiTrackAudioFeatures; diff --git a/src/services/playlistTrackIDService.tsx b/src/services/playlistTrackIDService.tsx index dba0656..eb8987c 100644 --- a/src/services/playlistTrackIDService.tsx +++ b/src/services/playlistTrackIDService.tsx @@ -28,10 +28,11 @@ async function getPlaylistTrackIDs(playlistID: string): Promise { const data = await response.json(); // Extract the track IDs from the fetched data - const ids = data.items.map((item: any) => item.track.id); + 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); - - // Check if there's another page of results, otherwise set nextURL to null to end the loop nextURL = data.next; } 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); + } +} From 84a0bfa62bb8e58fa0cece85ccb700357c7fd5f1 Mon Sep 17 00:00:00 2001 From: Anish Sudini Date: Tue, 29 Oct 2024 09:39:07 -0400 Subject: [PATCH 3/6] Fix for Playlist Manager UI Injection --- src/css/app.module.scss | 35 --- src/extensions/playlistExtension.tsx | 320 ++++++++++++++++----------- 2 files changed, 193 insertions(+), 162 deletions(-) diff --git a/src/css/app.module.scss b/src/css/app.module.scss index 48d5a27..663f30d 100644 --- a/src/css/app.module.scss +++ b/src/css/app.module.scss @@ -327,39 +327,4 @@ column-gap: 10px; } -.preset-buttons input, .preset-buttons button, #savePresetBtn, #loadPresetBtn, #undoBtn { - font-size: 16px; - margin-left: 5px; - margin-right: 5px; - margin-top: 10px; - padding: 10px; - color: white; -} - -.preset-buttons, #savePresetBtn, #loadPresetBtn, #undoBtn { - display: flex; - align-items: center; -} - -.preset-buttons button, #savePresetBtn, #loadPresetBtn, #undoBtn { - background-color: rgb(81, 126, 97); - border-radius: 5px; - cursor: pointer; - color: white; - border: none; - margin-right: 5px; -} - -.preset-buttons button:hover, #savePresetBtn, #loadPresetBtn, #undoBtn { - background-color: rgb(99, 155, 119); -} - -.preset-buttons input, #savePresetBtn, #loadPresetBtn, #undoBtn { - color: white; - background-color: rgb(43, 43, 43); - border-radius: 5px; - padding: 5px; - margin-right: 10px; - border: none; -} diff --git a/src/extensions/playlistExtension.tsx b/src/extensions/playlistExtension.tsx index 9fa2c44..b8962b8 100644 --- a/src/extensions/playlistExtension.tsx +++ b/src/extensions/playlistExtension.tsx @@ -6,26 +6,53 @@ import reorderPlaylist from "../services/reorderPlaylistService"; const initPlaylistPageLogic = () => { let currentPlaylistID: string | null = null; - // Wait for Spicetify to be ready - const waitForSpicetify = (callback: () => void, retryCount = 0) => { - if (retryCount > 10) { - console.error("Spicetify is not ready after multiple attempts."); - return; - } - if (typeof Spicetify !== "undefined" && Spicetify.Player && Spicetify.Player.data) { - callback(); - } else { - console.log(`Spicetify not ready, retrying... (${retryCount + 1})`); - setTimeout(() => waitForSpicetify(callback, retryCount + 1), 500); // Retry after 500ms - } + 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 to detect if the current page is a playlist page function isPlaylistPage() { const pathname = Spicetify.Platform.History.location.pathname; const matches = pathname.match(/playlist\/(.*)/); - if (!matches) return null; - return matches[1]; // Returns the playlist ID if it's a playlist page + return matches ? matches[1] : null; } // Retry logic for DOM element selection @@ -34,179 +61,218 @@ const initPlaylistPageLogic = () => { if (element) { callback(element); } else if (retryCount < 10) { - console.log(`Element ${selector} not found, retrying... (${retryCount + 1})`); setTimeout(() => waitForElement(selector, callback, retryCount + 1), 500); - } else { - console.error(`Element ${selector} not found after multiple attempts.`); } }; - // Programmatically click the custom order button and inject sorting options - const clickCustomOrderButtonAndInject = (playlistID: string) => { - console.log("Attempting to click custom order button..."); - waitForElement('.x-sortBox-sortDropdown', (customOrderButton) => { - console.log("Custom order button found:", customOrderButton); - - // Simulate a click to open the dropdown - (customOrderButton as HTMLElement).click(); + const svgIconMarkup = ` + + `; - // Inject sorting options after the dropdown opens - setTimeout(() => injectSortingOptions(playlistID), 500); // Delay to allow dropdown to expand - }); + 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; + } + } }; // Add sorting options to the custom order dropdown const injectSortingOptions = (playlistID: string) => { - console.log("Injecting sorting options..."); waitForElement('.main-contextMenu-menu', (customOrderDropdown) => { - console.log("Custom order dropdown element:", customOrderDropdown); - if (!document.querySelector('.custom-sorting')) { - const newSortingOptions = ` + const sortingOptions = ` `; + const insertPosition = customOrderDropdown.children[7]; - customOrderDropdown.insertAdjacentHTML("beforeend", newSortingOptions); + if (insertPosition) { + insertPosition.insertAdjacentHTML("beforebegin", sortingOptions); + } else { + customOrderDropdown.insertAdjacentHTML("beforeend", sortingOptions); + } document.querySelectorAll(".custom-sorting button").forEach((button, index) => { const sortingFeature = ["tempo", "danceability", "energy"][index]; - button.addEventListener("click", () => sortPlaylistByFeature(playlistID, sortingFeature)); + button.addEventListener("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); + }); }); - } else { - console.log("Sorting options already injected."); } }); }; // Sorting functionality for selected feature const sortPlaylistByFeature = async (playlistID: string, feature: string) => { - console.log(`Sorting playlist by feature: ${feature}`); - - // Fetch the track IDs from the playlist const ids = await getPlaylistTrackIDs(playlistID); - console.log(`Track IDs: ${ids}`); - - // Fetch the audio features for each track const features = await getMultiTrackAudioFeatures(ids); - console.log(`Audio Features:`, features); - - // Ensure the feature exists and sort by the feature value const sortedTracks = features - .filter((track) => track && track[feature] !== undefined) // Filter tracks that have the desired feature - .sort((a, b) => a[feature] - b[feature]); // Sort based on the feature value - - console.log(`Sorted Tracks:`, sortedTracks); - - // Extract the URIs for the sorted tracks + .filter((track) => track && track[feature] !== undefined) + .sort((a, b) => a[feature] - b[feature]); const sortedTrackURIs = sortedTracks.map((track) => `spotify:track:${track.id}`); - console.log(`Sorted Track URIs: ${sortedTrackURIs}`); - - // Call reorderPlaylist to apply the new order - const result = await reorderPlaylist(playlistID, sortedTrackURIs); - console.log(`Reorder result:`, result); + await reorderPlaylist(playlistID, sortedTrackURIs); + }; + + const getSavedPresets = () => { + return Object.keys(localStorage) + .filter(key => key.startsWith("preset-")) + .map(key => key.replace("preset-", "")); + }; + + const setupPresetDropdown = () => { + const inputElement = document.getElementById("presetNameInput") as HTMLInputElement; + const dropdownContainer = document.getElementById("presetDropdown"); + + if (!inputElement || !dropdownContainer) return; + + // Show dropdown on focus + inputElement.addEventListener("focus", () => { + dropdownContainer.innerHTML = ""; + dropdownContainer.style.position = "absolute"; + dropdownContainer.style.zIndex = "999"; + const savedPresets = getSavedPresets(); + savedPresets.forEach(preset => { + const presetItem = document.createElement("div"); + presetItem.textContent = preset; + presetItem.style.padding = "5px"; + presetItem.style.cursor = "pointer"; + presetItem.style.position = "relative"; + presetItem.style.zIndex = "999"; + presetItem.addEventListener("click", () => { + inputElement.value = preset; + dropdownContainer.style.display = "none"; + }); + dropdownContainer.appendChild(presetItem); + }); + dropdownContainer.style.display = savedPresets.length > 0 ? "block" : "none"; + }); + + // Hide dropdown on blur + inputElement.addEventListener("blur", () => { + setTimeout(() => { + dropdownContainer.style.display = "none"; + }, 150); // Timeout to allow click events to register + }); }; // Inject buttons for preset management and undo into the specified action bar const injectPresetButtons = (playlistID: string) => { - console.log("Injecting preset buttons..."); - waitForElement(".main-actionBar-ActionBarRow", (container) => { - console.log("Action bar element:", container); - - // Find the custom order button to insert the preset buttons before it - const customOrderButton = container.querySelector('.x-sortBox-sortDropdown'); - console.log("Custom order button element:", customOrderButton); - - if (customOrderButton && !document.querySelector(".preset-buttons")) { - const buttonHTML = ` -
- - - - -
- `; - - // Insert the preset buttons before the Custom Order button - customOrderButton.insertAdjacentHTML('beforebegin', buttonHTML); - - let presetName = ""; - document.getElementById("savePresetBtn").addEventListener("click", () => savePreset(presetName, playlistID)); - document.getElementById("loadPresetBtn").addEventListener("click", () => loadPreset(presetName, playlistID)); - document.getElementById("undoBtn").addEventListener("click", () => undoOrder(playlistID)); - document.getElementById("presetNameInput").addEventListener("input", (e) => { - presetName = (e.target as HTMLInputElement).value; - }); - } else { - console.log("Preset buttons already injected or Custom order button not found."); - } - }); - }; - - // Preset saving/loading - const savePreset = (name: string, 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(); + let presetName = ""; + document.getElementById("presetNameInput").addEventListener("input", (e) => { + presetName = (e.target as HTMLInputElement).value; + }); + document.getElementById("savePresetBtn").addEventListener("click", () => savePreset(presetName, playlistID)); + document.getElementById("loadPresetBtn").addEventListener("click", () => loadPreset(presetName, playlistID)); + document.getElementById("undoBtn").addEventListener("click", () => undoOrder(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: [], // We will add the real track order when we save it + trackOrder: prefixedTrackOrder, }; localStorage.setItem(`preset-${name}`, JSON.stringify(preset)); - alert(`Preset '${name}' saved.`); + Spicetify.showNotification(`Preset '${name}' saved.`); }; - const loadPreset = (name: string, playlistID: string) => { + const loadPreset = async (name: string, playlistID: string) => { const preset = JSON.parse(localStorage.getItem(`preset-${name}`) || "{}"); if (preset && preset.trackOrder) { - reorderPlaylist(playlistID, preset.trackOrder); + await reorderPlaylist(playlistID, preset.trackOrder); } else { - alert(`Preset '${name}' not found.`); + Spicetify.showNotification(`Preset '${name}' not found.`); } }; const undoOrder = (playlistID: string) => { - // Restore the original order here (assuming we stored it earlier) - alert(`Undoing the order for playlist ID: ${playlistID}`); + Spicetify.showNotification(`Undoing the order for playlist ID: ${playlistID}`); }; - // Initialization Logic - waitForSpicetify(() => { - console.log("Spicetify is ready, checking if it's a playlist page..."); + 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'; - const playlistID = isPlaylistPage(); - if (playlistID) { - console.log("Valid playlist page detected, playlist ID:", playlistID); + if (isExpanded && isContextMenuOpen && currentPlaylistID) { + injectSortingOptions(currentPlaylistID); + } + }); + + dropdownObserver.observe(dropdownButton, { + attributes: true, + attributeFilter: ['aria-expanded', 'data-context-menu-open'], + }); + }); + }; - clickCustomOrderButtonAndInject(playlistID); + const observer = new MutationObserver(() => { + const playlistID = isPlaylistPage(); + if (playlistID && playlistID !== currentPlaylistID) { + currentPlaylistID = playlistID; + observeDropdownOpen(); injectPresetButtons(playlistID); - } else { - console.log("Not a playlist page."); } + }); - // Listen for page changes to detect when a playlist page is loaded - Spicetify.Platform.History.listen(() => { - const newPlaylistID = isPlaylistPage(); - if (newPlaylistID && newPlaylistID !== currentPlaylistID) { - console.log("Navigated to a new playlist page, playlist ID:", newPlaylistID); - currentPlaylistID = newPlaylistID; - clickCustomOrderButtonAndInject(newPlaylistID); - injectPresetButtons(newPlaylistID); - } - }); + observer.observe(document.body, { + childList: true, + subtree: true, }); }; -// Execute the logic on load -initPlaylistPageLogic(); - +initPlaylistPageLogic(); \ No newline at end of file From d5378fb8e6f65690916925f82e6c26e6819f5d08 Mon Sep 17 00:00:00 2001 From: Anish Sudini Date: Wed, 30 Oct 2024 17:05:29 -0400 Subject: [PATCH 4/6] Implemented Fixes for CSS Bugs & Functionality Bugs/Flow Issues Modified code to include mutation observers for various aspects of the page to determine and implement permanent UI injections. Discuss style with the team, made some changes to meet overall agreeance. --- src/extensions/playlistExtension.tsx | 190 +++++++++++++++++++++------ 1 file changed, 149 insertions(+), 41 deletions(-) diff --git a/src/extensions/playlistExtension.tsx b/src/extensions/playlistExtension.tsx index b8962b8..e857fd6 100644 --- a/src/extensions/playlistExtension.tsx +++ b/src/extensions/playlistExtension.tsx @@ -48,14 +48,12 @@ const initPlaylistPageLogic = () => { // Call addStyles once to inject the CSS addStyles(); - // Function to detect if the current page is a playlist page function isPlaylistPage() { const pathname = Spicetify.Platform.History.location.pathname; const matches = pathname.match(/playlist\/(.*)/); return matches ? matches[1] : null; } - // Retry logic for DOM element selection const waitForElement = (selector: string, callback: (element: Element) => void, retryCount = 0) => { const element = document.querySelector(selector); if (element) { @@ -81,24 +79,58 @@ const initPlaylistPageLogic = () => { } }; - // Add sorting options to the custom order dropdown const injectSortingOptions = (playlistID: string) => { waitForElement('.main-contextMenu-menu', (customOrderDropdown) => { if (!document.querySelector('.custom-sorting')) { const sortingOptions = ` + + + + + + + `; @@ -110,8 +142,13 @@ const initPlaylistPageLogic = () => { customOrderDropdown.insertAdjacentHTML("beforeend", sortingOptions); } + const sortingFeatures = [ + "acousticness", "danceability", "energy", "instrumentalness", + "liveness", "loudness", "speechiness", "tempo", "valence", "duration_ms" + ]; + document.querySelectorAll(".custom-sorting button").forEach((button, index) => { - const sortingFeature = ["tempo", "danceability", "energy"][index]; + const sortingFeature = sortingFeatures[index]; button.addEventListener("click", () => { sortPlaylistByFeature(playlistID, sortingFeature); document.querySelectorAll(".main-contextMenu-menuItemButton").forEach((btn, i, btns) => { @@ -136,7 +173,6 @@ const initPlaylistPageLogic = () => { }); }; - // Sorting functionality for selected feature const sortPlaylistByFeature = async (playlistID: string, feature: string) => { const ids = await getPlaylistTrackIDs(playlistID); const features = await getMultiTrackAudioFeatures(ids); @@ -147,49 +183,86 @@ const initPlaylistPageLogic = () => { await reorderPlaylist(playlistID, sortedTrackURIs); }; - const getSavedPresets = () => { - return Object.keys(localStorage) - .filter(key => key.startsWith("preset-")) - .map(key => key.replace("preset-", "")); + 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 = () => { + const setupPresetDropdown = (playlistID: string) => { const inputElement = document.getElementById("presetNameInput") as HTMLInputElement; - const dropdownContainer = document.getElementById("presetDropdown"); + let dropdownContainer = document.getElementById("presetDropdown"); + + if (!dropdownContainer) { + dropdownContainer = document.createElement("div"); + dropdownContainer.id = "presetDropdown"; + document.body.appendChild(dropdownContainer); + } - if (!inputElement || !dropdownContainer) return; + const overlayElements = document.querySelectorAll("[data-overlayscrollbars-padding], [data-overlayscrollbars-viewport], [data-overlayscrollbars]"); - // Show dropdown on focus + 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 = ""; - dropdownContainer.style.position = "absolute"; - dropdownContainer.style.zIndex = "999"; - const savedPresets = getSavedPresets(); + 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.position = "relative"; - presetItem.style.zIndex = "999"; + presetItem.style.color = "white"; presetItem.addEventListener("click", () => { inputElement.value = preset; + presetName = preset; // Update presetName when a dropdown item is selected dropdownContainer.style.display = "none"; + applyOverlayZIndex(""); }); dropdownContainer.appendChild(presetItem); }); + dropdownContainer.style.display = savedPresets.length > 0 ? "block" : "none"; }); - - // Hide dropdown on blur + inputElement.addEventListener("blur", () => { setTimeout(() => { dropdownContainer.style.display = "none"; - }, 150); // Timeout to allow click events to register + applyOverlayZIndex(""); + }, 150); }); - }; + + window.addEventListener("resize", updateDropdownPosition); + window.addEventListener("scroll", updateDropdownPosition); + }; - // Inject buttons for preset management and undo into the specified action bar const injectPresetButtons = (playlistID: string) => { waitForElement(".main-actionBar-ActionBarRow", (container) => { const customOrderButton = container.querySelector('.x-sortBox-sortDropdown'); @@ -201,19 +274,20 @@ const initPlaylistPageLogic = () => { - `; customOrderButton.insertAdjacentHTML('beforebegin', buttonHTML); - setupPresetDropdown(); - let presetName = ""; - document.getElementById("presetNameInput").addEventListener("input", (e) => { - presetName = (e.target as HTMLInputElement).value; + 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); }); - document.getElementById("savePresetBtn").addEventListener("click", () => savePreset(presetName, playlistID)); - document.getElementById("loadPresetBtn").addEventListener("click", () => loadPreset(presetName, playlistID)); - document.getElementById("undoBtn").addEventListener("click", () => undoOrder(playlistID)); } }); }; @@ -225,23 +299,57 @@ const initPlaylistPageLogic = () => { name, trackOrder: prefixedTrackOrder, }; - localStorage.setItem(`preset-${name}`, JSON.stringify(preset)); + 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}`) || "{}"); + const preset = JSON.parse(localStorage.getItem(`preset-${name}-${playlistID}`) || "{}"); + if (preset && preset.trackOrder) { - await reorderPlaylist(playlistID, 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 undoOrder = (playlistID: string) => { - Spicetify.showNotification(`Undoing the order for playlist ID: ${playlistID}`); - }; - const observeDropdownOpen = () => { waitForElement('.x-sortBox-sortDropdown', (dropdownButton) => { const dropdownObserver = new MutationObserver(() => { From 2b92aebfb0bb28d708ef7a0b7a64e0090e227f5f Mon Sep 17 00:00:00 2001 From: Anish Sudini Date: Wed, 30 Oct 2024 17:15:42 -0400 Subject: [PATCH 5/6] Small Fixes for TypeScript Errors Fixed some TypeScript errors (that were not affecting code functionality) but just needed to add some error handling for them. --- src/extensions/playlistExtension.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/extensions/playlistExtension.tsx b/src/extensions/playlistExtension.tsx index e857fd6..f90db4e 100644 --- a/src/extensions/playlistExtension.tsx +++ b/src/extensions/playlistExtension.tsx @@ -242,7 +242,6 @@ const initPlaylistPageLogic = () => { presetItem.style.color = "white"; presetItem.addEventListener("click", () => { inputElement.value = preset; - presetName = preset; // Update presetName when a dropdown item is selected dropdownContainer.style.display = "none"; applyOverlayZIndex(""); }); @@ -279,12 +278,12 @@ const initPlaylistPageLogic = () => { customOrderButton.insertAdjacentHTML('beforebegin', buttonHTML); setupPresetDropdown(playlistID); - - document.getElementById("savePresetBtn").addEventListener("click", () => { + document.getElementById("savePresetBtn")?.addEventListener("click", () => { const presetName = (document.getElementById("presetNameInput") as HTMLInputElement).value; savePreset(presetName, playlistID); }); - document.getElementById("loadPresetBtn").addEventListener("click", () => { + + document.getElementById("loadPresetBtn")?.addEventListener("click", () => { const presetName = (document.getElementById("presetNameInput") as HTMLInputElement).value; loadPreset(presetName, playlistID); }); From b54e2009c4585d65a3dca9c0ab1a4271ccde02b4 Mon Sep 17 00:00:00 2001 From: Anish Sudini Date: Wed, 30 Oct 2024 18:29:11 -0400 Subject: [PATCH 6/6] Fixed Delay/Custom Order Issue Also removed Duration as a custom sorting option since it already is provided by Spotify. --- src/extensions/playlistExtension.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/extensions/playlistExtension.tsx b/src/extensions/playlistExtension.tsx index f90db4e..f7ae6cf 100644 --- a/src/extensions/playlistExtension.tsx +++ b/src/extensions/playlistExtension.tsx @@ -128,11 +128,6 @@ const initPlaylistPageLogic = () => { Valence - `; const insertPosition = customOrderDropdown.children[7]; @@ -144,12 +139,15 @@ const initPlaylistPageLogic = () => { const sortingFeatures = [ "acousticness", "danceability", "energy", "instrumentalness", - "liveness", "loudness", "speechiness", "tempo", "valence", "duration_ms" + "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) {