Jacopo De Gattis

Jacopo De Gattis

import "@johnlindquist/kit"
import { WidgetAPI } from "@johnlindquist/kit/types/pro";
import { ChildProcess, exec as executeCustom } from "child_process";
import { Browser } from "puppeteer";
const puppeteer = await npm("puppeteer");
const spotify = await npm("spotify-node-applescript");
const credentialsStorage = await db({ accessToken: null })
interface Track {
artist: string,
album: string,
disc_number: number,
duration: number,
played_count: number,
track_number: number,
starred: boolean,
popularity: number,
id: string,
name: string,
album_artist: string,
artwork_url: string,
spotify_url: string
}
interface RealtimeLyricsLines {
startTimeMs: string,
words: string,
syllables: [],
endTimeMs: string
}
interface RealtimeLyrics {
lyrics: {
syncType: string,
lines: Array<RealtimeLyricsLines>,
provider: string,
providerLyricsId: string,
providerDisplayName: string,
syncLyricsUri: string,
isDenseTypeface: boolean,
alternatives: Array<[]>,
language: string,
isRtlLanguage: boolean,
fullscreenAction: string,
},
colors: { background: number, text: number, highlightText: number },
hasVocalRemoval: boolean
}
interface State {
position: number,
currentLine: number,
currentLyrics: Array<RealtimeLyricsLines>,
previousProcess: ChildProcess,
isProcessLaunched: boolean,
previousTrack: string,
backgroundColor: string,
currentTrack: {
title: string,
artist: string,
isLoading: boolean,
position: number,
},
spotifyUser: {
email: string,
password: string,
tokenMaxAttempts: number
}
}
const state: State = {
position: 0,
currentLine: 0,
currentLyrics: null,
previousProcess: null,
isProcessLaunched: false,
previousTrack: null,
backgroundColor: "#984E51",
currentTrack: {
title: "",
artist: "",
isLoading: null,
position: 0
},
spotifyUser: {
email: "",
password: "",
tokenMaxAttempts: 2
}
}
const wgt: WidgetAPI = await widget(`
<div class="main-container">
<header>
<h2>{{currentTrack.title}} - {{currentTrack.artist}}</h2>
</header>
<div v-if="currentTrack.isLoading" class="loader">
Loading ...
</div>
<div class="lyrics-container" v-else>
<label v-for="(line, index) in currentLyrics">
<template v-if="currentLine === index">
<div class="highlighted">{{line.words}}</div>
</template>
<template v-else>
<div :key="index" :data-name="index" :data-index="index" id="unfocused-line" class="grayed-out">{{line.words}}</div>
</template>
</label>
</div>
</div>
<style>
.main-container {
padding-top: 50px;
padding-bottom: 50px;
padding-left: 50px;
overflow: scroll;
width: 700px;
height: 1200px;
width: 100%;
height: 100%;
}
.lryics-container {
margin-left: 0px;
padding-left: 0px;
}
.highlighted {
color: white;
}
.grayed-out {
color: #2e2e2e;
}
.grayed-out:hover {
cursor: pointer;
transition-duration: 0.2s;
color: white;
}
.lyrics-container > label {
display: block;
font-size: 1.4rem;
font-weight: 600;
}
</style>
<script>
const handler = (event) => {
ipcRenderer.send("WIDGET_CLICK", {
targetId: "jumpto",
index: event.target.dataset.index,
name: event.target.dataset.name,
widgetId: window.widgetId,
})
}
const setupButtons = () => {
const buttons = document.querySelectorAll("#unfocused-line");
buttons.forEach((button) => {
button.removeEventListener("click", handler);
button.addEventListener("click", handler);
})
}
setTimeout(setupButtons, 2000);
window.onSetState = setupButtons;
</script>
`, {
width: 700,
height: 1200,
alwaysOnTop: true,
backgroundColor: state.backgroundColor
})
wgt.onClick((event: any) => {
if (event.targetId === "jumpto") {
const seconds = parseInt(state.currentLyrics[parseInt(event.name)].startTimeMs) / 1000;
spotify.jumpTo(seconds, () => {});
}
})
const fetchToken = async (browser: Browser) => {
const page = await browser.newPage();
await page.goto("https://accounts.spotify.com/en/login?continue=https%3A%2F%2Fopen.spotify.com%2F", {
waitUntil: 'networkidle0',
});
if (!await page.$("#login-username")) return;
await page.type("#login-username", state.spotifyUser.email);
await page.type("#login-password", state.spotifyUser.password);
Promise.resolve([
page.click("#login-button"),
page.waitForNavigation(),
])
const scriptTag = await page.waitForSelector('script[id=session]');
const value = await scriptTag.evaluate(el => el.textContent);
return JSON.parse(value);
}
const syncLyrics: (
lyrics: Array<RealtimeLyricsLines>,
time: number
) => number = (lyrics, time) => {
const scores = [];
lyrics.forEach((lyric) => {
const score = time - parseInt(lyric.startTimeMs);
if (score >= 0) scores.push(score);
})
if (scores.length === 0) return;
const closest = Math.min(...scores);
return scores.indexOf(closest);
}
const fetchRealtimeLyrics: (
trackId: string,
browser: Browser,
attempt: number
) => Promise<RealtimeLyrics> = async (trackId, browser, attempt) => {
const endpoint = "https://spclient.wg.spotify.com/color-lyrics/v2/track";
let token = credentialsStorage.data.accessToken;
if (!token) {
token = await fetchToken(browser);
credentialsStorage.data.accessToken = token.accessToken;
credentialsStorage.write();
token = token.accessToken;
}
if (!token) return;
const request = await fetch(`${endpoint}/${trackId}`, {
method: "GET",
headers: {
"accept": "application/json",
"authorization": `Bearer ${token}`,
"user-agent": "Spotify/8.7.78.373 Android/29 (Android SDK built for arm64)",
"spotify-app-version": "8.7.78.373"
}
})
if (request.status === 401 && attempt < state.spotifyUser.tokenMaxAttempts) {
credentialsStorage.data.accessToken = null;
return await fetchRealtimeLyrics(trackId, browser, attempt++);
}
if (!(request.status === 200)) return;
const data: RealtimeLyrics = await request.json();
return data
}
const script = `
tell application "Spotify"
repeat until application "Spotify" is not running
set cstate to ((player position * 1000) as integer)
log cstate
end repeat
end tell
`
const pipeCommandOutput = (command: string, callback: (x: any) => void) => {
if (state.previousProcess) state.previousProcess.kill();
const cmd = executeCustom(command);
cmd.stdout.setEncoding("utf8");
cmd.stderr.on("data", callback);
return cmd;
}
const browser: Browser = await puppeteer.launch();
setInterval(() => {
spotify.isRunning((err: any, isRunning: boolean) => {
if (!isRunning || err) return
spotify.getTrack(async (err: any, track: Track) => {
if (err) return
if (state.previousTrack !== track.name) {
state.previousTrack = track.name;
wgt.setState({ ...state, position: 0, currentLine: 0 })
wgt.setState({ ...state, currentTrack: { isLoading: true }})
const data = await fetchRealtimeLyrics(track.id.split(":").pop(), browser, 0);
if (!data) return;
state.previousProcess = pipeCommandOutput(`osascript -e '${script}'`, (x: any) => {
const currIndex = syncLyrics(data.lyrics.lines, x)
wgt.setState({ position: x, currentLine: currIndex })
})
wgt.setState({
...state,
currentTrack: {
title: track.name,
artist: track.artist,
isLoading: false,
position: 0
},
currentLyrics: data.lyrics.lines
})
state.currentLyrics = data.lyrics.lines;
state.isProcessLaunched = true;
}
})
})
}, 1000)

