import React from "react" import classnames from "classnames" import Marquee from "react-fast-marquee" import Controls from "components/Player/Controls" 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 } function isOverflown(element) { if (!element) { return false } return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth; } class PlayerController extends React.Component { state = { colorAnalysis: null, currentDragWidth: 0, titleOverflown: false, currentDuration: 0, currentTime: 0, currentPlaying: app.cores.player.getState("currentAudioManifest"), loading: app.cores.player.getState("loading") ?? false, playbackStatus: app.cores.player.getState("playbackStatus") ?? "stopped", audioMuted: app.cores.player.getState("audioMuted") ?? false, volume: app.cores.player.getState("audioVolume"), syncModeLocked: app.cores.player.getState("syncModeLocked"), syncMode: app.cores.player.getState("syncMode"), } events = { "player.coverColorAnalysis.update": (colorAnalysis) => { this.setState({ colorAnalysis }) }, "player.seek.update": (seekTime) => { this.setState({ currentTime: seekTime, }) }, "player.status.update": (data) => { this.setState({ playbackStatus: data }) }, "player.current.update": (data) => { this.setState({ titleOverflown: false }) this.setState({ currentPlaying: data }) }, "player.syncModeLocked.update": (to) => { this.setState({ syncModeLocked: to }) }, "player.syncMode.update": (to) => { this.setState({ syncMode: to }) }, "player.mute.update": (data) => { this.setState({ audioMuted: data }) }, "player.volume.update": (data) => { this.setState({ audioVolume: data }) }, "player.loading.update": (data) => { this.setState({ loading: data }) }, } titleRef = React.createRef() 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({ currentDuration: currentState.duration, currentTime: currentState.time, colorAnalysis: currentState.colorAnalysis, }) const titleOverflown = isOverflown(this.titleRef.current) this.setState({ titleOverflown: titleOverflown }) }, 800) } onClickPreviousButton = () => { app.cores.player.playback.previous() } onClickNextButton = () => { app.cores.player.playback.next() } onClickTooglePlayButton = () => { if (this.state?.playbackStatus === "playing") { app.cores.player.playback.pause() } else { app.cores.player.playback.play() } } updateVolume = (value) => { app.cores.player.volume(value) } toogleMute = () => { app.cores.player.toogleMute() } 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() { //const bgColor = RGBStringToValues(getComputedStyle(document.documentElement).getPropertyValue("--background-color-accent-values")) return
{

{ this.state.plabackState === "stopped" ? "Nothing is playing" : <> {this.state.currentPlaying?.title ?? "Nothing is playing"} }

} {this.state.titleOverflown &&

{ this.state.plabackState === "stopped" ? "Nothing is playing" : <> {this.state.currentPlaying?.title ?? "Nothing is playing"} }

}
{ this.state.currentPlaying?.artist && <>

{this.state.currentPlaying?.artist ?? "Unknown"}

{ this.state.currentPlaying?.album && <> -

{this.state.currentPlaying?.album ?? "Unknown"}

} }
{ this.setState({ dragging: true, }) }} onMouseUp={(e) => { const rect = e.currentTarget.getBoundingClientRect() const seekTime = this.state.currentDuration * (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: { "cinematic-mode": false, "centered-player": false, "video-canvas-enabled": false, } } visualizerRef = React.createRef() videoCanvasRef = React.createRef() coverCanvasRef = 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 }) } } toogleClassName = (className, to) => { if (typeof to === "undefined") { to = !this.state.classnames[className] } if (to) { if (this.state.classnames[className] === true) { return false } //app.message.info("Toogling on " + className) this.setState({ classnames: { ...this.state.classnames, [className]: true }, }) return true } else { if (this.state.classnames[className] === false) { return false } //app.message.info("Toogling off " + className) this.setState({ classnames: { ...this.state.classnames, [className]: false }, }) 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) } 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") console.log(`[SyncLyrics] Video canvas loaded`) this.toogleVideoCanvas(true) } else { //app.message.info("No video canvas available for this song") console.log(`[SyncLyrics] 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") console.log(`[SyncLyrics] No lyrics available for this song, sync type [${data.syncType}]`) this.toogleCinematicMode(false) this.toogleCenteredControllerMode(true) } else { //app.message.info("Lyrics loaded, starting sync...") console.log(`[SyncLyrics] Starting sync with type [${data.syncType}]`) this.toogleCenteredControllerMode(false) this.startLyricsSync() } // transform times this.setState({ loading: false, syncType: data.syncType, canvas_url: data.canvas_url ?? null, lyrics: data.lines, }) } 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 === "") { //console.log(`[SyncLyrics] Toogling cinematic mode on because line is empty`) this.toogleCinematicMode(true) } else { //console.log(`[SyncLyrics] Toogling cinematic mode off because line is not empty`) this.toogleCinematicMode(false) } } else { if (this.state.classnames["cinematic-mode"] === true) { 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, }) if (app.layout.sidebar) { app.layout.sidebar.toggleVisibility(false) } if (app.layout.floatingStack) { app.layout.floatingStack.toogleGlobalVisibility(false) } app.cores.style.compactMode(true) app.cores.style.applyVariant("dark") // request full screen to browser if (document.fullscreenEnabled) { document.documentElement.requestFullscreen() } // listen when user exit full screen to exit cinematic mode document.addEventListener("fullscreenchange", () => { if (!document.fullscreenElement) { app.location.back() } }) 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 if (app.layout.sidebar) { app.layout.sidebar.toggleVisibility(true) } if (app.layout.floatingStack) { app.layout.floatingStack.toogleGlobalVisibility(true) } app.cores.style.compactMode(false) app.cores.style.applyInitialVariant() // exit full screen if (document.fullscreenEnabled) { document.exitFullscreen() } } 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 { ...acc, [key]: value, } }, {}), }, )} 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() }
} }