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"
const EventsHandlers = {
"playback": () => {
return app.cores.player.playback.toggle()
},
"previous": () => {
return app.cores.player.playback.previous()
},
"next": () => {
return app.cores.player.playback.next()
},
"volume": (ctx, value) => {
return app.cores.player.controls.volume(value)
},
"mute": () => {
return app.cores.player.controls.mute("toggle")
},
"like": async (ctx) => {
if (!ctx.track_manifest) {
return false
}
playback: (state) => {
if (state.live) {
return false
}
return app.cores.player.playback.toggle()
},
previous: () => {
return app.cores.player.playback.previous()
},
next: () => {
return app.cores.player.playback.next()
},
volume: (ctx, value) => {
return app.cores.player.controls.volume(value)
},
mute: () => {
return app.cores.player.controls.mute("toggle")
},
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 [playerState] = usePlayerStateContext()
const [playerState] = usePlayerStateContext()
const handleAction = (event, ...args) => {
if (typeof EventsHandlers[event] !== "function") {
throw new Error(`Unknown event "${event}"`)
}
const handleAction = (event, ...args) => {
if (typeof EventsHandlers[event] !== "function") {
throw new Error(`Unknown event "${event}"`)
}
return EventsHandlers[event](playerState, ...args)
}
return EventsHandlers[event](playerState, ...args)
}
return <div
className={
props.className ?? "player-controls"
}
>
<AudioPlayerChangeModeButton
disabled={playerState.control_locked}
/>
<antd.Button
type="ghost"
shape="round"
icon={<Icons.FiChevronLeft />}
onClick={() => handleAction("previous")}
disabled={playerState.control_locked}
/>
<antd.Button
type="primary"
shape="circle"
icon={playerState.livestream_mode ? <Icons.MdStop /> : playerState.playback_status === "playing" ? <Icons.MdPause /> : <Icons.MdPlayArrow />}
onClick={() => handleAction("playback")}
className="playButton"
disabled={playerState.control_locked}
>
{
playerState.loading && <div className="loadCircle">
<UseAnimations
animation={LoadingAnimation}
size="100%"
/>
</div>
}
</antd.Button>
<antd.Button
type="ghost"
shape="round"
icon={<Icons.FiChevronRight />}
onClick={() => handleAction("next")}
disabled={playerState.control_locked}
/>
{
!app.isMobile && <antd.Popover
content={React.createElement(
AudioVolume,
{
onChange: (value) => handleAction("volume", value),
defaultValue: playerState.volume
}
)}
trigger="hover"
>
<button
className="muteButton"
onClick={() => handleAction("mute")}
>
{
playerState.muted
? <Icons.FiVolumeX />
: <Icons.FiVolume2 />
}
</button>
</antd.Popover>
}
return (
<div className={props.className ?? "player-controls"}>
<AudioPlayerChangeModeButton disabled={props.streamMode} />
<antd.Button
type="ghost"
shape="round"
icon={<Icons.FiChevronLeft />}
onClick={() => handleAction("previous")}
disabled={props.streamMode}
/>
<antd.Button
type="primary"
shape="circle"
icon={
props.streamMode ? (
<Icons.MdStop />
) : playerState.playback_status === "playing" ? (
<Icons.MdPause />
) : (
<Icons.MdPlayArrow />
)
}
onClick={() => handleAction("playback")}
className="playButton"
>
{playerState.loading && (
<div className="loadCircle">
<UseAnimations
animation={LoadingAnimation}
size="100%"
/>
</div>
)}
</antd.Button>
<antd.Button
type="ghost"
shape="round"
icon={<Icons.FiChevronRight />}
onClick={() => handleAction("next")}
disabled={props.streamMode}
/>
{!app.isMobile && (
<antd.Popover
content={React.createElement(AudioVolume, {
onChange: (value) => handleAction("volume", value),
defaultValue: playerState.volume,
})}
trigger="hover"
>
<button
className="muteButton"
onClick={() => handleAction("mute")}
>
{playerState.muted ? (
<Icons.FiVolumeX />
) : (
<Icons.FiVolume2 />
)}
</button>
</antd.Popover>
)}
{
app.isMobile && <LikeButton
liked={playerState.track_manifest?.serviceOperations.fetchLikeStatus}
onClick={() => handleAction("like")}
/>
}
</div>
{app.isMobile && (
<LikeButton
liked={
playerState.track_manifest?.serviceOperations
.fetchLikeStatus
}
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"
const ExtraActions = (props) => {
const [playerState] = usePlayerStateContext()
const [playerState] = usePlayerStateContext()
const handleClickLike = async () => {
if (!playerState.track_manifest) {
console.error("Cannot like a track if nothing is playing")
return false
}
const handleClickLike = async () => {
if (!playerState.track_manifest) {
console.error("Cannot like a track if nothing is playing")
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">
{
app.isMobile && <Button
type="ghost"
icon={<Icons.MdAbc />}
disabled={!playerState.track_manifest?.lyrics_enabled}
/>
}
return (
<div className="extra_actions">
{app.isMobile && (
<Button
type="ghost"
icon={<Icons.MdAbc />}
disabled={!playerState.track_manifest?.lyrics_enabled}
/>
)}
{
!app.isMobile && playerState.track_manifest?._id && <LikeButton
liked={playerState.track_manifest?.serviceOperations.fetchLikeStatus}
onClick={handleClickLike}
/>
}
{!app.isMobile && (
<LikeButton
liked={
playerState.track_manifest?.serviceOperations
.fetchLikeStatus
}
onClick={handleClickLike}
disabled={!playerState.track_manifest?._id}
/>
)}
<Button
type="ghost"
icon={<Icons.MdQueueMusic />}
/>
</div>
<Button type="ghost" 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"
export default class SeekBar extends React.Component {
state = {
playing: app.cores.player.state["playback_status"] === "playing",
timeText: "00:00",
durationText: "00:00",
sliderTime: 0,
sliderLock: false,
}
state = {
playing: app.cores.player.state["playback_status"] === "playing",
timeText: "00:00",
durationText: "00:00",
sliderTime: 0,
sliderLock: false,
}
handleSeek = (value) => {
if (value > 0) {
// calculate the duration of the audio
const duration = app.cores.player.controls.duration()
handleSeek = (value) => {
if (value > 0) {
// calculate the duration of the audio
const duration = app.cores.player.controls.duration()
// calculate the seek of the audio
const seek = (value / 100) * duration
// calculate the seek of the audio
const seek = (value / 100) * duration
app.cores.player.controls.seek(seek)
} else {
app.cores.player.controls.seek(0)
}
}
app.cores.player.controls.seek(seek)
} else {
app.cores.player.controls.seek(0)
}
}
calculateDuration = (preCalculatedDuration) => {
// get current audio duration
const audioDuration = preCalculatedDuration ?? app.cores.player.controls.duration()
calculateDuration = (preCalculatedDuration) => {
// get current audio duration
const audioDuration =
preCalculatedDuration ?? app.cores.player.controls.duration()
if (isNaN(audioDuration)) {
return
}
if (isNaN(audioDuration)) {
return
}
// set duration
this.setState({
durationText: seekToTimeLabel(audioDuration)
})
}
// set duration
this.setState({
durationText: seekToTimeLabel(audioDuration),
})
}
calculateTime = () => {
// get current audio seek
const seek = app.cores.player.controls.seek()
calculateTime = () => {
// get current audio seek
const seek = app.cores.player.controls.seek()
// set time
this.setState({
timeText: seekToTimeLabel(seek)
})
}
// set time
this.setState({
timeText: seekToTimeLabel(seek),
})
}
updateProgressBar = () => {
if (this.state.sliderLock) {
return
}
updateProgressBar = () => {
if (this.state.sliderLock) {
return
}
const seek = app.cores.player.controls.seek()
const duration = app.cores.player.controls.duration()
const seek = app.cores.player.controls.seek()
const duration = app.cores.player.controls.duration()
const percent = (seek / duration) * 100
const percent = (seek / duration) * 100
this.setState({
sliderTime: percent
})
}
this.setState({
sliderTime: percent,
})
}
updateAll = () => {
this.calculateTime()
this.updateProgressBar()
}
updateAll = () => {
this.calculateTime()
this.updateProgressBar()
}
events = {
// handle when player changes playback status
"player.state.update:playback_status": (status) => {
this.setState({
playing: status === "playing",
})
events = {
// handle when player changes playback status
"player.state.update:playback_status": (status) => {
this.setState({
playing: status === "playing",
})
switch (status) {
case "stopped":
this.setState({
timeText: "00:00",
durationText: "00:00",
sliderTime: 0,
})
switch (status) {
case "stopped":
this.setState({
timeText: "00:00",
durationText: "00:00",
sliderTime: 0,
})
break
case "playing":
this.updateAll()
this.calculateDuration()
break
case "playing":
this.updateAll()
this.calculateDuration()
break
default:
break
}
},
// handle when player changes track
"player.state.update:track_manifest": (manifest) => {
if (!manifest) {
return false
}
break
default:
break
}
},
// handle when player changes track
"player.state.update:track_manifest": (manifest) => {
if (!manifest) {
return false
}
this.updateAll()
this.updateAll()
this.setState({
timeText: "00:00",
sliderTime: 0,
})
this.setState({
timeText: "00:00",
sliderTime: 0,
})
this.calculateDuration(manifest.metadata?.duration ?? manifest.duration)
},
"player.seeked": (seek) => {
this.calculateTime()
this.updateAll()
},
"player.durationchange": () => {
this.calculateDuration()
},
}
this.calculateDuration(
manifest.metadata?.duration ?? manifest.duration,
)
},
"player.seeked": (seek) => {
this.calculateTime()
this.updateAll()
},
"player.durationchange": () => {
this.calculateDuration()
},
}
tick = () => {
if (this.state.playing) {
this.interval = setInterval(() => {
this.updateAll()
}, 1000)
} else {
if (this.interval) {
clearInterval(this.interval)
}
}
}
tick = () => {
if (this.state.playing) {
this.interval = setInterval(() => {
this.updateAll()
}, 1000)
} else {
if (this.interval) {
clearInterval(this.interval)
}
}
}
componentDidMount = () => {
this.calculateDuration()
this.updateAll()
this.tick()
componentDidMount = () => {
this.calculateDuration()
this.updateAll()
this.tick()
for (const [event, callback] of Object.entries(this.events)) {
app.cores.player.eventBus().on(event, callback)
}
}
for (const [event, callback] of Object.entries(this.events)) {
app.cores.player.eventBus().on(event, callback)
}
}
componentWillUnmount = () => {
for (const [event, callback] of Object.entries(this.events)) {
app.cores.player.eventBus().off(event, callback)
}
}
componentWillUnmount = () => {
for (const [event, callback] of Object.entries(this.events)) {
app.cores.player.eventBus().off(event, callback)
}
}
componentDidUpdate = (prevProps, prevState) => {
if (this.state.playing !== prevState.playing) {
this.tick()
}
}
componentDidUpdate = (prevProps, prevState) => {
if (this.state.playing !== prevState.playing) {
this.tick()
}
}
render() {
return <div
className={classnames(
"player-seek_bar",
{
["stopped"]: this.props.stopped,
}
)}
>
<div
className={classnames(
"progress",
{
["hidden"]: this.props.streamMode,
}
)}
>
<Slider
size="small"
value={this.state.sliderTime}
disabled={this.props.stopped || this.props.streamMode || this.props.disabled}
min={0}
max={100}
step={0.1}
onChange={(_, value) => {
this.setState({
sliderTime: value,
sliderLock: true
})
}}
onChangeCommitted={() => {
this.setState({
sliderLock: false
})
render() {
return (
<div
className={classnames("player-seek_bar", {
["stopped"]: this.props.stopped,
})}
>
<div
className={classnames("progress", {
["hidden"]: this.props.streamMode,
})}
>
<Slider
size="small"
value={this.state.sliderTime}
disabled={
this.props.stopped ||
this.props.streamMode ||
this.props.disabled
}
min={0}
max={100}
step={0.1}
onChange={(_, value) => {
this.setState({
sliderTime: value,
sliderLock: true,
})
}}
onChangeCommitted={() => {
this.setState({
sliderLock: false,
})
this.handleSeek(this.state.sliderTime)
this.handleSeek(this.state.sliderTime)
if (!this.props.playing) {
app.cores.player.playback.play()
}
}}
valueLabelDisplay="auto"
valueLabelFormat={(value) => {
return seekToTimeLabel((value / 100) * app.cores.player.controls.duration())
}}
/>
</div>
<div className="timers">
<div>
<span>{this.state.timeText}</span>
</div>
<div>
{
this.props.streamMode ? <antd.Tag>Live</antd.Tag> : <span>{this.state.durationText}</span>
}
</div>
</div>
</div>
}
if (!this.props.playing) {
app.cores.player.playback.play()
}
}}
valueLabelDisplay="auto"
valueLabelFormat={(value) => {
return seekToTimeLabel(
(value / 100) *
app.cores.player.controls.duration(),
)
}}
/>
</div>
{!this.props.streamMode && (
<div className="timers">
<div>
<span>{this.state.timeText}</span>
</div>
<div>
<span>{this.state.durationText}</span>
</div>
</div>
)}
</div>
)
}
}

View File

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