mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-09 02:24:16 +00:00
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:
parent
12e9cb30ca
commit
0eaecf6fd3
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
width: 70%;
|
||||
width: 50%;
|
||||
margin: auto;
|
||||
|
||||
padding: 2px 25px;
|
||||
|
@ -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>
|
||||
|
84
packages/app/src/components/Player/Indicators/index.jsx
Normal file
84
packages/app/src/components/Player/Indicators/index.jsx
Normal 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
|
@ -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 && (
|
||||
|
@ -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);
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -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)
|
||||
|
202
packages/app/src/cores/player/classes/SyncRoom.js
Normal file
202
packages/app/src/cores/player/classes/SyncRoom.js
Normal 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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
||||
|
@ -1,2 +0,0 @@
|
||||
export { default as useHacks } from "./useHacks"
|
||||
export { default as useCenteredContainer } from "./useCenteredContainer"
|
43
packages/app/src/hooks/useCoverAnalysis/index.js
Normal file
43
packages/app/src/hooks/useCoverAnalysis/index.js
Normal 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,
|
||||
}
|
||||
}
|
47
packages/app/src/hooks/useFullScreen/index.js
Normal file
47
packages/app/src/hooks/useFullScreen/index.js
Normal 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,
|
||||
}
|
||||
}
|
69
packages/app/src/hooks/useLyrics/index.js
Normal file
69
packages/app/src/hooks/useLyrics/index.js
Normal 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,
|
||||
}
|
||||
}
|
60
packages/app/src/hooks/useSyncRoom/index.js
Normal file
60
packages/app/src/hooks/useSyncRoom/index.js
Normal 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(),
|
||||
}
|
||||
}
|
19
packages/app/src/hooks/useTrackManifest/index.js
Normal file
19
packages/app/src/hooks/useTrackManifest/index.js
Normal 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,
|
||||
}
|
||||
}
|
@ -71,8 +71,8 @@ const PlayerButton = (props) => {
|
||||
openPlayerView()
|
||||
}
|
||||
|
||||
if (track.manifest?.analyzeCoverColor) {
|
||||
track.manifest
|
||||
if (track?.analyzeCoverColor) {
|
||||
track
|
||||
.analyzeCoverColor()
|
||||
.then((analysis) => {
|
||||
setCoverAnalyzed(analysis)
|
||||
|
22
packages/app/src/pages/lyrics/components/Background.jsx
Normal file
22
packages/app/src/pages/lyrics/components/Background.jsx
Normal 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);
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user