Support for live radios

This commit is contained in:
SrGooglo 2025-02-19 16:32:44 +00:00
parent 5f3fb3a013
commit 6eef3480b1
6 changed files with 407 additions and 322 deletions

View File

@ -14,113 +14,122 @@ import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import "./index.less" import "./index.less"
const EventsHandlers = { const EventsHandlers = {
"playback": () => { playback: (state) => {
return app.cores.player.playback.toggle() if (state.live) {
}, return false
"previous": () => { }
return app.cores.player.playback.previous() return app.cores.player.playback.toggle()
}, },
"next": () => { previous: () => {
return app.cores.player.playback.next() return app.cores.player.playback.previous()
}, },
"volume": (ctx, value) => { next: () => {
return app.cores.player.controls.volume(value) return app.cores.player.playback.next()
}, },
"mute": () => { volume: (ctx, value) => {
return app.cores.player.controls.mute("toggle") return app.cores.player.controls.volume(value)
}, },
"like": async (ctx) => { mute: () => {
if (!ctx.track_manifest) { return app.cores.player.controls.mute("toggle")
return false },
} like: async (ctx) => {
if (!ctx.track_manifest) {
return false
}
const track = app.cores.player.track() const track = app.cores.player.track()
return await track.manifest.serviceOperations.toggleItemFavourite("track", ctx.track_manifest._id) return await track.manifest.serviceOperations.toggleItemFavourite(
}, "track",
ctx.track_manifest._id,
)
},
} }
const Controls = (props) => { const Controls = (props) => {
const [playerState] = usePlayerStateContext() const [playerState] = usePlayerStateContext()
const handleAction = (event, ...args) => { const handleAction = (event, ...args) => {
if (typeof EventsHandlers[event] !== "function") { if (typeof EventsHandlers[event] !== "function") {
throw new Error(`Unknown event "${event}"`) throw new Error(`Unknown event "${event}"`)
} }
return EventsHandlers[event](playerState, ...args) return EventsHandlers[event](playerState, ...args)
} }
return <div return (
className={ <div className={props.className ?? "player-controls"}>
props.className ?? "player-controls" <AudioPlayerChangeModeButton disabled={props.streamMode} />
} <antd.Button
> type="ghost"
<AudioPlayerChangeModeButton shape="round"
disabled={playerState.control_locked} icon={<Icons.FiChevronLeft />}
/> onClick={() => handleAction("previous")}
<antd.Button disabled={props.streamMode}
type="ghost" />
shape="round" <antd.Button
icon={<Icons.FiChevronLeft />} type="primary"
onClick={() => handleAction("previous")} shape="circle"
disabled={playerState.control_locked} icon={
/> props.streamMode ? (
<antd.Button <Icons.MdStop />
type="primary" ) : playerState.playback_status === "playing" ? (
shape="circle" <Icons.MdPause />
icon={playerState.livestream_mode ? <Icons.MdStop /> : playerState.playback_status === "playing" ? <Icons.MdPause /> : <Icons.MdPlayArrow />} ) : (
onClick={() => handleAction("playback")} <Icons.MdPlayArrow />
className="playButton" )
disabled={playerState.control_locked} }
> onClick={() => handleAction("playback")}
{ className="playButton"
playerState.loading && <div className="loadCircle"> >
<UseAnimations {playerState.loading && (
animation={LoadingAnimation} <div className="loadCircle">
size="100%" <UseAnimations
/> animation={LoadingAnimation}
</div> size="100%"
} />
</antd.Button> </div>
<antd.Button )}
type="ghost" </antd.Button>
shape="round" <antd.Button
icon={<Icons.FiChevronRight />} type="ghost"
onClick={() => handleAction("next")} shape="round"
disabled={playerState.control_locked} icon={<Icons.FiChevronRight />}
/> onClick={() => handleAction("next")}
{ disabled={props.streamMode}
!app.isMobile && <antd.Popover />
content={React.createElement( {!app.isMobile && (
AudioVolume, <antd.Popover
{ content={React.createElement(AudioVolume, {
onChange: (value) => handleAction("volume", value), onChange: (value) => handleAction("volume", value),
defaultValue: playerState.volume defaultValue: playerState.volume,
} })}
)} trigger="hover"
trigger="hover" >
> <button
<button className="muteButton"
className="muteButton" onClick={() => handleAction("mute")}
onClick={() => handleAction("mute")} >
> {playerState.muted ? (
{ <Icons.FiVolumeX />
playerState.muted ) : (
? <Icons.FiVolumeX /> <Icons.FiVolume2 />
: <Icons.FiVolume2 /> )}
} </button>
</button> </antd.Popover>
</antd.Popover> )}
}
{ {app.isMobile && (
app.isMobile && <LikeButton <LikeButton
liked={playerState.track_manifest?.serviceOperations.fetchLikeStatus} liked={
onClick={() => handleAction("like")} playerState.track_manifest?.serviceOperations
/> .fetchLikeStatus
} }
</div> onClick={() => handleAction("like")}
/>
)}
</div>
)
} }
export default Controls export default Controls

