diff --git a/websites/V/VRoid/metadata.json b/websites/V/VRoid/metadata.json new file mode 100644 index 000000000000..decf6a1362fb --- /dev/null +++ b/websites/V/VRoid/metadata.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://schemas.premid.app/metadata/1.9", + "author": { + "id": "193714715631812608", + "name": "theusaf" + }, + "service": "VRoid", + "description": { + "en": "The VRoid project is a 3D business by Pixiv Inc. with the philosophy of \"Make Creativities More Enjoyable\"", + "ja_JP": "VRoidプロジェクトは、「創作活動がもっと楽しくなる場所を創る」を理念とするピクシブ株式会社による3D事業です。誰もが個性豊かな自分の3Dキャラクターモデルを持ち、そのキャラクターを創作活動やコミュニケーションに活用することができる「1人1アバター」の世界。私たちのミッションは、その未来をテクノロジーとクリエイティブの力で実現することです。" + }, + "url": [ + "vroid.com", + "www.vroid.com", + "hub.vroid.com", + "developer.vroid.com" + ], + "version": "1.0.0", + "logo": "https://i.imgur.com/RAxM8Tw.png", + "thumbnail": "https://cdn.discordapp.com/attachments/459040398527037441/1144327742096150568/2023-08-24_10_36_51.png", + "color": "#ffec00", + "category": "other", + "tags": [ + "3d", + "anime", + "modeling", + "studio", + "character" + ], + "settings": [ + { + "multiLanguage": true, + "id": "language" + } + ] +} \ No newline at end of file diff --git a/websites/V/VRoid/presence.ts b/websites/V/VRoid/presence.ts new file mode 100644 index 000000000000..bdf7326eab54 --- /dev/null +++ b/websites/V/VRoid/presence.ts @@ -0,0 +1,377 @@ +const presence = new Presence({ + clientId: "1144333935967473685", + }), + browsingTimestamp = Math.floor(Date.now() / 1000), + slideshow = presence.createSlideshow(); + +const enum Assets { + Logo = "https://i.imgur.com/RAxM8Tw.png", +} + +function getImportantPath(): string[] { + const pathList = document.location.pathname.split("/").filter(Boolean); + if (pathList[0] === "en") pathList.shift(); + if (pathList[pathList.length - 1] !== "") pathList.push(""); + return pathList; +} + +function getTitle(): string { + const split = document.title.match(/(.*) [|-]/); + return split ? split[1].trim() : document.title; +} + +function applyCharacterSlideshow(presenceData: PresenceData): void { + const characters = [ + ...document.querySelectorAll( + "a[href*='/characters/']:nth-of-type(1)" + ), + ].map(link => link.parentElement); + for (const character of characters) { + const slide = Object.assign({}, presenceData), + imageUrl = character.querySelector( + "[data-background-image-url]" + ).dataset.backgroundImageUrl; + slide.largeImageKey = imageUrl; + slide.smallImageText = + character.children[1].firstElementChild.childNodes[0].textContent; + try { + slide.smallImageKey = character.children[3].querySelector( + "[data-background-image-url]" + ).dataset.backgroundImageUrl; + } catch { + /* ignore */ + } + slideshow.addSlide(imageUrl, slide, 5000); + } +} + +function applyArtworkSlideshow(presenceData: PresenceData): void { + const artworks = [ + ...document.querySelectorAll( + "a[href*='/artworks/']:nth-of-type(1)" + ), + ].map(link => link.parentElement); + for (const artwork of artworks) { + const slide = Object.assign({}, presenceData), + imageUrl = artwork.querySelector( + "[data-background-image-url]" + ).dataset.backgroundImageUrl; + slide.largeImageKey = imageUrl; + slideshow.addSlide(imageUrl, slide, 5000); + } +} + +let oldLang: string = null, + currentTargetLang: string = null, + oldPath: string = null, + strings: Awaited> = null, + fetchingStrings = false, + stringFetchTimeout: number = null; + +function fetchStrings() { + if (oldLang === currentTargetLang && strings) return; + if (fetchingStrings) return; + const targetLang = currentTargetLang; + fetchingStrings = true; + stringFetchTimeout = setTimeout(() => { + presence.error(`Failed to fetch strings for ${targetLang}.`); + fetchingStrings = false; + }, 5e3); + presence.info(`Fetching strings for ${targetLang}.`); + presence + .getStrings( + { + browsing: "general.browsing", + buttonReadArticle: "general.buttonReadArticle", + buttonViewPage: "general.buttonViewPage", + buttonViewProfile: "general.buttonViewProfile", + readingAbout: "general.readingAbout", + readingAPost: "general.readingAPost", + readingAnArticle: "general.readingAnArticle", + viewAProduct: "general.viewAProduct", + viewAProfile: "general.viewAProfile", + viewCategory: "general.viewCategory", + viewHome: "general.viewHome", + viewList: "general.viewList", + viewing: "general.viewing", + }, + targetLang + ) + .then(result => { + if (targetLang !== currentTargetLang) return; + clearTimeout(stringFetchTimeout); + strings = result; + fetchingStrings = false; + oldLang = targetLang; + presence.info(`Fetched strings for ${targetLang}.`); + }) + .catch(() => null); +} + +setInterval(fetchStrings, 3000); +fetchStrings(); + +/** + * Sets the current language to fetch strings for and returns whether any strings are loaded. + */ +function checkStringLanguage(lang: string) { + currentTargetLang = lang; + return !!strings; +} + +const settingsFetchStatus: Record = {}, + cachedSettings: Record = {}; + +function startSettingGetter(setting: string) { + if (!settingsFetchStatus[setting]) { + let success = false; + settingsFetchStatus[setting] = setTimeout(() => { + if (!success) + presence.error(`Failed to fetch setting '${setting}' in time.`); + delete settingsFetchStatus[setting]; + }, 2000); + presence + .getSetting(setting) + .then(result => { + cachedSettings[setting] = result; + success = true; + }) + .catch(() => null); + } +} + +function getSetting( + setting: string, + fallback: E = null +): E { + startSettingGetter(setting); + return (cachedSettings[setting] as E) ?? fallback; +} + +presence.on("UpdateData", () => { + const presenceData: PresenceData = { + largeImageKey: Assets.Logo, + startTimestamp: browsingTimestamp, + }, + lang = getSetting("language"), + pathList = getImportantPath(), + { hostname, href, pathname } = document.location; + + if (!lang) + presence.info("[WARN] Language setting not loaded, using default."); + + if (pathname !== oldPath) { + oldPath = pathname; + slideshow.deleteAllSlides(); + } + + if (!checkStringLanguage(lang)) return; + + switch (hostname) { + case "developer.vroid.com": { + if (pathList[1] === "docs") { + presenceData.details = `VRoid SDK - ${strings.readingAbout}`; + presenceData.state = document.querySelector("h1").textContent.trim(); + } else presenceData.details = `VRoid SDK - ${strings.browsing}`; + break; + } + case "hub.vroid.com": { + switch (pathList[0]) { + case "": { + presenceData.details = `VRoid Hub - ${strings.browsing}`; + break; + } + case "capture-application": + case "apps": { + const [selectedTab] = [ + ...document.querySelectorAll("[role=nav] a"), + ].sort((a, b) => { + return +![...a.classList].every(name => + [...b.classList].includes(name) + ); + }), + appTitle = + document.querySelector( + "header > h1" + ).textContent; + if (pathList[1]) { + presenceData.details = `VRoid Hub - ${strings.viewAProduct}`; + presenceData.buttons = [ + { label: strings.buttonViewPage, url: href }, + ]; + switch (pathList[2]) { + case "": { + presenceData.state = appTitle; + break; + } + case "character_models": { + presenceData.state = `${appTitle} - ${selectedTab.textContent}`; + applyCharacterSlideshow(presenceData); + break; + } + case "artworks": { + presenceData.state = `${appTitle} - ${selectedTab.textContent}`; + applyArtworkSlideshow(presenceData); + break; + } + } + } else { + presenceData.details = `VRoid Hub - ${strings.viewing}`; + presenceData.state = getTitle(); + } + break; + } + case "characters": { + const container = + document.querySelector( + "canvas + div img" + ).parentElement; + presenceData.details = `VRoid Hub - ${strings.viewAProduct}`; + presenceData.state = `${container.querySelector("a").textContent} / ${ + container.nextElementSibling.textContent + }`; + presenceData.largeImageKey = container.querySelector("img").src; + presenceData.buttons = [{ label: strings.buttonViewPage, url: href }]; + break; + } + case "artworks": { + presenceData.details = `VRoid Hub - ${strings.readingAPost}`; + presenceData.buttons = [{ label: strings.buttonViewPage, url: href }]; + applyArtworkSlideshow(presenceData); + break; + } + case "model_assets": { + const container = document.querySelector( + "header > div[style]" + ).parentElement; + presenceData.details = `VRoid Hub - ${strings.viewAProduct}`; + presenceData.state = container.querySelector( + "div:nth-of-type(2) > div > div" + ).textContent; + presenceData.largeImageKey = getComputedStyle( + container.querySelector("div[style]") + ).backgroundImage.match(/url\("(.*)"\)/)[1]; + presenceData.buttons = [{ label: strings.buttonViewPage, url: href }]; + break; + } + case "models": { + presenceData.details = `VRoid Hub - ${strings.viewList}`; + presenceData.state = + document.querySelector( + "header > h1" + ).textContent; + applyCharacterSlideshow(presenceData); + break; + } + case "tags": { + presenceData.details = `VRoid Hub - ${strings.viewCategory}`; + presenceData.state = `#${pathList[1]} - ${ + (pathList[2] === "artworks" + ? document.querySelector( + "section + div a:nth-of-type(2)" + ) + : document.querySelector( + "section + div a:nth-of-type(1)" + ) + ).textContent + }`; + if (pathList[2] === "artworks") applyArtworkSlideshow(presenceData); + else applyCharacterSlideshow(presenceData); + break; + } + case "users": { + const username = + document.querySelector("a > h1").textContent; + presenceData.details = `VRoid Hub - ${strings.viewAProfile}`; + presenceData.state = username; + presenceData.smallImageKey = document + .querySelector("header > a > div[style]") + .style.backgroundImage.match(/url\("(.*)"\)/)[1]; + presenceData.smallImageText = username; + presenceData.buttons = [ + { label: strings.buttonViewProfile, url: href }, + ]; + if (pathList[2] === "artworks") { + presenceData.state += ` - ${ + document.querySelector( + "header + div header + div a:nth-of-type(2)" + ).textContent + }`; + applyArtworkSlideshow(presenceData); + } else applyCharacterSlideshow(presenceData); + break; + } + case "hearts": { + presenceData.details = `VRoid Hub - ${strings.viewList}`; + presenceData.state = [ + ...document.querySelector("header + div h1") + .childNodes, + ] + .map(node => { + return node.nodeName === "svg" ? "❤️" : node.textContent; + }) + .join(""); + if (pathList[1] === "artworks") applyArtworkSlideshow(presenceData); + else applyCharacterSlideshow(presenceData); + break; + } + } + break; + } + default: { + switch (pathList[0]) { + case "": { + presenceData.details = strings.viewHome; + break; + } + case "studio": { + presenceData.details = strings.readingAbout; + presenceData.state = "VRoid Studio"; + break; + } + case "mobile": { + presenceData.details = strings.readingAbout; + presenceData.state = "VRoid Mobile"; + break; + } + case "wear": { + if (pathList[1]) { + presenceData.details = strings.viewing; + presenceData.state = getTitle(); + } else { + presenceData.details = strings.readingAbout; + presenceData.state = "VRoid Wear"; + } + break; + } + case "news": { + if (pathList[1]) { + presenceData.details = strings.readingAnArticle; + presenceData.state = + document.querySelector( + "article h1" + ).textContent; + presenceData.largeImageKey = + document.querySelector("article img").src; + presenceData.buttons = [ + { label: strings.buttonReadArticle, url: href }, + ]; + } else { + presenceData.details = strings.readingAnArticle; + presenceData.state = + document.querySelector("h1 > img").alt; + } + break; + } + } + } + } + + const slides = slideshow.getSlides(); + if (slides.length) { + if (!slideshow.currentSlide.details) + slideshow.currentSlide = slides[0].data; + presence.setActivity(slideshow); + } else if (presenceData.details) presence.setActivity(presenceData); + else presence.setActivity(); +});