// Name: spotify-playing
// Description: Show currently playing spotify track
// Author: Jacopo De Gattis
import "@johnlindquist/kit"
import { WidgetAPI } from "@johnlindquist/kit/types/pro";
const spotify = await npm("spotify-node-applescript")
const playing = false;
const state: Playback = {
name: "",
artist: "",
artwork_url: ""
}
interface Playback {
name: string,
artist: string,
artwork_url: string,
}
const wgt: WidgetAPI = await widget(`
<div class="px-1 flex flex-row w-full justify-between">
<div class="cover-img" v-bind:style="{'backgroundImage': state.artwork_url ? 'url(' + state.artwork_url + ')' : 'url(' + fallback_icon + ')' }"></div>
<div class="flex flex-row justify-start track-infos left-container">
<div class="flex flex-col ml-4 justify-center">
<pre class="font-inter">{{state.name ? state.name : "No music playing"}}</pre>
<pre class="text-xs">{{state.artist && state.artist}}</pre>
</div>
</div>
<div class="flex justify-center items-center btn-container">
<button v-if="state.name" id="play-pause" class="play-pause-btn flex justify-center items-center">
<img id="play-pause-img" v-bind:src="playing ? pause_icon : play_icon" />
</button>
</div>
</div>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;700&display=swap');
* {
user-select: none;
}
.cover-img {
width: 75px;
height: 75px;
border-radius: 10px;
box-shadow: 7px 7px 5px black;
background-size: 100%;
transition: background-image 1s ease-in;
}
.left-container {
width: 200px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
margin-right: 20px;
}
.track-infos img {
border-radius: 10px;
box-shadow: 7px 7px 5px black;
}
pre:first-child {
font-family: 'Inter';
font-weight: bold;
font-size: 100%;
}
pre:last-child {
font-family: 'Inter';
font-weight: light;
font-size: 3.0vw;
}
svg {
margin-top: 20px;
}
.btn-container {
margin-right: 20px;
}
.play-pause-btn {
padding: 5px;
background-color: #1ED760;
border-radius: 70%;
height: 50px;
width: 50px;
transition-duration: 0.3s;
box-shadow: 7px 7px 5px black;
}
.play-pause-btn:hover {
transform: scale(1.05);
}
</style>
`, {
state: {
pause_icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAHRJREFUSEtjZKAxYKSx+QwDasFzBgYGCSQfZjAwMMyE8kHs6UhyLxgYGCSxhQY+H/xH09DIwMDQABUD0fVo8ljNGrUAOZRGg4hhNBVh5MPRjAYOktGiYnAXFfgqnHQGBoYZSM4HqZUitcKhSnU9oHUyVXwAAOhlOBlZoVRaAAAAAElFTkSuQmCC",
play_icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAMlJREFUSEvV1UFqAkEQRuHP0+kRvIR7CYiucgUXEcG48hSCRxA8ggQXbgK6SUAZ6AZFcRzHQux9v0dVV//VEHwawXwvEbQwwQif2NWp8loFA/QTdI0PfD8qKRNk7hwdrKqK7hUU3D8M0cPvvaIqgsz8QRdTHMpEjwgyc5HatrwlqSMouP8Yp0HYXhPVFWTmJrWtGO+ztj1LkEVtzE4reRtBWItCHzlsTMM+WmhUhIVdSFw3U/5/pYWzLwu0qllUh3dx9yU7+akVHAG+vzwZybfc1AAAAABJRU5ErkJggg==",
fallback_icon: "https://community.spotify.com/t5/image/serverpage/image-id/55829iC2AD64ADB887E2A5/image-size/large?v=v2&px=999"
}
})
wgt.onClick((event: any) => {
if (["play-pause", "play-pause-img"].includes(event.targetId)) {
spotify.playPause((a) => {
wgt.setState({ playing: !playing })
});
}
})
const loop = () => {
wgt.setSize(400, 85)
setInterval(async () => {
spotify.isRunning((err, isRunning) => {
if (!isRunning) return
spotify.getTrack((err: any, track: any) => {
if (err) return
spotify.getState((err, state) => {
if (err) return
wgt.setState({
state: track,
playing: state.state === "playing" ? true : false
})
})
})
})
}, 1000)
}
wgt.onResized(() => wgt.setSize(400, 85))
loop()