View File

@ -7,40 +7,46 @@ import LikeButton from "@components/LikeButton"
import { usePlayerStateContext } from "@contexts/WithPlayerContext" import { usePlayerStateContext } from "@contexts/WithPlayerContext"
const ExtraActions = (props) => { const ExtraActions = (props) => {
const [playerState] = usePlayerStateContext() const [playerState] = usePlayerStateContext()
const handleClickLike = async () => { const handleClickLike = async () => {
if (!playerState.track_manifest) { if (!playerState.track_manifest) {
console.error("Cannot like a track if nothing is playing") console.error("Cannot like a track if nothing is playing")
return false return false
} }
const track = app.cores.player.track() const track = app.cores.player.track()
await track.manifest.serviceOperations.toggleItemFavourite("track", playerState.track_manifest._id) await track.manifest.serviceOperations.toggleItemFavourite(
} "track",
playerState.track_manifest._id,
)
}
return <div className="extra_actions"> return (
{ <div className="extra_actions">
app.isMobile && <Button {app.isMobile && (
type="ghost" <Button
icon={<Icons.MdAbc />} type="ghost"
disabled={!playerState.track_manifest?.lyrics_enabled} icon={<Icons.MdAbc />}
/> disabled={!playerState.track_manifest?.lyrics_enabled}
} />
)}
{ {!app.isMobile && (
!app.isMobile && playerState.track_manifest?._id && <LikeButton <LikeButton
liked={playerState.track_manifest?.serviceOperations.fetchLikeStatus} liked={
onClick={handleClickLike} playerState.track_manifest?.serviceOperations
/> .fetchLikeStatus
} }
onClick={handleClickLike}
disabled={!playerState.track_manifest?._id}
/>
)}
<Button <Button type="ghost" icon={<Icons.MdQueueMusic />} />
type="ghost" </div>
icon={<Icons.MdQueueMusic />} )
/>
</div>
} }
export default ExtraActions export default ExtraActions

View File

@ -0,0 +1,51 @@
import React from "react"
import SSEEvents from "@classes/SSEEvents"
import { MdPlayCircle, MdHeadphones } from "react-icons/md"
import "./index.less"
const LiveInfo = ({ radioId, initialData }) => {
const [data, setData] = React.useState(initialData ?? {})
const eventManager = React.useMemo(
() =>
new SSEEvents(
`${app.cores.api.client().mainOrigin}/music/radio/sse/radio:${radioId}`,
{
update: (data) => {
if (typeof data.now_playing === "string") {
data.now_playing = JSON.parse(data.now_playing)
}
console.log(`Radio data updated`, data)
setData(data)
},
},
),
[],
)
React.useEffect(() => {
return () => {
eventManager.close()
}
}, [])
return (
<div className="live-info">
{data.now_playing && (
<>
<div className="live-info-title">
<MdPlayCircle /> {data.now_playing.song.text}
</div>
<div className="live-info-listeners">
<MdHeadphones /> {data.listeners}
</div>
</>
)}
</div>
)
}
export default LiveInfo

View File

@ -0,0 +1,8 @@
.live-info {
display: flex;
flex-direction: column;
gap: 7px;
height: 100%;
}

View File

