diff --git a/.gitignore b/.gitignore index 1ad66afd..cfdeacbb 100644 --- a/.gitignore +++ b/.gitignore @@ -73,4 +73,7 @@ package *.orig # generated version file -version.generated.ts \ No newline at end of file +version.generated.ts + +# idea generated folder +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index f34fbf7e..bbedc16d 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,14 @@ Lofi is a mini Spotify player with visualizations. It is _not_ a replacement for - Visualization-ready (WebGL) - ≤ 100MB memory footprint +## Lyrics (new feature) + +| Custom Font and background color | Minimalistic and simple| +|--|--| +|![](https://github.com/alient12/lofi/assets/73688480/2f0824c9-1a18-4730-9906-e9cf04030d14)|![](https://github.com/alient12/lofi/assets/73688480/0a95ac65-68b8-4a9d-84e5-ab3ad671024e)| + +Installing `Circular Std Book` font is recommanded for lyrics. + # Building To build, you'll need `node-gyp`, a compatible Python version (2.x), and your operating system's SDK (Microsoft Build Tools or Xcode). diff --git a/src/constants.ts b/src/constants.ts index 1ccd12b6..dbd91bbd 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,6 +12,7 @@ export const MAX_BAR_THICKNESS = 20; export const MIN_FONT_SIZE = 6; export const MAX_FONT_SIZE = 32; export const TRACK_INFO_GAP = { X: 10, Y: 10 }; +export const LYRICS_GAP = { X: 0, Y: 10 }; export const MAX_CORNER_RADIUS = 20; export const MIN_SKIP_SONG_DELAY = 5; @@ -22,6 +23,7 @@ export enum WindowTitle { FullscreenViz = 'fullscreen-visualization', Settings = 'Lofi Settings', TrackInfo = 'track-info', + Lyrics = 'lyrics', } export enum WindowName { @@ -30,6 +32,7 @@ export enum WindowName { FullscreenViz = 'fullscreen-visualization', Settings = 'settings', TrackInfo = 'track-info', + Lyrics = 'lyrics', } export enum IpcMessage { @@ -53,4 +56,5 @@ export enum ApplicationUrl { Help = 'https://www.lofi.rocks/help', Discord = 'https://discord.gg/YuH9UJk', GitHub = 'https://github.com/dvx/lofi', + FindLyricsToken = 'https://github.com/akashrchandran/syrics/wiki/Finding-sp_dc', } diff --git a/src/main/main.ts b/src/main/main.ts index b6ff89e8..60ee4b41 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -13,6 +13,7 @@ import { nativeImage, Rectangle, screen, + session, shell, Tray, } from 'electron'; @@ -37,8 +38,10 @@ import { getAboutWindowOptions, getFullscreenVisualizationWindowOptions, getFullscreenVizBounds, + getLyricsWindowOptions, getSettingsWindowOptions, getTrackInfoWindowOptions, + moveLyric, moveTrackInfo, setAlwaysOnTop, settingsSchema, @@ -142,6 +145,7 @@ const createMainWindow = (): void => { // See: https://github.com/electron/electron/issues/9477#issuecomment-406833003 mainWindow.setBounds(bounds); moveTrackInfo(mainWindow, screen); + moveLyric(mainWindow, screen); mainWindow.webContents.send(IpcMessage.WindowMoved, bounds); }); @@ -162,6 +166,7 @@ const createMainWindow = (): void => { mainWindow.on('resize', () => { moveTrackInfo(mainWindow, screen); + moveLyric(mainWindow, screen); }); mainWindow.on('resized', () => { @@ -174,7 +179,10 @@ const createMainWindow = (): void => { ipcMain.on( IpcMessage.SettingsChanged, - (_: Event, { x, y, size, isAlwaysOnTop, isDebug, isVisibleInTaskbar, visualizationScreenId }: Settings) => { + ( + _: Event, + { x, y, size, isAlwaysOnTop, isDebug, isVisibleInTaskbar, visualizationScreenId, SPDCToken }: Settings + ) => { setAlwaysOnTop({ window: mainWindow, isAlwaysOnTop }); mainWindow.setSkipTaskbar(!isVisibleInTaskbar); showDevTool(mainWindow, isDebug); @@ -184,12 +192,26 @@ const createMainWindow = (): void => { mainWindow.center(); } moveTrackInfo(mainWindow, screen); + moveLyric(mainWindow, screen); const fullscreenVizWindow = findWindow(WindowTitle.FullscreenViz); if (fullscreenVizWindow) { const fullscreenVizBounds = getFullscreenVizBounds(mainWindow.getBounds(), screen, visualizationScreenId); fullscreenVizWindow.setBounds(fullscreenVizBounds); } + if (SPDCToken !== '') { + session.defaultSession.cookies.set({ + url: 'https://spotify.com', + name: 'sp_dc', + value: SPDCToken, + sameSite: 'no_restriction', + domain: '.spotify.com', + secure: true, + httpOnly: true, + path: '/', + expirationDate: new Date().getTime() + 24 * 60 * 60 * 1000, + }); + } } ); @@ -262,6 +284,13 @@ const createMainWindow = (): void => { }; } + case WindowName.Lyrics: { + return { + action: 'allow', + overrideBrowserWindowOptions: getLyricsWindowOptions(mainWindow, settings.isAlwaysOnTop), + }; + } + case WindowName.Auth: { shell.openExternal(details.url); break; @@ -324,6 +353,16 @@ const createMainWindow = (): void => { break; } + case WindowName.Lyrics: { + moveLyric(mainWindow, screen); + childWindow.setIgnoreMouseEvents(true); + setAlwaysOnTop({ window: childWindow, isAlwaysOnTop: settings.isAlwaysOnTop }); + if (MACOS) { + childWindow.setWindowButtonVisibility(false); + } + break; + } + default: { break; } @@ -342,6 +381,19 @@ app.on('ready', () => { } createMainWindow(); + if (settings.SPDCToken !== '') { + session.defaultSession.cookies.set({ + url: 'https://spotify.com', + name: 'sp_dc', + value: settings.SPDCToken, + sameSite: 'no_restriction', + domain: '.spotify.com', + secure: true, + httpOnly: true, + path: '/', + expirationDate: new Date().getTime() + 24 * 60 * 60 * 1000, + }); + } tray = new Tray(icon); @@ -397,6 +449,7 @@ app.on('ready', () => { const isOnLeft = checkIfAppIsOnLeftSide(currentDisplay, bounds.x, bounds.width); mainWindow.webContents.send(IpcMessage.WindowReady, { isOnLeft, displays }); moveTrackInfo(mainWindow, screen); + moveLyric(mainWindow, screen); }); }); diff --git a/src/main/main.utils.ts b/src/main/main.utils.ts index ec15e43f..301a5404 100644 --- a/src/main/main.utils.ts +++ b/src/main/main.utils.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { IpcMessage, + LYRICS_GAP, MAX_BAR_THICKNESS, MAX_SIDE_LENGTH, MAX_SKIP_SONG_DELAY, @@ -29,8 +30,8 @@ export const getCommonWindowOptions = (): BrowserWindowConstructorOptions => ({ export const getSettingsWindowOptions = (): BrowserWindowConstructorOptions => ({ ...getCommonWindowOptions(), - height: 420, - minHeight: 420, + height: 580, + minHeight: 580, width: 420, minWidth: 420, title: WindowTitle.Settings, @@ -74,6 +75,26 @@ export const getTrackInfoWindowOptions = ( }; }; +export const getLyricsWindowOptions = ( + mainWindow: BrowserWindow, + isAlwaysOnTop: boolean +): BrowserWindowConstructorOptions => { + const { x, y, width, height } = mainWindow.getBounds(); + return { + ...getCommonWindowOptions(), + parent: mainWindow, + skipTaskbar: true, + alwaysOnTop: isAlwaysOnTop, + height: 1000, + width: 1000, + transparent: true, + center: false, + x: x + LYRICS_GAP.X - width, + y: y + height + LYRICS_GAP.Y, + title: WindowTitle.Lyrics, + }; +}; + export const showDevTool = (window: BrowserWindow, isShow: boolean): void => { if (isShow) { window.webContents.openDevTools({ mode: 'detach' }); @@ -128,6 +149,27 @@ export const moveTrackInfo = (mainWindow: BrowserWindow, screen: Screen): void = mainWindow.webContents.send(IpcMessage.SideChanged, { isOnLeft }); }; +export const moveLyric = (mainWindow: BrowserWindow, screen: Screen): void => { + const { x, y, width, height } = mainWindow.getBounds(); + const LyricsWindow = findWindow(WindowTitle.Lyrics); + if (!LyricsWindow) { + return; + } + + const currentDisplay = screen.getDisplayNearestPoint({ x, y }); + const isOnLeft = checkIfAppIsOnLeftSide(currentDisplay, x, width); + + const originalBounds = LyricsWindow.getBounds(); + const newBounds = { + ...originalBounds, + x: isOnLeft ? x + LYRICS_GAP.X : x - originalBounds.width - LYRICS_GAP.X + width, + y: y + height + LYRICS_GAP.Y, + }; + + LyricsWindow.setBounds(newBounds); + mainWindow.webContents.send(IpcMessage.SideChanged, { isOnLeft }); +}; + export const settingsSchema = z.object({ x: z.number(), y: z.number(), diff --git a/src/models/settings.ts b/src/models/settings.ts index 891b5575..ef5af165 100644 --- a/src/models/settings.ts +++ b/src/models/settings.ts @@ -36,6 +36,19 @@ export interface Settings { showFreemiumWarning: boolean; cornerRadius: number; trackInfoRefreshTimeInSeconds: number; + SPDCToken: string; + isShowLyrics: boolean; + isAlwaysShowLyrics: boolean; + lyricMaxLength: number; + lyricsFontSize: number; + lyricsColor: string; + nextLyricsColor: string; + lyricsBackgroundColor: string; + lyricsBackgroundOpacity: number; + lyricsFont: string; + isLyricsBlur: boolean; + isLyricsRandomBackground: boolean; + lyricsCornerRadius: number; } export const DEFAULT_SETTINGS: Settings = { @@ -68,4 +81,17 @@ export const DEFAULT_SETTINGS: Settings = { showFreemiumWarning: true, cornerRadius: 0, trackInfoRefreshTimeInSeconds: 1, + SPDCToken: '', + isShowLyrics: false, + isAlwaysShowLyrics: false, + lyricMaxLength: 30, + lyricsFontSize: 14, + lyricsColor: '#FFFFFF', + nextLyricsColor: '#000000', + lyricsBackgroundColor: '#000000', + lyricsBackgroundOpacity: 50, + lyricsFont: 'Inter UI', + isLyricsBlur: true, + isLyricsRandomBackground: true, + lyricsCornerRadius: 0, }; diff --git a/src/renderer/api/lyrics-api.ts b/src/renderer/api/lyrics-api.ts new file mode 100644 index 00000000..0cee2328 --- /dev/null +++ b/src/renderer/api/lyrics-api.ts @@ -0,0 +1,122 @@ +/* eslint-disable no-console */ + +const TOKEN_URL = 'https://open.spotify.com/get_access_token?reason=transport&productType=web_player'; +const USER_AGENT = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.41 Safari/537.36'; + +export interface Line { + startTimeMs: string; + words: string; + syllables: []; + endTimeMs: string; +} +export interface LyricsData { + lyrics: { + syncType: string; + lines: { + startTimeMs: string; + words: string; + syllables: []; + endTimeMs: string; + }[]; + provider: string; + providerLyricsId: string; + providerDisplayName: string; + syncLyricsUri: string; + isDenseTypeface: boolean; + alternatives: []; + language: string; + isRtlLanguage: boolean; + fullscreenAction: string; + showUpsell: boolean; + capStatus: string; + impressionsRemaining: number; + }; + colors: { + background: number; + text: number; + highlightText: number; + }; + hasVocalRemoval: boolean; +} + +class SpotifyLyricsAPI { + private token: string; + + constructor() { + this.login() + .then() + .catch(() => { + this.token = ''; + }); + } + + async login(): Promise { + this.token = ''; + try { + // Make the request with fetch + const response = await fetch(TOKEN_URL, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'User-Agent': USER_AGENT, + 'app-platform': 'WebPlayer', + }, + redirect: 'manual', + }); + + // Check if the request was successful + if (!response.ok) { + console.error(`Request failed: ${response.status}`); + return false; + } + + // Parse the response body as JSON + const data = await response.json(); + + // Store the access token + this.token = data.accessToken; + + return true; + } catch (error) { + console.error(error); + return false; + } + } + + async getLyrics(trackId: string): Promise { + if (this.token === '' || trackId === '') { + return null; + } + try { + const response = await fetch( + `https://spclient.wg.spotify.com/color-lyrics/v2/track/${trackId}?format=json&market=from_token`, + { + method: 'GET', + credentials: 'same-origin', + headers: { + 'User-Agent': USER_AGENT, + 'app-platform': 'WebPlayer', + authorization: `Bearer ${this.token}`, + }, + redirect: 'manual', + } + ); + + // Check if the request was successful + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + + // Parse the response body as JSON + const data = await response.json(); + + return data; + } catch (error) { + console.error(error); + return null; + } + } +} + +export const SpotifyLyricsApiInstance = new SpotifyLyricsAPI(); diff --git a/src/renderer/app/bars/index.tsx b/src/renderer/app/bars/index.tsx index 7dd0a05c..174471d1 100644 --- a/src/renderer/app/bars/index.tsx +++ b/src/renderer/app/bars/index.tsx @@ -50,6 +50,9 @@ export const Bars: FunctionComponent = ({ barColor, barThickness, alwaysS className="vertical bar draggable" style={{ width: `${barThickness}px` }} /> +
+

