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); + } +}