diff --git a/components/FullScreenLyrics/FullScreenLyrics.tsx b/components/FullScreenLyrics/FullScreenLyrics.tsx index 2cd31462..ec1f27c8 100644 --- a/components/FullScreenLyrics/FullScreenLyrics.tsx +++ b/components/FullScreenLyrics/FullScreenLyrics.tsx @@ -1,227 +1,37 @@ -import { - MutableRefObject, - ReactElement, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; +import { ReactElement } from "react"; +import { LyricLine } from "./LyricLine"; import { LoadingSpinner } from "components"; import { PictureInPicture } from "components/icons"; import { useAuth, useHeader, - useLyrics, + useLyricsContext, useSpotify, useToast, useTranslations, } from "hooks"; -import { DisplayInFullScreen } from "types/spotify"; -import { - colorCodedToHex, - colorCodedToRGB, - getAllLinesFittingWidth, - getLineType, - getRandomColor, - hexToHsl, - IFormatLyricsResponse, - rgbToHex, - ToastMessage, -} from "utils"; - -interface FullScreenLyricsProps { - appRef?: MutableRefObject; -} - -interface ILyricLineProps { - line: IFormatLyricsResponse["lines"][0]; - lyricsProgressMs: number; - lyrics: IFormatLyricsResponse; - type: "current" | "previous" | "next"; - lyricLineColor: string; - lyricTextColor: string; -} - -const LINE_HEIGHT = 40; -const LYRICS_PIP_HEADER_HEIGH = 100; -const LYRICS_PADDING_LEFT = 10; -function LyricLine({ - line, - lyricsProgressMs, - lyrics, - type, - lyricLineColor, - lyricTextColor, -}: ILyricLineProps) { - const { player } = useSpotify(); - const { user } = useAuth(); - const isPremium = user?.product === "premium"; - const lineRef = useRef(null); - - const lineColors = { - current: "#fff", - previous: lyricLineColor + "80", - next: lyricTextColor, - }; - - useEffect(() => { - const line = lineRef.current; - if (!line) return; - const currentLine = line.classList.contains("current"); - if (currentLine) { - line.scrollIntoView({ - behavior: "smooth", - block: "center", - inline: "nearest", - }); - } - }, [lyricsProgressMs]); - - return ( - - ); -} - -function applyLyricLinePositionAndColor( - ctx: CanvasRenderingContext2D, - allLines: { - color: string; - text: string; - type: "current" | "previous" | "next"; - }[], - containerHeight: number -) { - const containerMiddle = containerHeight / 2; - const currentLineIndex = allLines.findIndex( - (line) => line.type === "current" - ); - - allLines.forEach((line, index) => { - const isOneOfFirstLines = - currentLineIndex * LINE_HEIGHT < containerMiddle - LINE_HEIGHT; - const isOfLastLines = - currentLineIndex * LINE_HEIGHT > - allLines.length * LINE_HEIGHT - LINE_HEIGHT - containerMiddle; - const canvasRest = containerMiddle % LINE_HEIGHT; - const bottomLineTrace = isOfLastLines - ? containerHeight + - LINE_HEIGHT + - (LINE_HEIGHT - canvasRest) - - LINE_HEIGHT * (allLines.length - currentLineIndex) - : containerMiddle; - const middleHeight = isOneOfFirstLines - ? LINE_HEIGHT + currentLineIndex * LINE_HEIGHT - : bottomLineTrace; - - const lineY = - LYRICS_PIP_HEADER_HEIGH + - middleHeight + - LINE_HEIGHT * (index - currentLineIndex); - ctx.fillStyle = line.color ?? "#fff"; - const limit = LINE_HEIGHT + LYRICS_PIP_HEADER_HEIGH; - const isOutsideCanvas = lineY < limit || lineY > containerHeight + limit; - if (isOutsideCanvas) return; - ctx.fillText(line.text, LYRICS_PADDING_LEFT, lineY); - }); -} +import { getLineType, ToastMessage } from "utils"; -export default function FullScreenLyrics({ - appRef, -}: FullScreenLyricsProps): ReactElement { +export default function FullScreenLyrics(): ReactElement { const { - currentlyPlaying, - setDisplayInFullScreen, - currentlyPlayingDuration, - isPlaying, - player, - pictureInPictureCanvas, setIsPictureInPictureLyircsCanvas, isPictureInPictureLyircsCanvas, videoRef, isPip, setIsPip, } = useSpotify(); - const { accessToken, user } = useAuth(); - const [lyricsProgressMs, setLyricsProgressMs] = useState(0); - const [lyricLineColor, setLyricLineColor] = useState("#fff"); - const [lyricTextColor, setLyricTextColor] = useState("#fff"); - const [spinnerFrame, setSpinnerFrame] = useState(null); - const [lyricsBackgroundColor, setLyricsBackgroundColor] = useState< - string | undefined - >(); - const { lyrics, lyricsError, lyricsLoading } = useLyrics({ - artist: currentlyPlaying?.artists?.[0]?.name, - title: currentlyPlaying?.name, - trackId: currentlyPlaying?.id, - accessToken: accessToken, - }); + const { + lyricsProgressMs, + lyricsBackgroundColor, + lyrics, + lyricsError, + lyricsLoading, + } = useLyricsContext(); + const { user } = useAuth(); const { translations } = useTranslations(); const isPremium = user?.product === "premium"; const { addToast } = useToast(); - const title = currentlyPlaying?.name ?? ""; - const artist = currentlyPlaying?.artists?.[0]?.name ?? ""; - const album = currentlyPlaying?.album?.name ?? ""; - const cover = currentlyPlaying?.album?.images?.[0]?.url ?? ""; useHeader({ disableOpacityChange: true, @@ -230,260 +40,6 @@ export default function FullScreenLyrics({ disableBackground: true, }); - useEffect(() => { - if (!isPremium) return; - (player as Spotify.Player).getCurrentState().then((state) => { - if (state) { - setLyricsProgressMs(state.position); - } - }); - - (player as Spotify.Player).on("player_state_changed", (state) => { - if (state) { - setLyricsProgressMs(state.position); - } - }); - }, [player, isPremium]); - - useLayoutEffect(() => { - const app = appRef?.current; - const newLyricsBackgroundColor = lyrics?.colors?.background - ? rgbToHex(colorCodedToRGB(lyrics.colors.background)) - : lyricsBackgroundColor ?? getRandomColor(); - const [h, s, l] = hexToHsl(newLyricsBackgroundColor, true) ?? []; - setLyricLineColor( - lyrics?.colors?.highlightText - ? colorCodedToHex(lyrics.colors.highlightText) - : lyricLineColor ?? `hsl(${h}, ${s}%, ${l - 20}%)` - ); - setLyricTextColor( - lyrics?.colors?.text - ? rgbToHex(colorCodedToRGB(lyrics.colors.text)) - : lyricTextColor ?? `hsl(${h}, ${s}%, ${l - 20}%)` - ); - - if (currentlyPlaying?.type !== "track" || !app) - return setDisplayInFullScreen(DisplayInFullScreen.App); - app.style.backgroundColor = newLyricsBackgroundColor; - setLyricsBackgroundColor(newLyricsBackgroundColor); - return () => { - app.style.backgroundColor = "inherit"; - }; - }, [ - appRef, - lyricsBackgroundColor, - lyricLineColor, - lyricTextColor, - currentlyPlaying?.type, - lyrics?.colors?.background, - lyrics?.colors?.highlightText, - lyrics?.colors?.text, - setDisplayInFullScreen, - ]); - - useEffect(() => { - if (!isPlaying || !currentlyPlayingDuration) { - return; - } - const minimumIntervalCheck = 200; - const playerCallBackInterval = setInterval(() => { - setLyricsProgressMs((value) => - value >= currentlyPlayingDuration ? 0 : value + minimumIntervalCheck - ); - }, minimumIntervalCheck); - - return () => { - clearInterval(playerCallBackInterval); - }; - }, [setLyricsProgressMs, isPlaying, currentlyPlayingDuration]); - - useEffect(() => { - if (!isPictureInPictureLyircsCanvas || !pictureInPictureCanvas.current) - return; - const ctx = pictureInPictureCanvas.current.getContext("2d"); - const canvasWidth = pictureInPictureCanvas.current.width; - const canvasHeight = pictureInPictureCanvas.current.height; - if (!ctx) return; - const lines = lyrics?.lines; - ctx.font = "24px Arial"; - - const centerX = canvasWidth / 2; - const centerY = canvasHeight / 2; - const radius = 40; - const lineWidth = 10; - const numSegments = 120; - const segmentAngle = (2 * Math.PI) / numSegments; - const rotationSpeed = 0.05; - let rotation = 0; - - function drawLoadingSpinner(rotation: number) { - if (!ctx) return; - ctx.clearRect(0, LYRICS_PIP_HEADER_HEIGH, canvasWidth, canvasHeight); - const [h, s] = hexToHsl(lyricsBackgroundColor ?? "", true) ?? [0, 0, 0]; - - for (let i = 0; i < numSegments; i++) { - const startAngle = i * segmentAngle + rotation; - const endAngle = startAngle + segmentAngle; - - ctx.beginPath(); - ctx.arc(centerX, centerY, radius, startAngle, endAngle); - ctx.lineWidth = lineWidth; - - const minLightness = 30; - const maxLightness = 60; - const adjustedLightness = - minLightness + (i * (maxLightness - minLightness)) / numSegments; - - const segmentColor = `hsl(${h}, ${s}%, ${adjustedLightness}%)`; - ctx.strokeStyle = segmentColor; - - ctx.strokeStyle = segmentColor; - ctx.stroke(); - } - - ctx.globalCompositeOperation = "destination-over"; - ctx.fillStyle = lyricsBackgroundColor ?? "#000"; - - ctx.fillRect(0, LYRICS_PIP_HEADER_HEIGH, canvasWidth, canvasHeight); - } - - function animate() { - drawLoadingSpinner(rotation); - rotation += rotationSpeed; - const frame = requestAnimationFrame(animate); - setSpinnerFrame(frame); - } - - if (!lyricsError && !lines && !spinnerFrame) { - ctx.fillStyle = "#fff"; - - animate(); - return; - } - - if (spinnerFrame && (lines || lyricsError)) { - cancelAnimationFrame(spinnerFrame); - setSpinnerFrame(null); - ctx.clearRect(0, LYRICS_PIP_HEADER_HEIGH, canvasWidth, canvasHeight); - } - }, [ - spinnerFrame, - isPictureInPictureLyircsCanvas, - lyrics?.lines, - lyricsBackgroundColor, - lyricsError, - pictureInPictureCanvas, - ]); - - useEffect(() => { - if (!isPictureInPictureLyircsCanvas || !pictureInPictureCanvas.current) - return; - const lines = lyrics?.lines; - const ctx = pictureInPictureCanvas.current.getContext("2d"); - const canvasWidth = pictureInPictureCanvas.current.width; - const canvasHeight = pictureInPictureCanvas.current.height; - if (!ctx) return; - const lyricsContainerHeight = canvasHeight - LYRICS_PIP_HEADER_HEIGH; - - ctx.clearRect( - 0, - LYRICS_PIP_HEADER_HEIGH, - canvasWidth, - lyricsContainerHeight - ); - ctx.font = "24px Arial"; - - const canvasMiddle = lyricsContainerHeight / 2; - if (lyricsError || !lines) { - ctx.fillStyle = lyricTextColor; - ctx.fillText(lyricsError ?? "", LYRICS_PADDING_LEFT, canvasMiddle); - } - - const allLines = getAllLinesFittingWidth({ - ctx, - lines: lines ?? [], - lyricLineColor, - lyricsProgressMs, - lyricTextColor, - canvasWidth, - }); - - applyLyricLinePositionAndColor(ctx, allLines, lyricsContainerHeight); - - ctx.globalCompositeOperation = "destination-over"; - ctx.fillStyle = lyricsBackgroundColor ?? "#000"; - - ctx.fillRect( - 0, - 0, - pictureInPictureCanvas.current.width, - pictureInPictureCanvas.current.height - ); - }, [ - lyricsError, - lyricsProgressMs, - isPictureInPictureLyircsCanvas, - lyricLineColor, - lyricTextColor, - lyricsBackgroundColor, - pictureInPictureCanvas, - lyrics?.lines, - ]); - - useEffect(() => { - if (!isPictureInPictureLyircsCanvas || !pictureInPictureCanvas.current) - return; - const ctx = pictureInPictureCanvas.current.getContext("2d"); - if (!ctx) { - return; - } - const drawImage = async (url: string) => { - const image = new Image(); - image.crossOrigin = "Anonymous"; - image.src = url; - await image.decode(); - ctx?.clearRect(10, 10, 80, 80); - ctx?.drawImage(image, 10, 10, 80, 80); - }; - ctx.clearRect(0, 0, 10, 100); - ctx.clearRect(10, 90, pictureInPictureCanvas.current.width, 10); - ctx.clearRect(0, 0, pictureInPictureCanvas.current.width, 10); - ctx.clearRect(90, 0, pictureInPictureCanvas.current.width - 90, 100); - - ctx.font = "22px Arial"; - ctx.fillStyle = lyricTextColor; - ctx.fillText(title, 100, 30); - ctx.font = "18px Arial"; - ctx.fillText(artist, 100, 60); - ctx.font = "16px Arial"; - ctx.fillText(album, 100, 90); - if (cover) { - drawImage(cover); - } else { - ctx.clearRect(10, 10, 80, 80); - } - - ctx.globalCompositeOperation = "destination-over"; - ctx.fillStyle = lyricsBackgroundColor || "#000"; - - ctx.fillRect( - 0, - 0, - pictureInPictureCanvas.current.width, - pictureInPictureCanvas.current.height - ); - }, [ - lyricsBackgroundColor, - title, - artist, - album, - cover, - isPictureInPictureLyircsCanvas, - lyrics, - pictureInPictureCanvas, - lyricTextColor, - ]); - return (
{!lyrics ? ( @@ -505,17 +61,7 @@ export default function FullScreenLyrics({ nextLine: lyrics.lines[i + 1], }); - return ( - - ); + return ; })}
)} diff --git a/components/FullScreenLyrics/LyricLine.tsx b/components/FullScreenLyrics/LyricLine.tsx new file mode 100644 index 00000000..a952fd26 --- /dev/null +++ b/components/FullScreenLyrics/LyricLine.tsx @@ -0,0 +1,100 @@ +import { ReactElement, useEffect, useRef } from "react"; + +import { useAuth, useLyricsContext, useSpotify } from "hooks"; +import { IFormatLyricsResponse } from "utils"; + +interface ILyricLineProps { + line: IFormatLyricsResponse["lines"][0]; + type: "current" | "previous" | "next"; +} + +export function LyricLine({ line, type }: ILyricLineProps): ReactElement { + const { player } = useSpotify(); + const { user } = useAuth(); + const isPremium = user?.product === "premium"; + const lineRef = useRef(null); + const { lyricsProgressMs, lyricTextColor, lyricLineColor, lyrics } = + useLyricsContext(); + + const lineColors = { + current: "#fff", + previous: lyricLineColor + "80", + next: lyricTextColor, + }; + + useEffect(() => { + const line = lineRef.current; + if (!line) return; + const currentLine = line.classList.contains("current"); + if (currentLine) { + line.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "nearest", + }); + } + }, [lyricsProgressMs]); + + return ( + + ); +} diff --git a/context/AppContextProvider.tsx b/context/AppContextProvider.tsx index 3bca4b60..6c529496 100644 --- a/context/AppContextProvider.tsx +++ b/context/AppContextProvider.tsx @@ -1,5 +1,6 @@ import { PropsWithChildren, ReactElement, useMemo } from "react"; +import { LyricsContextContextProvider } from "./LyricsContextProvider"; import { ContextMenuContextProvider } from "context/ContextMenuContext"; import { HeaderContextProvider, IHeaderContext } from "context/HeaderContext"; import { IModalContext, ModalContextProvider } from "context/ModalContext"; @@ -41,11 +42,13 @@ export function AppContextProvider({ - - - {children} - - + + + + {children} + + + diff --git a/context/LyricsContextProvider.tsx b/context/LyricsContextProvider.tsx new file mode 100644 index 00000000..d9da2332 --- /dev/null +++ b/context/LyricsContextProvider.tsx @@ -0,0 +1,95 @@ +import { + createContext, + Dispatch, + PropsWithChildren, + ReactElement, + SetStateAction, + useMemo, + useState, +} from "react"; + +import { useLyrics, useLyricsInPictureInPicture, useSpotify } from "hooks"; +import { DisplayInFullScreen } from "types/spotify"; +import { IFormatLyricsResponse } from "utils"; + +export interface ILyricsContext { + lyricsProgressMs: number; + lyricTextColor: string; + lyricsBackgroundColor?: string; + lyricLineColor: string; + lyrics: IFormatLyricsResponse | null; + lyricsError: string | null; + lyricsLoading: boolean; + setLyricsProgressMs: Dispatch>; + setLyricLineColor: Dispatch>; + setLyricTextColor: Dispatch>; + setLyricsBackgroundColor: Dispatch>; +} + +const LyricsContext = createContext(undefined); + +export function LyricsContextContextProvider({ + children, +}: PropsWithChildren): ReactElement { + const { displayInFullScreen, isPictureInPictureLyircsCanvas } = useSpotify(); + const [lyricsProgressMs, setLyricsProgressMs] = useState(0); + const [lyricLineColor, setLyricLineColor] = useState("#fff"); + const [lyricTextColor, setLyricTextColor] = useState("#fff"); + const [lyricsBackgroundColor, setLyricsBackgroundColor] = useState< + string | undefined + >(); + const requestLyrics = !!( + displayInFullScreen === DisplayInFullScreen.Lyrics || + (isPictureInPictureLyircsCanvas && document.pictureInPictureElement) + ); + + const { lyrics, lyricsError, lyricsLoading } = useLyrics({ + setLyricsBackgroundColor, + requestLyrics, + }); + + useLyricsInPictureInPicture({ + setLyricsProgressMs, + setLyricLineColor, + setLyricTextColor, + lyrics, + lyricsBackgroundColor, + setLyricsBackgroundColor, + lyricTextColor, + lyricLineColor, + lyricsProgressMs, + lyricsError, + requestLyrics, + }); + + const value = useMemo( + () => ({ + lyricsProgressMs, + lyricLineColor, + lyricTextColor, + lyricsBackgroundColor, + lyrics, + lyricsError, + lyricsLoading, + setLyricsProgressMs, + setLyricLineColor, + setLyricTextColor, + setLyricsBackgroundColor, + }), + [ + lyricsProgressMs, + lyricLineColor, + lyricTextColor, + lyricsBackgroundColor, + lyrics, + lyricsError, + lyricsLoading, + ] + ); + + return ( + {children} + ); +} + +export default LyricsContext; diff --git a/hooks/index.ts b/hooks/index.ts index 0dd8a51d..10947736 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -28,3 +28,5 @@ export * from "./useToast"; export * from "./useToggle"; export * from "./useTranslations"; export * from "./useUserPlaylists"; +export * from "./useLyricsInPictureInPicture"; +export * from "./useLyricsContext"; diff --git a/hooks/useLyrics.ts b/hooks/useLyrics.ts index 9aaf1640..f39d7c29 100644 --- a/hooks/useLyrics.ts +++ b/hooks/useLyrics.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { useToggle } from "hooks"; +import { useAuth, useSpotify, useToggle } from "hooks"; import { formatLyrics, getLyrics, @@ -10,21 +10,12 @@ import { within, } from "utils"; -export function useLyrics({ - artist, - title, - trackId, - accessToken, -}: { - artist?: string; - title?: string; - trackId?: string | null; - accessToken?: string; -}): { +export function useLyrics({ requestLyrics }: { requestLyrics: boolean }): { lyrics: IFormatLyricsResponse | null; lyricsLoading: boolean; lyricsError: string | null; } { + const { currentlyPlaying } = useSpotify(); const [lyrics, setLyrics] = useState(null); const [lyricsLoading, setLoading] = useToggle(); const [lyricsError, setLyricsError] = useState(null); @@ -33,8 +24,13 @@ export function useLyrics({ id?: string | null; data: GetLyrics; }>({ error: null, data: null, id: null }); + const { accessToken } = useAuth(); + const artist = currentlyPlaying?.artists?.[0].name; + const title = currentlyPlaying?.name; + const trackId = currentlyPlaying?.id; useEffect(() => { + if (!requestLyrics) return; setLoading.on(); setLyricsError(null); setLyrics(null); @@ -62,9 +58,18 @@ export function useLyrics({ setLoading.reset(); setLyricsError(null); }; - }, [accessToken, artist, setLoading, setLyricsError, title, trackId]); + }, [ + accessToken, + artist, + setLoading, + setLyricsError, + title, + trackId, + requestLyrics, + ]); useEffect(() => { + if (!requestLyrics) return; if (!res || !artist || !title || res.id !== artist + title) return; if (res.error === "timeout") { setLyricsError( @@ -88,7 +93,7 @@ export function useLyrics({ setLyricsError(null); setLyrics(formatLyrics(res.data)); - }, [artist, res, setLoading, title]); + }, [artist, res, setLoading, title, requestLyrics]); return { lyrics, diff --git a/hooks/useLyricsContext.ts b/hooks/useLyricsContext.ts new file mode 100644 index 00000000..7a8c0852 --- /dev/null +++ b/hooks/useLyricsContext.ts @@ -0,0 +1,13 @@ +import { useContext } from "react"; + +import LyricsContext, { ILyricsContext } from "context/LyricsContextProvider"; + +export function useLyricsContext(): ILyricsContext { + const context = useContext(LyricsContext); + if (!context) + throw new Error( + "useLyricsContext must be used within a LyricsContextProvider" + ); + + return context; +} diff --git a/hooks/useLyricsInPictureInPicture.ts b/hooks/useLyricsInPictureInPicture.ts new file mode 100644 index 00000000..dab96ad5 --- /dev/null +++ b/hooks/useLyricsInPictureInPicture.ts @@ -0,0 +1,334 @@ +import { + Dispatch, + SetStateAction, + useEffect, + useLayoutEffect, + useState, +} from "react"; + +import { useAuth, useSpotify } from "hooks"; +import { DisplayInFullScreen } from "types/spotify"; +import { + colorCodedToHex, + colorCodedToRGB, + getAllLinesFittingWidth, + getRandomColor, + hexToHsl, + IFormatLyricsResponse, + LYRICS_PADDING_LEFT, + LYRICS_PIP_HEADER_HEIGH, + rgbToHex, +} from "utils"; +import { applyLyricLinePositionAndColor } from "utils/applyLyricLinePositionAndColor"; + +interface IUseLyricsInPictureInPicture { + setLyricsProgressMs: Dispatch>; + setLyricLineColor: Dispatch>; + setLyricTextColor: Dispatch>; + lyrics: IFormatLyricsResponse | null; + lyricsBackgroundColor?: string; + lyricTextColor: string; + lyricLineColor: string; + lyricsProgressMs: number; + setLyricsBackgroundColor: Dispatch>; + lyricsError: string | null; + requestLyrics: boolean; +} + +export function useLyricsInPictureInPicture({ + setLyricsProgressMs, + setLyricLineColor, + setLyricTextColor, + setLyricsBackgroundColor, + lyrics, + lyricsBackgroundColor, + lyricTextColor, + lyricLineColor, + lyricsProgressMs, + lyricsError, + requestLyrics, +}: IUseLyricsInPictureInPicture): void { + const { + isPictureInPictureLyircsCanvas, + currentlyPlaying, + setDisplayInFullScreen, + currentlyPlayingDuration, + isPlaying, + player, + pictureInPictureCanvas, + } = useSpotify(); + const [spinnerFrame, setSpinnerFrame] = useState(null); + const { user } = useAuth(); + const isPremium = user?.product === "premium"; + const title = currentlyPlaying?.name ?? ""; + const artist = currentlyPlaying?.artists?.[0]?.name ?? ""; + const album = currentlyPlaying?.album?.name ?? ""; + const cover = currentlyPlaying?.album?.images?.[0]?.url ?? ""; + + useEffect(() => { + if (!isPremium || !player) return; + (player as Spotify.Player).getCurrentState().then((state) => { + if (state) { + setLyricsProgressMs(state.position); + } + }); + + (player as Spotify.Player).on("player_state_changed", (state) => { + if (state) { + setLyricsProgressMs(state.position); + } + }); + }, [player, isPremium, setLyricsProgressMs]); + + useLayoutEffect(() => { + if (!requestLyrics) return; + const newLyricsBackgroundColor = lyrics?.colors?.background + ? rgbToHex(colorCodedToRGB(lyrics.colors.background)) + : lyricsBackgroundColor ?? getRandomColor(); + const [h, s, l] = hexToHsl(newLyricsBackgroundColor, true) ?? []; + setLyricLineColor( + lyrics?.colors?.highlightText + ? colorCodedToHex(lyrics.colors.highlightText) + : lyricLineColor ?? `hsl(${h}, ${s}%, ${l - 20}%)` + ); + setLyricTextColor( + lyrics?.colors?.text + ? rgbToHex(colorCodedToRGB(lyrics.colors.text)) + : lyricTextColor ?? `hsl(${h}, ${s}%, ${l - 20}%)` + ); + + if (currentlyPlaying?.type !== "track") + return setDisplayInFullScreen(DisplayInFullScreen.App); + setLyricsBackgroundColor(newLyricsBackgroundColor); + }, [ + lyricsBackgroundColor, + lyricLineColor, + lyricTextColor, + currentlyPlaying?.type, + lyrics?.colors, + setDisplayInFullScreen, + setLyricLineColor, + setLyricTextColor, + setLyricsBackgroundColor, + requestLyrics, + ]); + + useEffect(() => { + if (!isPlaying || !currentlyPlayingDuration || !requestLyrics) { + return; + } + const minimumIntervalCheck = 200; + const playerCallBackInterval = setInterval(() => { + setLyricsProgressMs((value) => + value >= currentlyPlayingDuration ? 0 : value + minimumIntervalCheck + ); + }, minimumIntervalCheck); + + return () => { + clearInterval(playerCallBackInterval); + }; + }, [isPlaying, setLyricsProgressMs, currentlyPlayingDuration, requestLyrics]); + + useEffect(() => { + if (!isPictureInPictureLyircsCanvas || !pictureInPictureCanvas.current) + return; + const ctx = pictureInPictureCanvas.current.getContext("2d"); + const canvasWidth = pictureInPictureCanvas.current.width; + const canvasHeight = pictureInPictureCanvas.current.height; + if (!ctx) return; + const lines = lyrics?.lines; + ctx.font = "24px Arial"; + + const centerX = canvasWidth / 2; + const centerY = canvasHeight / 2; + const radius = 40; + const lineWidth = 10; + const numSegments = 120; + const segmentAngle = (2 * Math.PI) / numSegments; + const rotationSpeed = 0.05; + let rotation = 0; + + function drawLoadingSpinner(rotation: number) { + if (!ctx) return; + ctx.clearRect(0, LYRICS_PIP_HEADER_HEIGH, canvasWidth, canvasHeight); + const [h, s] = hexToHsl(lyricsBackgroundColor ?? "", true) ?? [0, 0, 0]; + + for (let i = 0; i < numSegments; i++) { + const startAngle = i * segmentAngle + rotation; + const endAngle = startAngle + segmentAngle; + + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, startAngle, endAngle); + ctx.lineWidth = lineWidth; + + const minLightness = 30; + const maxLightness = 60; + const adjustedLightness = + minLightness + (i * (maxLightness - minLightness)) / numSegments; + + const segmentColor = `hsl(${h}, ${s}%, ${adjustedLightness}%)`; + ctx.strokeStyle = segmentColor; + ctx.stroke(); + } + + ctx.globalCompositeOperation = "destination-over"; + ctx.fillStyle = lyricsBackgroundColor ?? "#000"; + + ctx.fillRect(0, LYRICS_PIP_HEADER_HEIGH, canvasWidth, canvasHeight); + } + + function animate() { + drawLoadingSpinner(rotation); + rotation += rotationSpeed; + const frame = requestAnimationFrame(animate); + setSpinnerFrame(frame); + } + + if (!lyricsError && !lines && !spinnerFrame) { + ctx.fillStyle = "#fff"; + + animate(); + return; + } + + if (spinnerFrame && (lines || lyricsError)) { + cancelAnimationFrame(spinnerFrame); + setSpinnerFrame(null); + ctx.globalCompositeOperation = "destination-over"; + ctx.fillStyle = lyricsBackgroundColor ?? "#000"; + + ctx.fillRect( + 0, + 0, + pictureInPictureCanvas.current.width, + pictureInPictureCanvas.current.height + ); + } + }, [ + spinnerFrame, + isPictureInPictureLyircsCanvas, + lyrics?.lines, + lyricsBackgroundColor, + lyricsError, + pictureInPictureCanvas, + ]); + + useEffect(() => { + if ( + !isPictureInPictureLyircsCanvas || + !pictureInPictureCanvas.current || + !requestLyrics + ) + return; + const lines = lyrics?.lines; + const ctx = pictureInPictureCanvas.current.getContext("2d"); + const canvasWidth = pictureInPictureCanvas.current.width; + const canvasHeight = pictureInPictureCanvas.current.height; + if (!ctx) return; + const lyricsContainerHeight = canvasHeight - LYRICS_PIP_HEADER_HEIGH; + + ctx.clearRect( + 0, + LYRICS_PIP_HEADER_HEIGH, + canvasWidth, + lyricsContainerHeight + ); + ctx.font = "24px Arial"; + + const canvasMiddle = lyricsContainerHeight / 2; + if (lyricsError || !lines) { + ctx.fillStyle = lyricTextColor; + ctx.fillText(lyricsError ?? "", LYRICS_PADDING_LEFT, canvasMiddle); + } + + const allLines = getAllLinesFittingWidth({ + ctx, + lines: lines ?? [], + lyricLineColor, + lyricsProgressMs, + lyricTextColor, + canvasWidth, + }); + + applyLyricLinePositionAndColor(ctx, allLines, lyricsContainerHeight); + + ctx.globalCompositeOperation = "destination-over"; + ctx.fillStyle = lyricsBackgroundColor ?? "#000"; + + ctx.fillRect( + 0, + 0, + pictureInPictureCanvas.current.width, + pictureInPictureCanvas.current.height + ); + }, [ + lyricsError, + lyricsProgressMs, + isPictureInPictureLyircsCanvas, + lyricLineColor, + lyricTextColor, + lyricsBackgroundColor, + pictureInPictureCanvas, + lyrics?.lines, + requestLyrics, + ]); + + useEffect(() => { + if ( + !isPictureInPictureLyircsCanvas || + !pictureInPictureCanvas.current || + !requestLyrics + ) + return; + const ctx = pictureInPictureCanvas.current.getContext("2d"); + if (!ctx) { + return; + } + const drawImage = async (url: string) => { + const image = new Image(); + image.crossOrigin = "Anonymous"; + image.src = url; + await image.decode(); + ctx?.clearRect(10, 10, 80, 80); + ctx?.drawImage(image, 10, 10, 80, 80); + }; + ctx.clearRect(0, 0, 10, 100); + ctx.clearRect(10, 90, pictureInPictureCanvas.current.width, 10); + ctx.clearRect(0, 0, pictureInPictureCanvas.current.width, 10); + ctx.clearRect(90, 0, pictureInPictureCanvas.current.width - 90, 100); + + ctx.font = "22px Arial"; + ctx.fillStyle = lyricTextColor; + ctx.fillText(title, 100, 30); + ctx.font = "18px Arial"; + ctx.fillText(artist, 100, 60); + ctx.font = "16px Arial"; + ctx.fillText(album, 100, 90); + if (cover) { + drawImage(cover); + } else { + ctx.clearRect(10, 10, 80, 80); + } + + ctx.globalCompositeOperation = "destination-over"; + ctx.fillStyle = lyricsBackgroundColor ?? "#000"; + + ctx.fillRect( + 0, + 0, + pictureInPictureCanvas.current.width, + pictureInPictureCanvas.current.height + ); + }, [ + lyricsBackgroundColor, + title, + artist, + album, + cover, + isPictureInPictureLyircsCanvas, + lyrics, + pictureInPictureCanvas, + lyricTextColor, + requestLyrics, + ]); +} diff --git a/layouts/AppContainer.tsx b/layouts/AppContainer.tsx index f0360a7a..8e4f0422 100644 --- a/layouts/AppContainer.tsx +++ b/layouts/AppContainer.tsx @@ -3,6 +3,7 @@ import { PropsWithChildren, ReactElement, useEffect, + useLayoutEffect, useRef, useState, } from "react"; @@ -14,7 +15,7 @@ import { SideBar, TopBar, } from "components"; -import { useOnSmallScreen, useSpotify } from "hooks"; +import { useLyricsContext, useOnSmallScreen, useSpotify } from "hooks"; import FullScreenPlayer from "layouts/FullScreenPlayer"; import { DisplayInFullScreen } from "types/spotify"; import { requestFullScreen } from "utils"; @@ -23,6 +24,7 @@ export function AppContainer({ children }: PropsWithChildren): ReactElement { const appRef = useRef(); const { displayInFullScreen, currentlyPlaying, hideSideBar, setHideSideBar } = useSpotify(); + const { lyricsBackgroundColor } = useLyricsContext(); const shouldDisplayLyrics = displayInFullScreen === DisplayInFullScreen.Lyrics && currentlyPlaying?.type === "track"; @@ -60,6 +62,19 @@ export function AppContainer({ children }: PropsWithChildren): ReactElement { } }, [shouldDisplayPlayer]); + useLayoutEffect(() => { + const app = appRef?.current; + if (!app) return; + if (displayInFullScreen === DisplayInFullScreen.Lyrics) { + app.style.backgroundColor = lyricsBackgroundColor ?? ""; + return; + } + app.style.backgroundColor = "inherit"; + return () => { + app.style.backgroundColor = "inherit"; + }; + }, [appRef, lyricsBackgroundColor, displayInFullScreen]); + return (
@@ -83,7 +98,7 @@ export function AppContainer({ children }: PropsWithChildren): ReactElement { > {shouldDisplayLyrics ? ( - + ) : shouldDisplayQueue ? ( ) : shouldDisplayPlayer ? ( diff --git a/utils/applyLyricLinePositionAndColor.ts b/utils/applyLyricLinePositionAndColor.ts new file mode 100644 index 00000000..8e58d0c9 --- /dev/null +++ b/utils/applyLyricLinePositionAndColor.ts @@ -0,0 +1,48 @@ +import { + LINE_HEIGHT, + LYRICS_PADDING_LEFT, + LYRICS_PIP_HEADER_HEIGH, +} from "utils"; + +export function applyLyricLinePositionAndColor( + ctx: CanvasRenderingContext2D, + allLines: { + color: string; + text: string; + type: "current" | "previous" | "next"; + }[], + containerHeight: number +): void { + const containerMiddle = containerHeight / 2; + const currentLineIndex = allLines.findIndex( + (line) => line.type === "current" + ); + + allLines.forEach((line, index) => { + const isOneOfFirstLines = + currentLineIndex * LINE_HEIGHT < containerMiddle - LINE_HEIGHT; + const isOfLastLines = + currentLineIndex * LINE_HEIGHT > + allLines.length * LINE_HEIGHT - LINE_HEIGHT - containerMiddle; + const canvasRest = containerMiddle % LINE_HEIGHT; + const bottomLineTrace = isOfLastLines + ? containerHeight + + LINE_HEIGHT + + (LINE_HEIGHT - canvasRest) - + LINE_HEIGHT * (allLines.length - currentLineIndex) + : containerMiddle; + const middleHeight = isOneOfFirstLines + ? LINE_HEIGHT + currentLineIndex * LINE_HEIGHT + : bottomLineTrace; + + const lineY = + LYRICS_PIP_HEADER_HEIGH + + middleHeight + + LINE_HEIGHT * (index - currentLineIndex); + ctx.fillStyle = line.color ?? "#fff"; + const limit = LINE_HEIGHT + LYRICS_PIP_HEADER_HEIGH; + const isOutsideCanvas = lineY < limit || lineY > containerHeight + limit; + if (isOutsideCanvas) return; + ctx.fillText(line.text, LYRICS_PADDING_LEFT, lineY); + }); +} diff --git a/utils/constants.ts b/utils/constants.ts index e7aae2a5..b70afb1a 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -43,3 +43,7 @@ export const TOP_TRACKS_SHORT_TERM_COLOR = "rgb(176, 153, 199)"; export const CONTEXT_MENU_ITEM_HEIGHT = 42.5; export const CONTEXT_MENU_SIDE_OFFSET = 50; export const CONTEXT_MENU_TOP_BOTTOM_OFFSET = CONTEXT_MENU_SIDE_OFFSET * 2; + +export const LINE_HEIGHT = 40; +export const LYRICS_PIP_HEADER_HEIGH = 100; +export const LYRICS_PADDING_LEFT = 10;