Lyrics

+
); }; diff --git a/src/renderer/app/cover/index.tsx b/src/renderer/app/cover/index.tsx index 8e648210..63deb5a8 100644 --- a/src/renderer/app/cover/index.tsx +++ b/src/renderer/app/cover/index.tsx @@ -6,10 +6,12 @@ import styled, { css } from 'styled-components'; import { IpcMessage, WindowName } from '../../../constants'; import { Settings, VisualizationType } from '../../../models/settings'; +import { LyricsData, SpotifyLyricsApiInstance } from '../../api/lyrics-api'; import { AccountType, SpotifyApiInstance } from '../../api/spotify-api'; import { WindowPortal } from '../../components'; import { useCurrentlyPlaying } from '../../contexts/currently-playing.context'; import { CurrentlyPlayingActions, CurrentlyPlayingType } from '../../reducers/currently-playing.reducer'; +import { Lyrics } from '../../windows/lyrics'; import { TrackInfo } from '../../windows/track-info'; import { Bars } from '../bars'; import { Controls } from './controls'; @@ -52,9 +54,12 @@ export const Cover: FunctionComponent = ({ settings, message, onVisualiza barColor, isAlwaysShowSongProgress, isAlwaysShowTrackInfo, + isAlwaysShowLyrics, + isShowLyrics, isOnLeft, size, skipSongDelay, + SPDCToken, volumeIncrement, visualizationId, visualizationType, @@ -64,7 +69,10 @@ export const Cover: FunctionComponent = ({ settings, message, onVisualiza const [currentSongId, setCurrentSongId] = useState(''); const [shouldShowTrackInfo, setShouldShowTrackInfo] = useState(isAlwaysShowTrackInfo); + const [shouldAlwaysShowLyrics, setShouldAlwaysShowLyrics] = useState(isAlwaysShowLyrics); const [errorToDisplay, setErrorToDisplay] = useState(''); + const [currentLyrics, setCurrentLyrics] = useState(); + const [lyricsLoggedIn, setLyricsLoggedIn] = useState(false); useEffect(() => setErrorToDisplay(message), [message]); @@ -72,6 +80,10 @@ export const Cover: FunctionComponent = ({ settings, message, onVisualiza setShouldShowTrackInfo(isAlwaysShowTrackInfo); }, [isAlwaysShowTrackInfo]); + useEffect(() => { + setShouldAlwaysShowLyrics(isAlwaysShowLyrics); + }, [isAlwaysShowLyrics]); + const artist = useMemo(() => truncateText(state.artist), [state.artist]); const songTitle = useMemo(() => truncateText(state.track), [state.track]); @@ -116,6 +128,22 @@ export const Cover: FunctionComponent = ({ settings, message, onVisualiza })(); }, [artist, currentSongId, refreshTrackLiked, songTitle, state.id, state.isPlaying]); + useEffect(() => { + (async () => { + const lyrics = await SpotifyLyricsApiInstance.getLyrics(currentSongId); + setCurrentLyrics(lyrics); + })(); + }, [currentSongId]); + + useEffect(() => { + (async () => { + const loggedIn = await SpotifyLyricsApiInstance.login(); + setLyricsLoggedIn(loggedIn); + if (loggedIn) console.log(`Lyrics API logged in with sp_dc: ${SPDCToken}`); + else console.log(`The provided sp_dc is invalid, please check it again!\n sp_dc: ${SPDCToken}`); + })(); + }, [SPDCToken]); + const keepAlive = useCallback(async (): Promise => { if (state.isPlaying || state.userProfile?.accountType !== AccountType.Premium) { return; @@ -211,8 +239,14 @@ export const Cover: FunctionComponent = ({ settings, message, onVisualiza return (
!isAlwaysShowTrackInfo && setShouldShowTrackInfo(true)} - onMouseLeave={() => !isAlwaysShowTrackInfo && setShouldShowTrackInfo(false)}> + onMouseEnter={() => { + if (!isAlwaysShowTrackInfo) setShouldShowTrackInfo(true); + if (isShowLyrics && !isAlwaysShowLyrics) setShouldAlwaysShowLyrics(true); + }} + onMouseLeave={() => { + if (!isAlwaysShowTrackInfo) setShouldShowTrackInfo(false); + if (isShowLyrics && !isAlwaysShowLyrics) setShouldAlwaysShowLyrics(false); + }}> = ({ settings, message, onVisualiza )} + {shouldAlwaysShowLyrics && ( + + + + )} (isCJK ? 0.5 * maxLength : maxLength)) { + currentLine += 1; + lines[currentLine] = ''; + } + + lines[currentLine] += isCJK ? segments[i] : `${segments[i]} `; + } + + return lines; +} + +interface LyricTextProps { + lyrics?: LyricsData; + loggedIn?: boolean; + isTokenEmpty?: boolean; + maxLength?: number; + nextLyricsColor?: string; + isLyricsBlur?: boolean; +} + +function generateRandomLightColor(opacity: number): string { + const h = Math.floor(Math.random() * 360); + const s = 69; + const l = 69; + + return `hsl(${h}, ${s}%, ${l}%, ${opacity / 255})`; +} + +const LyricsText: FunctionComponent = ({ + lyrics, + loggedIn, + isTokenEmpty, + maxLength, + nextLyricsColor, + isLyricsBlur, +}) => { + const { state } = useCurrentlyPlaying(); + if (!loggedIn) { + return ( +
+ {isTokenEmpty && Add sp_dc token} + {!isTokenEmpty && Not a valid sp_dc token} +
+ ); + } + if (lyrics === null || lyrics === undefined || lyrics.lyrics === undefined || lyrics.lyrics.lines === undefined) { + return ( +
+ No Lyrics found +
+ ); + } + let closestLineIndex = -1; + lyrics.lyrics.lines.forEach((line: Line, index: number) => { + if (Number(line.startTimeMs) < state.progress) { + closestLineIndex = index; + } + }); + + const prevLyric = closestLineIndex > 0 ? lyrics.lyrics.lines[closestLineIndex - 1] : null; + let lyric = lyrics.lyrics.lines[closestLineIndex]; + const nextLyric = + closestLineIndex < lyrics.lyrics.lines.length - 1 ? lyrics.lyrics.lines[closestLineIndex + 1] : null; + + if (lyric === undefined) { + nextLyric.words = lyrics.lyrics.lines[0].words; + lyric = { ...nextLyric }; + lyric.words = ''; + } + + const blurStyle = isLyricsBlur ? `blur(${0.55}px)` : 'none'; + + return ( +
+ {prevLyric && + breakTextIntoLines(prevLyric.words, maxLength).map((line) => ( + + {line} + + ))} + + {breakTextIntoLines(lyric.words, maxLength).map((line) => ( + {line} + ))} + + {nextLyric && + breakTextIntoLines(nextLyric.words, maxLength).map((line) => ( + + {line} + + ))} +
+ ); +}; + +interface LyricsProps { + lyrics?: LyricsData; + loggedIn?: boolean; + isOnLeft?: boolean; +} + +export const Lyrics: FunctionComponent = ({ lyrics, loggedIn, isOnLeft }) => { + const { state } = useSettings(); + const backgroundColor = useMemo(() => { + const normalizedOpacity = Math.floor((state.lyricsBackgroundOpacity / 100) * 255); + const isLyricsRandomBackground = state.isLyricsRandomBackground && DEFAULT_SETTINGS.isLyricsRandomBackground; + const color = isLyricsRandomBackground + ? generateRandomLightColor(normalizedOpacity) + : state.lyricsBackgroundColor || DEFAULT_SETTINGS.lyricsBackgroundColor; + + return isLyricsRandomBackground ? color : `${color}${normalizedOpacity.toString(16)}`; + }, [state]); + const maxLength = state.lyricMaxLength || DEFAULT_SETTINGS.lyricMaxLength; + const nextLyricsColor = state.nextLyricsColor || DEFAULT_SETTINGS.nextLyricsColor; + const isLyricsBlur = state.isLyricsBlur && DEFAULT_SETTINGS.isLyricsBlur; + const lyricsCornerRadius = state.lyricsCornerRadius || DEFAULT_SETTINGS.lyricsCornerRadius; + + return ( + + + + ); +}; diff --git a/src/renderer/windows/settings/help-link.tsx b/src/renderer/windows/settings/help-link.tsx new file mode 100644 index 00000000..344c93a9 --- /dev/null +++ b/src/renderer/windows/settings/help-link.tsx @@ -0,0 +1,20 @@ +import { ipcRenderer } from 'electron'; +import React, { FunctionComponent, useCallback } from 'react'; + +import { ApplicationUrl, IpcMessage } from '../../../constants'; + +interface Props { + url: ApplicationUrl; + icon: string; +} + +export const HelpLink: FunctionComponent = ({ url, icon }) => { + const openLink = useCallback((link: ApplicationUrl) => { + ipcRenderer.send(IpcMessage.OpenLink, link); + }, []); + return ( +