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

View File

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

View File

@ -39,21 +39,21 @@ const EventsHandlers = {
const track = app.cores.player.track() const track = app.cores.player.track()
return await track.manifest.serviceOperations.toggleItemFavorite( return await track.serviceOperations.toggleItemFavorite(
"track", "track",
ctx.track_manifest._id, track._id,
) )
}, },
} }
const Controls = (props) => { const Controls = (props) => {
const [trackInstance, setTrackInstance] = React.useState({}) const [trackManifest, setTrackManifest] = React.useState({})
const onPlayerStateChange = React.useCallback((state) => { const onPlayerStateChange = React.useCallback((state) => {
const instance = app.cores.player.track() const track = app.cores.player.track()
if (instance) { if (track) {
setTrackInstance(instance) setTrackManifest(track)
} }
}, []) }, [])
@ -131,12 +131,9 @@ const Controls = (props) => {
{app.isMobile && ( {app.isMobile && (
<LikeButton <LikeButton
liked={ liked={trackManifest?.serviceOperations?.isItemFavorited}
trackInstance?.manifest?.serviceOperations
?.isItemFavorited
}
onClick={() => handleAction("like")} onClick={() => handleAction("like")}
disabled={!trackInstance?.manifest?._id} disabled={!trackManifest?._id}
/> />
)} )}
</div> </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 SeekBar from "@components/Player/SeekBar"
import Controls from "@components/Player/Controls" import Controls from "@components/Player/Controls"
import Actions from "@components/Player/Actions" import Actions from "@components/Player/Actions"
import Indicators from "@components/Player/Indicators"
import RGBStringToValues from "@utils/rgbToValues" import RGBStringToValues from "@utils/rgbToValues"
@ -25,40 +26,6 @@ function isOverflown(parent, element) {
return elementRect.width > parentRect.width 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) => { const ServiceIndicator = (props) => {
if (!props.service) { if (!props.service) {
return null return null
@ -96,14 +63,12 @@ const Player = (props) => {
} }
} }
const { title, artist, service, cover_analysis, cover } = const { title, artist, service, cover } = playerState.track_manifest ?? {}
playerState.track_manifest ?? {}
const playing = playerState.playback_status === "playing" const playing = playerState.playback_status === "playing"
const stopped = playerState.playback_status === "stopped" const stopped = playerState.playback_status === "stopped"
const titleText = !playing && stopped ? "Stopped" : (title ?? "Untitled") const titleText = !playing && stopped ? "Stopped" : (title ?? "Untitled")
const subtitleText = ""
React.useEffect(() => { React.useEffect(() => {
const titleIsOverflown = isOverflown( const titleIsOverflown = isOverflown(
@ -115,13 +80,11 @@ const Player = (props) => {
}, [title]) }, [title])
React.useEffect(() => { React.useEffect(() => {
const trackInstance = app.cores.player.track() const track = app.cores.player.track()
if (playerState.track_manifest && trackInstance) { if (playerState.track_manifest && track) {
if ( if (typeof track.analyzeCoverColor === "function") {
typeof trackInstance.manifest.analyzeCoverColor === "function" track
) {
trackInstance.manifest
.analyzeCoverColor() .analyzeCoverColor()
.then((analysis) => { .then((analysis) => {
setCoverAnalysis(analysis) setCoverAnalysis(analysis)
@ -203,9 +166,11 @@ const Player = (props) => {
</Marquee> </Marquee>
)} )}
{!playerState.radioId && (
<p className="toolbar_player_info_subtitle"> <p className="toolbar_player_info_subtitle">
{artist ?? ""} {artist ?? ""}
</p> </p>
)}
</div> </div>
{playerState.radioId && ( {playerState.radioId && (

View File

@ -159,6 +159,13 @@
} }
.toolbar_player_info_subtitle { .toolbar_player_info_subtitle {
display: inline-block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 400; font-weight: 400;
@ -246,6 +253,8 @@
border-radius: 12px; border-radius: 12px;
gap: 10px;
background-color: rgba(var(--layoutBackgroundColor), 0.7); background-color: rgba(var(--layoutBackgroundColor), 0.7);
-webkit-backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px);
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 PlayerProcessors from "./PlayerProcessors"
import AudioPlayerStorage from "../player.storage" 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 { export default class AudioBase {
constructor(player) { constructor(player) {
this.player = player this.player = player
this.console = player.console
} }
audio = new Audio() audio = new Audio()
@ -16,6 +23,7 @@ export default class AudioBase {
processors = {} processors = {}
waitUpdateTimeout = null waitUpdateTimeout = null
_firstSegmentReceived = false
initialize = async () => { initialize = async () => {
// create a audio context // create a audio context
@ -26,73 +34,289 @@ export default class AudioBase {
latencyHint: "playback", latencyHint: "playback",
}) })
// configure some settings for audio // configure some settings for audio with optimized settings
this.audio.crossOrigin = "anonymous" this.audio.crossOrigin = "anonymous"
this.audio.preload = "metadata" this.audio.preload = "auto"
this.audio.loop = this.player.state.playback_mode === "repeat" this.audio.loop = this.player.state.playback_mode === "repeat"
this.audio.volume = 1
// listen all events // listen all events
for (const [key, value] of Object.entries(this.audioEvents)) { for (const [key, value] of Object.entries(this.audioEvents)) {
this.audio.addEventListener(key, value) this.audio.addEventListener(key, value)
} }
// setup demuxer for mpd // setup shaka player for mpd
this.createDemuxer() this.createDemuxer()
// create element source // create element source with low latency buffer
this.elementSource = this.context.createMediaElementSource(this.audio) this.elementSource = this.context.createMediaElementSource(this.audio)
// initialize audio processors await this.processorsManager.initialize(),
await this.processorsManager.initialize()
await this.processorsManager.attachAllNodes() await this.processorsManager.attachAllNodes()
} }
createDemuxer() { itemInit = async (manifest) => {
this.demuxer = MediaPlayer().create() 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: { streaming: {
buffer: { bufferingGoal: 15,
resetSourceBuffersForTrackSwitch: true, 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() { flush() {
this.audio.pause() this.audio.pause()
this.audio.src = null
this.audio.currentTime = 0 this.audio.currentTime = 0
if (this.demuxer) {
this.demuxer.destroy()
}
this.createDemuxer() this.createDemuxer()
} }
audioEvents = { audioEvents = {
ended: () => { ended: () => {
try {
this.player.next() this.player.next()
}, } catch (e) {
loadeddata: () => { console.error(e)
this.player.state.loading = false
},
loadedmetadata: () => {
if (this.audio.duration === Infinity) {
this.player.state.live = true
} else {
this.player.state.live = false
} }
}, },
play: () => { play: () => {
this.player.state.playback_status = "playing" this.player.state.playback_status = "playing"
}, },
pause: () => {
this.player.state.playback_status = "paused"
if (typeof this._timeTickInterval !== "undefined") {
clearInterval(this._timeTickInterval)
}
},
playing: () => { playing: () => {
this.player.state.loading = false this.player.state.loading = false
@ -102,15 +326,24 @@ export default class AudioBase {
clearTimeout(this.waitUpdateTimeout) clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null this.waitUpdateTimeout = null
} }
if (typeof this._timeTickInterval !== "undefined") {
clearInterval(this._timeTickInterval)
}
this.timeTick()
this._timeTickInterval = setInterval(this.timeTick, 1000)
}, },
pause: () => { loadeddata: () => {
this.player.state.playback_status = "paused" this.player.state.loading = false
}, },
durationchange: () => { loadedmetadata: () => {
this.player.eventBus.emit( if (this.audio.duration === Infinity) {
`player.durationchange`, this.player.state.live = true
this.audio.duration, } else {
) this.player.state.live = false
}
}, },
waiting: () => { waiting: () => {
if (this.waitUpdateTimeout) { if (this.waitUpdateTimeout) {

View File

@ -4,12 +4,15 @@ import AudioPlayerStorage from "../player.storage"
export default class PlayerState { export default class PlayerState {
static defaultState = { static defaultState = {
loading: false, loading: false,
playback_status: "stopped", playback_status: "stopped",
playback_mode: AudioPlayerStorage.get("mode") ?? "normal",
track_manifest: null, track_manifest: null,
demuxer_metadata: null,
muted: app.isMobile ? false : (AudioPlayerStorage.get("mute") ?? false), muted: app.isMobile ? false : (AudioPlayerStorage.get("mute") ?? false),
volume: app.isMobile ? 1 : (AudioPlayerStorage.get("volume") ?? 0.3), volume: app.isMobile ? 1 : (AudioPlayerStorage.get("volume") ?? 0.3),
playback_mode: AudioPlayerStorage.get("mode") ?? "normal",
} }
constructor(player) { constructor(player) {
@ -23,12 +26,21 @@ export default class PlayerState {
if (change.type === "update") { if (change.type === "update") {
const stateKey = change.path[0] const stateKey = change.path[0]
this.player.eventBus.emit(`player.state.update:${stateKey}`, change.object[stateKey]) this.player.eventBus.emit(
this.player.eventBus.emit("player.state.update", change.object) `player.state.update:${stateKey}`,
change.object[stateKey],
)
this.player.eventBus.emit(
"player.state.update",
change.object,
)
} }
}) })
} catch (error) { } 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 { export default class ServiceProviders {
providers = [ providers = [
// add by default here // add by default here
new ComtyMusicServiceInterface() new ComtyMusicServiceInterface(),
] ]
findProvider(providerId) { findProvider(providerId) {
return this.providers.find((provider) => provider.constructor.id === providerId) return this.providers.find(
(provider) => provider.constructor.id === providerId,
)
} }
register(provider) { register(provider) {
@ -15,32 +17,42 @@ export default class ServiceProviders {
} }
has(providerId) { 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) => { operation = async (operationName, providerId, manifest, args) => {
const provider = await this.findProvider(providerId) const provider = await this.findProvider(providerId)
if (!provider) { if (!provider) {
console.error(`Failed to resolve manifest, provider [${providerId}] not registered`) console.error(
`Failed to resolve manifest, provider [${providerId}] not registered`,
)
return manifest return manifest
} }
const operationFn = provider[operationName] const operationFn = provider[operationName]
if (typeof operationFn !== "function") { 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 manifest
} }
return await operationFn(manifest, args) return await operationFn(manifest, args)
} }
resolve = async (providerId, manifest) => { resolve = async (manifest) => {
const provider = await this.findProvider(providerId) let providerId = manifest.service ?? "default"
const provider = this.findProvider(providerId)
if (!provider) { if (!provider) {
console.error(`Failed to resolve manifest, provider [${providerId}] not registered`) console.error(
`Failed to resolve manifest, provider [${providerId}] not registered`,
)
return manifest return manifest
} }
@ -49,7 +61,7 @@ export default class ServiceProviders {
resolveMany = async (manifests) => { resolveMany = async (manifests) => {
manifests = manifests.map(async (manifest) => { manifests = manifests.map(async (manifest) => {
return await this.resolve(manifest.service ?? "default", manifest) return await this.resolve(manifest)
}) })
manifests = await Promise.all(manifests) 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") { if (typeof params.album !== "undefined") {
this.album = params.album this.album = params.album
if (typeof this.album === "object") {
this.album = this.album.title
}
} }
if (typeof params.artist !== "undefined") { if (typeof params.artist !== "undefined") {
this.artist = params.artist this.artist = params.artist
if (typeof this.artist === "object") {
this.artist = this.artist.name
}
} }
if (typeof params.source !== "undefined") { if (typeof params.source !== "undefined") {
this.source = params.source 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") { if (typeof params.metadata !== "undefined") {
this.metadata = params.metadata this.metadata = params.metadata
} }
@ -45,6 +61,15 @@ export default class TrackManifest {
this.liked = params.liked 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 return this
} }
@ -60,9 +85,10 @@ export default class TrackManifest {
// set default service to default // set default service to default
service = "default" service = "default"
mpd_mode = false
async initialize() { async initialize() {
if (!this.params.file) { if (!this.params.file || !(this.params.file instanceof File)) {
return this return this
} }
@ -93,7 +119,12 @@ export default class TrackManifest {
analyzeCoverColor = async () => { analyzeCoverColor = async () => {
const fac = new FastAverageColor() 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 = { serviceOperations = {
@ -164,8 +195,11 @@ export default class TrackManifest {
album: this.album, album: this.album,
artist: this.artist, artist: this.artist,
source: this.source, source: this.source,
dash_manifest: this.dash_manifest,
encoded_manifest: this.encoded_manifest,
metadata: this.metadata, metadata: this.metadata,
liked: this.liked, 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 ActivityEvent from "@classes/ActivityEvent"
import QueueManager from "@classes/QueueManager" import QueueManager from "@classes/QueueManager"
import TrackInstance from "./classes/TrackInstance" import TrackManifest from "./classes/TrackManifest"
import MediaSession from "./classes/MediaSession" import MediaSession from "./classes/MediaSession"
import ServiceProviders from "./classes/Services" import ServiceProviders from "./classes/Services"
import PlayerState from "./classes/PlayerState" import PlayerState from "./classes/PlayerState"
import PlayerUI from "./classes/PlayerUI" import PlayerUI from "./classes/PlayerUI"
import AudioBase from "./classes/AudioBase" import AudioBase from "./classes/AudioBase"
import SyncRoom from "./classes/SyncRoom"
import setSampleRate from "./helpers/setSampleRate" import setSampleRate from "./helpers/setSampleRate"
@ -23,16 +24,14 @@ export default class Player extends Core {
// player config // player config
static defaultSampleRate = 48000 static defaultSampleRate = 48000
base = new AudioBase(this)
state = new PlayerState(this) state = new PlayerState(this)
ui = new PlayerUI(this) ui = new PlayerUI(this)
serviceProviders = new ServiceProviders() serviceProviders = new ServiceProviders()
nativeControls = new MediaSession(this) nativeControls = new MediaSession(this)
syncRoom = new SyncRoom(this)
base = new AudioBase(this) queue = new QueueManager()
queue = new QueueManager({
loadFunction: this.createInstance,
})
public = { public = {
start: this.start, start: this.start,
@ -68,10 +67,16 @@ export default class Player extends Core {
base: () => { base: () => {
return this.base return this.base
}, },
sync: () => this.syncRoom,
inOnSyncMode: this.inOnSyncMode,
state: this.state, state: this.state,
ui: this.ui.public, ui: this.ui.public,
} }
inOnSyncMode() {
return !!this.syncRoom.state.joined_room
}
async afterInitialize() { async afterInitialize() {
if (app.isMobile) { if (app.isMobile) {
this.state.volume = 1 this.state.volume = 1
@ -81,53 +86,20 @@ export default class Player extends Core {
await this.base.initialize() 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 } = {}) { async start(manifest, { time, startIndex = 0, radioId } = {}) {
this.console.debug("start():", {
manifest: manifest,
time: time,
startIndex: startIndex,
radioId: radioId,
})
this.ui.attachPlayerComponent() this.ui.attachPlayerComponent()
if (this.queue.currentItem) { if (this.queue.currentItem) {
await this.queue.currentItem.pause() await this.base.pause()
} }
//await this.abortPreloads()
await this.queue.flush() await this.queue.flush()
this.state.loading = true this.state.loading = true
@ -147,32 +119,31 @@ export default class Player extends Core {
return false return false
} }
if (playlist.some((item) => typeof item === "string")) { // resolve only the first item if needed
playlist = await this.serviceProviders.resolveMany(playlist) 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)) { // create instance for the first element
playlist = await this.serviceProviders.resolveMany(playlist) playlist[0] = new TrackManifest(playlist[0], this)
}
for await (let [index, _manifest] of playlist.entries()) { this.queue.add(playlist)
let instance = new TrackInstance(_manifest, this)
this.queue.add(instance) const item = this.queue.setCurrent(startIndex)
}
const item = this.queue.set(startIndex) this.base.play(item, {
this.play(item, {
time: time ?? 0, time: time ?? 0,
}) })
// send the event to the server // send the event to the server
if (item.manifest._id && item.manifest.service === "default") { if (item._id && item.service === "default") {
new ActivityEvent("player.play", { new ActivityEvent("player.play", {
identifier: "unique", // this must be unique to prevent duplicate events and ensure only have unique track events identifier: "unique", // this must be unique to prevent duplicate events and ensure only have unique track events
track_id: item.manifest._id, track_id: item._id,
service: item.manifest.service, service: item.service,
}) })
} }
@ -182,13 +153,15 @@ export default class Player extends Core {
// similar to player.start, but add to the queue // 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 // if next is true, it will add to the queue to the top of the queue
async addToQueue(manifest, { next = false } = {}) { async addToQueue(manifest, { next = false } = {}) {
if (typeof manifest === "string") { if (this.inOnSyncMode()) {
manifest = await this.serviceProviders.resolve(manifest) 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", { console.log("Added to queue", {
manifest, manifest,
@ -197,6 +170,10 @@ export default class Player extends Core {
} }
next() { next() {
if (this.inOnSyncMode()) {
return false
}
//const isRandom = this.state.playback_mode === "shuffle" //const isRandom = this.state.playback_mode === "shuffle"
const item = this.queue.next() const item = this.queue.next()
@ -204,19 +181,27 @@ export default class Player extends Core {
return this.stopPlayback() return this.stopPlayback()
} }
return this.play(item) return this.base.play(item)
} }
previous() { previous() {
if (this.inOnSyncMode()) {
return false
}
const item = this.queue.previous() const item = this.queue.previous()
return this.play(item) return this.base.play(item)
} }
// //
// Playback Control // Playback Control
// //
async togglePlayback() { async togglePlayback() {
if (this.inOnSyncMode()) {
return false
}
if (this.state.playback_status === "paused") { if (this.state.playback_status === "paused") {
await this.resumePlayback() await this.resumePlayback()
} else { } else {
@ -238,7 +223,7 @@ export default class Player extends Core {
this.base.processors.gain.fade(0) this.base.processors.gain.fade(0)
setTimeout(() => { setTimeout(() => {
this.queue.currentItem.pause() this.base.pause()
resolve() resolve()
}, Player.gradualFadeMs) }, Player.gradualFadeMs)
@ -258,7 +243,7 @@ export default class Player extends Core {
} }
// ensure audio elemeto starts from 0 volume // ensure audio elemeto starts from 0 volume
this.queue.currentItem.resume().then(() => { this.base.resume().then(() => {
resolve() resolve()
}) })
this.base.processors.gain.fade(this.state.volume) this.base.processors.gain.fade(this.state.volume)
@ -282,14 +267,13 @@ export default class Player extends Core {
} }
stopPlayback() { stopPlayback() {
this.base.flush()
this.queue.flush()
this.state.playback_status = "stopped" this.state.playback_status = "stopped"
this.state.track_manifest = null this.state.track_manifest = null
this.queue.currentItem = null this.queue.currentItem = null
//this.abortPreloads() this.base.flush()
this.queue.flush()
this.nativeControls.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() openPlayerView()
} }
if (track.manifest?.analyzeCoverColor) { if (track?.analyzeCoverColor) {
track.manifest track
.analyzeCoverColor() .analyzeCoverColor()
.then((analysis) => { .then((analysis) => {
setCoverAnalyzed(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 classnames from "classnames"
import Marquee from "react-fast-marquee" import Marquee from "react-fast-marquee"
import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
import Controls from "@components/Player/Controls" import Controls from "@components/Player/Controls"
import Indicators from "@components/Player/Indicators"
import SeekBar from "@components/Player/SeekBar" import SeekBar from "@components/Player/SeekBar"
import LiveInfo from "@components/Player/LiveInfo" import LiveInfo from "@components/Player/LiveInfo"
import useHideOnMouseStop from "@hooks/useHideOnMouseStop"
import { usePlayerStateContext } from "@contexts/WithPlayerContext" import { usePlayerStateContext } from "@contexts/WithPlayerContext"
function isOverflown(element) { function isOverflown(element) {
@ -23,7 +23,7 @@ function isOverflown(element) {
) )
} }
const PlayerController = React.forwardRef((props, ref) => { const PlayerController = (props, ref) => {
const [playerState] = usePlayerStateContext() const [playerState] = usePlayerStateContext()
const titleRef = React.useRef() const titleRef = React.useRef()
@ -34,51 +34,13 @@ const PlayerController = React.forwardRef((props, ref) => {
}) })
const [titleIsOverflown, setTitleIsOverflown] = React.useState(false) 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(() => { React.useEffect(() => {
setTitleIsOverflown(isOverflown(titleRef.current)) setTitleIsOverflown(isOverflown(titleRef.current))
setTrackDuration(app.cores.player.controls.duration())
}, [playerState.track_manifest]) }, [playerState.track_manifest])
React.useEffect(() => { if (playerState.playback_status === "stopped") {
syncPlayback() return null
}, []) }
const isStopped = playerState.playback_status === "stopped"
return ( return (
<div <div
@ -101,12 +63,7 @@ const PlayerController = React.forwardRef((props, ref) => {
}, },
)} )}
> >
{playerState.playback_status === "stopped" || {playerState.track_manifest?.title}
(!playerState.track_manifest?.title &&
"Nothing is playing")}
{playerState.playback_status !== "stopped" &&
playerState.track_manifest?.title}
</h4> </h4>
} }
@ -115,17 +72,11 @@ const PlayerController = React.forwardRef((props, ref) => {
//gradient //gradient
//gradientColor={bgColor} //gradientColor={bgColor}
//gradientWidth={20} //gradientWidth={20}
play={!isStopped} play={playerState.playback_status === "playing"}
> >
<h4> <h4>
{isStopped ? ( {playerState.track_manifest?.title ??
"Nothing is playing" "Untitled"}
) : (
<>
{playerState.track_manifest
?.title ?? "Untitled"}
</>
)}
</h4> </h4>
</Marquee> </Marquee>
)} )}
@ -146,41 +97,13 @@ const PlayerController = React.forwardRef((props, ref) => {
{!playerState.live && <SeekBar />} {!playerState.live && <SeekBar />}
<div className="lyrics-player-controller-tags"> <Indicators
{playerState.track_manifest?.metadata?.lossless && ( track={playerState.track_manifest}
<Tag playerState={playerState}
icon={
<Icons.Lossless
style={{
margin: 0,
}}
/> />
}
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>
</div> </div>
) )
}) }
export default PlayerController export default PlayerController

View File

@ -4,6 +4,7 @@ import { motion, AnimatePresence } from "motion/react"
import { usePlayerStateContext } from "@contexts/WithPlayerContext" import { usePlayerStateContext } from "@contexts/WithPlayerContext"
// eslint-disable-next-line
const LyricsText = React.forwardRef((props, textRef) => { const LyricsText = React.forwardRef((props, textRef) => {
const [playerState] = usePlayerStateContext() const [playerState] = usePlayerStateContext()
@ -74,6 +75,7 @@ const LyricsText = React.forwardRef((props, textRef) => {
} else { } else {
setVisible(true) setVisible(true)
if (textRef.current) {
// find line element by id // find line element by id
const lineElement = textRef.current.querySelector( const lineElement = textRef.current.querySelector(
`#lyrics-line-${currentLineIndex}`, `#lyrics-line-${currentLineIndex}`,
@ -90,6 +92,7 @@ const LyricsText = React.forwardRef((props, textRef) => {
textRef.current.scrollTop = 0 textRef.current.scrollTop = 0
} }
} }
}
}, [currentLineIndex]) }, [currentLineIndex])
//* Handle when playback status change //* 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 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 useMaxScreen from "@hooks/useMaxScreen"
import useTrackManifest from "@hooks/useTrackManifest"
import { usePlayerStateContext } from "@contexts/WithPlayerContext" import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import PlayerController from "./components/controller" import PlayerController from "./components/controller"
import LyricsVideo from "./components/video" import LyricsVideo from "./components/video"
import LyricsText from "./components/text" import LyricsText from "./components/text"
import Background from "./components/Background"
import "./index.less" 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 = () => { const EnhancedLyricsPage = () => {
useMaxScreen() useMaxScreen()
const [playerState] = usePlayerStateContext() 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 videoRef = React.useRef()
const textRef = useRef() const textRef = React.useRef()
const isMounted = useRef(true)
const currentTrackId = useRef(null)
const dominantColor = useMemo( const { toggleFullScreen } = useFullScreen({
() => ({ "--dominant-color": getDominantColorStr(coverAnalysis) }), onExit: () => app?.location?.last && app.location.back(),
[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,
}) })
if (!isMounted.current) return const { trackManifest } = useTrackManifest(playerState.track_manifest)
const processedLyrics = const { dominantColor } = useCoverAnalysis(trackManifest)
result.sync_audio_at && !result.sync_audio_at_ms
? {
...result,
sync_audio_at_ms: parseTimeToMs(
result.sync_audio_at,
),
}
: result
console.log("Fetched Lyrics >", processedLyrics) const { syncRoom, subscribeLyricsUpdates, unsubscribeLyricsUpdates } =
setLyrics(processedLyrics || false) useSyncRoom()
} catch (error) {
console.error("Failed to fetch lyrics", error)
setLyrics(false)
}
}, [translationEnabled, playerState.track_manifest])
// Track manifest comparison const { lyrics, setLyrics } = useLyrics({
useEffect(() => { trackManifest,
const newManifest = playerState.track_manifest })
if (JSON.stringify(newManifest) !== JSON.stringify(trackManifest)) { // Inicialización y limpieza
setTrackManifest(newManifest) React.useEffect(() => {
}
}, [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
toggleFullScreen(true) toggleFullScreen(true)
document.addEventListener("fullscreenchange", handleFullScreenChange)
if (syncRoom) {
subscribeLyricsUpdates(setLyrics)
}
return () => { return () => {
isMounted.current = false
toggleFullScreen(false) toggleFullScreen(false)
document.removeEventListener(
"fullscreenchange", if (syncRoom) {
handleFullScreenChange, 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 ( return (
<div <div
className={classnames("lyrics", { className={classnames("lyrics", {
@ -175,15 +65,21 @@ const EnhancedLyricsPage = () => {
> >
<div className="lyrics-background-color" /> <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} /> <LyricsVideo ref={videoRef} lyrics={lyrics} />
<LyricsText ref={textRef} lyrics={lyrics} /> <LyricsText ref={textRef} lyrics={lyrics} />
<PlayerController
lyrics={lyrics} <PlayerController lyrics={lyrics} />
translationEnabled={translationEnabled}
toggleTranslationEnabled={handleTranslationToggle}
/>
</div> </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 { .lyrics-background-color {
position: absolute; position: absolute;
z-index: 100; z-index: 105;
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -170,11 +194,6 @@
opacity: 1; opacity: 1;
height: 30px; height: 30px;
} }
.lyrics-player-controller-tags {
opacity: 1;
height: 10px;
}
} }
.lyrics-player-controller-info { .lyrics-player-controller-info {
@ -187,6 +206,8 @@
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
overflow: hidden;
.lyrics-player-controller-info-title { .lyrics-player-controller-info-title {
font-size: 1.4rem; font-size: 1.4rem;
font-weight: 600; font-weight: 600;
@ -219,6 +240,7 @@
.lyrics-player-controller-info-details { .lyrics-player-controller-info-details {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 100%;
align-items: center; align-items: center;
@ -229,7 +251,7 @@
font-weight: 400; font-weight: 400;
// do not wrap text // do not wrap text
white-space: nowrap; word-break: break-word;
h3 { h3 {
margin: 0; margin: 0;
@ -243,22 +265,24 @@
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
} }
.lyrics-player-controller-tags { .toolbar_player_indicators_wrapper {
position: absolute;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 0px; padding: 4px;
gap: 10px; .toolbar_player_indicators {
padding: 4px 6px;
font-size: 0.9rem;
opacity: 0; border-radius: 8px;
}
transition: all 150ms ease-in-out;
} }
} }
} }