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}
+
+
+
+
+
}
+ onClick={this.onClickPreviousButton}
+ disabled={this.state.currentState?.syncModeLocked}
+ />
+
:
}
+ onClick={this.onClickTooglePlayButton}
+ disabled={this.state.currentState?.syncModeLocked}
+ >
+ {
+ this.state.currentState?.loading &&
+
+
+ }
+
+
}
+ onClick={this.onClickNextButton}
+ disabled={this.state.currentState?.syncModeLocked}
+ />
+
+
+
+
+
{
+ 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