From 791a42c020136756e7b763a16be6d04aadfa7f92 Mon Sep 17 00:00:00 2001 From: SrGooglo Date: Sun, 28 May 2023 01:31:40 +0000 Subject: [PATCH] added a lyrics viewer --- packages/app/src/pages/lyrics/index.jsx | 647 +++++++++++++++++++++++ packages/app/src/pages/lyrics/index.less | 472 +++++++++++++++++ 2 files changed, 1119 insertions(+) create mode 100644 packages/app/src/pages/lyrics/index.jsx create mode 100644 packages/app/src/pages/lyrics/index.less diff --git a/packages/app/src/pages/lyrics/index.jsx b/packages/app/src/pages/lyrics/index.jsx new file mode 100644 index 00000000..96bd37e8 --- /dev/null +++ b/packages/app/src/pages/lyrics/index.jsx @@ -0,0 +1,647 @@ +import React from "react" +import classnames from "classnames" +import { Button } from "antd" + +import UseAnimations from "react-useanimations" +import LoadingAnimation from "react-useanimations/lib/loading" +import { Icons } from "components/Icons" + +import Image from "components/Image" + +import request from "comty.js/handlers/request" + +import "./index.less" + +function composeRgbValues(values) { + let value = "" + + // only get the first 3 values + for (let i = 0; i < 3; i++) { + // if last value, don't add comma + if (i === 2) { + value += `${values[i]}` + continue + } + + value += `${values[i]}, ` + } + + return value +} + +function calculateLineTime(line) { + if (!line) { + return 0 + } + + return line.endTimeMs - line.startTimeMs +} + +class PlayerController extends React.Component { + state = { + hovering: false, + colorAnalysis: null, + currentState: null, + currentDragWidth: 0, + } + + events = { + "player.coverColorAnalysis.update": (colorAnalysis) => { + this.setState({ colorAnalysis }) + }, + "player.seek.update": (seekTime) => { + const updatedState = this.state.currentState + + updatedState.time = seekTime + + this.setState({ + currentState: updatedState, + }) + }, + } + + startSync() { + // create a interval to get state from player + if (this.syncInterval) { + clearInterval(this.syncInterval) + } + + this.syncInterval = setInterval(() => { + const currentState = app.cores.player.currentState() + + this.setState({ currentState }) + }, 800) + } + + onClickPreviousButton = () => { + app.cores.player.playback.previous() + } + + onClickNextButton = () => { + app.cores.player.playback.next() + } + + onClickTooglePlayButton = () => { + if (this.state.currentState?.playbackStatus === "playing") { + app.cores.player.playback.pause() + } else { + app.cores.player.playback.play() + } + } + + componentDidMount() { + for (const event in this.events) { + app.eventBus.on(event, this.events[event]) + } + + if (this.syncInterval) { + clearInterval(this.syncInterval) + } + + this.startSync() + } + + componentWillUnmount() { + for (const event in this.events) { + app.eventBus.off(event, this.events[event]) + } + + if (this.syncInterval) { + clearInterval(this.syncInterval) + } + } + + onDragEnd = (seekTime) => { + this.setState({ + currentDragWidth: 0, + dragging: false, + }) + + app.cores.player.seek(seekTime) + } + + render() { + return
+
{ + this.setState({ hovering: true }) + }} + onMouseLeave={() => { + this.setState({ hovering: false }) + }} + className={classnames( + "player_controller", + { + ["player_controller--hovering"]: this.state.hovering || this.state.dragging, + } + )} + > +
+ +
+ +
+
+
+ {this.state.currentState?.manifest?.title} +
+
+ {this.state.currentState?.manifest?.artist} - {this.state.currentState?.manifest?.album} +
+
+ +
+ +
+
+ +
+
{ + this.setState({ + dragging: true, + }) + }} + onMouseUp={(e) => { + const rect = e.currentTarget.getBoundingClientRect() + const seekTime = this.state.currentState?.duration * (e.clientX - rect.left) / rect.width + + this.onDragEnd(seekTime) + }} + onMouseMove={(e) => { + const rect = e.currentTarget.getBoundingClientRect() + const atWidth = (e.clientX - rect.left) / rect.width * 100 + + this.setState({ currentDragWidth: atWidth }) + }} + > +
+
+
+
+
+ } +} + +export default class SyncLyrics extends React.Component { + state = { + loading: true, + notAvailable: false, + + currentManifest: null, + currentStatus: null, + + canvas_url: null, + lyrics: null, + currentLine: null, + + colorAnalysis: null, + + classnames: [ + { + name: "cinematic-mode", + enabled: false, + }, + { + name: "centered-player", + enabled: false, + }, + { + name: "video-canvas-enabled", + enabled: false, + } + ] + } + + visualizerRef = React.createRef() + + videoCanvasRef = React.createRef() + + thumbnailCanvasRef = React.createRef() + + events = { + "player.current.update": (currentManifest) => { + this.setState({ currentManifest }) + + if (document.startViewTransition) { + document.startViewTransition(this.loadLyrics) + } else { + this.loadLyrics() + } + }, + "player.coverColorAnalysis.update": (colorAnalysis) => { + this.setState({ colorAnalysis }) + }, + "player.status.update": (currentStatus) => { + this.setState({ currentStatus }) + } + } + + isCurrentLine = (line) => { + if (!this.state.currentLine) { + return false + } + + return this.state.currentLine.startTimeMs === line.startTimeMs + } + + loadLyrics = async () => { + if (this.syncInterval) { + clearInterval(this.syncInterval) + } + + if (!this.state.currentManifest) { + return false + } + + this.setState({ + loading: true, + notAvailable: false, + lyrics: null, + currentLine: null, + canvas_url: null, + }) + + const api = app.cores.api.instance().instances.music + + let response = await request({ + instance: api, + method: "get", + url: `/lyrics/${this.state.currentManifest._id}`, + }).catch((err) => { + console.error(err) + + this.setState({ + notAvailable: true, + }) + + return {} + }) + + let data = response.data ?? { + lines: [], + syncType: null, + } + + console.log(this.state.currentManifest) + console.log(data) + + if (data.lines.length > 0 && data.syncType === "LINE_SYNCED") { + data.lines = data.lines.map((line, index) => { + const ref = React.createRef() + + line.ref = ref + + line.startTimeMs = Number(line.startTimeMs) + + const nextLine = data.lines[index + 1] + + // calculate end time + line.endTimeMs = nextLine ? Number(nextLine.startTimeMs) : Math.floor(app.cores.player.duration() * 1000) + + return line + }) + } + + if (data.canvas_url) { + //app.message.info("Video canvas loaded") + this.toogleVideoCanvas(true) + } else { + //app.message.info("No video canvas available for this song") + this.toogleVideoCanvas(false) + } + + // if has no lyrics or are unsynced, toogle cinematic mode off and center controller + if (data.lines.length === 0 || data.syncType !== "LINE_SYNCED") { + //app.message.info("No lyrics available for this song") + + this.toogleCinematicMode(false) + this.toogleCenteredControllerMode(true) + } else { + //app.message.info("Lyrics loaded, starting sync...") + + this.toogleCenteredControllerMode(false) + this.startLyricsSync() + } + + // transform times + this.setState({ + loading: false, + syncType: data.syncType, + canvas_url: data.canvas_url, + lyrics: data.lines, + }) + } + + toogleClassName = (className, to) => { + let currentState = this.state.classnames.find((c) => c.name === className) + + if (!currentState) { + return false + } + + if (typeof to === "undefined") { + to = !currentState?.enabled + } + + if (to) { + if (currentState.enabled) { + return false + } + + //app.message.info("Toogling on " + className) + + currentState.enabled = true + + this.setState({ + classnames: this.state.classnames, + }) + + return true + } else { + if (!currentState.enabled) { + return false + } + + //app.message.info("Toogling off " + className) + + currentState.enabled = false + + this.setState({ + classnames: this.state.classnames, + }) + + return true + } + } + + toogleVideoCanvas = (to) => { + return this.toogleClassName("video-canvas-enabled", to) + } + + toogleCenteredControllerMode = (to) => { + return this.toogleClassName("centered-player", to) + } + + toogleCinematicMode = (to) => { + return this.toogleClassName("cinematic-mode", to) + } + + startLyricsSync = () => { + // create interval to sync lyrics + if (this.syncInterval) { + clearInterval(this.syncInterval) + } + + // scroll to top + this.visualizerRef.current.scrollTop = 0 + + this.syncInterval = setInterval(() => { + if (!this.state.lyrics || !Array.isArray(this.state.lyrics) || this.state.lyrics.length === 0 || !this.state.lyrics[0]) { + console.warn(`Clearing interval because lyrics is not found or lyrics is empty, probably because memory leak or unmounted component`) + clearInterval(this.syncInterval) + return false + } + + const { time } = app.cores.player.currentState() + + // transform audio seek time to lyrics time (ms from start) // remove decimals + const transformedTime = Math.floor(time * 1000) + + const hasStartedFirst = transformedTime >= this.state.lyrics[0].startTimeMs + + if (!hasStartedFirst) { + if (this.state.canvas_url) { + this.toogleCinematicMode(true) + } + + return false + } + + // find the closest line to the transformed time + const line = this.state.lyrics.find((line) => { + // match the closest line to the transformed time + return transformedTime >= line.startTimeMs && transformedTime <= line.endTimeMs + }) + + if (!line || !line.ref) { + console.warn(`Clearing interval because cannot find line to sync or line REF is not found, probably because memory leak or unmounted component`) + clearInterval(this.syncInterval) + + return false + } + + if (line) { + if (this.isCurrentLine(line)) { + return false + } + + // set current line + this.setState({ + currentLine: line, + }) + + //console.log(line) + + if (!line.ref.current) { + console.log(line) + console.warn(`Clearing interval because line CURRENT ref is not found, probably because memory leak or unmounted component`) + clearInterval(this.syncInterval) + + return false + } + + this.visualizerRef.current.scrollTo({ + top: line.ref.current.offsetTop - (this.visualizerRef.current.offsetHeight / 2), + behavior: "smooth", + }) + + if (this.state.canvas_url) { + if (line.words === "♪" || line.words === "♫" || line.words === " " || line.words === "") { + this.toogleCinematicMode(true) + } else { + this.toogleCinematicMode(false) + } + } + } + }, 100) + } + + componentDidMount = async () => { + // register player events + for (const [event, callback] of Object.entries(this.events)) { + app.eventBus.on(event, callback) + } + + // get current playback status and time + const { + manifest, + playbackStatus, + colorAnalysis, + } = app.cores.player.currentState() + + await this.setState({ + currentManifest: manifest, + currentStatus: playbackStatus, + colorAnalysis, + }) + + app.SidebarController.toggleVisibility(false) + app.cores.style.compactMode(true) + app.cores.style.applyVariant("dark") + app.layout.floatingStack.toogleGlobalVisibility(false) + + window._hacks = { + toogleVideoCanvas: this.toogleVideoCanvas, + toogleCinematicMode: this.toogleCinematicMode, + toogleCenteredControllerMode: this.toogleCenteredControllerMode, + } + + await this.loadLyrics() + } + + componentWillUnmount() { + // unregister player events + for (const [event, callback] of Object.entries(this.events)) { + app.eventBus.off(event, callback) + } + + // clear sync interval + if (this.syncInterval) { + clearInterval(this.syncInterval) + } + + delete window._hacks + + app.SidebarController.toggleVisibility(true) + app.cores.style.compactMode(false) + app.cores.style.applyInitialVariant() + app.layout.floatingStack.toogleGlobalVisibility(true) + } + + renderLines() { + if (!this.state.lyrics || this.state.notAvailable || this.state.syncType !== "LINE_SYNCED") { + return null + } + + return this.state.lyrics.map((line, index) => { + return
+

+ {line.words} +

+
+ }) + } + + render() { + return
{ + return { + [classname.name]: classname.enabled, + } + }).reduce((a, b) => { + return { + ...a, + ...b, + } + }, {}), + }, + )} + style={{ + "--predominant-color": this.state.colorAnalysis?.hex ?? "unset", + "--predominant-color-rgb-values": this.state.colorAnalysis?.value ? composeRgbValues(this.state.colorAnalysis?.value) : [0, 0, 0], + "--line-time": `${calculateLineTime(this.state.currentLine)}ms`, + "--line-animation-play-state": this.state.currentStatus === "playing" ? "running" : "paused", + }} + > + +
+ +
+
+ +
+ +
+ + + +
+
+ { + this.renderLines() + } +
+
+
+ } +} \ No newline at end of file diff --git a/packages/app/src/pages/lyrics/index.less b/packages/app/src/pages/lyrics/index.less new file mode 100644 index 00000000..fc28df44 --- /dev/null +++ b/packages/app/src/pages/lyrics/index.less @@ -0,0 +1,472 @@ +@enabled-video-canvas-opacity: 0.4; + +.lyrics_viewer { + display: flex; + flex-direction: column; + + isolation: isolate; + + //align-items: center; + + width: 100%; + height: 100%; + + padding: 50px 0; + + overflow-y: hidden; + + transition: all 150ms ease-in-out; + + background-color: rgba(var(--predominant-color-rgb-values), 0.8); + background: + linear-gradient(20deg, rgba(var(--predominant-color-rgb-values), 0.8), rgba(var(--predominant-color-rgb-values), 0.2)), + url(https://grainy-gradients.vercel.app/noise.svg); + + //background-size: 1%; + background-position: center; + + &.video-canvas-enabled { + .lyrics_viewer_video_canvas { + video { + opacity: @enabled-video-canvas-opacity; + } + } + } + + &.centered-player { + .lyrics_viewer_thumbnail { + width: 100vw; + + height: 80vh; //fallback + height: 80dvh; + + opacity: 1; + } + + .player_controller_wrapper { + top: 0; + left: 0; + + width: 100%; + height: 100%; + + align-items: center; + justify-content: center; + + margin: 0; + + .player_controller { + margin-top: 40%; + + max-width: 50vw; + max-height: 50vh; + + width: 100%; + //height: 100%; + + border-radius: 18px; + + gap: 50px; + + .player_controller_cover { + width: 0px; + } + + .player_controller_info { + .player_controller_info_title { + font-size: 3rem; + } + } + } + } + } + + &.cinematic-mode { + .lyrics_viewer_mask { + backdrop-filter: blur(0px); + -webkit-backdrop-filter: blur(0px) + } + + .lyrics_viewer_background { + video { + opacity: 1; + } + } + + .lyrics_viewer_content { + .lyrics_viewer_lines { + opacity: 0; + } + } + } + + &.text_dark { + .lyrics_viewer_content { + .lyrics_viewer_lines { + .lyrics_viewer_lines_line { + color: var(--text-color-white); + + h2 { + color: var(--text-color-white); + } + } + } + } + } + + .lyrics_viewer_mask { + position: absolute; + + z-index: 200; + + top: 0; + left: 0; + + width: 100%; + height: 100%; + + backdrop-filter: blur(21px); + -webkit-backdrop-filter: blur(21px) + } + + .lyrics_viewer_video_canvas { + position: absolute; + top: 0; + + width: 100%; + //height: 100dvh; + height: 100%; + + display: flex; + flex-direction: row; + + align-items: center; + justify-content: center; + + transition: all 150ms ease-in-out; + + video { + width: 100%; + height: 100%; + + opacity: 0; + + object-fit: cover; + + transition: all 150ms ease-in-out; + + } + } + + .lyrics_viewer_thumbnail { + position: absolute; + + top: 0; + left: 0; + + z-index: 250; + + display: flex; + flex-direction: row; + + align-items: center; + justify-content: center; + + opacity: 0; + + width: 0px; + height: 0px; + + transition: all 150ms ease-in-out; + + overflow: hidden; + + img { + width: 25vw; + height: 25vw; + + max-width: 500px; + max-height: 500px; + + object-fit: cover; + border-radius: 12px; + + box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.2); + } + } + + .lyrics_viewer_content { + z-index: 250; + transition: all 150ms ease-in-out; + + .lyrics_viewer_lines { + display: flex; + flex-direction: column; + + align-items: center; + justify-content: center; + + width: 90%; + + margin: auto; + + font-family: "Space Grotesk", sans-serif; + + transition: all 150ms ease-in-out; + + &::after, + &::before { + content: ""; + display: block; + width: 100%; + //height: 50dvh; + height: 50vh; + } + + .lyrics_viewer_lines_line { + transition: all 150ms ease-in-out; + z-index: 250; + + &.current { + margin: 20px 0; + font-size: 2rem; + + animation: spacing-letters var(--line-time) ease-in-out forwards; + animation-play-state: var(--line-animation-play-state); + } + } + } + } +} + +@keyframes spacing-letters { + 0% { + letter-spacing: 0.3rem; + } + + 100% { + letter-spacing: 0; + } +} + +.player_controller_wrapper { + display: flex; + flex-direction: column; + + position: absolute; + + bottom: 0; + left: 0; + + margin: 50px; + + z-index: 350; + + transition: all 150ms ease-in-out; + + .player_controller { + display: flex; + flex-direction: row; + + align-items: center; + + width: 25vw; + + min-width: 350px; + max-width: 500px; + + height: 220px; + + background-color: rgba(var(--background-color-accent-values), 0.4); + // background: + // linear-gradient(20deg, rgba(var(--background-color-accent-values), 0.8), transparent), + // url(https://grainy-gradients.vercel.app/noise.svg); + + -webkit-backdrop-filter: blur(21px); + backdrop-filter: blur(21px); + + box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.2); + + padding: 20px; + + border-radius: 12px; + + gap: 20px; + + color: var(--text-color); + + transition: all 150ms ease-in-out; + + overflow: hidden; + + &.player_controller--hovering { + .player_controller_controls { + height: 8vh; + max-height: 100px; + opacity: 1; + } + + .player_controller_progress_wrapper { + bottom: 7px; + + .player_controller_progress { + height: 10px; + + width: 90%; + + background-color: var(--background-color-accent); + } + } + } + + .player_controller_cover { + display: flex; + flex-direction: column; + + align-items: center; + justify-content: center; + + width: 10vw; + height: 100%; + + img { + width: 100%; + height: 100%; + + object-fit: cover; + + border-radius: 12px; + } + } + + .player_controller_left { + display: flex; + flex-direction: column; + + align-items: center; + justify-content: center; + + height: 100%; + + transition: all 150ms ease-in-out; + + .player_controller_info { + display: flex; + flex-direction: column; + + align-items: flex-start; + + gap: 10px; + + transition: all 150ms ease-in-out; + + .player_controller_info_title { + font-size: 1.5rem; + font-weight: 600; + + color: var(--background-color-contrast) + } + + .player_controller_info_artist { + font-size: 0.8rem; + font-weight: 400; + } + } + } + + .player_controller_controls { + display: flex; + flex-direction: row; + + align-items: center; + justify-content: center; + + gap: 8px; + + padding: 10px; + + height: 0px; + opacity: 0; + + transition: all 150ms ease-in-out; + + .playButton { + position: relative; + + display: flex; + + align-items: center; + justify-content: center; + + .loadCircle { + position: absolute; + + z-index: 330; + + top: 0; + right: 0; + left: 0; + + width: 100%; + height: 100%; + + margin: auto; + + align-self: center; + justify-self: center; + + transform: scale(1.5); + + svg { + width: 100%; + height: 100%; + + path { + stroke: var(--text-color); + stroke-width: 1; + } + } + } + } + } + + .player_controller_progress_wrapper { + position: absolute; + + box-sizing: border-box; + + bottom: 0; + left: 0; + + margin: auto; + + width: 100%; + + .player_controller_progress { + display: flex; + flex-direction: row; + + align-items: center; + + height: 5px; + width: 100%; + + margin: auto; + + transition: all 150ms ease-in-out; + + border-radius: 12px; + + .player_controller_progress_bar { + height: 100%; + + background-color: var(--background-color-contrast); + + border-radius: 12px; + + transition: all 150ms ease-in-out; + } + } + } + } +} \ No newline at end of file