// NAME: Copy Playlists // AUTHOR: einzigartigerName // DESCRIPTION: copy/combine playlist/queue directly in Spotify (function CopyPlaylist() { const { CosmosAPI, BridgeAPI, LocalStorage, PlaybackControl, ContextMenu, URI } = Spicetify if (!(CosmosAPI || BridgeAPI)) { setTimeout(CopyPlaylist, 1000); return; } const STORAGE_KEY = "combine_buffer_spicetify" const TOP_BTN_TOOLTIP = "Combine Playlists" const MENU_BTN_CREATE_NEW = "Create Playlist" const MENU_BTN_INSERT_BUFFER = "Copy to Buffer" class PlaylistCollection { constructor() { const menu = createMenu() this.container = menu.container this.items = menu.menu this.lastScroll = 0 this.container.onclick = () => { this.storeScroll() this.container.remove() } this.pattern this.apply() } apply() { this.items.textContent = '' // Remove all childs this.items.append(createMenuItem("Create Playlist", () => highjackCreateDialog(mergePlaylists(this.pattern)))) this.items.append(createMenuItem("Clear Buffer", () => LIST.clearStorage())) const select = createPatternSelect(this.filter); select.onchange = (event) => { this.pattern = event.srcElement.selectedIndex; } this.items.append(select); const collection = this.getStorage(); collection.forEach((item) => this.items.append(new CardContainer(item))) } getStorage() { const storageRaw = LocalStorage.get(STORAGE_KEY); let storage = []; if (storageRaw) { storage = JSON.parse(storageRaw); } else { LocalStorage.set(STORAGE_KEY, "[]") } return storage; } addToStorage(data) { /** @type {Object[]} */ const storage = this.getStorage(); storage.push(data); LocalStorage.set(STORAGE_KEY, JSON.stringify(storage)); this.apply() } removeFromStorage(id) { const storage = this.getStorage() .filter(item => item.id !== id) LocalStorage.set(STORAGE_KEY, JSON.stringify(storage)); this.apply() } clearStorage() { LocalStorage.set(STORAGE_KEY, "[]"); this.apply() } moveItem(uri, direction) { var storage = this.getStorage() var from; for (var i = 0; i < storage.length; i++) { if (storage[i].uri === uri) { from = i break; } } if (!from) { return } var to = from + direction if (to < 0 || to >= storage.length) { return } var tmp = storage[from] storage[from] = storage[to] storage[to] = tmp LocalStorage.set(STORAGE_KEY, JSON.stringify(storage)); this.apply() } changePosition(x, y) { this.items.style.left = x + "px" this.items.style.top = y + 10 + "px" } storeScroll() { this.lastScroll = this.items.scrollTop } setScroll() { this.items.scrollTop = this.lastScroll } } /* * Displays Stored Playlist * {id, uri, name, tracks, imgUri, owner} */ class CardContainer extends HTMLElement { constructor(info) { super() this.innerHTML = `
${info.imgUri ? ` ` : ""}
` const up = this.querySelector(".order-controls-up") up.onclick = (event) => { LIST.moveItem(info.uri, -1) event.stopPropagation() } const remove = this.querySelector(".order-controls-remove") remove.onclick = (event) => { LIST.removeFromStorage(info.id) event.stopPropagation() } const down = this.querySelector(".order-controls-down") down.onclick = (event) => { LIST.moveItem(info.uri, +1) event.stopPropagation() } const imageLink = this.querySelector(".card-image-link"); const infoLink = this.querySelector(".card-info-link"); if (imageLink) imageLink.addEventListener("click", ((e) => showPlaylist(e))); if (infoLink) infoLink.addEventListener("click", ((e) => showPlaylist(e))); } } customElements.define("combine-buffer-card-container", CardContainer) const LIST = new PlaylistCollection() // New Playlist Button const playlistDialogButton = document.querySelector("#new-playlist-button-mount-point > div > button") if (!playlistDialogButton) return; document.querySelector("#view-browser-navigation-top-bar") .append(createTopBarButton()) createPlaylistContextMenu().register() /************************************************************************** UI Building **************************************************************************/ // If Queue Page add Buttons const iframeInterval = setInterval(() => { /** @type {HTMLIFrameElement} */ const currentIframe = document.querySelector("iframe.active"); if (!currentIframe || currentIframe.id !== "app-queue" ) { return; } const headers = currentIframe.contentDocument.querySelectorAll( ".glue-page-header__buttons" ); for (const e of headers) { e.append(createQueueButton( "Save as Playlist", "Save the current Queue as a new Playlist", () => { let tracks = getQueueTracks(); highjackCreateDialog(tracks); }, )); e.append(createQueueButton( "Copy into Buffer", "Insert the current Queue into the Buffer", () => { queueToBuffer() }, )); } if (headers.length > 0) clearInterval(iframeInterval); }, 500) // Creates the Main Menu function createMenu() { const container = document.createElement("div") container.id = "combine-playlist-spicetify" container.className = "context-menu-container" container.style.zIndex = "1029" const style = document.createElement("style") style.textContent = ` #combine-menu { display: inline-block; width: 33%; max-height: 70%; overflow: hidden auto; padding: 10px } .combine-pattern { margin-top: 7px; } .order-controls { position: absolute; right: 0; padding: 0 5px 5px 0; z-index: 3 } .button.button-icon-only::before { color: var(--modspotify_main_fg); } .order-controls-up { position: relative; top: 100%; } .order-controls-remove { position: relative; top: 50%; } .order-controls-down { position: relative; bottom: 100%; } .card-info-subtitle-owner { color: var(--modspotify_secondary_fg); } .card-info-subtitle-tracks { font-weight: lighter; color: var(--modspotify_secondary_fg); } ` const menu = document.createElement("ul") menu.id = "combine-menu" menu.className = "context-menu" container.append(style, menu) return { container, menu } } // Creates a Button in the Combine Menu function createMenuItem(name, callback) { const item = document.createElement("div"); item.classList.add("item"); item.onclick = callback; item.onmouseover = () => item.classList.add("hover"); item.onmouseleave = () => item.classList.remove("hover"); const text = document.createElement("span"); text.classList.add("text"); text.innerText = name; item.append(text); return item; } // Creates the SubMenu in Playlist Context function createPlaylistContextMenu() { var createFromCurrent = new Spicetify.ContextMenu.Item( MENU_BTN_CREATE_NEW, (uris) => { if (uris.length === 1) { fetchPlaylist(uris[0]) .then((buffer) => highjackCreateDialog(buffer.tracks)) .catch((err) => Spicetify.showNotification(`${err}`)); return; } else { Spicetify.showNotification("Unable to find Playlist URI") } }, (_) => true ) var insertIntoBuffer = new Spicetify.ContextMenu.Item( MENU_BTN_INSERT_BUFFER, (uris) => { if (uris.length === 1) { fetchPlaylist(uris[0]) .then((buffer) => {LIST.addToStorage(buffer)}) .catch((err) => Spicetify.showNotification(`${err}`)); return; } }, (_) => true ) return new Spicetify.ContextMenu.SubMenu( "Copy Playlist", [ createFromCurrent, insertIntoBuffer], (uris) => { if (uris.length === 1) { const uriObj = Spicetify.URI.fromString(uris[0]); switch (uriObj.type) { case Spicetify.URI.Type.PLAYLIST: case Spicetify.URI.Type.PLAYLIST_V2: return true; } return false; } // Multiple Items selected. return false; } ) } // Creates the Button to View Merge Buffer function createTopBarButton() { const button = document.createElement("button") button.classList.add("button", "spoticon-copy-16", "merge-button") button.setAttribute("data-tooltip", TOP_BTN_TOOLTIP) button.setAttribute("data-contextmenu", "") button.setAttribute("data-uri", "spotify:special:copy") button.onclick = () => { const bound = button.getBoundingClientRect() LIST.changePosition(bound.left, bound.top) document.body.append(LIST.container) LIST.setScroll() } return button } // Creates the Dropdown Menu for Merge Pattern function createPatternSelect(defaultOpt = 0) { const select = document.createElement("select"); select.className = "GlueDropdown combine-pattern"; const appendOpt = document.createElement("option"); appendOpt.text = "Append"; const shuffleOpt = document.createElement("option"); shuffleOpt.text = "Shuffle"; const alternateOpt = document.createElement("option"); alternateOpt.text = "Alternate"; select.onclick = (ev) => ev.stopPropagation(); select.append(appendOpt, shuffleOpt, alternateOpt); select.options[defaultOpt].selected = true; return select; } // Queue button function createQueueButton(name, tooltip, callback) { const b = document.createElement("button"); b.classList.add("button", "button-green"); b.innerText = name; b.setAttribute("data-tooltip", tooltip); b.onclick = callback; return b; } // Highjack Spotifies 'New Playlist' Dialog function highjackCreateDialog(tracks) { playlistDialogButton.click() var createButton = document.querySelector("body > div.Modal__portal > div > div > div > div.PlaylistAnnotationModal__submit-button-container > button") var buttonContainer = document.querySelector("body > div.Modal__portal > div > div > div > div.PlaylistAnnotationModal__submit-button-container") var highjackedButton = createButton.cloneNode(true) highjackedButton.addEventListener("click", () => onCreateNewPlaylist(tracks)) window.addEventListener("keypress", (event) => { if (event.code === `Enter`) { // Cancel the default action, if needed event.preventDefault(); // Trigger the button element with a click createButton.click(); } }); createButton.remove() buttonContainer.insertAdjacentElement("afterbegin", highjackedButton) } /************************************************************************** OnCLick Functions **************************************************************************/ // Create a new Playlist from Inputs function onCreateNewPlaylist(tracks) { var exitButton = document.querySelector("body > div.Modal__portal > div > div > div > div.PlaylistAnnotationModal__close-button > button"); var nameInput = document.querySelector("body > div.Modal__portal > div > div > div > div.PlaylistAnnotationModal__content > div.PlaylistAnnotationModal__playlist-name > input") var descInput = document.querySelector("body > div.Modal__portal > div > div > div > div.PlaylistAnnotationModal__content > div.PlaylistAnnotationModal__playlist-description > textarea") var imageInput = document.querySelector("body > div.Modal__portal > div > div > div > div.PlaylistAnnotationModal__content > div.PlaylistAnnotationModal__img-container > div > div.PlaylistAnnotationModal__img-holder > img") var name = nameInput.value if (!name) { name = nameInput.getAttribute("placeholder") } var desc = descInput.value var img; if (imageInput) { img = imageInput.getAttribute("src") } createPlaylist(name) .then(res => addTracks(res.uri, tracks)) .then((_) => Spicetify.showNotification(`Created Playlist: "${name}"`)) .catch((err) => Spicetify.showNotification(`${err}`)); exitButton.click() if (exitButton) { exitButton.click() } } // Get All Tracks in Queue and remove delimiter function getQueueTracks() { return Spicetify.Queue.next_tracks .map((t) => t.uri) .filter((t) => { return t != "spotify:delimiter"; }) } // Copy the Queue to the Combine Buffer function queueToBuffer() { let tracks = getQueueTracks(); var date = new Date() var year = new Intl.DateTimeFormat('en', { year: 'numeric' }).format(date); var month = new Intl.DateTimeFormat('en', { month: 'short' }).format(date); var day = new Intl.DateTimeFormat('en', { day: '2-digit' }).format(date); const timeOptions = { hour: 'numeric', minute: 'numeric', hour12: false}; var time = new Intl.DateTimeFormat(`en`, timeOptions).format(date); let queue = { id: `spotify:queue-${date}`, uri: `spotify:queue`, name: "Queue", imgUri: undefined, tracks: tracks, owner: `${time} - ${day} ${month} ${year}`, } LIST.addToStorage(queue); } // Show the clicked Playlist async function showPlaylist(event) { console.log(event) } /************************************************************************** Merge Playlists **************************************************************************/ // Merge all Playlists function mergePlaylists(pattern) { var tracks = LIST.getStorage().map((pl) => pl.tracks) switch (pattern) { case 1: return shuffle(tracks); case 2: return alternate(tracks); default: return append(tracks); } } // Alternate Playlists function alternate(arrays) { var combined = [] while (arrays.length != 0) { var current = arrays.shift() if (current.length != 0) { combined.push(current.shift()) if (current.length != 0) { arrays.push(current) } } } return combined } // Shuffle all tracks using the Durstenfeld Shuffle function shuffle(arrays) { var combined = append(arrays) for (var i = combined.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var temp = combined[i]; combined[i] = combined[j]; combined[j] = temp; } return combined; } // Simply Concat all Playlist function append(arrays) { var combined = [] arrays.forEach((arr) => combined = combined.concat(arr)) return combined; } /************************************************************************** Calls to the CosmosAPI **************************************************************************/ // Fetch all Track from Playlist URI async function fetchPlaylist(uri) { return await new Promise((resolve, reject) => { Spicetify.BridgeAPI.cosmosJSON( { method: "GET", uri: `sp://core-playlist/v1/playlist/${uri}/`, body: { policy: { link: true, }, }, }, (error, res) => { if (error) { reject(error); return; } let id = `${uri}-${new Date()}` let tracks = res.items.map((track) => track.link) let img = res.playlist.picture let name = res.playlist.name let owner = res.playlist.owner.name let playlist = {id: id, uri: uri, name: name, tracks: tracks, imgUri: img, owner: owner} resolve(playlist); } ); }); } // Create a new Playlist async function createPlaylist(name) { return await new Promise((resolve, reject) => { Spicetify.BridgeAPI.cosmosJSON( { method: "POST", uri: `sp://core-playlist/v1/rootlist`, body: { operation: "create", playlist: !0, before: "start", name: name, }, }, (error, res) => { if (error) { reject(error); return; } resolve(res); } ); }); } // add track list to playlist async function addTracks(uri, tracks) { return await new Promise((resolve, reject) => { Spicetify.BridgeAPI.cosmosJSON( { method: "POST", uri: `sp://core-playlist/v1/playlist/${uri}`, body: { operation: "add", uris: tracks, after: "end" } }, (error, res) => { if (error) { reject(error); return; } resolve(res); } ); }); } })();