Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ Lofi is a mini Spotify player with visualizations. It is _not_ a replacement for
- Visualization-ready (WebGL)
- ≤ 100MB memory footprint

## Lyrics (new feature)

Lofi now uses [lrclib.net](https://lrclib.net) as the main source for lyrics. When a song is played, the app automatically searches for synchronized lyrics using the track title and artist name via the lrclib public API. Everything is fetched from the lrclib community-powered database.

- [lrclib API documentation](https://lrclib.net/docs)
- [lrclib GitHub repository](https://github.com/tranxuanthang/lrclib)

| 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).
Expand Down
4 changes: 4 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +23,7 @@ export enum WindowTitle {
FullscreenViz = 'fullscreen-visualization',
Settings = 'Lofi Settings',
TrackInfo = 'track-info',
Lyrics = 'lyrics',
}

export enum WindowName {
Expand All @@ -30,6 +32,7 @@ export enum WindowName {
FullscreenViz = 'fullscreen-visualization',
Settings = 'settings',
TrackInfo = 'track-info',
Lyrics = 'lyrics',
}

export enum IpcMessage {
Expand All @@ -53,4 +56,5 @@ export enum ApplicationUrl {
Help = 'https://www.lofi.rocks/help',
Discord = 'https://discord.gg/YuH9UJk',
GitHub = 'https://github.com/dvx/lofi',
Spotify = 'spotify:',
}
28 changes: 27 additions & 1 deletion src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ import {
getAboutWindowOptions,
getFullscreenVisualizationWindowOptions,
getFullscreenVizBounds,
getLyricsWindowOptions,
getSettingsWindowOptions,
getTrackInfoWindowOptions,
moveLyric,
moveTrackInfo,
setAlwaysOnTop,
settingsSchema,
Expand Down Expand Up @@ -142,6 +144,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);
});
Expand All @@ -162,6 +165,7 @@ const createMainWindow = (): void => {

mainWindow.on('resize', () => {
moveTrackInfo(mainWindow, screen);
moveLyric(mainWindow, screen);
});

mainWindow.on('resized', () => {
Expand All @@ -174,7 +178,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 }: Settings
) => {
setAlwaysOnTop({ window: mainWindow, isAlwaysOnTop });
mainWindow.setSkipTaskbar(!isVisibleInTaskbar);
showDevTool(mainWindow, isDebug);
Expand All @@ -184,6 +191,7 @@ const createMainWindow = (): void => {
mainWindow.center();
}
moveTrackInfo(mainWindow, screen);
moveLyric(mainWindow, screen);

const fullscreenVizWindow = findWindow(WindowTitle.FullscreenViz);
if (fullscreenVizWindow) {
Expand Down Expand Up @@ -262,6 +270,13 @@ const createMainWindow = (): void => {
};
}

case WindowName.Lyrics: {
return {
action: 'allow',
overrideBrowserWindowOptions: getLyricsWindowOptions(mainWindow, settings.isAlwaysOnTop),
};
}

case WindowName.Auth: {
shell.openExternal(details.url);
break;
Expand Down Expand Up @@ -324,6 +339,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;
}
Expand Down Expand Up @@ -397,6 +422,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);
});
});

Expand Down
46 changes: 44 additions & 2 deletions src/main/main.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from 'zod';

import {
IpcMessage,
LYRICS_GAP,
MAX_BAR_THICKNESS,
MAX_SIDE_LENGTH,
MAX_SKIP_SONG_DELAY,
Expand All @@ -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,
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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(),
Expand Down
26 changes: 24 additions & 2 deletions src/models/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export interface Settings {
isOnLeft: boolean;
isAlwaysShowTrackInfo: boolean;
isAlwaysShowSongProgress: boolean;
showTrackInfoTemporarilyInSeconds: number;
barThickness: number;
barColor: string;
size: number;
Expand All @@ -37,6 +36,18 @@ export interface Settings {
showFreemiumWarning: boolean;
cornerRadius: number;
trackInfoRefreshTimeInSeconds: number;
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 = {
Expand All @@ -56,7 +67,6 @@ export const DEFAULT_SETTINGS: Settings = {
isAlwaysOnTop: true,
isVisibleInTaskbar: true,
isAlwaysShowTrackInfo: false,
showTrackInfoTemporarilyInSeconds: 0,
isAlwaysShowSongProgress: false,
barThickness: 2,
barColor: '#74C999',
Expand All @@ -70,4 +80,16 @@ export const DEFAULT_SETTINGS: Settings = {
showFreemiumWarning: true,
cornerRadius: 0,
trackInfoRefreshTimeInSeconds: 1,
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,
};
106 changes: 106 additions & 0 deletions src/renderer/api/lyrics-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/* eslint-disable no-console */

export interface Line {
startTimeMs: string;
words: string;
syllables: any[];
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 {
constructor() {
}
async login(): Promise<boolean> {
return true;
}

async getLyrics(trackTitle: string, artistName: string): Promise<LyricsData> {
if (!trackTitle || !artistName) {
return null;
}
try {
const searchUrl = `https://lrclib.net/api/search?track_name=${encodeURIComponent(trackTitle)}&artist_name=${encodeURIComponent(artistName)}`;
const searchRes = await fetch(searchUrl);
if (!searchRes.ok) throw new Error('lrclib search failed');
const searchResults = await searchRes.json();
if (!searchResults || !Array.isArray(searchResults) || searchResults.length === 0) return null;
const lyricId = searchResults[0].id;
const lyricsUrl = `https://lrclib.net/api/get/${lyricId}`;
const lyricsRes = await fetch(lyricsUrl);
if (!lyricsRes.ok) throw new Error('lrclib get failed');
const lrclibData = await lyricsRes.json();
const lines = (lrclibData.syncedLyrics || '').split('\n').map((line: string) => {
const match = line.match(/^\[(\d+):(\d+).(\d+)\](.*)$/);
if (!match) return null;
const min = parseInt(match[1], 10);
const sec = parseInt(match[2], 10);
const ms = parseInt(match[3], 10) * 10;
const startTimeMs = ((min * 60 + sec) * 1000 + ms).toString();
return {
startTimeMs,
endTimeMs: startTimeMs,
words: match[4].trim(),
syllables: [] as any[],
};
}).filter(Boolean);
return {
lyrics: {
syncType: 'LINE_SYNCED',
lines,
provider: 'lrclib',
providerLyricsId: lyricId,
providerDisplayName: 'lrclib',
syncLyricsUri: lyricsUrl,
isDenseTypeface: false,
alternatives: [],
language: lrclibData.language || 'en',
isRtlLanguage: false,
fullscreenAction: '',
showUpsell: false,
capStatus: '',
impressionsRemaining: 0,
},
colors: {
background: 0,
text: 0,
highlightText: 0,
},
hasVocalRemoval: false,
};
} catch (error) {
console.error(error);
return null;
}
}
}

export const SpotifyLyricsApiInstance = new SpotifyLyricsAPI();
3 changes: 3 additions & 0 deletions src/renderer/app/bars/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export const Bars: FunctionComponent<Props> = ({ barColor, barThickness, alwaysS
className="vertical bar draggable"
style={{ width: `${barThickness}px` }}
/>
<div>
<p>Lyrics</p>
</div>
</>
);
};
Loading