improve Media player

This commit is contained in:
SrGooglo 2023-02-24 14:31:00 +00:00
parent 79565eb041
commit ec76683b4d
2 changed files with 495 additions and 241 deletions

View File

@ -1,12 +1,12 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import Slider from "@mui/material/Slider"
import classnames from "classnames"
import { Icons } from "components/Icons" import { Icons, createIconRender } from "components/Icons"
import "./index.less" import "./index.less"
// FIXME: JS events are not working properly, the methods cause a memory leak
// FIXME: Use a better way to preload audios
// TODO: Check AUDIO quality and show a quality indicator // TODO: Check AUDIO quality and show a quality indicator
// TODO: Add close button // TODO: Add close button
// TODO: Add repeat & shuffle mode // TODO: Add repeat & shuffle mode
@ -31,14 +31,33 @@ const AudioVolume = (props) => {
</div> </div>
} }
const PlayerStatus = React.memo((props) => { class SeekBar extends React.Component {
const [playing, setPlaying] = React.useState(false) state = {
const [time, setTime] = React.useState("00:00") timeText: "00:00",
const [duration, setDuration] = React.useState("00:00") durationText: "00:00",
const [progressBar, setProgressBar] = React.useState(0) sliderTime: 0,
sliderLock: false,
}
const updateDuration = () => { handleSeek = (value) => {
const audioDuration = app.AudioPlayer.currentAudio.instance.duration() if (value > 0) {
// calculate the duration of the audio
const duration = app.cores.player.duration()
// calculate the seek of the audio
const seek = (value / 100) * duration
app.cores.player.seek(seek)
} else {
app.cores.player.seek(0)
}
}
calculateDuration = () => {
// get current audio duration
const audioDuration = app.cores.player.duration()
console.log(`Audio duration: ${audioDuration}`)
// convert duration to minutes and seconds // convert duration to minutes and seconds
const minutes = Math.floor(audioDuration / 60) const minutes = Math.floor(audioDuration / 60)
@ -53,12 +72,14 @@ const PlayerStatus = React.memo((props) => {
const secondsString = seconds < 10 ? `0${seconds}` : seconds const secondsString = seconds < 10 ? `0${seconds}` : seconds
// set duration // set duration
setDuration(`${minutesString}:${secondsString}`) this.setState({
durationText: `${minutesString}:${secondsString}`
})
} }
const updateTimer = () => { calculateTime = () => {
// get audio seek // get current audio seek
const seek = app.AudioPlayer.currentAudio.instance.seek() const seek = app.cores.player.seek()
// convert seek to minutes and seconds // convert seek to minutes and seconds
const minutes = Math.floor(seek / 60) const minutes = Math.floor(seek / 60)
@ -73,219 +94,301 @@ const PlayerStatus = React.memo((props) => {
const secondsString = seconds < 10 ? `0${seconds}` : seconds const secondsString = seconds < 10 ? `0${seconds}` : seconds
// set time // set time
setTime(`${minutesString}:${secondsString}`) this.setState({
timeText: `${minutesString}:${secondsString}`
})
} }
const updateProgressBar = () => { updateProgressBar = () => {
const seek = app.AudioPlayer.currentAudio.instance.seek() if (this.state.sliderLock) {
const duration = app.AudioPlayer.currentAudio.instance.duration() return
}
const seek = app.cores.player.seek()
const duration = app.cores.player.duration()
const percent = (seek / duration) * 100 const percent = (seek / duration) * 100
setProgressBar(percent) this.setState({
sliderTime: percent
})
} }
const onUpdateSeek = (value) => { updateAll = () => {
// calculate the duration of the audio this.calculateTime()
const duration = app.AudioPlayer.currentAudio.instance.duration() this.updateProgressBar()
// calculate the seek of the audio
const seek = (value / 100) * duration
// update the progress bar
setProgressBar(value)
// seek to the new value
app.AudioPlayer.currentAudio.instance.seek(seek)
} }
const tooltipFormatter = (value) => { events = {
if (!app.AudioPlayer.currentAudio) { "player.status.update": (status) => {
return "00:00" console.log(`Player status updated: ${status}`)
switch (status) {
case "stopped":
this.setState({
timeText: "00:00",
durationText: "00:00",
sliderTime: 0,
})
break
case "playing":
this.updateAll()
this.calculateDuration()
break
default:
break
}
},
"player.current.update": (currentAudioManifest) => {
console.log(`Player current audio updated:`, currentAudioManifest)
this.updateAll()
this.setState({
timeText: "00:00",
sliderTime: 0,
})
this.calculateDuration()
},
"player.duration.update": (duration) => {
console.log(`Player duration updated: ${duration}`)
this.calculateDuration()
},
"player.seek.update": (seek) => {
console.log(`Player seek updated: ${seek}`)
this.calculateTime()
this.updateAll()
}
} }
const duration = app.AudioPlayer.currentAudio.instance.duration() tick = () => {
if (this.props.playing) {
const seek = (value / 100) * duration this.interval = setInterval(() => {
this.updateAll()
// convert seek to minutes and seconds
const minutes = Math.floor(seek / 60)
// add leading zero if minutes is less than 10
const minutesString = minutes < 10 ? `0${minutes}` : minutes
// get seconds
const seconds = Math.floor(seek - minutes * 60)
// add leading zero if seconds is less than 10
const secondsString = seconds < 10 ? `0${seconds}` : seconds
return `${minutesString}:${secondsString}`
}
// create a interval when audio is playing, and destroy it when audio is paused
React.useEffect(() => {
const interval = setInterval(() => {
if (app.AudioPlayer.currentAudio) {
updateTimer()
updateProgressBar()
}
}, 1000) }, 1000)
} else {
return () => { clearInterval(this.interval)
clearInterval(interval)
}
}, [playing])
const events = {
"audioPlayer.seeked": () => {
updateTimer()
updateProgressBar()
},
"audioPlayer.playing": () => {
updateDuration()
updateTimer()
updateProgressBar()
},
"audioPlayer.paused": () => {
setPlaying(false)
},
"audioPlayer.stopped": () => {
setPlaying(false)
} }
} }
React.useEffect(() => { componentDidMount = () => {
// listen events this.calculateDuration()
for (const [event, callback] of Object.entries(events)) { this.tick()
for (const [event, callback] of Object.entries(this.events)) {
app.eventBus.on(event, callback) app.eventBus.on(event, callback)
} }
}
return () => { componentWillUnmount = () => {
// remove events for (const [event, callback] of Object.entries(this.events)) {
for (const [event, callback] of Object.entries(events)) {
app.eventBus.off(event, callback) app.eventBus.off(event, callback)
} }
} }
}, [])
componentDidUpdate = (prevProps, prevState) => {
if (this.props.playing !== prevProps.playing) {
this.tick()
}
}
render() {
return <div className="status"> return <div className="status">
<div className="progress"> <div className="progress">
<antd.Slider <Slider
value={progressBar} size="small"
onAfterChange={onUpdateSeek} value={this.state.sliderTime}
tooltip={{ formatter: tooltipFormatter }} disabled={this.props.stopped}
disabled={!app.AudioPlayer.currentAudio} 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)
if (!this.props.playing) {
app.cores.player.playback.play()
}
}}
/> />
</div> </div>
<div className="timers"> <div className="timers">
<div> <div>
<span>{time}</span> <span>{this.state.timeText}</span>
</div> </div>
<div> <div>
<span>{duration}</span> <span>{this.state.durationText}</span>
</div> </div>
</div> </div>
</div> </div>
}
}
const AudioPlayerChangeModeButton = (props) => {
const [mode, setMode] = React.useState(app.cores.player.playback.mode())
const modeToIcon = {
"normal": "MdArrowForward",
"repeat": "MdRepeat",
"shuffle": "MdShuffle",
}
const onClick = () => {
const modes = Object.keys(modeToIcon)
const newMode = modes[(modes.indexOf(mode) + 1) % modes.length]
app.cores.player.playback.mode(newMode)
setMode(newMode)
}
return <antd.Button
icon={createIconRender(modeToIcon[mode])}
onClick={onClick}
type="ghost"
/>
}
export default class AudioPlayer extends React.Component {
state = {
loading: app.cores.player.getState("loading") ?? false,
currentPlaying: app.cores.player.getState("currentAudioManifest"),
playbackStatus: app.cores.player.getState("playbackStatus") ?? "stopped",
audioMuted: app.cores.player.getState("audioMuted") ?? false,
audioVolume: app.cores.player.getState("audioVolume") ?? 0.3,
bpm: app.cores.player.getState("trackBPM") ?? 0,
showControls: false,
}
events = {
"player.bpm.update": (data) => {
this.setState({ bpm: data })
},
"player.loading.update": (data) => {
this.setState({ loading: data })
},
"player.status.update": (data) => {
this.setState({ playbackStatus: data })
},
"player.current.update": (data) => {
this.setState({ currentPlaying: data })
},
"player.mute.update": (data) => {
this.setState({ audioMuted: data })
},
"player.volume.update": (data) => {
this.setState({ audioVolume: data })
},
}
componentDidMount = async () => {
Object.entries(this.events).forEach(([event, callback]) => {
app.eventBus.on(event, callback)
}) })
export default (props) => {
const [mute, setMute] = React.useState(app.AudioPlayer.audioMuted)
const [volume, setVolume] = React.useState(app.AudioPlayer.audioVolume)
const [currentPlaying, setCurrentPlaying] = React.useState(null)
const [playing, setPlaying] = React.useState(false)
const [loading, setLoading] = React.useState(true)
const toogleMute = () => {
setMute(app.AudioPlayer.toogleMute())
} }
const updateVolume = (value) => { componentWillUnmount() {
console.log("Updating volume", value) Object.entries(this.events).forEach(([event, callback]) => {
app.eventBus.off(event, callback)
})
}
setVolume(app.AudioPlayer.setVolume(value)) onMouse = (event) => {
const { type } = event
if (value > 0) { if (type === "mouseenter") {
setMute(false) this.setState({ showControls: true })
} else if (type === "mouseleave") {
this.setState({ showControls: false })
} }
} }
const onClickPlayButton = () => { minimize = () => {
setPlaying(!playing)
if (playing) {
app.AudioPlayer.pauseAudioQueue()
} else {
app.AudioPlayer.playCurrentAudio()
}
} }
const onClickNextButton = () => { updateVolume = (value) => {
app.AudioPlayer.nextAudio() app.cores.player.volume(value)
} }
const onClickPreviousButton = () => { toogleMute = (to) => {
app.AudioPlayer.previousAudio() app.cores.player.toogleMute(to)
} }
const busEvents = { onClickPlayButton = () => {
"audioPlayer.playing": (data) => { app.cores.player.playback.toogle()
if (data) {
setCurrentPlaying(data)
} }
setLoading(false) onClickPreviousButton = () => {
setPlaying(true) app.cores.player.playback.previous()
},
"audioPlayer.pause": () => {
setPlaying(false)
},
"audioPlayer.stopped": () => {
setPlaying(false)
},
"audioPlayer.loaded": () => {
setLoading(false)
},
"audioPlayer.loading": () => {
setLoading(true)
}
} }
// listen to events onClickNextButton = () => {
React.useEffect(() => { app.cores.player.playback.next()
for (const event in busEvents) {
app.eventBus.on(event, busEvents[event])
} }
return () => { render() {
for (const event in busEvents) { const {
app.eventBus.off(event, busEvents[event]) loading,
} currentPlaying,
} playbackStatus,
}, []) audioMuted,
audioVolume,
} = this.state
console.log(currentPlaying) return <div
className={classnames(
return <div className="embbededMediaPlayerWrapper"> "embbededMediaPlayerWrapper",
{
["hovering"]: this.state.showControls,
}
)}
onMouseEnter={this.onMouse}
onMouseLeave={this.onMouse}
>
<div className="player">
<div className="minimize_btn">
<antd.Button
icon={<Icons.MdFirstPage />}
onClick={this.minimize}
shape="circle"
/>
</div>
<div <div
className="cover" className="cover"
style={{ style={{
backgroundImage: `url(${(currentPlaying?.cover) ?? "/assets/no_song.png"})`, backgroundImage: `url(${(currentPlaying?.thumbnail) ?? "/assets/no_song.png"})`,
}} }}
/> />
<div className="player">
<div className="header"> <div className="header">
<div className="info"> <div className="info">
<div className="title"> <div className="title">
<h2> <h2>
{loading ? "Loading..." : (currentPlaying?.title ?? "Untitled")} {
currentPlaying?.title
? currentPlaying?.title
: (loading ? "Loading..." : (currentPlaying?.title ?? "Untitled"))
}
</h2> </h2>
</div> </div>
<div> <div>
{ {
!loading && <div className="artist"> currentPlaying?.artist && <div className="artist">
<h3> <h3>
{currentPlaying?.artist ?? "Unknown"} {currentPlaying?.artist ?? "Unknown"}
</h3> </h3>
@ -294,51 +397,57 @@ export default (props) => {
</div> </div>
</div> </div>
<div className="indicators"> <div className="indicators">
{loading ? {
<antd.Spin /> : loading && <antd.Spin />
<> }
<Icons.MdOutlineExplicit />
<Icons.MdOutlineHighQuality />
</>}
</div> </div>
</div> </div>
<div className="controls"> <div className="controls">
<div> <AudioPlayerChangeModeButton />
<antd.Button <antd.Button
type="ghost" type="ghost"
shape="round" shape="round"
onClick={onClickPreviousButton}
icon={<Icons.ChevronLeft />} icon={<Icons.ChevronLeft />}
onClick={this.onClickPreviousButton}
/> />
</div>
<div className="playButton">
<antd.Button <antd.Button
type="primary" type="primary"
shape="circle" shape="circle"
icon={playing ? <Icons.Pause /> : <Icons.Play />} icon={playbackStatus === "playing" ? <Icons.Pause /> : <Icons.Play />}
onClick={onClickPlayButton} onClick={this.onClickPlayButton}
/> />
</div>
<div>
<antd.Button <antd.Button
type="ghost" type="ghost"
shape="round" shape="round"
onClick={onClickNextButton}
icon={<Icons.ChevronRight />} icon={<Icons.ChevronRight />}
onClick={this.onClickNextButton}
/> />
</div> <antd.Popover
<antd.Popover content={React.createElement(AudioVolume, { onChange: updateVolume, defaultValue: volume })} trigger="hover"> content={React.createElement(
<div AudioVolume,
onClick={toogleMute} { onChange: this.updateVolume, defaultValue: audioVolume }
className="muteButton" )}
trigger="hover"
> >
{mute ? <Icons.VolumeX /> : <Icons.Volume2 />} <div
className="muteButton"
onClick={this.toogleMute}
>
{
audioMuted
? <Icons.VolumeX />
: <Icons.Volume2 />
}
</div> </div>
</antd.Popover> </antd.Popover>
</div> </div>
</div>
<PlayerStatus /> <SeekBar
stopped={playbackStatus === "stopped"}
playing={playbackStatus === "playing"}
/>
</div>
</div> </div>
} }
}

View File

@ -5,58 +5,162 @@
right: 0; right: 0;
bottom: 0; bottom: 0;
margin: 50px; display: flex;
flex-direction: row;
.cover { align-items: center;
position: relative; justify-content: center;
z-index: 310;
width: 100%; width: 270px;
height: 200px; height: fit-content;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
border-radius: 10px;
transition: all 0.3s ease-in-out; margin: 40px;
border-radius: 12px;
&.hovering {
.actions_wrapper {
.actions {
opacity: 1;
transform: translate(-90%, 0);
}
}
.minimize_btn {
opacity: 1;
}
}
.actions_wrapper {
display: flex;
flex-direction: column;
align-items: center;
z-index: 300;
position: absolute;
top: 0;
left: 0;
height: 96%;
.actions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 60px;
height: fit-content;
padding: 10px 8px 10px 5px;
background-color: rgba(var(--bg_color_2), 0.3);
transform: translate(-25%, 0);
border-radius: 12px 0 0 12px;
opacity: 0.5;
transition: all 150ms ease-in-out;
.ant-btn {
margin-bottom: 20px;
svg {
margin: 0 !important;
}
&:last-child {
margin-bottom: 0;
}
}
}
}
.minimize_btn {
position: absolute;
z-index: 330;
top: 0;
left: 0;
opacity: 0;
transition: all 150ms ease-in-out;
transform: translate(60%, 60%);
.ant-btn {
background-color: var(--background-color-accent);
svg {
margin: 0 !important;
}
}
} }
.player { .player {
position: relative; position: relative;
z-index: 320; z-index: 310;
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
min-width: 250px; align-items: flex-start;
max-width: 250px; justify-content: center;
width: 100%;
height: 100%;
padding: 10px; padding: 10px;
padding-bottom: 40px;
border-radius: 8px; border-radius: 8px;
background-color: var(--background-color-accent); background-color: var(--background-color-accent);
align-items: flex-start;
justify-content: center;
// animate in from bottom to top // animate in from bottom to top
animation: fadeIn 150ms ease-out forwards; animation: fadeIn 150ms ease-out forwards;
transition: all 150ms ease-out; transition: all 150ms ease-out;
transform: translate(0, 20px);
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.2); box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.2);
.cover {
z-index: 320;
width: 100%;
height: 250px;
min-height: 250px;
border-radius: 10px;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
transition: all 0.3s ease-in-out;
img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
}
.header { .header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 100%; margin: 15px 0;
margin-bottom: 15px; width: 100%;
.info { .info {
display: flex; display: flex;
@ -115,7 +219,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-evenly;
width: 100%; width: 100%;
@ -124,9 +228,9 @@
margin: 0 !important; margin: 0 !important;
} }
justify-content: space-evenly;
.muteButton { .muteButton {
padding: 10px;
svg { svg {
font-size: 1rem; font-size: 1rem;
} }
@ -135,24 +239,24 @@
} }
.status { .status {
position: relative;
z-index: 330; z-index: 330;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; align-items: center;
justify-content: center;
align-self: center;
width: 90%;
height: fit-content; height: fit-content;
padding: 10px; margin: 20px 0 10px 0;
background-color: var(--background-color-accent);
border-radius: 8px; border-radius: 8px;
// create box shadow only top of the player
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.2);
.progress { .progress {
width: 100%; width: 100%;
} }
@ -178,6 +282,47 @@
color: var(--text-color); color: var(--text-color);
} }
} }
.bpmTicker {
position: absolute;
top: 0;
left: 0;
width: 30px;
height: 30px;
border-radius: 50%;
background-color: var(--background-color-contrast);
transform: translate(-50%, -50%);
&.pulse {
animation: pulseBPM;
animation-iteration-count: infinite;
animation-fill-mode: forwards;
animation-duration: 1s;
}
}
}
@keyframes pulseBPM {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
}
} }
.ant-popover-inner-content { .ant-popover-inner-content {