Refactor player internals and sync

Replace `TrackInstance` with direct `TrackManifest` usage in the player
core. Introduce a `SyncRoom` class and related hooks (`useSyncRoom`,
`useLyrics`, etc.) for real-time state synchronization and shared lyrics
display. Enhance player indicators to show detailed audio format metadata
(codec, sample rate, bit depth). Relocate the Indicators component and update
the Lyrics page to utilize these new features and components.
This commit is contained in:
SrGooglo 2025-05-21 19:03:08 +00:00
parent 12e9cb30ca
commit 0eaecf6fd3
28 changed files with 1266 additions and 628 deletions

View File

@ -9,28 +9,25 @@ import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import "./index.less"
const ExtraActions = (props) => {
const [trackInstance, setTrackInstance] = React.useState({})
const [track, setTrack] = React.useState({})
const onPlayerStateChange = React.useCallback((state) => {
const instance = app.cores.player.track()
const track = app.cores.player.track()
if (instance) {
setTrackInstance(instance)
if (track) {
setTrack(track)
}
}, [])
const [playerState] = usePlayerStateContext(onPlayerStateChange)
usePlayerStateContext(onPlayerStateChange)
const handleClickLike = async () => {
if (!trackInstance) {
if (!track) {
console.error("Cannot like a track if nothing is playing")
return false
}
await trackInstance.manifest.serviceOperations.toggleItemFavorite(
"tracks",
trackInstance.manifest._id,
)
await track.serviceOperations.toggleItemFavorite("tracks", track._id)
}
return (
@ -39,18 +36,15 @@ const ExtraActions = (props) => {
<Button
type="ghost"
icon={<Icons.MdAbc />}
disabled={!trackInstance?.manifest?.lyrics_enabled}
disabled={!track?.lyrics_enabled}
/>
)}
{!app.isMobile && (
<LikeButton
liked={
trackInstance?.manifest?.serviceOperations
?.isItemFavorited
}
liked={track?.serviceOperations?.isItemFavorited}
onClick={handleClickLike}
disabled={!trackInstance?.manifest?._id}
disabled={!track?._id}
/>
)}

View File

@ -5,7 +5,7 @@
align-items: center;
justify-content: space-between;
width: 70%;
width: 50%;
margin: auto;
padding: 2px 25px;

View File

@ -39,21 +39,21 @@ const EventsHandlers = {
const track = app.cores.player.track()
return await track.manifest.serviceOperations.toggleItemFavorite(
return await track.serviceOperations.toggleItemFavorite(
"track",
ctx.track_manifest._id,
track._id,
)
},
}
const Controls = (props) => {
const [trackInstance, setTrackInstance] = React.useState({})
const [trackManifest, setTrackManifest] = React.useState({})
const onPlayerStateChange = React.useCallback((state) => {
const instance = app.cores.player.track()
const track = app.cores.player.track()
if (instance) {
setTrackInstance(instance)
if (track) {
setTrackManifest(track)
}
}, [])
@ -131,12 +131,9 @@ const Controls = (props) => {
{app.isMobile && (
<LikeButton
liked={
trackInstance?.manifest?.serviceOperations
?.isItemFavorited
}
liked={trackManifest?.serviceOperations?.isItemFavorited}
onClick={() => handleAction("like")}
disabled={!trackInstance?.manifest?._id}
disabled={!trackManifest?._id}
/>
)}
</div>

View File

@ -0,0 +1,84 @@
import React from "react"
import { Tooltip } from "antd"
import { Icons } from "@components/Icons"
function getIndicators(track, playerState) {
const indicators = []
if (playerState.live) {
indicators.push({
icon: <Icons.FiRadio style={{ color: "var(--colorPrimary)" }} />,
})
}
if (playerState.format_metadata && playerState.format_metadata?.trackInfo) {
const dmuxData = playerState.format_metadata
// this commonly used my mpd's
const trackInfo = dmuxData.trackInfo[0]
const trackAudio = trackInfo?.audio
const codec = trackInfo?.codecName ?? dmuxData.codec
const sampleRate = trackAudio?.samplingFrequency ?? dmuxData.sampleRate
const bitDepth = trackAudio?.bitDepth ?? dmuxData.bitsPerSample
const bitrate = trackAudio?.bitrate ?? dmuxData.bitrate
if (codec) {
if (codec.toLowerCase().includes("flac")) {
indicators.push({
icon: <Icons.Lossless />,
tooltip: `${sampleRate / 1000} kHz / ${bitDepth ?? 16} Bits`,
})
}
if (codec.toLowerCase().includes("vorbis")) {
indicators.push({
icon: <Icons.Ogg />,
tooltip: `Vorbis ${sampleRate / 1000} kHz / ${bitrate / 1000} kbps`,
})
}
}
}
return indicators
}
const Indicators = ({ track, playerState }) => {
if (!track) {
return null
}
const indicators = React.useMemo(
() => getIndicators(track, playerState),
[track, playerState],
)
if (indicators.length === 0) {
return null
}
return (
<div className="toolbar_player_indicators_wrapper">
<div className="toolbar_player_indicators">
{indicators.map((indicator, index) => {
if (indicator.tooltip) {
return (
<Tooltip
key={indicators.length}
title={indicator.tooltip}
>
{indicator.icon}
</Tooltip>
)
}
return React.cloneElement(indicator.icon, {
key: index,
})
})}
</div>
</div>
)
}
export default Indicators

View File

@ -9,6 +9,7 @@ import LiveInfo from "@components/Player/LiveInfo"
import SeekBar from "@components/Player/SeekBar"
import Controls from "@components/Player/Controls"
import Actions from "@components/Player/Actions"
import Indicators from "@components/Player/Indicators"
import RGBStringToValues from "@utils/rgbToValues"
@ -25,40 +26,6 @@ function isOverflown(parent, element) {
return elementRect.width > parentRect.width
}
const Indicators = ({ track, playerState }) => {
if (!track) {
return null
}
const indicators = []
if (track.metadata) {
if (track.metadata.lossless) {
indicators.push(
<antd.Tooltip title="Lossless Audio">
<Icons.Lossless />
</antd.Tooltip>,
)
}
}
if (playerState.live) {
indicators.push(
<Icons.FiRadio style={{ color: "var(--colorPrimary)" }} />,
)
}
if (indicators.length === 0) {
return null
}
return (
<div className="toolbar_player_indicators_wrapper">
<div className="toolbar_player_indicators">{indicators}</div>
</div>
)
}
const ServiceIndicator = (props) => {
if (!props.service) {
return null
@ -96,14 +63,12 @@ const Player = (props) => {
}
}
const { title, artist, service, cover_analysis, cover } =
playerState.track_manifest ?? {}
const { title, artist, service, cover } = playerState.track_manifest ?? {}
const playing = playerState.playback_status === "playing"
const stopped = playerState.playback_status === "stopped"
const titleText = !playing && stopped ? "Stopped" : (title ?? "Untitled")
const subtitleText = ""
React.useEffect(() => {
const titleIsOverflown = isOverflown(
@ -115,13 +80,11 @@ const Player = (props) => {
}, [title])
React.useEffect(() => {
const trackInstance = app.cores.player.track()
const track = app.cores.player.track()
if (playerState.track_manifest && trackInstance) {
if (
typeof trackInstance.manifest.analyzeCoverColor === "function"
) {
trackInstance.manifest
if (playerState.track_manifest && track) {
if (typeof track.analyzeCoverColor === "function") {
track
.analyzeCoverColor()
.then((analysis) => {
setCoverAnalysis(analysis)
@ -203,9 +166,11 @@ const Player = (props) => {
</Marquee>
)}
{!playerState.radioId && (
<p className="toolbar_player_info_subtitle">
{artist ?? ""}
</p>
)}
</div>
{playerState.radioId && (

View File

@ -159,6 +159,13 @@
}
.toolbar_player_info_subtitle {
display: inline-block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 0.8rem;
font-weight: 400;
@ -246,6 +253,8 @@
border-radius: 12px;
gap: 10px;
background-color: rgba(var(--layoutBackgroundColor), 0.7);
-webkit-backdrop-filter: blur(5px);
backdrop-filter: blur(5px);

View File

@ -1,10 +1,17 @@
import { MediaPlayer, Debug } from "dashjs"
import shaka from "shaka-player/dist/shaka-player.compiled.js"
import PlayerProcessors from "./PlayerProcessors"
import AudioPlayerStorage from "../player.storage"
import TrackManifest from "../classes/TrackManifest"
import findInitializationChunk from "../helpers/findInitializationChunk"
import parseSourceFormatMetadata from "../helpers/parseSourceFormatMetadata"
import handleInlineDashManifest from "../helpers/handleInlineDashManifest"
export default class AudioBase {
constructor(player) {
this.player = player
this.console = player.console
}
audio = new Audio()
@ -16,6 +23,7 @@ export default class AudioBase {
processors = {}
waitUpdateTimeout = null
_firstSegmentReceived = false
initialize = async () => {
// create a audio context
@ -26,73 +34,289 @@ export default class AudioBase {
latencyHint: "playback",
})
// configure some settings for audio
// configure some settings for audio with optimized settings
this.audio.crossOrigin = "anonymous"
this.audio.preload = "metadata"
this.audio.preload = "auto"
this.audio.loop = this.player.state.playback_mode === "repeat"
this.audio.volume = 1
// listen all events
for (const [key, value] of Object.entries(this.audioEvents)) {
this.audio.addEventListener(key, value)
}
// setup demuxer for mpd
// setup shaka player for mpd
this.createDemuxer()
// create element source
// create element source with low latency buffer
this.elementSource = this.context.createMediaElementSource(this.audio)
// initialize audio processors
await this.processorsManager.initialize()
await this.processorsManager.initialize(),
await this.processorsManager.attachAllNodes()
}
createDemuxer() {
this.demuxer = MediaPlayer().create()
itemInit = async (manifest) => {
if (!manifest) {
return null
}
this.demuxer.updateSettings({
if (
typeof manifest === "string" ||
(!manifest.source && !manifest.dash_manifest)
) {
this.console.time("resolve")
manifest = await this.player.serviceProviders.resolve(manifest)
this.console.timeEnd("resolve")
}
if (!(manifest instanceof TrackManifest)) {
this.console.time("init manifest")
manifest = new TrackManifest(manifest, this.player)
this.console.timeEnd("init manifest")
}
if (manifest.mpd_mode === true && !manifest.dash_manifest) {
this.console.time("fetch dash manifest")
manifest.dash_manifest = await fetch(manifest.source).then((r) =>
r.text(),
)
this.console.timeEnd("fetch dash manifest")
}
return manifest
}
play = async (manifest, params = {}) => {
// Pre-initialize audio context if needed
if (this.context.state === "suspended") {
await this.context.resume()
}
manifest = await this.itemInit(manifest)
this.console.time("load source")
await this.loadSource(manifest)
this.console.timeEnd("load source")
this.player.queue.currentItem = manifest
this.player.state.track_manifest = manifest.toSeriableObject()
this.player.nativeControls.update(manifest.toSeriableObject())
// reset audio properties
this.audio.currentTime = params.time ?? 0
this.audio.volume = 1
if (this.processors && this.processors.gain) {
this.processors.gain.set(this.player.state.volume)
}
if (this.audio.paused) {
try {
this.console.time("play")
await this.audio.play()
this.console.timeEnd("play")
} catch (error) {
this.console.error(
"Error during audio.play():",
error,
"State:",
this.audio.readyState,
)
}
}
let initChunk = manifest.source
if (this.demuxer && manifest.dash_manifest) {
initChunk = findInitializationChunk(
manifest.source,
manifest.dash_manifest,
)
}
try {
this.player.state.format_metadata =
await parseSourceFormatMetadata(initChunk)
} catch (e) {
this.player.state.format_metadata = null
console.warn("Could not parse audio metadata from source:", e)
}
}
pause = async () => {
this.audio.pause()
}
resume = async () => {
this.audio.play()
}
async loadSource(manifest) {
if (!manifest || !(manifest instanceof TrackManifest)) {
return null
}
// reset some state
this._firstSegmentReceived = false
this.player.state.format_metadata = null
const isMpd = manifest.mpd_mode
if (isMpd) {
const audioSrcAtt = this.audio.getAttribute("src")
if (audioSrcAtt && !audioSrcAtt.startsWith("blob:")) {
this.audio.removeAttribute("src")
this.audio.load()
}
if (!this.demuxer) {
this.console.log("Creating demuxer cause not initialized")
this.createDemuxer()
}
if (manifest._preloaded) {
this.console.log(
`using preloaded source >`,
manifest._preloaded,
)
return await this.demuxer.load(manifest._preloaded)
}
const inlineManifest =
"inline://" + manifest.source + "::" + manifest.dash_manifest
return await this.demuxer
.load(inlineManifest, 0, "application/dash+xml")
.catch((err) => {
this.console.error("Error loading inline manifest", err)
})
}
// if not using demuxer, destroy previous instance
if (this.demuxer) {
await this.demuxer.unload()
await this.demuxer.destroy()
this.demuxer = null
}
// load source
this.audio.src = manifest.source
return this.audio.load()
}
async createDemuxer() {
// Destroy previous instance if exists
if (this.demuxer) {
await this.demuxer.unload()
await this.demuxer.detach()
await this.demuxer.destroy()
}
this.demuxer = new shaka.Player()
this.demuxer.attach(this.audio)
this.demuxer.configure({
manifest: {
//updatePeriod: 5,
disableVideo: true,
disableText: true,
dash: {
ignoreMinBufferTime: true,
ignoreMaxSegmentDuration: true,
autoCorrectDrift: false,
enableFastSwitching: true,
useStreamOnceInPeriodFlattening: false,
},
},
streaming: {
buffer: {
resetSourceBuffersForTrackSwitch: true,
bufferingGoal: 15,
rebufferingGoal: 1,
bufferBehind: 30,
stallThreshold: 0.5,
},
},
// debug: {
// logLevel: Debug.LOG_LEVEL_DEBUG,
// },
})
this.demuxer.initialize(this.audio, null, false)
shaka.net.NetworkingEngine.registerScheme(
"inline",
handleInlineDashManifest,
)
this.demuxer.addEventListener("error", (event) => {
console.error("Demuxer error", event)
})
}
timeTick = async () => {
if (
!this.audio ||
!this.audio.duration ||
this.audio.duration === Infinity
) {
return false
}
const remainingTime = this.audio.duration - this.audio.currentTime
// if remaining time is less than 3s, try to init next item
if (parseInt(remainingTime) <= 10) {
// check if queue has next item
if (this.player.queue.nextItems[0]) {
this.player.queue.nextItems[0] = await this.itemInit(
this.player.queue.nextItems[0],
)
if (
this.demuxer &&
this.player.queue.nextItems[0].source &&
this.player.queue.nextItems[0].mpd_mode &&
!this.player.queue.nextItems[0]._preloaded
) {
const manifest = this.player.queue.nextItems[0]
// preload next item
this.console.time("preload next item")
this.player.queue.nextItems[0]._preloaded =
await this.demuxer.preload(
"inline://" +
manifest.source +
"::" +
manifest.dash_manifest,
0,
"application/dash+xml",
)
this.console.timeEnd("preload next item")
}
}
}
}
flush() {
this.audio.pause()
this.audio.src = null
this.audio.currentTime = 0
if (this.demuxer) {
this.demuxer.destroy()
}
this.createDemuxer()
}
audioEvents = {
ended: () => {
try {
this.player.next()
},
loadeddata: () => {
this.player.state.loading = false
},
loadedmetadata: () => {
if (this.audio.duration === Infinity) {
this.player.state.live = true
} else {
this.player.state.live = false
} catch (e) {
console.error(e)
}
},
play: () => {
this.player.state.playback_status = "playing"
},
pause: () => {
this.player.state.playback_status = "paused"
if (typeof this._timeTickInterval !== "undefined") {
clearInterval(this._timeTickInterval)
}
},
playing: () => {
this.player.state.loading = false
@ -102,15 +326,24 @@ export default class AudioBase {
clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null
}
if (typeof this._timeTickInterval !== "undefined") {
clearInterval(this._timeTickInterval)
}
this.timeTick()
this._timeTickInterval = setInterval(this.timeTick, 1000)
},
pause: () => {
this.player.state.playback_status = "paused"
loadeddata: () => {
this.player.state.loading = false
},
durationchange: () => {
this.player.eventBus.emit(
`player.durationchange`,
this.audio.duration,
)
loadedmetadata: () => {
if (this.audio.duration === Infinity) {
this.player.state.live = true
} else {
this.player.state.live = false
}
},
waiting: () => {
if (this.waitUpdateTimeout) {

View File

@ -4,12 +4,15 @@ import AudioPlayerStorage from "../player.storage"
export default class PlayerState {
static defaultState = {
loading: false,
playback_status: "stopped",
playback_mode: AudioPlayerStorage.get("mode") ?? "normal",
track_manifest: null,
demuxer_metadata: null,
muted: app.isMobile ? false : (AudioPlayerStorage.get("mute") ?? false),
volume: app.isMobile ? 1 : (AudioPlayerStorage.get("volume") ?? 0.3),
playback_mode: AudioPlayerStorage.get("mode") ?? "normal",
}
constructor(player) {
@ -23,12 +26,21 @@ export default class PlayerState {
if (change.type === "update") {
const stateKey = change.path[0]
this.player.eventBus.emit(`player.state.update:${stateKey}`, change.object[stateKey])
this.player.eventBus.emit("player.state.update", change.object)
this.player.eventBus.emit(
`player.state.update:${stateKey}`,
change.object[stateKey],
)
this.player.eventBus.emit(
"player.state.update",
change.object,
)
}
})
} catch (error) {
this.player.console.error(`Failed to dispatch state updater >`, error)
this.player.console.error(
`Failed to dispatch state updater >`,
error,
)
}
})

View File

@ -3,11 +3,13 @@ import ComtyMusicServiceInterface from "../providers/comtymusic"
export default class ServiceProviders {
providers = [
// add by default here
new ComtyMusicServiceInterface()
new ComtyMusicServiceInterface(),
]
findProvider(providerId) {
return this.providers.find((provider) => provider.constructor.id === providerId)
return this.providers.find(
(provider) => provider.constructor.id === providerId,
)
}
register(provider) {
@ -15,32 +17,42 @@ export default class ServiceProviders {
}
has(providerId) {
return this.providers.some((provider) => provider.constructor.id === providerId)
return this.providers.some(
(provider) => provider.constructor.id === providerId,
)
}
operation = async (operationName, providerId, manifest, args) => {
const provider = await this.findProvider(providerId)
if (!provider) {
console.error(`Failed to resolve manifest, provider [${providerId}] not registered`)
console.error(
`Failed to resolve manifest, provider [${providerId}] not registered`,
)
return manifest
}
const operationFn = provider[operationName]
if (typeof operationFn !== "function") {
console.error(`Failed to resolve manifest, provider [${providerId}] operation [${operationName}] not found`)
console.error(
`Failed to resolve manifest, provider [${providerId}] operation [${operationName}] not found`,
)
return manifest
}
return await operationFn(manifest, args)
}
resolve = async (providerId, manifest) => {
const provider = await this.findProvider(providerId)
resolve = async (manifest) => {
let providerId = manifest.service ?? "default"
const provider = this.findProvider(providerId)
if (!provider) {
console.error(`Failed to resolve manifest, provider [${providerId}] not registered`)
console.error(
`Failed to resolve manifest, provider [${providerId}] not registered`,
)
return manifest
}
@ -49,7 +61,7 @@ export default class ServiceProviders {
resolveMany = async (manifests) => {
manifests = manifests.map(async (manifest) => {
return await this.resolve(manifest.service ?? "default", manifest)
return await this.resolve(manifest)
})
manifests = await Promise.all(manifests)

View File

@ -0,0 +1,202 @@
import { RTEngineClient } from "linebridge-client"
import SessionModel from "@models/session"
export default class SyncRoom {
constructor(player) {
this.player = player
}
static pushInterval = 1000
static maxTimeOffset = parseFloat(0.15)
state = {
joined_room: null,
last_track_id: null,
}
pushInterval = null
socket = null
start = async () => {
if (!this.socket) {
await this.createSocket()
}
await this.pushState()
setInterval(this.pushState, SyncRoom.pushInterval)
this.player.eventBus.on("player.state.update", this.pushState)
this.socket.on(
`sync_room:${app.userData._id}:request_lyrics`,
async () => {
let lyrics = null
if (this.player.queue.currentItem) {
lyrics =
await this.player.queue.currentItem.manifest.serviceOperations.fetchLyrics(
{
preferTranslation: false,
},
)
}
this.socket.emit(
`sync_room:${app.userData._id}:request_lyrics`,
lyrics,
)
},
)
}
stop = async () => {
if (this.pushInterval) {
clearInterval(this.pushInterval)
}
if (this.socket) {
await this.socket.destroy()
}
}
pushState = async () => {
if (!this.socket) {
return null
}
let track_manifest = null
const currentItem = this.player.queue.currentItem
if (currentItem) {
track_manifest = {
...currentItem.toSeriableObject(),
}
}
// check if has changed the track
if (
this.state.last_track_id &&
this.state.last_track_id !== track_manifest?._id
) {
// try to get lyrics
const lyrics = await currentItem.serviceOperations
.fetchLyrics()
.catch(() => null)
this.socket.emit(`sync_room:push_lyrics`, lyrics)
}
this.state.last_track_id = track_manifest?._id
await this.socket.emit(`sync_room:push`, {
...this.player.state,
track_manifest: track_manifest,
duration: this.player.duration(),
currentTime: this.player.seek(),
})
}
syncState = async (data) => {
console.log(data)
if (!data || !data.track_manifest) {
return false
}
// first check if manifest id is different
if (
!this.player.state.track_manifest ||
data.track_manifest._id !== this.player.state.track_manifest._id
) {
if (data.track_manifest && data.track_manifest.encoded_manifest) {
let mpd = new Blob(
[window.atob(data.track_manifest.encoded_manifest)],
{
type: "application/dash+xml",
},
)
data.track_manifest.dash_manifest = URL.createObjectURL(mpd)
}
// start the player
this.player.start(data.track_manifest)
}
// check if currentTime is more than maxTimeOffset
const serverTime = data.currentTime ?? 0
const currentTime = this.player.seek()
const offset = serverTime - currentTime
console.log({
serverTime: serverTime,
currentTime: currentTime,
maxTimeOffset: SyncRoom.maxTimeOffset,
offset: offset,
})
if (
typeof serverTime === "number" &&
typeof currentTime === "number" &&
Math.abs(offset) > SyncRoom.maxTimeOffset
) {
// seek to currentTime
this.player.seek(serverTime)
}
// check if playback is paused
if (
!app.cores.player.base().audio.paused &&
data.playback_status === "paused"
) {
this.player.pausePlayback()
}
if (
app.cores.player.base().audio.paused &&
data.playback_status === "playing"
) {
this.player.resumePlayback()
}
}
join = async (user_id) => {
if (!this.socket) {
await this.createSocket()
}
this.socket.emit(`sync_room:join`, user_id)
this.socket.on(`sync:receive`, this.syncState)
this.state.joined_room = {
user_id: user_id,
members: [],
}
}
leave = async () => {
await this.socket.emit(`sync_room:leave`, this.state.joined_room)
this.state.joined_room = null
if (this.socket) {
await this.socket.disconnect()
}
}
createSocket = async () => {
if (this.socket) {
await this.socket.disconnect()
}
this.socket = new RTEngineClient({
refName: "sync-room",
url: app.cores.api.client().mainOrigin + "/music",
token: SessionModel.token,
})
await this.socket.connect()
}
}

View File

@ -1,100 +0,0 @@
import TrackManifest from "./TrackManifest"
export default class TrackInstance {
constructor(manifest, player) {
if (typeof manifest === "undefined") {
throw new Error("Manifest is required")
}
if (!player) {
throw new Error("Player core is required")
}
if (!(manifest instanceof TrackManifest)) {
manifest = new TrackManifest(manifest, player)
}
if (!manifest.source) {
throw new Error("Manifest must have a source")
}
this.player = player
this.manifest = manifest
this.id = this.manifest.id ?? this.manifest._id
}
play = async (params = {}) => {
const startTime = performance.now()
const isMpd = this.manifest.source.endsWith(".mpd")
const audioEl = this.player.base.audio
if (!isMpd) {
// if a demuxer exists (from a previous MPD track), destroy it
if (this.player.base.demuxer) {
this.player.base.demuxer.destroy()
this.player.base.demuxer = null
}
// set the audio source directly
if (audioEl.src !== this.manifest.source) {
audioEl.src = this.manifest.source
audioEl.load() // important to apply the new src and stop previous playback
}
} else {
// ensure the direct 'src' attribute is removed if it was set
const currentSrc = audioEl.getAttribute("src")
if (currentSrc && !currentSrc.startsWith("blob:")) {
// blob: indicates MSE is likely already in use
audioEl.removeAttribute("src")
audioEl.load() // tell the element to update its state after src removal
}
// ensure a demuxer instance exists
if (!this.player.base.demuxer) {
this.player.base.createDemuxer()
}
// attach the mpd source to the demuxer
await this.player.base.demuxer.attachSource(this.manifest.source)
}
// reset audio properties
audioEl.currentTime = params.time ?? 0
audioEl.volume = 1
if (this.player.base.processors && this.player.base.processors.gain) {
this.player.base.processors.gain.set(this.player.state.volume)
}
if (audioEl.paused) {
try {
await audioEl.play()
} catch (error) {
console.error("[INSTANCE] Error during audio.play():", error)
}
} else {
console.log(
"[INSTANCE] Audio is already playing or will start shortly.",
)
}
this._loadMs = performance.now() - startTime
console.log(`[INSTANCE] [tooks ${this._loadMs}ms] Playing >`, this)
}
pause = async () => {
console.log("[INSTANCE] Pausing >", this)
this.player.base.audio.pause()
}
resume = async () => {
console.log("[INSTANCE] Resuming >", this)
this.player.base.audio.play()
}
}

View File

@ -27,16 +27,32 @@ export default class TrackManifest {
if (typeof params.album !== "undefined") {
this.album = params.album
if (typeof this.album === "object") {
this.album = this.album.title
}
}
if (typeof params.artist !== "undefined") {
this.artist = params.artist
if (typeof this.artist === "object") {
this.artist = this.artist.name
}
}
if (typeof params.source !== "undefined") {
this.source = params.source
}
if (typeof params.dash_manifest !== "undefined") {
this.dash_manifest = params.dash_manifest
}
if (typeof params.encoded_manifest !== "undefined") {
this.encoded_manifest = params.encoded_manifest
}
if (typeof params.metadata !== "undefined") {
this.metadata = params.metadata
}
@ -45,6 +61,15 @@ export default class TrackManifest {
this.liked = params.liked
}
if (typeof params.public !== "undefined") {
this.public = params.public
}
if (this.source) {
this.mpd_mode =
this.source.startsWith("blob:") || this.source.endsWith(".mpd")
}
return this
}
@ -60,9 +85,10 @@ export default class TrackManifest {
// set default service to default
service = "default"
mpd_mode = false
async initialize() {
if (!this.params.file) {
if (!this.params.file || !(this.params.file instanceof File)) {
return this
}
@ -93,7 +119,12 @@ export default class TrackManifest {
analyzeCoverColor = async () => {
const fac = new FastAverageColor()
return await fac.getColorAsync(this.cover)
const img = new Image()
img.src = this.cover + "?t=a"
img.crossOrigin = "anonymous"
return await fac.getColorAsync(img)
}
serviceOperations = {
@ -164,8 +195,11 @@ export default class TrackManifest {
album: this.album,
artist: this.artist,
source: this.source,
dash_manifest: this.dash_manifest,
encoded_manifest: this.encoded_manifest,
metadata: this.metadata,
liked: this.liked,
service: this.service,
}
}
}

View File

@ -0,0 +1,81 @@
export default (baseUri, mpdText, periodId = null, repId = null) => {
// parse xml
const parser = new DOMParser()
const xml = parser.parseFromString(mpdText, "application/xml")
// check parse errors
const err = xml.querySelector("parsererror")
if (err) {
console.error("Failed to parse MPD:", err.textContent)
return null
}
// select period (by ID or first)
let period = null
if (periodId) {
period = xml.querySelector(`Period[id="${periodId}"]`)
}
// if not found, select first
if (!period) {
period = xml.querySelector("Period")
}
// ultimately, return err
if (!period) {
console.error("Cannot find a <Period> on provided MPD")
return null
}
// select representation (by ID or first)
let rep = null
if (repId) {
rep = xml.querySelector(`Representation[id="${repId}"]`)
}
if (!rep) {
rep = period.querySelector("AdaptationSet Representation")
}
if (!rep) {
console.error("Cannot find a <Representation> on Period")
return null
}
// read the associated SegmentTemplate (it may be in AdaptationSet or in Representation)
let tmpl = rep.querySelector("SegmentTemplate")
if (!tmpl) {
// fallback: look in the parent AdaptationSet
const adaptation = rep.closest("AdaptationSet")
tmpl = adaptation && adaptation.querySelector("SegmentTemplate")
}
if (!tmpl) {
console.error(
"Could not find <SegmentTemplate> in either Representation or AdaptationSet.",
)
return null
}
// extract the initialization attribute
const initAttr = tmpl.getAttribute("initialization")
if (!initAttr) {
console.warn(
"The <SegmentTemplate> does not declare initialization; it may be self-initializing.",
)
return null
}
// replace $RepresentationID$ if necessary
const initPath = initAttr.replace(
/\$RepresentationID\$/g,
rep.getAttribute("id"),
)
return new URL(initPath, baseUri).toString()
}

View File

@ -0,0 +1,15 @@
export default (uri) => {
const manifest = uri.split("inline://")[1]
const [baseUri, manifestString] = manifest.split("::")
const response = {
data: new Uint8Array(new TextEncoder().encode(manifestString)).buffer,
headers: {},
uri: baseUri,
originalUri: baseUri,
timeMs: performance.now(),
fromCache: true,
}
return Promise.resolve(response)
}

View File

@ -0,0 +1,12 @@
import { parseWebStream } from "music-metadata"
export default async (source) => {
const stream = await fetch(source, {
method: "GET",
headers: {
//Range: "bytes=0-1024",
},
}).then((response) => response.body)
return (await parseWebStream(stream)).format
}

View File

@ -2,12 +2,13 @@ import { Core } from "@ragestudio/vessel"
import ActivityEvent from "@classes/ActivityEvent"
import QueueManager from "@classes/QueueManager"
import TrackInstance from "./classes/TrackInstance"
import TrackManifest from "./classes/TrackManifest"
import MediaSession from "./classes/MediaSession"
import ServiceProviders from "./classes/Services"
import PlayerState from "./classes/PlayerState"
import PlayerUI from "./classes/PlayerUI"
import AudioBase from "./classes/AudioBase"
import SyncRoom from "./classes/SyncRoom"
import setSampleRate from "./helpers/setSampleRate"
@ -23,16 +24,14 @@ export default class Player extends Core {
// player config
static defaultSampleRate = 48000
base = new AudioBase(this)
state = new PlayerState(this)
ui = new PlayerUI(this)
serviceProviders = new ServiceProviders()
nativeControls = new MediaSession(this)
syncRoom = new SyncRoom(this)
base = new AudioBase(this)
queue = new QueueManager({
loadFunction: this.createInstance,
})
queue = new QueueManager()
public = {
start: this.start,
@ -68,10 +67,16 @@ export default class Player extends Core {
base: () => {
return this.base
},
sync: () => this.syncRoom,
inOnSyncMode: this.inOnSyncMode,
state: this.state,
ui: this.ui.public,
}
inOnSyncMode() {
return !!this.syncRoom.state.joined_room
}
async afterInitialize() {
if (app.isMobile) {
this.state.volume = 1
@ -81,53 +86,20 @@ export default class Player extends Core {
await this.base.initialize()
}
//
// Instance managing methods
//
async abortPreloads() {
for await (const instance of this.queue.nextItems) {
if (instance.abortController?.abort) {
instance.abortController.abort()
}
}
}
//
// Playback methods
//
async play(instance, params = {}) {
if (!instance) {
throw new Error("Audio instance is required")
}
// resume audio context if needed
if (this.base.context.state === "suspended") {
this.base.context.resume()
}
// update manifest
this.state.track_manifest =
this.queue.currentItem.manifest.toSeriableObject()
// play
//await this.queue.currentItem.audio.play()
await this.queue.currentItem.play(params)
// update native controls
this.nativeControls.update(this.queue.currentItem.manifest)
return this.queue.currentItem
}
// TODO: Improve performance for large playlists
async start(manifest, { time, startIndex = 0, radioId } = {}) {
this.console.debug("start():", {
manifest: manifest,
time: time,
startIndex: startIndex,
radioId: radioId,
})
this.ui.attachPlayerComponent()
if (this.queue.currentItem) {
await this.queue.currentItem.pause()
await this.base.pause()
}
//await this.abortPreloads()
await this.queue.flush()
this.state.loading = true
@ -147,32 +119,31 @@ export default class Player extends Core {
return false
}
if (playlist.some((item) => typeof item === "string")) {
playlist = await this.serviceProviders.resolveMany(playlist)
// resolve only the first item if needed
if (
typeof playlist[0] === "string" ||
(!playlist[0].source && !playlist[0].dash_manifest)
) {
playlist[0] = await this.serviceProviders.resolve(playlist[0])
}
if (playlist.some((item) => !item.source)) {
playlist = await this.serviceProviders.resolveMany(playlist)
}
// create instance for the first element
playlist[0] = new TrackManifest(playlist[0], this)
for await (let [index, _manifest] of playlist.entries()) {
let instance = new TrackInstance(_manifest, this)
this.queue.add(playlist)
this.queue.add(instance)
}
const item = this.queue.setCurrent(startIndex)
const item = this.queue.set(startIndex)
this.play(item, {
this.base.play(item, {
time: time ?? 0,
})
// send the event to the server
if (item.manifest._id && item.manifest.service === "default") {
if (item._id && item.service === "default") {
new ActivityEvent("player.play", {
identifier: "unique", // this must be unique to prevent duplicate events and ensure only have unique track events
track_id: item.manifest._id,
service: item.manifest.service,
track_id: item._id,
service: item.service,
})
}
@ -182,13 +153,15 @@ export default class Player extends Core {
// similar to player.start, but add to the queue
// if next is true, it will add to the queue to the top of the queue
async addToQueue(manifest, { next = false } = {}) {
if (typeof manifest === "string") {
manifest = await this.serviceProviders.resolve(manifest)
if (this.inOnSyncMode()) {
return false
}
let instance = new TrackInstance(manifest, this)
if (this.state.playback_status === "stopped") {
return this.start(manifest)
}
this.queue.add(instance, next === true ? "start" : "end")
this.queue.add(manifest, next === true ? "start" : "end")
console.log("Added to queue", {
manifest,
@ -197,6 +170,10 @@ export default class Player extends Core {
}
next() {
if (this.inOnSyncMode()) {
return false
}
//const isRandom = this.state.playback_mode === "shuffle"
const item = this.queue.next()
@ -204,19 +181,27 @@ export default class Player extends Core {
return this.stopPlayback()
}
return this.play(item)
return this.base.play(item)
}
previous() {
if (this.inOnSyncMode()) {
return false
}
const item = this.queue.previous()
return this.play(item)
return this.base.play(item)
}
//
// Playback Control
//
async togglePlayback() {
if (this.inOnSyncMode()) {
return false
}
if (this.state.playback_status === "paused") {
await this.resumePlayback()
} else {
@ -238,7 +223,7 @@ export default class Player extends Core {
this.base.processors.gain.fade(0)
setTimeout(() => {
this.queue.currentItem.pause()
this.base.pause()
resolve()
}, Player.gradualFadeMs)
@ -258,7 +243,7 @@ export default class Player extends Core {
}
// ensure audio elemeto starts from 0 volume
this.queue.currentItem.resume().then(() => {
this.base.resume().then(() => {
resolve()
})
this.base.processors.gain.fade(this.state.volume)
@ -282,14 +267,13 @@ export default class Player extends Core {
}
stopPlayback() {
this.base.flush()
this.queue.flush()
this.state.playback_status = "stopped"
this.state.track_manifest = null
this.queue.currentItem = null
//this.abortPreloads()
this.base.flush()
this.queue.flush()
this.nativeControls.flush()
}

View File

@ -1,2 +0,0 @@
export { default as useHacks } from "./useHacks"
export { default as useCenteredContainer } from "./useCenteredContainer"

View File

@ -0,0 +1,43 @@
import { useState, useEffect } from "react"
const getDominantColorStr = (analysis) => {
if (!analysis) return "0,0,0"
return analysis.value?.join(", ") || "0,0,0"
}
export default (trackManifest) => {
const [coverAnalysis, setCoverAnalysis] = useState(null)
useEffect(() => {
const getCoverAnalysis = async () => {
const track = app.cores.player.track()
if (!track?.analyzeCoverColor) {
return null
}
try {
const analysis = await track.analyzeCoverColor()
setCoverAnalysis(analysis)
} catch (error) {
console.error("Failed to get cover analysis:", error)
setCoverAnalysis(null)
}
}
if (trackManifest) {
getCoverAnalysis()
} else {
setCoverAnalysis(null)
}
}, [trackManifest])
const dominantColor = {
"--dominant-color": getDominantColorStr(coverAnalysis),
}
return {
coverAnalysis,
dominantColor,
}
}

View File

@ -0,0 +1,47 @@
import { useCallback, useEffect, useState } from "react"
const toggleFullScreen = (to) => {
const targetState = to ?? !document.fullscreenElement
try {
if (targetState) {
document.documentElement.requestFullscreen()
} else if (document.fullscreenElement) {
document.exitFullscreen()
}
} catch (error) {
console.error("Fullscreen toggle failed:", error)
}
}
export default ({ onEnter, onExit } = {}) => {
const [isFullScreen, setIsFullScreen] = useState(false)
const handleFullScreenChange = useCallback(() => {
const fullScreenState = !!document.fullscreenElement
setIsFullScreen(fullScreenState)
if (fullScreenState) {
onEnter?.()
} else {
onExit?.()
}
}, [onEnter, onExit])
useEffect(() => {
document.addEventListener("fullscreenchange", handleFullScreenChange)
return () => {
document.removeEventListener(
"fullscreenchange",
handleFullScreenChange,
)
}
}, [handleFullScreenChange])
return {
isFullScreen,
toggleFullScreen,
handleFullScreenChange,
}
}

View File

@ -0,0 +1,69 @@
import { useState, useCallback, useEffect } from "react"
import parseTimeToMs from "@utils/parseTimeToMs"
export default ({ trackManifest }) => {
const [lyrics, setLyrics] = useState(null)
const processLyrics = useCallback((rawLyrics) => {
if (!rawLyrics) return false
return rawLyrics.sync_audio_at && !rawLyrics.sync_audio_at_ms
? {
...rawLyrics,
sync_audio_at_ms: parseTimeToMs(rawLyrics.sync_audio_at),
}
: rawLyrics
}, [])
const loadCurrentTrackLyrics = useCallback(async () => {
let data = null
const track = app.cores.player.track()
if (!trackManifest || !track) {
return null
}
// if is in sync mode, fetch lyrics from sync room
if (app.cores.player.inOnSyncMode()) {
const syncRoomSocket = app.cores.player.sync().socket
if (syncRoomSocket) {
data = await syncRoomSocket
.call("sync_room:request_lyrics")
.catch(() => null)
}
} else {
data = await track.serviceOperations.fetchLyrics().catch(() => null)
}
// if no data founded, flush lyrics
if (!data) {
return setLyrics(null)
}
// process & set lyrics
data = processLyrics(data)
setLyrics(data)
console.log("Track Lyrics:", data)
}, [trackManifest, processLyrics])
// Load lyrics when track manifest changes or when translation is toggled
useEffect(() => {
if (!trackManifest) {
setLyrics(null)
return
}
if (!lyrics || lyrics.track_id !== trackManifest._id) {
loadCurrentTrackLyrics()
}
}, [trackManifest, lyrics?.track_id, loadCurrentTrackLyrics])
return {
lyrics,
setLyrics,
loadCurrentTrackLyrics,
}
}

View File

@ -0,0 +1,60 @@
import { useState, useRef, useCallback, useEffect } from "react"
export default () => {
const [syncRoom, setSyncRoom] = useState(null)
const syncSocket = useRef(null)
const subscribeLyricsUpdates = useCallback(
(callback) => {
if (!syncSocket.current) {
return null
}
syncSocket.current.on("sync:lyrics:receive", callback)
return () => syncSocket.current.off("sync:lyrics:receive", callback)
},
[syncSocket.current],
)
const unsubscribeLyricsUpdates = useCallback(
(callback) => {
if (!syncSocket.current) {
return null
}
syncSocket.current.off("sync:lyrics:receive", callback)
},
[syncSocket.current],
)
useEffect(() => {
const roomId = new URLSearchParams(window.location.search).get("sync")
if (roomId) {
app.cores.player
.sync()
.join(roomId)
.then(() => {
setSyncRoom(roomId)
syncSocket.current = app.cores.player.sync().socket
})
}
return () => {
if (syncSocket.current) {
app.cores.player.sync().leave()
setSyncRoom(null)
syncSocket.current = null
}
}
}, [])
return {
syncRoom,
subscribeLyricsUpdates,
unsubscribeLyricsUpdates,
isInSyncMode: app.cores.player.inOnSyncMode(),
}
}

View File

@ -0,0 +1,19 @@
import { useState, useEffect } from "react"
export default (playerTrackManifest) => {
const [trackManifest, setTrackManifest] = useState(null)
useEffect(() => {
if (
JSON.stringify(playerTrackManifest) !==
JSON.stringify(trackManifest)
) {
setTrackManifest(playerTrackManifest)
}
}, [playerTrackManifest, trackManifest])
return {
trackManifest,
setTrackManifest,
}
}

View File

@ -71,8 +71,8 @@ const PlayerButton = (props) => {
openPlayerView()
}
if (track.manifest?.analyzeCoverColor) {
track.manifest
if (track?.analyzeCoverColor) {
track
.analyzeCoverColor()
.then((analysis) => {
setCoverAnalyzed(analysis)

View File

@ -0,0 +1,22 @@
import React from 'react';
const Background = ({ trackManifest, hasVideoSource }) => {
if (!trackManifest || hasVideoSource) {
return null;
}
return (
<div className="lyrics-background-wrapper">
<div className="lyrics-background-cover">
<img
src={trackManifest.cover}
alt="Album cover"
loading="eager"
draggable={false}
/>
</div>
</div>
);
};
export default React.memo(Background);

View File

@ -3,13 +3,13 @@ import { Tag, Button } from "antd"
import classnames from "classnames"
import Marquee from "react-fast-marquee"
import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
import { Icons } from "@components/Icons"
import Controls from "@components/Player/Controls"
import Indicators from "@components/Player/Indicators"
import SeekBar from "@components/Player/SeekBar"
import LiveInfo from "@components/Player/LiveInfo"
import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
function isOverflown(element) {
@ -23,7 +23,7 @@ function isOverflown(element) {
)
}
const PlayerController = React.forwardRef((props, ref) => {
const PlayerController = (props, ref) => {
const [playerState] = usePlayerStateContext()
const titleRef = React.useRef()
@ -34,51 +34,13 @@ const PlayerController = React.forwardRef((props, ref) => {
})
const [titleIsOverflown, setTitleIsOverflown] = React.useState(false)
const [currentTime, setCurrentTime] = React.useState(0)
const [trackDuration, setTrackDuration] = React.useState(0)
const [draggingTime, setDraggingTime] = React.useState(false)
const [currentDragWidth, setCurrentDragWidth] = React.useState(0)
const [syncInterval, setSyncInterval] = React.useState(null)
async function onDragEnd(seekTime) {
setDraggingTime(false)
app.cores.player.controls.seek(seekTime)
syncPlayback()
}
async function syncPlayback() {
if (!playerState.track_manifest) {
return false
}
const currentTrackTime = app.cores.player.controls.seek()
setCurrentTime(currentTrackTime)
}
//* Handle when playback status change
React.useEffect(() => {
if (playerState.playback_status === "playing") {
setSyncInterval(setInterval(syncPlayback, 1000))
} else {
if (syncInterval) {
clearInterval(syncInterval)
}
}
}, [playerState.playback_status])
React.useEffect(() => {
setTitleIsOverflown(isOverflown(titleRef.current))
setTrackDuration(app.cores.player.controls.duration())
}, [playerState.track_manifest])
React.useEffect(() => {
syncPlayback()
}, [])
const isStopped = playerState.playback_status === "stopped"
if (playerState.playback_status === "stopped") {
return null
}
return (
<div
@ -101,12 +63,7 @@ const PlayerController = React.forwardRef((props, ref) => {
},
)}
>
{playerState.playback_status === "stopped" ||
(!playerState.track_manifest?.title &&
"Nothing is playing")}
{playerState.playback_status !== "stopped" &&
playerState.track_manifest?.title}
{playerState.track_manifest?.title}
</h4>
}
@ -115,17 +72,11 @@ const PlayerController = React.forwardRef((props, ref) => {
//gradient
//gradientColor={bgColor}
//gradientWidth={20}
play={!isStopped}
play={playerState.playback_status === "playing"}
>
<h4>
{isStopped ? (
"Nothing is playing"
) : (
<>
{playerState.track_manifest
?.title ?? "Untitled"}
</>
)}
{playerState.track_manifest?.title ??
"Untitled"}
</h4>
</Marquee>
)}
@ -146,41 +97,13 @@ const PlayerController = React.forwardRef((props, ref) => {
{!playerState.live && <SeekBar />}
<div className="lyrics-player-controller-tags">
{playerState.track_manifest?.metadata?.lossless && (
<Tag
icon={
<Icons.Lossless
style={{
margin: 0,
}}
<Indicators
track={playerState.track_manifest}
playerState={playerState}
/>
}
bordered={false}
/>
)}
{playerState.track_manifest?.explicit && (
<Tag bordered={false}>Explicit</Tag>
)}
{props.lyrics?.sync_audio_at && (
<Tag bordered={false} icon={<Icons.TbMovie />}>
Video
</Tag>
)}
{props.lyrics?.available_langs?.length > 1 && (
<Button
icon={<Icons.MdTranslate />}
type={
props.translationEnabled ? "primary" : "default"
}
onClick={() => props.toggleTranslationEnabled()}
size="small"
/>
)}
</div>
</div>
</div>
)
})
}
export default PlayerController

View File

@ -4,6 +4,7 @@ import { motion, AnimatePresence } from "motion/react"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
// eslint-disable-next-line
const LyricsText = React.forwardRef((props, textRef) => {
const [playerState] = usePlayerStateContext()
@ -74,6 +75,7 @@ const LyricsText = React.forwardRef((props, textRef) => {
} else {
setVisible(true)
if (textRef.current) {
// find line element by id
const lineElement = textRef.current.querySelector(
`#lyrics-line-${currentLineIndex}`,
@ -90,6 +92,7 @@ const LyricsText = React.forwardRef((props, textRef) => {
textRef.current.scrollTop = 0
}
}
}
}, [currentLineIndex])
//* Handle when playback status change

View File

@ -1,171 +1,61 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react"
import React from "react"
import classnames from "classnames"
import parseTimeToMs from "@utils/parseTimeToMs"
import useFullScreen from "@hooks/useFullScreen"
import useSyncRoom from "@hooks/useSyncRoom"
import useCoverAnalysis from "@hooks/useCoverAnalysis"
import useLyrics from "@hooks/useLyrics"
import useMaxScreen from "@hooks/useMaxScreen"
import useTrackManifest from "@hooks/useTrackManifest"
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import PlayerController from "./components/controller"
import LyricsVideo from "./components/video"
import LyricsText from "./components/text"
import Background from "./components/Background"
import "./index.less"
const getDominantColorStr = (analysis) => {
if (!analysis) return "0,0,0"
return analysis.value?.join(", ") || "0,0,0"
}
const toggleFullScreen = (to) => {
const targetState = to ?? !document.fullscreenElement
try {
if (targetState) {
document.documentElement.requestFullscreen()
} else if (document.fullscreenElement) {
document.exitFullscreen()
}
} catch (error) {
console.error("Fullscreen toggle failed:", error)
}
}
const EnhancedLyricsPage = () => {
useMaxScreen()
const [playerState] = usePlayerStateContext()
const [trackManifest, setTrackManifest] = React.useState(null)
const [lyrics, setLyrics] = React.useState(null)
const [translationEnabled, setTranslationEnabled] = React.useState(false)
const [coverAnalysis, setCoverAnalysis] = React.useState(null)
const videoRef = useRef()
const textRef = useRef()
const isMounted = useRef(true)
const currentTrackId = useRef(null)
const videoRef = React.useRef()
const textRef = React.useRef()
const dominantColor = useMemo(
() => ({ "--dominant-color": getDominantColorStr(coverAnalysis) }),
[coverAnalysis],
)
const handleFullScreenChange = useCallback(() => {
if (!document.fullscreenElement && app?.location?.last) {
app.location.back()
}
}, [])
const loadCurrentTrackLyrics = useCallback(async () => {
if (!playerState.track_manifest) return
const instance = app.cores.player.track()
if (!instance) return
try {
const result =
await instance.manifest.serviceOperations.fetchLyrics({
preferTranslation: translationEnabled,
const { toggleFullScreen } = useFullScreen({
onExit: () => app?.location?.last && app.location.back(),
})
if (!isMounted.current) return
const { trackManifest } = useTrackManifest(playerState.track_manifest)
const processedLyrics =
result.sync_audio_at && !result.sync_audio_at_ms
? {
...result,
sync_audio_at_ms: parseTimeToMs(
result.sync_audio_at,
),
}
: result
const { dominantColor } = useCoverAnalysis(trackManifest)
console.log("Fetched Lyrics >", processedLyrics)
setLyrics(processedLyrics || false)
} catch (error) {
console.error("Failed to fetch lyrics", error)
setLyrics(false)
}
}, [translationEnabled, playerState.track_manifest])
const { syncRoom, subscribeLyricsUpdates, unsubscribeLyricsUpdates } =
useSyncRoom()
// Track manifest comparison
useEffect(() => {
const newManifest = playerState.track_manifest
const { lyrics, setLyrics } = useLyrics({
trackManifest,
})
if (JSON.stringify(newManifest) !== JSON.stringify(trackManifest)) {
setTrackManifest(newManifest)
}
}, [playerState.track_manifest])
// Lyrics loading trigger
useEffect(() => {
if (!trackManifest) {
setLyrics(null)
return
}
if (!lyrics || lyrics.track_id !== trackManifest._id) {
loadCurrentTrackLyrics()
}
}, [trackManifest, lyrics?.track_id])
// Cover analysis
useEffect(() => {
const getCoverAnalysis = async () => {
const trackInstance = app.cores.player.track()
if (!trackInstance?.manifest.analyzeCoverColor) return
try {
const analysis =
await trackInstance.manifest.analyzeCoverColor()
if (isMounted.current) setCoverAnalysis(analysis)
} catch (error) {
console.error("Failed to get cover analysis", error)
}
}
if (playerState.track_manifest) {
getCoverAnalysis()
}
}, [playerState.track_manifest])
// Initialization and cleanup
useEffect(() => {
isMounted.current = true
// Inicialización y limpieza
React.useEffect(() => {
toggleFullScreen(true)
document.addEventListener("fullscreenchange", handleFullScreenChange)
if (syncRoom) {
subscribeLyricsUpdates(setLyrics)
}
return () => {
isMounted.current = false
toggleFullScreen(false)
document.removeEventListener(
"fullscreenchange",
handleFullScreenChange,
)
if (syncRoom) {
unsubscribeLyricsUpdates(setLyrics)
}
}
}, [])
// Translation toggler
const handleTranslationToggle = useCallback(
(to) => setTranslationEnabled((prev) => to ?? !prev),
[],
)
// Memoized background component
const renderBackground = useMemo(() => {
if (!playerState.track_manifest || lyrics?.video_source) return null
return (
<div className="lyrics-background-wrapper">
<div className="lyrics-background-cover">
<img
src={playerState.track_manifest.cover}
alt="Album cover"
/>
</div>
</div>
)
}, [playerState.track_manifest, lyrics?.video_source])
return (
<div
className={classnames("lyrics", {
@ -175,15 +65,21 @@ const EnhancedLyricsPage = () => {
>
<div className="lyrics-background-color" />
{renderBackground}
{playerState.playback_status === "stopped" && (
<div className="lyrics-stopped-decorator">
<img src="./basic_alt.svg" alt="Basic Logo" />
</div>
)}
<Background
trackManifest={trackManifest}
hasVideoSource={!!lyrics?.video_source}
/>
<LyricsVideo ref={videoRef} lyrics={lyrics} />
<LyricsText ref={textRef} lyrics={lyrics} />
<PlayerController
lyrics={lyrics}
translationEnabled={translationEnabled}
toggleTranslationEnabled={handleTranslationToggle}
/>
<PlayerController lyrics={lyrics} />
</div>
)
}

View File

@ -15,10 +15,34 @@
}
}
.lyrics-stopped-decorator {
position: absolute;
z-index: 100;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
padding: 35vh;
opacity: 0.5;
filter: grayscale(0.9);
img {
position: relative;
width: 100%;
height: 100%;
object-fit: contain;
}
}
.lyrics-background-color {
position: absolute;
z-index: 100;
z-index: 105;
width: 100%;
height: 100%;
@ -170,11 +194,6 @@
opacity: 1;
height: 30px;
}
.lyrics-player-controller-tags {
opacity: 1;
height: 10px;
}
}
.lyrics-player-controller-info {
@ -187,6 +206,8 @@
transition: all 150ms ease-in-out;
overflow: hidden;
.lyrics-player-controller-info-title {
font-size: 1.4rem;
font-weight: 600;
@ -219,6 +240,7 @@
.lyrics-player-controller-info-details {
display: flex;
flex-direction: row;
width: 100%;
align-items: center;
@ -229,7 +251,7 @@
font-weight: 400;
// do not wrap text
white-space: nowrap;
word-break: break-word;
h3 {
margin: 0;
@ -243,22 +265,24 @@
transition: all 150ms ease-in-out;
}
.lyrics-player-controller-tags {
.toolbar_player_indicators_wrapper {
position: absolute;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 0px;
padding: 4px;
gap: 10px;
.toolbar_player_indicators {
padding: 4px 6px;
font-size: 0.9rem;
opacity: 0;
transition: all 150ms ease-in-out;
border-radius: 8px;
}
}
}
}