// @ts-check // NAME: Full App Display // AUTHOR: khanhas // VERSION: 1.0 // DESCRIPTION: Fancy artwork and track status display. /// (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", `${Spicetify.SVGIcons.projector}`, activate ); Spicetify.Mousetrap.bind("f11", toggleFad); })();