您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
A simple userscript to give you the ability to favorite more avatars.
// ==UserScript== // @name VRChat Extended Favorites // @namespace http://tampermonkey.net/ // @version 2025.09.22.2 // @description A simple userscript to give you the ability to favorite more avatars. // @author https://github.com/xskutsu // @license AGPL-3.0 // @match *://vrchat.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=vrchat.com // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_setClipboard // ==/UserScript== (function () { "use strict"; function getAvatarID(avatarURL) { return avatarURL.split("avtr_")[1]; } class Storage { static gmKey = "extended_favorites.favorites_storage"; static data = new Map(); static stringify() { return JSON.stringify([...this.data.entries()]); } static save() { GM_setValue(this.gmKey, this.stringify()); } static parse(data) { return new Map(JSON.parse(data)); } static load(data = GM_getValue(this.gmKey, "[]")) { try { this.data = this.parse(data); } catch (error) { console.error("Failed to load favorite avatars! Something has gone wrong. Falling back to blank."); this.data = new Map(); console.error(error); alert("Failed to load favorite avatars, falling back to blank. Check the console for more information."); } } static addFavorite(cardData) { this.data.set(cardData.id, cardData); this.save(); } static removeFavorite(cardData) { const result = this.data.delete(cardData.id); if (result) { this.save(); } return result; } static isFavorited(cardData) { return this.data.has(cardData.id); } static getFavorites() { return [...this.data.values()]; } static wipe() { this.data.clear(); this.save(); } } Storage.load(); function switchToAvatar(cardData) { return fetch("https://vrchat.com/api/1/avatars/avtr_" + cardData.id + "/select", { method: "PUT", credentials: "include" }); } function updateButtonState(cardData, button, isFavorited = Storage.isFavorited(cardData)) { if (isFavorited) { button.textContent = "Remove from Extended ⭐"; button.style.backgroundColor = "#B60000"; } else { button.textContent = "Add to Extended ⭐"; button.style.backgroundColor = ""; } } function extractAvatarDataFromCard(container) { const imageContainer = container.querySelectorAll('[aria-label="Avatar Image"]')[0]; const imageElement = imageContainer.querySelector("img"); const avatarURL = imageContainer.href; return { avatarName: imageElement.alt, avatarURL: avatarURL, imageURL: imageElement.src, id: getAvatarID(avatarURL) } } function isNotFavoritable(container) { return container.querySelector('div[role="note"][title="Avatar Release Status"]') !== null; } function addButtonToCard(container) { const informationContainer = container.querySelector(".flex-grow-1"); const button = document.createElement("button"); button.textContent = "..."; button.style.marginTop = "auto"; button.setAttribute("data-extended-fav", "true"); informationContainer.appendChild(button); if (isNotFavoritable(container)) { button.textContent = "Can't Add (Private)"; button.style.backgroundColor = "#000000"; return; } const cardData = extractAvatarDataFromCard(container); updateButtonState(cardData, button); button.addEventListener("click", function () { const isFavorited = Storage.isFavorited(cardData); if (isFavorited) { Storage.removeFavorite(cardData); } else { Storage.addFavorite(cardData); } updateButtonState(cardData, button, !isFavorited); }); } const avatarCardSelector = '[data-scrollkey^="avtr"][aria-label="Avatar Card"]'; document.body.querySelectorAll(avatarCardSelector).forEach(addButtonToCard); function refreshCardButtons() { document.querySelectorAll(avatarCardSelector).forEach(container => { const button = container.querySelector("button[data-extended-fav]"); if (!button) { return; } const cardData = extractAvatarDataFromCard(container); updateButtonState(cardData, button); }); } const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { if (mutation.type !== 'childList') { return; } mutation.addedNodes.forEach(node => { if (node.nodeType !== Node.ELEMENT_NODE) { return; } if (node.matches(avatarCardSelector)) { addButtonToCard(node); } node.querySelectorAll(avatarCardSelector).forEach(addButtonToCard); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); class MenuFactory { static makeBackground() { const container = document.createElement("div"); container.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; justify-content: center; align-items: center; z-index: 99999; background-color: #00000099; width: 100vw; height: 100vh;`; return container; } static makeContainer() { const container = document.createElement("div"); container.style.cssText = ` background-color: #202020; border: 3px solid #9090BB;`; return container; } static makeButton(textContent, clickCallback = null, isDire = false) { const button = document.createElement("button"); button.textContent = textContent; if (isDire) { button.style.cssText = ` background-color: #B60000;`; } if (clickCallback) { button.addEventListener("click", function () { clickCallback(); }); } return button; } static makeAvatarCard(cardData) { const container = document.createElement("div"); container.style.cssText = ` border: 3px solid #9090BB; padding: 6px;`; const iconImg = document.createElement("img"); iconImg.style.cssText = ` aspect-ratio: 4 / 3; height: 210px; width: 280px;`; iconImg.src = cardData.imageURL; container.appendChild(iconImg); const titleText = document.createElement("p"); titleText.textContent = cardData.avatarName; container.appendChild(titleText); const buttonContainer = document.createElement("div"); buttonContainer.style.cssText = ` display: flex; justify-content: center; align-items: center;`; container.appendChild(buttonContainer); const removeButton = this.makeButton("Remove", null, true); removeButton.addEventListener("click", function () { if (removeButton.textContent === "Sure?") { Storage.removeFavorite(cardData); refreshCardButtons(); Menu.closeMenu(); Menu.openMenu(); } removeButton.textContent = "Sure?"; }); buttonContainer.appendChild(removeButton); buttonContainer.appendChild(this.makeButton("Use Avatar", async function () { const response = await switchToAvatar(cardData); if (response.ok) { alert("Switched to avatar!"); return; } switch (response.status) { case 403: alert("HTTP Error 403: Is the avatar private?"); break; default: alert("HTTP Error " + response.status.toString()); } })); buttonContainer.appendChild(this.makeButton("Open URL", function () { open(cardData.avatarURL); })); return container; } static makeAvatarList() { const container = document.createElement("div"); container.style.cssText = ` display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; overflow-y: auto; overflow-x: hidden; max-height: 92.1465vh; padding: 6px;`; for (const cardData of Storage.getFavorites()) { container.appendChild(this.makeAvatarCard(cardData)); } return container; } static makeTopBar() { const container = document.createElement("div"); container.style.cssText = ` border-bottom: 3px solid #9090BB; padding-bottom: 5px; display: flex; justify-content: center; align-items: center; padding: 6px;`; const titleText = document.createElement("a"); titleText.textContent = "Extended Favorites | Made with ♡ for you by xskutsu"; titleText.href = "https://github.com/xskutsu"; titleText.style.cssText = ` margin-right: auto; font-weight: bold;`; container.appendChild(titleText); container.appendChild(this.makeButton("Import", function () { let data = prompt("Paste your exported favorites JSON here:"); if (data) { try { if (data.startsWith('"') && data.endsWith('"')) { data = data.slice(1, -1).replace(/\\"/g, '"'); } const imported = Storage.parse(data); let addedCount = 0; for (const cardData of imported.values()) { if (!Storage.isFavorited(cardData)) { Storage.addFavorite(cardData); addedCount++; } } alert(`Added ${addedCount} avatars from a list of ${imported.size}.`); refreshCardButtons(); Menu.closeMenu(); Menu.openMenu(); } catch (error) { alert("Failed to import favorites. Check the console for more information."); console.error(error); } } })); container.appendChild(this.makeButton("Export", function () { const data = Storage.stringify(); GM_setClipboard(data); alert("Favorites JSON copied to clipboard!"); })); container.appendChild(this.makeButton("Wipe", function () { const key = "Yes, I am sure!"; if (prompt('Are you sure you want to wipe ALL extended favorite avatars? If so, type "' + key + '" exactly if you are sure.') === key) { Storage.wipe(); refreshCardButtons(); Menu.closeMenu(); Menu.openMenu(); } }, true)); container.appendChild(this.makeButton("Add Visible", function () { let entriesCount = 0; let unfavoritableCount = 0; for (const container of document.body.querySelectorAll(avatarCardSelector)) { if (isNotFavoritable(container)) { unfavoritableCount++; continue; } const cardData = extractAvatarDataFromCard(container); if (Storage.isFavorited(cardData)) { continue; } entriesCount++; Storage.addFavorite(cardData); } if (unfavoritableCount == 0) { alert(`Added ${entriesCount} avatars to Extended Favorites. (Does not include duplicates.)`); } else { alert(`Added ${entriesCount} avatars to Extended Favorites, ${unfavoritableCount} were unable due to being private. (Does not include duplicates.)`); } refreshCardButtons(); Menu.closeMenu(); Menu.openMenu(); })); container.appendChild(this.makeButton("Exit", function () { Menu.closeMenu(); }, true)); return container; } static make() { const container = this.makeContainer(); container.appendChild(this.makeTopBar()); container.appendChild(this.makeAvatarList()); const background = this.makeBackground(); background.appendChild(container); return background; } } class Menu { static isOpen = false; static container = null; static openMenu() { if (this.container !== null) { this.closeMenu(); } this.isOpen = true; this.container = MenuFactory.make(); document.body.appendChild(this.container); } static closeMenu() { this.isOpen = false; if (this.container !== null) { this.container.remove(); } } static toggleMenu() { if (this.isOpen) { this.closeMenu(); } else { this.openMenu(); } } } GM_registerMenuCommand("Toggle Menu", function () { Menu.toggleMenu(); }); })();