dotfiles/spicetify/extensions/fullAppDisplay.js
2022-02-13 21:02:44 -05:00

765 lines
24 KiB
JavaScript

// @ts-check
// NAME: Full App Display
// AUTHOR: khanhas
// VERSION: 1.0
// DESCRIPTION: Fancy artwork and track status display.
/// <reference path="../globals.d.ts" />
(function FullAppDisplay() {
if (!Spicetify.Keyboard || !Spicetify.React || !Spicetify.ReactDOM) {
setTimeout(FullAppDisplay, 200);
return;
}
const { React: react, ReactDOM: reactDOM } = Spicetify;
const { useState, useEffect } = react;
const CONFIG = getConfig();
let updateVisual;
const style = document.createElement("style");
const styleBase = `
#full-app-display {
display: none;
position: fixed;
width: 100%;
height: 100%;
cursor: default;
left: 0;
top: 0;
}
#fad-header {
position: fixed;
width: 100%;
height: 80px;
-webkit-app-region: drag;
}
#fad-body {
height: 100vh;
}
#fad-foreground {
position: relative;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
transform: scale(var(--fad-scale));
}
#fad-art-image {
position: relative;
width: 100%;
height: 100%;
padding-bottom: 100%;
border-radius: 15px;
background-size: cover;
}
#fad-art-inner {
position: absolute;
left: 3%;
bottom: 0;
width: 94%;
height: 94%;
z-index: -1;
backface-visibility: hidden;
transform: translateZ(0);
filter: blur(6px);
backdrop-filter: blur(6px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6);
}
#fad-progress-container {
width: 100%;
display: flex;
align-items: center;
}
#fad-progress {
width: 100%;
height: 6px;
border-radius: 6px;
background-color: #ffffff50;
overflow: hidden;
}
#fad-progress-inner {
height: 100%;
border-radius: 6px;
background-color: #ffffff;
box-shadow: 4px 0 12px rgba(0, 0, 0, 0.8);
}
#fad-duration {
margin-left: 10px;
}
#fad-background {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: -2;
}
body.fad-activated #full-app-display {
display: block
}
.fad-background-fade {
transition: background-image 1s linear;
}
body.video-full-screen.video-full-screen--hide-ui {
cursor: auto;
}
#fad-controls button {
background-color: transparent;
border: 0;
color: currentColor;
padding: 0 5px;
}
#fad-artist svg, #fad-album svg {
display: inline-block;
}
::-webkit-scrollbar {
width: 8px;
}
`;
const styleChoices = [
`
#fad-foreground {
flex-direction: row;
text-align: left;
}
#fad-art {
width: calc(100vw - 840px);
min-width: 200px;
max-width: 340px;
}
#fad-details {
padding-left: 50px;
line-height: initial;
max-width: 70%;
color: #FFFFFF;
}
#fad-title {
font-size: 87px;
font-weight: var(--glue-font-weight-black);
}
#fad-artist, #fad-album {
font-size: 54px;
font-weight: var(--glue-font-weight-medium);
}
#fad-artist svg, #fad-album svg {
margin-right: 5px;
}
#fad-status {
display: flex;
min-width: 400px;
max-width: 400px;
align-items: center;
}
#fad-status.active {
margin-top: 20px;
}
#fad-controls {
display: flex;
margin-right: 10px;
}
#fad-elapsed {
min-width: 52px;
}`,
`
#fad-art {
width: calc(100vh - 400px);
max-width: 340px;
}
#fad-foreground {
flex-direction: column;
text-align: center;
}
#fad-details {
padding-top: 50px;
line-height: initial;
max-width: 70%;
color: #FFFFFF;
}
#fad-title {
font-size: 54px;
font-weight: var(--glue-font-weight-black);
}
#fad-artist, #fad-album {
font-size: 33px;
font-weight: var(--glue-font-weight-medium);
}
#fad-artist svg, #fad-album svg {
width: 25px;
height: 25px;
margin-right: 5px;
}
#fad-status {
display: flex;
min-width: 400px;
max-width: 400px;
align-items: center;
flex-direction: column;
}
#fad-status.active {
margin: 20px auto 0;
}
#fad-controls {
margin-top: 20px;
order: 2
}
#fad-elapsed {
min-width: 56px;
margin-right: 10px;
text-align: right;
}`,
];
const lyricsPlusBase = `
#fad-body {
display: grid;
grid-template-columns: 1fr 1fr;
}
#fad-foreground {
padding: 0 50px 0 100px;
width: 50vw;
}
#fad-lyrics-plus-container {
width: 50vw;
}
`;
const lyricsPlusStyleChoices = [
`
#fad-title {
font-size: 54px;
}
#fad-art {
max-width: 210px;
margin-left: 50px;
}`,
``,
];
updateStyle();
const DisplayIcon = ({ icon, size }) => {
return react.createElement("svg", {
width: size,
height: size,
viewBox: "0 0 16 16",
fill: "currentColor",
dangerouslySetInnerHTML: {
__html: icon,
},
});
};
const SubInfo = ({ text, id, icon }) => {
return react.createElement(
"div",
{
id,
},
CONFIG.icons && react.createElement(DisplayIcon, { icon, size: 35 }),
react.createElement("span", null, text)
);
};
const ButtonIcon = ({ icon, onClick }) => {
return react.createElement(
"button",
{
onClick,
},
react.createElement(DisplayIcon, { icon, size: 20 })
);
};
const ProgressBar = () => {
const [value, setValue] = useState(Spicetify.Player.getProgress());
useEffect(() => {
const update = ({ data }) => setValue(data);
Spicetify.Player.addEventListener("onprogress", update);
return () => Spicetify.Player.removeEventListener("onprogress", update);
});
const duration = Spicetify.Platform.PlayerAPI._state.duration;
return react.createElement(
"div",
{ id: "fad-progress-container" },
react.createElement("span", { id: "fad-elapsed" }, Spicetify.Player.formatTime(value)),
react.createElement(
"div",
{ id: "fad-progress" },
react.createElement("div", {
id: "fad-progress-inner",
style: {
width: (value / duration) * 100 + "%",
},
})
),
react.createElement("span", { id: "fad-duration" }, Spicetify.Player.formatTime(duration))
);
};
const PlayerControls = () => {
const [value, setValue] = useState(Spicetify.Player.isPlaying());
useEffect(() => {
const update = ({ data }) => setValue(!data.is_paused);
Spicetify.Player.addEventListener("onplaypause", update);
return () => Spicetify.Player.removeEventListener("onplaypause", update);
});
return react.createElement(
"div",
{ id: "fad-controls" },
react.createElement(ButtonIcon, {
icon: Spicetify.SVGIcons["skip-back"],
onClick: Spicetify.Player.back,
}),
react.createElement(ButtonIcon, {
icon: Spicetify.SVGIcons[value ? "pause" : "play"],
onClick: Spicetify.Player.togglePlay,
}),
react.createElement(ButtonIcon, {
icon: Spicetify.SVGIcons["skip-forward"],
onClick: Spicetify.Player.next,
})
);
};
class FAD extends react.Component {
constructor(props) {
super(props);
this.state = {
title: "",
artist: "",
album: "",
cover: "",
};
this.currTrackImg = new Image();
this.nextTrackImg = new Image();
this.mousetrap = new Spicetify.Mousetrap();
}
async getAlbumDate(uri) {
const id = uri.replace("spotify:album:", "");
const albumInfo = await Spicetify.CosmosAsync.get(`hm://album/v1/album-app/album/${id}/desktop`);
const albumDate = new Date(albumInfo.year, (albumInfo.month || 1) - 1, albumInfo.day || 0);
const recentDate = new Date();
recentDate.setMonth(recentDate.getMonth() - 6);
return albumDate.toLocaleString("default", albumDate > recentDate ? { year: "numeric", month: "short" } : { year: "numeric" });
}
async fetchInfo() {
const meta = Spicetify.Player.data.track.metadata;
// prepare title
let rawTitle = meta.title;
if (CONFIG.trimTitle) {
rawTitle = rawTitle
.replace(/\(.+?\)/g, "")
.replace(/\[.+?\]/g, "")
.replace(/\s\-\s.+?$/, "")
.trim();
}
// prepare artist
let artistName;
if (CONFIG.showAllArtists) {
artistName = Object.keys(meta)
.filter((key) => key.startsWith("artist_name"))
.sort()
.map((key) => meta[key])
.join(", ");
} else {
artistName = meta.artist_name;
}
// prepare album
let albumText = meta.album_title || "";
if (CONFIG.showAlbum) {
const albumURI = meta.album_uri;
if (albumURI?.startsWith("spotify:album:")) {
albumText += " • " + (await this.getAlbumDate(albumURI));
}
}
if (meta.image_xlarge_url === this.currTrackImg.src) {
this.setState({
title: rawTitle || "",
artist: artistName || "",
album: albumText || "",
});
return;
}
// TODO: Pre-load next track
// Wait until next track image is downloaded then update UI text and images
const previousImg = this.currTrackImg.cloneNode();
this.currTrackImg.src = meta.image_xlarge_url;
this.currTrackImg.onload = () => {
const bgImage = `url("${this.currTrackImg.src}")`;
this.animateCanvas(previousImg, this.currTrackImg);
this.setState({
title: rawTitle || "",
artist: artistName || "",
album: albumText || "",
cover: bgImage,
});
};
this.currTrackImg.onerror = () => {
// Placeholder
this.currTrackImg.src =
"data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCI+CiAgPHJlY3Qgc3R5bGU9ImZpbGw6I2ZmZmZmZiIgd2lkdGg9IjQwIiBoZWlnaHQ9IjQwIiB4PSIwIiB5PSIwIiAvPgogIDxwYXRoIGZpbGw9IiNCM0IzQjMiIGQ9Ik0yNi4yNSAxNi4xNjJMMjEuMDA1IDEzLjEzNEwyMS4wMTIgMjIuNTA2QzIwLjU5NCAyMi4xOTIgMjAuMDgxIDIxLjk5OSAxOS41MTkgMjEuOTk5QzE4LjE0MSAyMS45OTkgMTcuMDE5IDIzLjEyMSAxNy4wMTkgMjQuNDk5QzE3LjAxOSAyNS44NzggMTguMTQxIDI2Ljk5OSAxOS41MTkgMjYuOTk5QzIwLjg5NyAyNi45OTkgMjIuMDE5IDI1Ljg3OCAyMi4wMTkgMjQuNDk5QzIyLjAxOSAyNC40MjIgMjIuMDA2IDE0Ljg2NyAyMi4wMDYgMTQuODY3TDI1Ljc1IDE3LjAyOUwyNi4yNSAxNi4xNjJaTTE5LjUxOSAyNS45OThDMTguNjkyIDI1Ljk5OCAxOC4wMTkgMjUuMzI1IDE4LjAxOSAyNC40OThDMTguMDE5IDIzLjY3MSAxOC42OTIgMjIuOTk4IDE5LjUxOSAyMi45OThDMjAuMzQ2IDIyLjk5OCAyMS4wMTkgMjMuNjcxIDIxLjAxOSAyNC40OThDMjEuMDE5IDI1LjMyNSAyMC4zNDYgMjUuOTk4IDE5LjUxOSAyNS45OThaIi8+Cjwvc3ZnPgo=";
};
}
animateCanvas(prevImg, nextImg) {
const { innerWidth: width, innerHeight: height } = window;
this.back.width = width;
this.back.height = height;
const dim = width > height ? width : height;
const ctx = this.back.getContext("2d");
ctx.imageSmoothingEnabled = false;
ctx.filter = `blur(30px) brightness(0.6)`;
const blur = 30;
if (!CONFIG.enableFade) {
ctx.globalAlpha = 1;
ctx.drawImage(nextImg, -blur * 2, -blur * 2 - (width - height) / 2, dim + 4 * blur, dim + 4 * blur);
return;
}
let factor = 0.0;
const animate = () => {
ctx.globalAlpha = 1;
ctx.drawImage(prevImg, -blur * 2, -blur * 2 - (width - height) / 2, dim + 4 * blur, dim + 4 * blur);
ctx.globalAlpha = Math.sin((Math.PI / 2) * factor);
ctx.drawImage(nextImg, -blur * 2, -blur * 2 - (width - height) / 2, dim + 4 * blur, dim + 4 * blur);
if (factor < 1.0) {
factor += 0.016;
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
componentDidMount() {
this.updateInfo = this.fetchInfo.bind(this);
Spicetify.Player.addEventListener("songchange", this.updateInfo);
this.updateInfo();
updateVisual = () => {
updateStyle();
this.fetchInfo();
};
this.onQueueChange = async (queue) => {
queue = queue.data;
let nextTrack;
if (queue.queued.length) {
nextTrack = queue.queued[0];
} else {
nextTrack = queue.nextUp[0];
}
this.nextTrackImg.src = nextTrack.metadata.image_xlarge_url;
};
const scaleLimit = { min: 0.1, max: 4, step: 0.05 };
this.onScaleChange = (event) => {
if (!event.ctrlKey) return;
let dir = event.deltaY < 0 ? 1 : -1;
let temp = (CONFIG["scale"] || 1) + dir * scaleLimit.step;
if (temp < scaleLimit.min) {
temp = scaleLimit.min;
} else if (temp > scaleLimit.max) {
temp = scaleLimit.max;
}
CONFIG["scale"] = temp;
saveConfig();
updateVisual();
};
Spicetify.Platform.PlayerAPI._events.addListener("queue_update", this.onQueueChange);
this.mousetrap.bind("esc", deactivate);
window.dispatchEvent(new Event("fad-request"));
}
componentWillUnmount() {
Spicetify.Player.removeEventListener("songchange", this.updateInfo);
Spicetify.Platform.PlayerAPI._events.removeListener("queue_update", this.onQueueChange);
this.mousetrap.unbind("esc");
}
render() {
return react.createElement(
"div",
{
id: "full-app-display",
className: "Video VideoPlayer--fullscreen VideoPlayer--landscape",
onDoubleClick: deactivate,
onContextMenu: openConfig,
},
react.createElement("canvas", {
id: "fad-background",
ref: (el) => (this.back = el),
}),
react.createElement("div", { id: "fad-header" }),
react.createElement(
"div",
{ id: "fad-body" },
react.createElement(
"div",
{
id: "fad-foreground",
style: {
"--fad-scale": CONFIG["scale"] || 1,
},
ref: (el) => {
if (!el) return;
el.onmousewheel = this.onScaleChange;
},
},
react.createElement(
"div",
{ id: "fad-art" },
react.createElement(
"div",
{
id: "fad-art-image",
className: CONFIG.enableFade && "fad-background-fade",
style: {
backgroundImage: this.state.cover,
},
},
react.createElement("div", { id: "fad-art-inner" })
)
),
react.createElement(
"div",
{ id: "fad-details" },
react.createElement("div", { id: "fad-title" }, this.state.title),
react.createElement(SubInfo, {
id: "fad-artist",
text: this.state.artist,
icon: Spicetify.SVGIcons.artist,
}),
CONFIG.showAlbum &&
react.createElement(SubInfo, {
id: "fad-album",
text: this.state.album,
icon: Spicetify.SVGIcons.album,
}),
react.createElement(
"div",
{
id: "fad-status",
className: (CONFIG.enableControl || CONFIG.enableProgress) && "active",
},
CONFIG.enableControl && react.createElement(PlayerControls),
CONFIG.enableProgress && react.createElement(ProgressBar)
)
)
),
CONFIG.lyricsPlus &&
react.createElement("div", {
id: "fad-lyrics-plus-container",
style: {
"--lyrics-color-active": "#ffffff",
"--lyrics-color-inactive": "#ffffff50",
},
})
)
);
}
}
const classes = ["video", "video-full-screen", "video-full-window", "video-full-screen--hide-ui", "fad-activated"];
const container = document.createElement("div");
container.id = "fad-main";
let lastApp;
async function toggleFullscreen() {
if (CONFIG.enableFullscreen) {
await document.documentElement.requestFullscreen();
} else if (document.webkitIsFullScreen) {
await document.exitFullscreen();
}
}
async function activate() {
await toggleFullscreen();
document.body.classList.add(...classes);
document.body.append(style, container);
reactDOM.render(react.createElement(FAD), container);
requestLyricsPlus();
}
function deactivate() {
if (CONFIG.enableFullscreen || document.webkitIsFullScreen) {
document.exitFullscreen();
}
document.body.classList.remove(...classes);
reactDOM.unmountComponentAtNode(container);
style.remove();
container.remove();
window.dispatchEvent(new Event("fad-request"));
if (lastApp && lastApp !== "/lyrics-plus") {
Spicetify.Platform.History.push(lastApp);
}
}
function toggleFad() {
if (document.body.classList.contains("fad-activated")) {
deactivate();
} else {
activate();
}
}
function updateStyle() {
style.innerHTML =
styleBase +
styleChoices[CONFIG.vertical ? 1 : 0] +
(CONFIG.lyricsPlus ? lyricsPlusBase + lyricsPlusStyleChoices[CONFIG.vertical ? 1 : 0] : "");
}
function requestLyricsPlus() {
if (CONFIG.lyricsPlus) {
lastApp = Spicetify.Platform.History.location.pathname;
if (lastApp !== "/lyrics-plus") {
Spicetify.Platform.History.push("/lyrics-plus");
}
}
window.dispatchEvent(new Event("fad-request"));
}
function getConfig() {
try {
const parsed = JSON.parse(Spicetify.LocalStorage.get("full-app-display-config"));
if (parsed && typeof parsed === "object") {
return parsed;
}
throw "";
} catch {
Spicetify.LocalStorage.set("full-app-display-config", "{}");
return {};
}
}
function saveConfig() {
Spicetify.LocalStorage.set("full-app-display-config", JSON.stringify(CONFIG));
}
const ConfigItem = ({ name, field, func }) => {
const [value, setValue] = useState(CONFIG[field]);
return react.createElement(
"div",
{ className: "setting-row" },
react.createElement("label", { className: "col description" }, name),
react.createElement(
"div",
{ className: "col action" },
react.createElement(
"button",
{
className: "switch" + (value ? "" : " disabled"),
onClick: () => {
const state = !value;
CONFIG[field] = state;
setValue(state);
saveConfig();
func();
},
},
react.createElement(DisplayIcon, { icon: Spicetify.SVGIcons.check, size: 16 })
)
)
);
};
function openConfig(event) {
event.preventDefault();
const style = react.createElement("style", {
dangerouslySetInnerHTML: {
__html: `
.setting-row::after {
content: "";
display: table;
clear: both;
}
.setting-row .col {
display: flex;
padding: 10px 0;
align-items: center;
}
.setting-row .col.description {
float: left;
padding-right: 15px;
}
.setting-row .col.action {
float: right;
text-align: right;
}
button.switch {
align-items: center;
border: 0px;
border-radius: 50%;
background-color: rgba(var(--spice-rgb-shadow), .7);
color: var(--spice-text);
cursor: pointer;
display: flex;
margin-inline-start: 12px;
padding: 8px;
}
button.switch.disabled {
color: rgba(var(--spice-rgb-text), .3);
}
`,
},
});
let configContainer = react.createElement(
"div",
null,
style,
react.createElement(ConfigItem, {
name: "Enable Lyrics Plus integration",
field: "lyricsPlus",
func: () => {
updateVisual();
requestLyricsPlus();
},
}),
react.createElement(ConfigItem, { name: "Enable progress bar", field: "enableProgress", func: updateVisual }),
react.createElement(ConfigItem, { name: "Enable controls", field: "enableControl", func: updateVisual }),
react.createElement(ConfigItem, { name: "Trim title", field: "trimTitle", func: updateVisual }),
react.createElement(ConfigItem, { name: "Show album", field: "showAlbum", func: updateVisual }),
react.createElement(ConfigItem, { name: "Show all artists", field: "showAllArtists", func: updateVisual }),
react.createElement(ConfigItem, { name: "Show icons", field: "icons", func: updateVisual }),
react.createElement(ConfigItem, { name: "Vertical mode", field: "vertical", func: updateStyle }),
react.createElement(ConfigItem, { name: "Enable fullscreen", field: "enableFullscreen", func: toggleFullscreen }),
react.createElement(ConfigItem, { name: "Enable song change animation", field: "enableFade", func: updateVisual })
);
Spicetify.PopupModal.display({
title: "Full App Display",
content: configContainer,
});
}
// Add activator on top bar
new Spicetify.Topbar.Button(
"Full App Display",
`<svg role="img" height="16" width="16" viewBox="0 0 16 16" fill="currentColor">${Spicetify.SVGIcons.projector}</svg>`,
activate
);
Spicetify.Mousetrap.bind("f11", toggleFad);
})();