@ -8,212 +8,218 @@ import seekToTimeLabel from "@utils/seekToTimeLabel"
import "./index.less" import "./index.less"
export default class SeekBar extends React.Component { export default class SeekBar extends React.Component {
state = { state = {
playing: app.cores.player.state["playback_status"] === "playing", playing: app.cores.player.state["playback_status"] === "playing",
timeText: "00:00", timeText: "00:00",
durationText: "00:00", durationText: "00:00",
sliderTime: 0, sliderTime: 0,
sliderLock: false, sliderLock: false,
} }
handleSeek = (value) => { handleSeek = (value) => {
if (value > 0) { if (value > 0) {
// calculate the duration of the audio // calculate the duration of the audio
const duration = app.cores.player.controls.duration() const duration = app.cores.player.controls.duration()
// calculate the seek of the audio // calculate the seek of the audio
const seek = (value / 100) * duration const seek = (value / 100) * duration
app.cores.player.controls.seek(seek) app.cores.player.controls.seek(seek)
} else { } else {
app.cores.player.controls.seek(0) app.cores.player.controls.seek(0)
} }
} }
calculateDuration = (preCalculatedDuration) => { calculateDuration = (preCalculatedDuration) => {
// get current audio duration // get current audio duration
const audioDuration = preCalculatedDuration ?? app.cores.player.controls.duration() const audioDuration =
preCalculatedDuration ?? app.cores.player.controls.duration()
if (isNaN(audioDuration)) { if (isNaN(audioDuration)) {
return return
} }
// set duration // set duration
this.setState({ this.setState({
durationText: seekToTimeLabel(audioDuration) durationText: seekToTimeLabel(audioDuration),
}) })
} }
calculateTime = () => { calculateTime = () => {
// get current audio seek // get current audio seek
const seek = app.cores.player.controls.seek() const seek = app.cores.player.controls.seek()
// set time // set time
this.setState({ this.setState({
timeText: seekToTimeLabel(seek) timeText: seekToTimeLabel(seek),
}) })
} }
updateProgressBar = () => { updateProgressBar = () => {
if (this.state.sliderLock) { if (this.state.sliderLock) {
return return
} }
const seek = app.cores.player.controls.seek() const seek = app.cores.player.controls.seek()
const duration = app.cores.player.controls.duration() const duration = app.cores.player.controls.duration()
const percent = (seek / duration) * 100 const percent = (seek / duration) * 100
this.setState({ this.setState({
sliderTime: percent sliderTime: percent,
}) })
} }
updateAll = () => { updateAll = () => {
this.calculateTime() this.calculateTime()
this.updateProgressBar() this.updateProgressBar()
} }
events = { events = {
// handle when player changes playback status // handle when player changes playback status
"player.state.update:playback_status": (status) => { "player.state.update:playback_status": (status) => {
this.setState({ this.setState({
playing: status === "playing", playing: status === "playing",
}) })
switch (status) { switch (status) {
case "stopped": case "stopped":
this.setState({ this.setState({
timeText: "00:00", timeText: "00:00",
durationText: "00:00", durationText: "00:00",
sliderTime: 0, sliderTime: 0,
}) })
break break
case "playing": case "playing":
this.updateAll() this.updateAll()
this.calculateDuration() this.calculateDuration()
break break
default: default:
break break
} }
}, },
// handle when player changes track // handle when player changes track
"player.state.update:track_manifest": (manifest) => { "player.state.update:track_manifest": (manifest) => {
if (!manifest) { if (!manifest) {
return false return false
} }
this.updateAll() this.updateAll()
this.setState({ this.setState({
timeText: "00:00", timeText: "00:00",
sliderTime: 0, sliderTime: 0,
}) })
this.calculateDuration(manifest.metadata?.duration ?? manifest.duration) this.calculateDuration(
}, manifest.metadata?.duration ?? manifest.duration,
"player.seeked": (seek) => { )
this.calculateTime() },
this.updateAll() "player.seeked": (seek) => {
}, this.calculateTime()
"player.durationchange": () => { this.updateAll()
this.calculateDuration() },
}, "player.durationchange": () => {
} this.calculateDuration()
},
}
tick = () => { tick = () => {
if (this.state.playing) { if (this.state.playing) {
this.interval = setInterval(() => { this.interval = setInterval(() => {
this.updateAll() this.updateAll()
}, 1000) }, 1000)
} else { } else {
if (this.interval) { if (this.interval) {
clearInterval(this.interval) clearInterval(this.interval)
} }
} }
} }
componentDidMount = () => { componentDidMount = () => {
this.calculateDuration() this.calculateDuration()
this.updateAll() this.updateAll()
this.tick() this.tick()
for (const [event, callback] of Object.entries(this.events)) { for (const [event, callback] of Object.entries(this.events)) {
app.cores.player.eventBus().on(event, callback) app.cores.player.eventBus().on(event, callback)
} }
} }
componentWillUnmount = () => { componentWillUnmount = () => {
for (const [event, callback] of Object.entries(this.events)) { for (const [event, callback] of Object.entries(this.events)) {
app.cores.player.eventBus().off(event, callback) app.cores.player.eventBus().off(event, callback)
} }
} }
componentDidUpdate = (prevProps, prevState) => { componentDidUpdate = (prevProps, prevState) => {
if (this.state.playing !== prevState.playing) { if (this.state.playing !== prevState.playing) {
this.tick() this.tick()
} }
} }
render() { render() {
return <div return (
className={classnames( <div
"player-seek_bar", className={classnames("player-seek_bar", {
{ ["stopped"]: this.props.stopped,
["stopped"]: this.props.stopped, })}
} >
)} <div
> className={classnames("progress", {
<div ["hidden"]: this.props.streamMode,
className={classnames( })}
"progress", >
{ <Slider
["hidden"]: this.props.streamMode, size="small"
} value={this.state.sliderTime}
)} disabled={
> this.props.stopped ||
<Slider this.props.streamMode ||
size="small" this.props.disabled
value={this.state.sliderTime} }
disabled={this.props.stopped || this.props.streamMode || this.props.disabled} min={0}
min={0} max={100}
max={100} step={0.1}
step={0.1} onChange={(_, value) => {
onChange={(_, value) => { this.setState({
this.setState({ sliderTime: value,
sliderTime: value, sliderLock: true,
sliderLock: true })
}) }}
}} onChangeCommitted={() => {
onChangeCommitted={() => { this.setState({
this.setState({ sliderLock: false,
sliderLock: false })
})
this.handleSeek(this.state.sliderTime) this.handleSeek(this.state.sliderTime)
if (!this.props.playing) { if (!this.props.playing) {
app.cores.player.playback.play() app.cores.player.playback.play()
} }
}} }}
valueLabelDisplay="auto" valueLabelDisplay="auto"
valueLabelFormat={(value) => { valueLabelFormat={(value) => {
return seekToTimeLabel((value / 100) * app.cores.player.controls.duration()) return seekToTimeLabel(
}} (value / 100) *
/> app.cores.player.controls.duration(),
</div> )
<div className="timers"> }}
<div> />
<span>{this.state.timeText}</span> </div>
</div> {!this.props.streamMode && (
<div> <div className="timers">
{ <div>
this.props.streamMode ? <antd.Tag>Live</antd.Tag> : <span>{this.state.durationText}</span> <span>{this.state.timeText}</span>
} </div>
</div> <div>
</div> <span>{this.state.durationText}</span>
</div> </div>
} </div>
)}
</div>
)
}
} }

