New folder structure for extension

Separated client- and server-side features into seperate root folders. Since Stadia Avatar now has two versions (Userscript and Chrome extension).

Added core extension functionality.

Created a page constructor for extension popup. High probability that I will create a seperate repo for this feature, as it's pretty neat and very useful for future extensions.
This commit is contained in:
Victor Westerlund 2021-02-08 04:50:47 +01:00
parent e51ee8e382
commit 74b2635755
23 changed files with 480 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*.crx
*.pem

View file

@ -0,0 +1,18 @@
{
"extension_description": {
"message": "Custom Stadia avatars for you and your friends",
"description": "Short summary of this extension's function."
},
"avatar_set": {
"message": "Set avatar from",
"description": "List of different options to set a users's avatar"
},
"avatar_set_url_support": {
"message": "All image formats supported by Chrome except SVG can be added",
"description": "Disclaimer about supported image formats"
},
"page_return": {
"message": "Go back",
"description": "Tooltip when hovering the back button on a page"
}
}

View file

@ -0,0 +1,163 @@
:root {
/* color components */
--palette-background: 33,33,33;
--palette-contrast: 255,255,255;
--palette-header: 45,45,45;
--palette-accent-high: 253,74,24;
--palette-accent-low: 170,3,88;
/* compiled colors */
--color-background: rgb(var(--palette-background));
--color-contrast: rgb(var(--palette-contrast));
--color-header: rgb(var(--palette-header));
--color-accent: linear-gradient(145deg, var(--palette-accent-high) 0%, var(--palette-accent-low) 100%);
}
* {
margin: 0;
color: var(--color-contrast);
}
*::-webkit-scrollbar {
display: none;
}
html,
body {
background-color: var(--color-background);
display: flex;
flex-direction: column;
align-items: center;
}
body {
--page-depth: 0;
--animation-speed: 400ms;
width: 400px;
transition: var(--animation-speed) transform cubic-bezier(0.4, 0, 0.2, 1);
transform: translateX(calc((100% * var(--page-depth)) * -1));
}
.page .header,
header {
width: 100%;
background-color: var(--color-header);
border-bottom: solid 1px black;
}
header {
--header-padding: 30px;
--pfp-size: 80px;
--pfp-stroke-offset: 5px;
--pfp-stroke-width: 4px;
--pfp-stroke-size: calc(var(--pfp-stroke-offset) + var(--pfp-stroke-width));
height: calc(var(--pfp-size) + (var(--pfp-stroke-size) * 2));
padding: var(--header-padding) 0 var(--header-padding) 0;
display: flex;
justify-content: center;
align-items: center;
}
#myAvatar {
width: var(--pfp-size);
height: var(--pfp-size);
box-shadow:
0 0 0 var(--pfp-stroke-offset) var(--color-header),
0 0 0 var(--pfp-stroke-size) rgb(var(--palette-accent-high));
border-radius: 100%;
}
ol {
list-style-type: none;
width: 100%;
font-size: 15px;
padding-left: 0;
}
ol li {
--padding: 25px;
width: calc(100% - (var(--padding) * 2));
height: 64px;
padding: 0 var(--padding) 0 var(--padding);
display: flex;
align-items: center;
cursor: pointer;
}
.page .back:hover,
ol li:hover {
background: rgba(var(--palette-contrast),.1);
}
ol li img {
height: 20px;
filter: brightness(0) invert(1);
margin-right: 20px;
}
/* ---- */
.page {
transition: var(--animation-speed) opacity;
position: absolute;
top: 0;
left: 100%;
width: 100%;
opacity: 1;
}
.page .header {
--height: 60px;
--button-size: 40px;
--padding: 10px;
height: 60px;
box-sizing: border-box;
padding: 0 var(--padding) 0 var(--padding);
display: flex;
align-items: center;
}
.page .back {
--chevron-size: 10px;
--chevron-stroke-width: 3px;
width: var(--button-size);
height: var(--button-size);
border-radius: 4px;
border: solid 1px var(--color-background);
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
}
.page .back::before {
content: '';
border-style: solid;
border-width: var(--chevron-stroke-width) var(--chevron-stroke-width) 0 0;
display: inline-block;
width: var(--chevron-size);
height: var(--chevron-size);
top: 0;
left: calc(var(--chevron-size) / 3);
transform: rotate(-135deg);
position: relative;
vertical-align: top;
}
.page .header p {
width: calc(100% - (var(--button-size) * 2));
font-size: 17px;
text-align: center;
}
.page .body {
position: relative;
top: var(--height);
box-sizing: border-box;
padding: 20px;
}

