diff --git a/README.md b/README.md index f34fbf7..0ec08e9 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/src/constants.ts b/src/constants.ts index 1ccd12b..4bc33f7 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', + Spotify = 'spotify:', } diff --git a/src/main/main.ts b/src/main/main.ts index b6ff89e..336a699 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -37,8 +37,10 @@ import { getAboutWindowOptions, getFullscreenVisualizationWindowOptions, getFullscreenVizBounds, + getLyricsWindowOptions, getSettingsWindowOptions, getTrackInfoWindowOptions, + moveLyric, moveTrackInfo, setAlwaysOnTop, settingsSchema, @@ -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); }); @@ -162,6 +165,7 @@ const createMainWindow = (): void => { mainWindow.on('resize', () => { moveTrackInfo(mainWindow, screen); + moveLyric(mainWindow, screen); }); mainWindow.on('resized', () => { @@ -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); @@ -184,6 +191,7 @@ const createMainWindow = (): void => { mainWindow.center(); } moveTrackInfo(mainWindow, screen); + moveLyric(mainWindow, screen); const fullscreenVizWindow = findWindow(WindowTitle.FullscreenViz); if (fullscreenVizWindow) { @@ -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; @@ -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; } @@ -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); }); }); diff --git a/src/main/main.utils.ts b/src/main/main.utils.ts index ec15e43..301a540 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 8d9c59f..194a88f 100644 --- a/src/models/settings.ts +++ b/src/models/settings.ts @@ -23,7 +23,6 @@ export interface Settings { isOnLeft: boolean; isAlwaysShowTrackInfo: boolean; isAlwaysShowSongProgress: boolean; - showTrackInfoTemporarilyInSeconds: number; barThickness: number; barColor: string; size: number; @@ -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 = { @@ -56,7 +67,6 @@ export const DEFAULT_SETTINGS: Settings = { isAlwaysOnTop: true, isVisibleInTaskbar: true, isAlwaysShowTrackInfo: false, - showTrackInfoTemporarilyInSeconds: 0, isAlwaysShowSongProgress: false, barThickness: 2, barColor: '#74C999', @@ -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, }; diff --git a/src/renderer/api/lyrics-api.ts b/src/renderer/api/lyrics-api.ts new file mode 100644 index 0000000..3c36152 --- /dev/null +++ b/src/renderer/api/lyrics-api.ts @@ -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 { + return true; + } + + async getLyrics(trackTitle: string, artistName: string): Promise { + 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(); diff --git a/src/renderer/app/bars/index.tsx b/src/renderer/app/bars/index.tsx index 7dd0a05..174471d 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 4977f67..4b71753 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,7 +54,8 @@ export const Cover: FunctionComponent = ({ settings, message, onVisualiza barColor, isAlwaysShowSongProgress, isAlwaysShowTrackInfo, - showTrackInfoTemporarilyInSeconds, + isAlwaysShowLyrics, + isShowLyrics, isOnLeft, size, skipSongDelay, @@ -65,8 +68,10 @@ export const Cover: FunctionComponent = ({ settings, message, onVisualiza const [currentSongId, setCurrentSongId] = useState(''); const [shouldShowTrackInfo, setShouldShowTrackInfo] = useState(isAlwaysShowTrackInfo); - const [trackInfoTimer, setTrackInfoTimer] = useState(null); + const [shouldAlwaysShowLyrics, setShouldAlwaysShowLyrics] = useState(isAlwaysShowLyrics); const [errorToDisplay, setErrorToDisplay] = useState(''); + const [currentLyrics, setCurrentLyrics] = useState(); + const [lyricsLoggedIn, setLyricsLoggedIn] = useState(false); useEffect(() => setErrorToDisplay(message), [message]); @@ -74,6 +79,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]); @@ -110,45 +119,27 @@ export const Cover: FunctionComponent = ({ settings, message, onVisualiza useEffect(() => { (async () => { - if (state.isPlaying) { - if (state.id !== currentSongId) { - setCurrentSongId(state.id); - console.log(`New song '${songTitle}' by '${artist}.`); - await refreshTrackLiked(); - } - - if (!isAlwaysShowTrackInfo && showTrackInfoTemporarilyInSeconds) { - setShouldShowTrackInfo(true); - - const timer = setTimeout(() => { - if (!document.getElementById('visible-ui')?.matches(':hover')) { - setShouldShowTrackInfo(false); - } - setTrackInfoTimer(null); - }, showTrackInfoTemporarilyInSeconds * ONE_SECOND_IN_MS); - - setTrackInfoTimer(timer); - } + if (state.isPlaying && state.id !== currentSongId) { + setCurrentSongId(state.id); + console.log(`New song '${songTitle}' by '${artist}.`); + await refreshTrackLiked(); } })(); - }, [ - artist, - currentSongId, - refreshTrackLiked, - songTitle, - state.id, - state.isPlaying, - isAlwaysShowTrackInfo, - showTrackInfoTemporarilyInSeconds, - ]); + }, [artist, currentSongId, refreshTrackLiked, songTitle, state.id, state.isPlaying]); useEffect(() => { - return () => { - if (trackInfoTimer) { - clearTimeout(trackInfoTimer); - } - }; - }, [trackInfoTimer]); + (async () => { + const lyrics = await SpotifyLyricsApiInstance.getLyrics(songTitle, artist); + setCurrentLyrics(lyrics); + })(); + }, [songTitle, artist]); + + useEffect(() => { + (async () => { + const loggedIn = await SpotifyLyricsApiInstance.login(); + setLyricsLoggedIn(loggedIn); + })(); + }, []); const keepAlive = useCallback(async (): Promise => { if (state.isPlaying || state.userProfile?.accountType !== AccountType.Premium) { @@ -223,10 +214,7 @@ export const Cover: FunctionComponent = ({ settings, message, onVisualiza }, [handlePlaybackChanged, trackInfoRefreshTimeInSeconds]); useEffect(() => { - const refreshTrackLikedIntervalId = setInterval( - refreshTrackLiked, - 2 * trackInfoRefreshTimeInSeconds * ONE_SECOND_IN_MS - ); + const refreshTrackLikedIntervalId = setInterval(refreshTrackLiked, 2 * trackInfoRefreshTimeInSeconds * ONE_SECOND_IN_MS); return () => { if (refreshTrackLikedIntervalId) { clearInterval(refreshTrackLikedIntervalId); @@ -248,8 +236,14 @@ export const Cover: FunctionComponent = ({ settings, message, onVisualiza return (
!isAlwaysShowTrackInfo && setShouldShowTrackInfo(true)} - onMouseLeave={() => !isAlwaysShowTrackInfo && !trackInfoTimer && 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 {state.id ? ( <> {shouldShowTrackInfo && ( - + )} + {shouldAlwaysShowLyrics && ( + + + + )} ( - -

- -

-
-); +export const Waiting: FunctionComponent = () => { + const handleOpenSpotify = useCallback(() => { + ipcRenderer.send(IpcMessage.OpenLink, ApplicationUrl.Spotify); + }, []); + + return ( + +

+ +

+
+ ); +}; diff --git a/src/renderer/components/form.styled.ts b/src/renderer/components/form.styled.ts index 60433aa..b1dcbd9 100644 --- a/src/renderer/components/form.styled.ts +++ b/src/renderer/components/form.styled.ts @@ -94,3 +94,11 @@ export const Slider = styled(Input).attrs({ box-shadow: -407px 0 0 400px ${INPUT_COLOR_HEX}; } `; + +export const TextArea = styled.textarea` + ${BaseSettingsInputStyle}; + resize: none; + width: 23.5rem; + height: 5rem; + margin-left: 0.25rem; +`; diff --git a/src/renderer/components/index.tsx b/src/renderer/components/index.tsx index ddd5ee5..e534d39 100644 --- a/src/renderer/components/index.tsx +++ b/src/renderer/components/index.tsx @@ -11,6 +11,7 @@ import { Row, Select, Slider, + TextArea, } from './form.styled'; import { LoginButton } from './login-button'; import { StyledCheckbox, StyledTabs } from './mantine.styled'; @@ -35,6 +36,7 @@ export { StyledCheckbox, StyledTabs, StyledWindow, + TextArea, TitleBar, WindowPortal, }; diff --git a/src/renderer/components/mantine.styled.ts b/src/renderer/components/mantine.styled.ts index de161bc..50c6ddf 100644 --- a/src/renderer/components/mantine.styled.ts +++ b/src/renderer/components/mantine.styled.ts @@ -26,7 +26,7 @@ export const StyledTabs = styled(Tabs)` } & .mantine-tabs-tab { - min-width: 80px; + min-width: 67px; margin: 0; color: white; diff --git a/src/renderer/components/window-portal.tsx b/src/renderer/components/window-portal.tsx index bb901ee..02b692e 100644 --- a/src/renderer/components/window-portal.tsx +++ b/src/renderer/components/window-portal.tsx @@ -63,7 +63,7 @@ interface Props { url?: string; name: string; title?: string; - features?: { width?: number; height?: number; isFullscreen?: boolean; focusable?: boolean }; + features?: { width?: number; height?: number; isFullscreen?: boolean }; children: ReactNode; onOpen?: (window: Window) => void; onUnload?: () => void; diff --git a/src/renderer/windows/lyrics/index.tsx b/src/renderer/windows/lyrics/index.tsx new file mode 100644 index 0000000..3c44d12 --- /dev/null +++ b/src/renderer/windows/lyrics/index.tsx @@ -0,0 +1,264 @@ +/* eslint-disable no-console */ +import React, { FunctionComponent, useMemo, useEffect, useState } from 'react'; +import styled from 'styled-components'; + +import { DEFAULT_SETTINGS } from '../../../models/settings'; +import { Line, LyricsData } from '../../api/lyrics-api'; +import { useCurrentlyPlaying } from '../../contexts/currently-playing.context'; +import { useSettings } from '../../contexts/settings.context'; + +const LyricsWrapper = styled.div` + position: fixed; + transition: background-color 1.5s cubic-bezier(0.4, 0, 0.2, 1); + padding: 0.5em; + max-width: 30ch; + + div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + + .left { + text-align: 'start'; + } + + .right { + text-align: 'end'; + } + } +`; + +const FocusedText = styled.div` + margin-top: 5px; + margin-bottom: 5px; +`; + +const FocusedTextWrapper = styled.div` + margin-top: 10px; + margin-bottom: 10px; +`; + +function breakTextIntoLines(text: string, maxLength: number): string[] { + const isCJK = /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/u.test(text); + const segments = isCJK ? Array.from(text) : text.split(' '); + const lines: string[] = ['']; + let currentLine = 0; + + for (let i = 0; i < segments.length; i += 1) { + if ((lines[currentLine] + segments[i]).length > (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(); + const [manualIndex, setManualIndex] = useState(null); + const [lastManualScroll, setLastManualScroll] = useState(0); + const lines = lyrics?.lyrics?.lines || []; + + useEffect(() => { + if (!lines.length) return; + const onWheel = (e: WheelEvent) => { + if (lines.length === 0) return; + setManualIndex((prev) => { + let idx = prev === null ? getSyncedIndex() : prev; + idx += e.deltaY > 0 ? 1 : -1; + idx = Math.max(0, Math.min(lines.length - 1, idx)); + return idx; + }); + setLastManualScroll(Date.now()); + }; + window.addEventListener('wheel', onWheel, { passive: false }); + return () => window.removeEventListener('wheel', onWheel); + }, [lines]); + + useEffect(() => { + if (manualIndex === null) return; + const timeout = setTimeout(() => { + setManualIndex(null); + }, 2000); + return () => clearTimeout(timeout); + }, [manualIndex, lastManualScroll]); + + function getSyncedIndex() { + let idx = -1; + lines.forEach((line: Line, i: number) => { + if (Number(line.startTimeMs) < state.progress) { + idx = i; + } + }); + return Math.max(0, idx); + } + + if (!loggedIn) { + return ( +
+ {isTokenEmpty && Add sp_dc token} + {!isTokenEmpty && Not a valid sp_dc token} +
+ ); + } + if (!lines.length) { + return ( +
+ No Lyrics found +
+ ); + } + + const currentIndex = manualIndex !== null ? manualIndex : getSyncedIndex(); + const prevLyric = currentIndex > 0 ? lines[currentIndex - 1] : null; + let lyric = lines[currentIndex]; + const nextLyric = currentIndex < lines.length - 1 ? lines[currentIndex + 1] : null; + + if (!lyric) { + lyric = { startTimeMs: '0', endTimeMs: '0', words: '', syllables: [] }; + } + + 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 [randomBg, setRandomBg] = useState(''); + const [targetBg, setTargetBg] = useState(''); + + useEffect(() => { + if (state.isLyricsRandomBackground && DEFAULT_SETTINGS.isLyricsRandomBackground) { + const interval = setInterval(() => { + const normalizedOpacity = Math.floor((state.lyricsBackgroundOpacity / 100) * 255); + setTargetBg(generateRandomLightColor(normalizedOpacity)); + }, 3000); + return () => clearInterval(interval); + } + }, [state.isLyricsRandomBackground, state.lyricsBackgroundOpacity]); + + useEffect(() => { + if (!targetBg) return; + let animationFrame: number; + let start: number | null = null; + const duration = 1500; + const from = randomBg || targetBg; + const to = targetBg; + + function lerpColor(from: string, to: string, t: number) { + const fromMatch = from.match(/hsl\((\d+), (\d+)%?, (\d+)%?, ([\d.]+)\)/); + const toMatch = to.match(/hsl\((\d+), (\d+)%?, (\d+)%?, ([\d.]+)\)/); + if (!fromMatch || !toMatch) return to; + const [fh, fs, fl, fa] = fromMatch.slice(1).map(Number); + const [th, ts, tl, ta] = toMatch.slice(1).map(Number); + const lerp = (a: number, b: number) => a + (b - a) * t; + return `hsl(${Math.round(lerp(fh, th))}, ${Math.round(lerp(fs, ts))}%, ${Math.round(lerp(fl, tl))}%, ${lerp(fa, ta)})`; + } + + function animate(ts: number) { + if (start === null) start = ts; + const elapsed = ts - start; + const t = Math.min(elapsed / duration, 1); + setRandomBg(lerpColor(from, to, t)); + if (t < 1) { + animationFrame = requestAnimationFrame(animate); + } else { + setRandomBg(to); + } + } + animationFrame = requestAnimationFrame(animate); + return () => cancelAnimationFrame(animationFrame); + }, [targetBg]); + + const backgroundColor = useMemo(() => { + const normalizedOpacity = Math.floor((state.lyricsBackgroundOpacity / 100) * 255); + const isLyricsRandomBackground = state.isLyricsRandomBackground && DEFAULT_SETTINGS.isLyricsRandomBackground; + if (isLyricsRandomBackground) { + return randomBg || generateRandomLightColor(normalizedOpacity); + } + const color = state.lyricsBackgroundColor || DEFAULT_SETTINGS.lyricsBackgroundColor; + return `${color}${normalizedOpacity.toString(16)}`; + }, [state, randomBg]); + + 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 0000000..344c93a --- /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 ( +