View File

@ -5,6 +5,7 @@ import classnames from "classnames"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
import { usePlayerStateContext } from "@contexts/WithPlayerContext" import { usePlayerStateContext } from "@contexts/WithPlayerContext"
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"
@ -25,9 +26,7 @@ function isOverflown(parent, element) {
return elementRect.width > parentRect.width return elementRect.width > parentRect.width
} }
const Indicators = (props) => { const Indicators = ({ track, playerState }) => {
const { track } = props
if (!track) { if (!track) {
return null return null
} }
@ -40,6 +39,12 @@ const Indicators = (props) => {
} }
} }
if (playerState.live) {
indicators.push(
<Icons.FiRadio style={{ color: "var(--colorPrimary)" }} />,
)
}
if (indicators.length === 0) { if (indicators.length === 0) {
return null return null
} }
@ -152,12 +157,6 @@ const Player = (props) => {
onClick={() => app.location.push("/lyrics")} onClick={() => app.location.push("/lyrics")}
/> />
{/* <antd.Button
icon={<Icons.MdOfflineBolt />}
>
HyperDrive
</antd.Button> */}
<antd.Button <antd.Button
icon={<Icons.FiX />} icon={<Icons.FiX />}
shape="circle" shape="circle"
@ -206,20 +205,26 @@ const Player = (props) => {
</p> </p>
</div> </div>
{playerState.radioId && (
<LiveInfo radioId={playerState.radioId} />
)}
<div className="toolbar_player_actions"> <div className="toolbar_player_actions">
<Controls /> <Controls streamMode={playerState.live} />
<SeekBar <SeekBar
stopped={playerState.playback_status === "stopped"} stopped={playerState.playback_status === "stopped"}
playing={playerState.playback_status === "playing"} playing={playerState.playback_status === "playing"}
streamMode={playerState.livestream_mode} streamMode={playerState.live}
disabled={playerState.control_locked}
/> />
<ExtraActions /> <ExtraActions streamMode={playerState.live} />
</div> </div>
<Indicators track={playerState.track_manifest} /> <Indicators
track={playerState.track_manifest}
playerState={playerState}
/>
</div> </div>
</div> </div>
</div> </div>