View file

@ -0,0 +1 @@
<svg width="2500" height="2500" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M128 0c-14.144 0-25.6 11.456-25.6 25.6v89.6c0 14.128 11.456 25.6 25.6 25.6 14.144 0 25.6-11.472 25.6-25.6V55.584c29.824 10.56 51.2 38.976 51.2 72.416 0 42.4-34.4 76.8-76.8 76.8S51.2 170.4 51.2 128c0-21.216 8.592-40.416 22.496-54.304 10-10 10-26.208 0-36.208s-26.208-10-36.208 0C14.336 60.64 0 92.64 0 128c0 70.688 57.312 128 128 128s128-57.312 128-128S198.688 0 128 0" fill="#FFFFF"/></svg>

After

Width:  |  Height:  |  Size: 519 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"/></svg>

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,33 @@
import { AvatarURL } from "./popup_modules/ChangeAvatar.mjs";
// Localize by replacing __MSG_***__ strings in DOM
function localizePage() {
for (var i = 0; i < document.body.children.length; i++) {
const element = document.body.children[i];
let valStrH = element.innerHTML.toString();
const valNewH = valStrH.replace(/__MSG_(\w+)__/g, (match,key) => {
return key ? chrome.i18n.getMessage(key) : "";
});
if(valNewH != valStrH) {
element.innerHTML = valNewH;
}
}
}
function eventHandler(event) {
const target = event.target.closest("[button]");
switch(target.getAttribute("button")) {
case "avatar:url": new AvatarURL(); break;
}
}
document.addEventListener("DOMContentLoaded", function () {
// Bind click listeners to all button attributes
for(const button of document.querySelectorAll("[button]")) {
button.addEventListener("click",event => eventHandler(event));
}
});
localizePage();

View file

@ -0,0 +1,12 @@
import { Page } from "./Page.mjs";
export class AvatarURL extends Page {
constructor() {
super(chrome.i18n.getMessage("avatar_set") + " URL");
this.appendHTML(`<p>${chrome.i18n.getMessage("avatar_set_url_support")}</p>`);
this.open();
}
}

View file

@ -0,0 +1,70 @@
export class Page {
constructor(title = "") {
this.body = null;
this.pageDepth = () => {
return parseInt(getComputedStyle(document.body).getPropertyValue("--page-depth"));
}
this.create(title);
}
create(title) {
// Create elements
const wrapper = document.createElement("div");
this.body = document.createElement("div");
const header = document.createElement("div");
const backButton = document.createElement("div");
const pageDepth = (this.pageDepth() + 1) * 100;
// Add element attributes
wrapper.classList.add("page");
wrapper.style.setProperty("left",pageDepth + "%");
this.body.classList.add("body");
header.classList.add("header");
backButton.classList.add("back");
backButton.setAttribute("title",chrome.i18n.getMessage("page_return"));
// Attach interfaces
wrapper.close = () => this.close(); // Attach public interface to close this page
backButton.addEventListener("click",() => this.close());
// Append document subtree
header.appendChild(backButton);
header.insertAdjacentHTML("beforeend",`<p>${title}</p>`);
wrapper.appendChild(header);
wrapper.appendChild(this.body);
document.body.appendChild(wrapper);
}
destroy() {
const wrapper = this.body.closest(".page");
while(wrapper.firstChild) {
wrapper.removeChild(wrapper.lastChild);
}
wrapper.remove();
}
appendHTML(HTML) {
this.body.insertAdjacentHTML("beforeend",HTML);
}
// ----
close() {
const delay = parseInt(getComputedStyle(document.body).getPropertyValue("--animation-speed"));
document.body.style.setProperty("--page-depth",this.pageDepth() - 1);
setTimeout(() => {
this.destroy();
},delay);
}
open() {
document.body.style.setProperty("--page-depth",this.pageDepth() + 1);
}
}

View file

@ -0,0 +1,26 @@
{
"name": "Stadia Avatar",
"version": "1.0.1",
"author": "Victor Westerlund",
"default_locale": "en",
"homepage_url": "https://github.com/VictorWesterlund/stadia-avatar",
"description": "__MSG_extension_description__",
"icons": {
"16": "assets/img/logo_icon_16.png",
"32": "assets/img/logo_icon_32.png",
"48": "assets/img/logo_icon_48.png",
"128": "assets/img/logo_icon_128.png"
},
"host_permissions": [
"*://stadia.google.com/*"
],
"action": {
"default_popup": "popup.html",
"default_title": "__MSG_extension_description__",
"default_icon": {
"16": "assets/img/logo_icon_16.png",
"32": "assets/img/logo_icon_32.png"
}
},
"manifest_version": 3
}

