Spotify Lyrics Widget (Musixmatch service)

Hi everyone, In the last few days I've been implementing a Spotify Lyrics' Widget which provide full lyrics from "Musixmatch" service. This also includes the highlighting of the current track line, exactly as it is in the Spotify official lyrics.

A few details about this project:

  • First thing first lyrics are fetched from the Spotify "Musixmatch" service, available after their partnership together. I extracted the API from the Official Spotify android version therefore it may be considered an infringement of their Terms of Service. I then suggest you to use this Widget with a secondary account to avoid your primary account begin banned (is really difficult to get banned btw).
  • Another important thing to notice is that I made this project for learning purposes only, I wanted to catch the lyrics API and reimplement it inside my widget. As I said earlier I think this may be against Spotify Terms of Service so use it at your own risk.
  • If you want to configure your credentials you can simply go inside the state variable and set the email and password fields inside the spotifyUser object.
  • The first time you launch the app, the token will be store inside a .json file using the db feature of kit. However this token will expire and when that happens the widget will automatically fetch a new one for you.
  • You can click on every line to set the current track position to that exact line.
  • The currently sang line is highlighted in white inside the widget.
  • You can change the background color by switching the color in the backgroundColor field inside the state variable.
  • Right now if a track doesn't have lyrics the "Loading..." text will appear. I'm working for a better solution.

Preview: Screenshot 2022-11-06 at 11 43 54

Feel free to ask me everything in case of any doubts or problem. Here's the code, enjoy !

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)