View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="assets/css/popup.css">
</head>
<body>
<header>
<img id="myAvatar" src="https://gravatar.com/avatar/default?s=80"/>
</header>
<ol>
<li button="avatar:url" title="__MSG_avatar_set__ URL">
<img src="assets/img/icon_set_url.svg"/>
<p>__MSG_avatar_set__ URL</p>
</li>
<li button="avatar:gravatar" title="__MSG_avatar_set__ Gravatar">
<img src="assets/img/icon_set_gravatar.svg"/>
<p>__MSG_avatar_set__ Gravatar</p>
</li>
</ol>
<script src="assets/js/popup.js" type="module"></script>
</body>
</html>

132
client/userscript-client.js Normal file
View file

@ -0,0 +1,132 @@
// ==UserScript==
// @name Stadia Avatars
// @namespace https://victorwesterlund.com/
// @version 1.0
// @description victorWesterlund/stadia-avatar
// @author VictorWesterlund
// @match https://stadia.google.com/*
// @grant none
// @noframes
// ==/UserScript==
(function() {
'use strict';
const stadiaAvatar = new URL("https://api.victorwesterlund.com/stadia-avatar/get");
const gravatar = new URL("https://www.gravatar.com/");
/*
G: Suitable for display on all websites with any audience type.
PG: May contain rude gestures, provocatively dressed individuals, the lesser swear words, or mild violence.
R: May contain such things as harsh profanity, intense violence, nudity, or hard drug use.
X: May contain hardcore sexual imagery or extremely disturbing violence.
*/
gravatar.searchParams.set("rating","G");
// Stylesheet for Stadia Avatars
class StadiaAvatarCSS {
constructor() {
this.sheet = null;
this.createStylesheet();
}
createStylesheet() {
const style = document.createElement("style");
style.setAttribute("data-stadia-avatars","");
style.setAttribute("data-late-css","");
document.head.appendChild(style);
this.sheet = style.sheet;
}
// Serialized group of selectors based on context
selectors(group,id = false) {
switch(group) {
case "me": return `
.ksZYgc,
.rybUIf
`;
case "friends": return `
c-wiz[data-p='%.@.null,"${id}"]'] .drvCDc,
.Y1rZWd[data-player-id="${id}"] .Fnd1Pd,
.Y1rZWd[data-playerid="${id}"] .Fnd1Pd,
.w2Sl7c[data-playerid="${id}"] .drvCDc
`;
}
}
add(selectors,avatar) {
this.sheet.insertRule(`${selectors} { background-image: url(${avatar}) !important; }`);
}
}
const avatars = new StadiaAvatarCSS();
// ----
// Return the player ID attribute of an element
const getID = (target) => {
const id = target.getAttribute("data-player-id") ?? target.getAttribute("data-playerid");
return id;
}
async function getStadiaAvatar(playerID) {
stadiaAvatar.searchParams.set("userID",playerID);
const response = await fetch(stadiaAvatar);
return response.json();
}
// Fetch avatar and append to stylesheet
function replaceWithGravatar(group,playerID) {
getStadiaAvatar(playerID).then(response => {
if(response.status !== "OK") {
return false;
}
gravatar.pathname = "/avatar/" + response.avatar; // Append Gravatar hash
avatars.add(avatars.selectors(group,playerID),gravatar); // Add style override by group
}).catch(
// Ignore missing avatars
);
}
// ----
replaceWithGravatar("me",getID(document.querySelector("[jsname='HiaYvf']")));
function updateFamily(group,wrapper) {
for(const element of wrapper) {
const id = getID(element);
if(!id) {
continue;
}
replaceWithGravatar(group,id);
}
}
let timeout = null;
const friendsList = (mutation,observer) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
let elements = [];
elements = Array.prototype.concat.apply(elements,document.querySelector("[jsaction='JIbuQc:mbLu7b']").children);
elements = Array.prototype.concat.apply(elements,document.querySelector("[jsname='FhFdCc']").children);
updateFamily("friends",elements);
},700);
}
const friendsMenu = document.querySelector("[jsname='TpfyL']");
const friends = new MutationObserver(friendsList);
friends.observe(friendsMenu,{
childList: true,
subtree: true
});
})();