migrate player to playerv2

This commit is contained in:
SrGooglo 2023-08-02 20:38:28 +00:00
parent 664b33cd3a
commit aa8e0864cc
25 changed files with 1892 additions and 333 deletions

View File

@ -377,13 +377,13 @@ export class BottomBar extends React.Component {
</div>
{
this.context.currentManifest && <div
this.context.track_manifest && <div
className="item"
>
<PlayerButton
manifest={this.context.currentManifest}
playback={this.context.playbackStatus}
colorAnalysis={this.context.coverColorAnalysis}
manifest={this.context.track_manifest}
playback={this.context.playback_status}
colorAnalysis={this.context.track_manifest?.cover_analysis}
/>
</div>
}

View File

@ -21,7 +21,7 @@ export default (props) => {
const onClickPlay = (e) => {
e.stopPropagation()
app.cores.player.startPlaylist(playlist.list)
app.cores.player.start(playlist.list)
}
return <div

View File

@ -9,14 +9,17 @@ export default (props) => {
const { data } = props
const startPlaylist = () => {
app.cores.player.startPlaylist(data.list, 0)
app.cores.player.start(data.list, 0)
}
const navigateToPlaylist = () => {
app.location.push(`/play/${data._id}`)
}
return <div className="playlistTimelineEntry">
return <div
className="playlistTimelineEntry"
style={props.style}
>
<div className="playlistTimelineEntry_content">
<div className="playlistTimelineEntry_thumbnail">
<Image

View File

@ -5,7 +5,7 @@
width: 100%;
max-width: 600px;
height: 100%;
height: fit-content;
background-color: var(--background-color-accent);

View File

@ -35,7 +35,14 @@ export default (props) => {
return
}
app.cores.player.startPlaylist(playlist.list, index)
// check if is currently playing
if (app.cores.player.state.track_manifest?._id === track._id) {
app.cores.player.playback.toggle()
} else {
app.cores.player.start(playlist.list, {
startIndex: index
})
}
}
const handleTrackLike = async (track) => {

View File

@ -14,20 +14,24 @@ import "./index.less"
export default (props) => {
// use react context to get the current track
const {
currentManifest,
playbackStatus,
track_manifest,
playback_status,
} = React.useContext(Context)
const isLiked = props.track?.liked
const isCurrent = currentManifest?._id === props.track._id
const isPlaying = isCurrent && playbackStatus === "playing"
const isCurrent = track_manifest?._id === props.track._id
const isPlaying = isCurrent && playback_status === "playing"
const handleClickPlayBtn = React.useCallback(() => {
if (typeof props.onClickPlayBtn === "function") {
props.onClickPlayBtn(props.track)
} else {
console.warn("Searcher: onClick is not a function, using default action...")
app.cores.player.start(props.track)
if (!isCurrent) {
app.cores.player.start(props.track)
} else {
app.cores.player.playback.toggle()
}
}
})
@ -80,7 +84,7 @@ export default (props) => {
{
props.track.service === "tidal" && <Icons.SiTidal />
}
<div className="music-track_info_duration">
{
props.track.metadata?.duration

View File

@ -73,19 +73,19 @@ export class BackgroundMediaPlayer extends React.Component {
className={classnames(
"background_media_player",
{
["lightBackground"]: this.context.coverColorAnalysis?.isLight,
["lightBackground"]: this.context.track_manifest?.cover_analysis?.isLight ?? false,
["expanded"]: this.state.expanded,
}
)}
style={{
backgroundColor: this.context.coverColorAnalysis?.rgba,
"--averageColorValues": this.context.coverColorAnalysis?.rgba,
backgroundColor: this.context.track_manifest?.cover_analysis?.rgba,
"--averageColorValues": this.context.track_manifest?.cover_analysis?.rgba,
}}
>
<div
className="background_media_player__background"
style={{
backgroundImage: `url(${this.context.currentManifest?.cover ?? this.context.currentManifest?.thumbnail})`
backgroundImage: `url(${this.context.track_manifest?.cover ?? this.context.track_manifest?.thumbnail})`
}}
/>
@ -98,12 +98,12 @@ export class BackgroundMediaPlayer extends React.Component {
className={classnames(
"background_media_player__icon",
{
["bounce"]: this.context.playbackStatus === "playing",
["bounce"]: this.context.playback_status === "playing",
}
)}
>
{
this.context.playbackStatus === "playing" ? <Icons.MdMusicNote /> : <Icons.MdPause />
this.context.playback_status === "playing" ? <Icons.MdMusicNote /> : <Icons.MdPause />
}
</div>
@ -113,14 +113,14 @@ export class BackgroundMediaPlayer extends React.Component {
>
{
!this.state.expanded && <Marquee
gradientColor={RGBStringToValues(this.state.thumbnailAnalysis?.rgb)}
gradientColor={RGBStringToValues(this.context.track_cover_analysis?.rgb)}
gradientWidth={20}
play={this.context.playbackStatus !== "stopped"}
play={this.context.playback_status !== "stopped"}
>
<h4>
{
this.context.playbackStatus === "stopped" ? "Nothing is playing" : <>
{`${this.context.currentManifest?.title} - ${this.context.currentManifest?.artist}` ?? "Untitled"}
this.context.playback_status === "stopped" ? "Nothing is playing" : <>
{`${this.context.track_manifest?.title} - ${this.context.track_manifest?.artist}` ?? "Untitled"}
</>
}
</h4>
@ -131,8 +131,8 @@ export class BackgroundMediaPlayer extends React.Component {
<Icons.MdAlbum />
{
this.context.playbackStatus === "stopped" ? "Nothing is playing" : <>
{this.context.currentManifest?.title ?? "Untitled"}
this.context.playback_status === "stopped" ? "Nothing is playing" : <>
{this.context.track_manifest?.title ?? "Untitled"}
</>
}
</h4>
@ -165,7 +165,7 @@ export class BackgroundMediaPlayer extends React.Component {
size="small"
type="ghost"
shape="circle"
icon={this.context.playbackStatus === "playing" ? <Icons.MdPause /> : <Icons.MdPlayArrow />}
icon={this.context.playback_status === "playing" ? <Icons.MdPause /> : <Icons.MdPlayArrow />}
onClick={app.cores.player.playback.toggle}
/>

View File

@ -36,12 +36,60 @@ const ServiceIndicator = (props) => {
}
}
const MemeDancer = (props) => {
const defaultBpm = 120
const [currentBpm, setCurrentBpm] = React.useState(defaultBpm)
const videoRef = React.useRef()
const togglePlayback = (to) => {
videoRef.current[to ? "play" : "pause"]()
}
React.useEffect(() => {
app.cores.player.eventBus.on("bpm.change", (bpm) => {
setCurrentBpm(bpm)
})
app.cores.player.eventBus.on("player.state.update:playback_status", (status) => {
if (status === "playing") {
togglePlayback(true)
}else {
togglePlayback(false)
}
})
}, [])
React.useEffect(() => {
if (typeof currentBpm === "number" && isFinite(currentBpm)) {
let playbackRate = currentBpm / 120;
playbackRate = Math.min(4.0, Math.max(0.1, playbackRate)); // Limit the range between 0.1 and 4.0
videoRef.current.playbackRate = playbackRate;
}
}, [currentBpm])
return <div className="meme_dancer">
<video
ref={videoRef}
muted
autoPlay
loop
controls={false}
>
<source
src="https://media.tenor.com/-VG9cLwSYTcAAAPo/dancing-triangle-dancing.mp4"
/>
</video>
</div>
}
// TODO: Queue view
export class AudioPlayer extends React.Component {
static contextType = Context
state = {
showControls: false,
showDancer: false,
}
onMouse = (event) => {
@ -79,7 +127,7 @@ export class AudioPlayer extends React.Component {
}
onClickPlayButton = () => {
if (this.context.streamMode) {
if (this.context.sync_mode) {
return app.cores.player.playback.stop()
}
@ -94,6 +142,10 @@ export class AudioPlayer extends React.Component {
app.cores.player.playback.next()
}
toggleDancer = () => {
this.setState({ showDancer: !this.state.showDancer })
}
render() {
return <div
className={classnames(
@ -116,7 +168,7 @@ export class AudioPlayer extends React.Component {
/>
{
!this.context.syncModeLocked && !this.context.syncMode && <antd.Button
!this.context.control_locked && !this.context.sync_mode && <antd.Button
icon={<Icons.MdShare />}
onClick={this.inviteSync}
shape="circle"
@ -139,40 +191,44 @@ export class AudioPlayer extends React.Component {
}
<div className="player">
{
this.state.showDancer && <MemeDancer />
}
<ServiceIndicator
service={this.context.currentManifest?.service}
service={this.context.track_manifest?.service}
/>
<div
className="cover"
style={{
backgroundImage: `url(${(this.context.currentManifest?.cover ?? this.context.currentManifest?.thumbnail) ?? "/assets/no_song.png"})`,
backgroundImage: `url(${(this.context.track_manifest?.cover ?? this.context.track_manifest?.thumbnail) ?? "/assets/no_song.png"})`,
}}
/>
<div className="header">
<div className="info">
<div className="title">
<h2>
<h2 onDoubleClick={this.toggleDancer}>
{
this.context.currentManifest?.title
? this.context.currentManifest?.title
: (this.context.loading ? "Loading..." : (this.context.currentPlaying?.title ?? "Untitled"))
this.context.track_manifest?.title
? this.context.track_manifest?.title
: (this.context.loading ? "Loading..." : (this.context.track_manifest?.metadata?.title ?? "Untitled"))
}
</h2>
</div>
<div className="subTitle">
{
this.context.currentManifest?.artist && <div className="artist">
this.context.track_manifest?.metadata?.artist && <div className="artist">
<h3>
{this.context.currentManifest?.artist ?? "Unknown"}
{this.context.track_manifest?.metadata?.artist ?? "Unknown"}
</h3>
</div>
}
{
!app.isMobile && this.context.playbackStatus !== "stopped" && <LikeButton
onClick={app.cores.player.toggleCurrentTrackLike}
liked={this.context.liked}
!app.isMobile && this.context.playback_status !== "stopped" && <LikeButton
//onClick={app.cores.player.toggleCurrentTrackLike}
liked={this.context.track_manifest?.metadata?.liked}
/>
}
</div>
@ -180,20 +236,20 @@ export class AudioPlayer extends React.Component {
</div>
<Controls
syncModeLocked={this.context.syncModeLocked}
syncMode={this.context.syncMode}
playbackStatus={this.context.playbackStatus}
audioMuted={this.context.audioMuted}
audioVolume={this.context.audioVolume}
syncModeLocked={this.context.control_locked}
syncMode={this.context.sync_mode}
playbackStatus={this.context.playback_status}
audioMuted={this.context.muted}
audioVolume={this.context.volume}
onVolumeUpdate={this.updateVolume}
onMuteUpdate={this.toggleMute}
controls={{
previous: this.onClickPreviousButton,
toggle: this.onClickPlayButton,
next: this.onClickNextButton,
like: app.cores.player.toggleCurrentTrackLike,
//like: app.cores.player.toggleCurrentTrackLike,
}}
liked={this.context.liked}
liked={this.context.track_manifest?.metadata?.liked}
/>
<SeekBar

View File

@ -238,6 +238,18 @@ html {
}
}
.meme_dancer {
width: 100%;
video {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 12px;
}
}
@keyframes fadeIn {
from {
opacity: 0;

View File

@ -8,6 +8,7 @@ import "./index.less"
export default class SeekBar extends React.Component {
state = {
playing: false,
timeText: "00:00",
durationText: "00:00",
sliderTime: 0,
@ -28,16 +29,14 @@ export default class SeekBar extends React.Component {
}
}
calculateDuration = () => {
calculateDuration = (preCalculatedDuration) => {
// get current audio duration
const audioDuration = app.cores.player.duration()
const audioDuration = preCalculatedDuration || app.cores.player.duration()
if (isNaN(audioDuration)) {
return
}
console.log(`Audio duration: ${audioDuration}`)
// set duration
this.setState({
durationText: seekToTimeLabel(audioDuration)
@ -74,9 +73,14 @@ export default class SeekBar extends React.Component {
this.updateProgressBar()
}
eventBus = app.cores.player.eventBus
events = {
"player.status.update": (status) => {
console.log(`Player status updated: ${status}`)
// handle when player changes playback status
"player.state.update:playback_status": (status) => {
this.setState({
playing: status === "playing",
})
switch (status) {
case "stopped":
@ -96,9 +100,8 @@ export default class SeekBar extends React.Component {
break
}
},
"player.current.update": (currentAudioManifest) => {
console.log(`Player current audio updated:`, currentAudioManifest)
// handle when player changes track
"player.state.update:track_manifest": (manifest) => {
this.updateAll()
this.setState({
@ -106,23 +109,16 @@ export default class SeekBar extends React.Component {
sliderTime: 0,
})
this.calculateDuration()
this.calculateDuration(manifest.metadata?.duration ?? manifest.duration)
},
"player.duration.update": (duration) => {
console.log(`Player duration updated: ${duration}`)
this.calculateDuration()
},
"player.seek.update": (seek) => {
console.log(`Player seek updated: ${seek}`)
"player.seeked": (seek) => {
this.calculateTime()
this.updateAll()
},
}
tick = () => {
if (this.props.playing || this.props.streamMode) {
if (this.state.playing) {
this.interval = setInterval(() => {
this.updateAll()
}, 1000)
@ -138,18 +134,18 @@ export default class SeekBar extends React.Component {
this.tick()
for (const [event, callback] of Object.entries(this.events)) {
app.eventBus.on(event, callback)
this.eventBus.on(event, callback)
}
}
componentWillUnmount = () => {
for (const [event, callback] of Object.entries(this.events)) {
app.eventBus.off(event, callback)
this.eventBus.off(event, callback)
}
}
componentDidUpdate = (prevProps, prevState) => {
if (this.props.playing !== prevProps.playing) {
if (this.state.playing !== prevState.playing) {
this.tick()
}
}

View File

@ -1,86 +1,60 @@
import React from "react"
export const DefaultContextValues = {
currentManifest: null,
playbackStatus: null,
coverColorAnalysis: null,
loading: false,
audioMuted: false,
audioVolume: 1,
minimized: false,
streamMode: false,
bpm: 0,
syncMode: false,
syncModeLocked: false,
liked: false,
muted: false,
volume: 1,
sync_mode: false,
livestream_mode: false,
control_locked: false,
track_cover_analysis: null,
track_metadata: null,
playback_mode: "repeat",
playback_status: null,
}
export const Context = React.createContext(DefaultContextValues)
export class WithPlayerContext extends React.Component {
state = {
currentManifest: app.cores.player.getState("currentAudioManifest"),
playbackStatus: app.cores.player.getState("playbackStatus") ?? "stopped",
coverColorAnalysis: app.cores.player.getState("coverColorAnalysis"),
loading: app.cores.player.getState("loading") ?? false,
audioMuted: app.cores.player.getState("audioMuted") ?? false,
audioVolume: app.cores.player.getState("audioVolume") ?? 0.3,
minimized: app.cores.player.getState("minimized") ?? false,
streamMode: app.cores.player.getState("livestream") ?? false,
bpm: app.cores.player.getState("trackBPM") ?? 0,
syncMode: app.cores.player.getState("syncModeLocked"),
syncModeLocked: app.cores.player.getState("syncMode"),
liked: app.cores.player.getState("liked"),
loading: app.cores.player.state["loading"],
minimized: app.cores.player.state["minimized"],
muted: app.cores.player.state["muted"],
volume: app.cores.player.state["volume"],
sync_mode: app.cores.player.state["sync_mode"],
livestream_mode: app.cores.player.state["livestream_mode"],
control_locked: app.cores.player.state["control_locked"],
track_manifest: app.cores.player.state["track_manifest"],
playback_mode: app.cores.player.state["playback_mode"],
playback_status: app.cores.player.state["playback_status"],
}
events = {
"player.syncModeLocked.update": (to) => {
this.setState({ syncModeLocked: to })
"player.state.update": (state) => {
this.setState(state)
},
"player.syncMode.update": (to) => {
this.setState({ syncMode: to })
},
"player.livestream.update": (data) => {
this.setState({ streamMode: data })
},
"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({ currentManifest: data })
},
"player.mute.update": (data) => {
this.setState({ audioMuted: data })
},
"player.volume.update": (data) => {
this.setState({ audioVolume: data })
},
"player.minimized.update": (minimized) => {
this.setState({ minimized })
},
"player.coverColorAnalysis.update": (data) => {
this.setState({ coverColorAnalysis: data })
},
"player.toggle.like": (data) => {
this.setState({ liked: data })
}
}
eventBus = app.cores.player.eventBus
componentDidMount() {
for (const [event, handler] of Object.entries(this.events)) {
app.eventBus.on(event, handler)
this.eventBus.on(event, handler)
}
}
componentWillUnmount() {
for (const [event, handler] of Object.entries(this.events)) {
app.eventBus.off(event, handler)
this.eventBus.off(event, handler)
}
}

View File

@ -15,31 +15,13 @@ import EqProcessorNode from "./processors/eqNode"
import GainProcessorNode from "./processors/gainNode"
import CompressorProcessorNode from "./processors/compressorNode"
const servicesToManifestResolver = {
"tidal": async (manifest) => {
const resolvedManifest = await SyncModel.tidalCore.getTrackManifest(manifest.id)
console.log(resolvedManifest)
manifest.source = resolvedManifest.playback.url
manifest.title = resolvedManifest.metadata.title
manifest.artist = resolvedManifest.metadata.artists.map(artist => artist.name).join(", ")
manifest.album = resolvedManifest.metadata.album.title
const coverUID = resolvedManifest.metadata.album.cover.replace(/-/g, "/")
manifest.cover = `https://resources.tidal.com/images/${coverUID}/1280x1280.jpg`
return manifest
}
}
import servicesToManifestResolver from "./servicesToManifestResolver"
function useMusicSync(event, data) {
const currentRoomData = app.cores.sync.music.currentRoomData()
if (!currentRoomData) {
console.warn("No room data available")
this.console.warn("No room data available")
return false
}
@ -55,124 +37,7 @@ const defaultAudioProccessors = [
CompressorProcessorNode,
]
class MediaSession {
initialize() {
CapacitorMusicControls.addListener("controlsNotification", (info) => {
console.log(info)
this.handleControlsEvent(info)
})
// ANDROID (13, see bug above as to why it's necessary)
document.addEventListener("controlsNotification", (event) => {
console.log(event)
const info = { message: event.message, position: 0 }
this.handleControlsEvent(info)
})
}
update(manifest) {
if ("mediaSession" in navigator) {
return navigator.mediaSession.metadata = new MediaMetadata({
title: manifest.title,
artist: manifest.artist,
album: manifest.album,
artwork: [
{
src: manifest.cover ?? manifest.thumbnail,
sizes: "512x512",
type: "image/jpeg",
}
],
})
}
return CapacitorMusicControls.create({
track: manifest.title,
artist: manifest.artist,
album: manifest.album,
cover: manifest.cover,
hasPrev: false,
hasNext: false,
hasClose: true,
isPlaying: true,
dismissable: false,
playIcon: "media_play",
pauseIcon: "media_pause",
prevIcon: "media_prev",
nextIcon: "media_next",
closeIcon: "media_close",
notificationIcon: "notification"
})
}
updateIsPlaying(to, timeElapsed = 0) {
if ("mediaSession" in navigator) {
return navigator.mediaSession.playbackState = to ? "playing" : "paused"
}
return CapacitorMusicControls.updateIsPlaying({
isPlaying: to,
elapsed: timeElapsed,
})
}
destroy() {
if ("mediaSession" in navigator) {
navigator.mediaSession.playbackState = "none"
}
this.active = false
return CapacitorMusicControls.destroy()
}
handleControlsEvent(action) {
const message = action.message
switch (message) {
case "music-controls-next": {
return app.cores.player.playback.next()
}
case "music-controls-previous": {
return app.cores.player.playback.previous()
}
case "music-controls-pause": {
return app.cores.player.playback.pause()
}
case "music-controls-play": {
return app.cores.player.playback.play()
}
case "music-controls-destroy": {
return app.cores.player.playback.stop()
}
// External controls (iOS only)
case "music-controls-toggle-play-pause": {
return app.cores.player.playback.toggle()
}
// Headset events (Android only)
// All media button events are listed below
case "music-controls-media-button": {
return app.cores.player.playback.toggle()
}
case "music-controls-headset-unplugged": {
return app.cores.player.playback.pause()
}
case "music-controls-headset-plugged": {
return app.cores.player.playback.play()
}
default:
break;
}
}
}
import MediaSession from "./mediaSession"
// TODO: Check if source playing is a stream. Also handle if it's a stream resuming after a pause will seek to the last position
export default class Player extends Core {
@ -183,9 +48,7 @@ export default class Player extends Core {
static websocketListen = "music"
static refName = "player"
static namespace = "player"
static namespace = "player_dep"
// default statics
static maxBufferLoadQueue = 2
@ -240,7 +103,7 @@ export default class Player extends Core {
start: this.start.bind(this),
startPlaylist: this.startPlaylist.bind(this),
isIdCurrent: function (id) {
console.log("isIdCurrent", id, this.state.currentAudioManifest?._id === id)
this.console.log("isIdCurrent", id, this.state.currentAudioManifest?._id === id)
return this.state.currentAudioManifest?._id === id
}.bind(this),
@ -292,12 +155,12 @@ export default class Player extends Core {
}.bind(this),
toggle: function () {
if (!this.currentAudioInstance) {
console.error("No audio instance")
this.console.error("No audio instance")
return null
}
if (this.state.syncModeLocked) {
console.warn("Sync mode is locked, cannot do this action")
this.console.warn("Sync mode is locked, cannot do this action")
return false
}
@ -345,10 +208,10 @@ export default class Player extends Core {
async initializeAudioProcessors() {
if (this.audioProcessors.length > 0) {
console.log("Destroying audio processors")
this.console.log("Destroying audio processors")
this.audioProcessors.forEach((processor) => {
console.log(`Destroying audio processor ${processor.constructor.name}`, processor)
this.console.log(`Destroying audio processor ${processor.constructor.name}`, processor)
processor._destroy()
})
@ -360,13 +223,13 @@ export default class Player extends Core {
}
for await (const processor of this.audioProcessors) {
console.log(`Initializing audio processor ${processor.constructor.name}`, processor)
this.console.log(`Initializing audio processor ${processor.constructor.name}`, processor)
if (typeof processor._init === "function") {
try {
await processor._init(this.audioContext)
} catch (error) {
console.error(`Failed to initialize audio processor ${processor.constructor.name} >`, error)
this.console.error(`Failed to initialize audio processor ${processor.constructor.name} >`, error)
continue
}
}
@ -431,7 +294,7 @@ export default class Player extends Core {
this.state.coverColorAnalysis = color
})
.catch((err) => {
console.error(err)
this.console.error(err)
})
}
}
@ -539,7 +402,7 @@ export default class Player extends Core {
this.native_controls.initialize()
}
async initializeBeforeRuntimeInitialize() {
async initializeAfterRuntimeInitialize() {
for (const [eventName, eventHandler] of Object.entries(this.wsEvents)) {
app.cores.api.listenEvent(eventName, eventHandler, Player.websocketListen)
}
@ -555,7 +418,7 @@ export default class Player extends Core {
async toggleCurrentTrackLike() {
if (!this.currentAudioInstance) {
console.error("No track playing")
this.console.error("No track playing")
return false
}
@ -572,12 +435,12 @@ export default class Player extends Core {
attachPlayerComponent() {
if (this.currentDomWindow) {
console.warn("EmbbededMediaPlayer already attached")
this.console.warn("EmbbededMediaPlayer already attached")
return false
}
if (!app.layout.floatingStack) {
console.error("Floating stack not found")
this.console.error("Floating stack not found")
return false
}
@ -586,12 +449,12 @@ export default class Player extends Core {
detachPlayerComponent() {
if (!this.currentDomWindow) {
console.warn("EmbbededMediaPlayer not attached")
this.console.warn("EmbbededMediaPlayer not attached")
return false
}
if (!app.layout.floatingStack) {
console.error("Floating stack not found")
this.console.error("Floating stack not found")
return false
}
@ -606,7 +469,7 @@ export default class Player extends Core {
enqueueLoadBuffer(audioElement) {
if (!audioElement) {
console.error("Audio element is required")
this.console.error("Audio element is required")
return false
}
@ -635,7 +498,7 @@ export default class Player extends Core {
const audioElement = this.bufferLoadQueue.shift()
if (audioElement.signal.aborted) {
console.warn("Aborted audio element")
this.console.warn("Aborted audio element")
this.bufferLoadQueueLoading = false
@ -651,7 +514,7 @@ export default class Player extends Core {
resolve()
}, { once: true })
console.log("Preloading audio buffer", audioElement.src)
this.console.log("Preloading audio buffer", audioElement.src)
audioElement.load()
})
@ -703,7 +566,7 @@ export default class Player extends Core {
async createInstance(manifest) {
if (!manifest) {
console.error("Manifest is required")
this.console.error("Manifest is required")
return false
}
@ -716,20 +579,20 @@ export default class Player extends Core {
// check if manifest has `manifest` property
if (manifest.service) {
if (manifest.service !== "inherit") {
if (manifest.service !== "inherit" && !manifest.source) {
const resolver = servicesToManifestResolver[manifest.service]
if (!resolver) {
console.error(`Service ${manifest.service} is not supported`)
this.console.error(`Service ${manifest.service} is not supported`)
return false
}
manifest = await resolver(manifest)
}
}
if (!manifest.src && !manifest.source) {
console.error("Manifest source is required")
this.console.error("Manifest source is required")
return false
}
@ -773,7 +636,7 @@ export default class Player extends Core {
instanceObj.audioElement.addEventListener("loadeddata", () => {
this.state.loading = false
console.log("Loaded audio data", instanceObj.audioElement.src)
this.console.log("Loaded audio data", instanceObj.audioElement.src)
})
instanceObj.audioElement.addEventListener("playing", () => {
@ -854,7 +717,7 @@ export default class Player extends Core {
async attachProcessorsToInstance(instance) {
for await (const [index, processor] of this.audioProcessors.entries()) {
if (typeof processor._attach !== "function") {
console.error(`Processor ${processor.constructor.refName} not support attach`)
this.console.error(`Processor ${processor.constructor.refName} not support attach`)
continue
}
@ -864,7 +727,7 @@ export default class Player extends Core {
const lastProcessor = instance.attachedProcessors[instance.attachedProcessors.length - 1].processor
console.log("Attached processors", instance.attachedProcessors)
this.console.log("Attached processors", instance.attachedProcessors)
// now attach to destination
lastProcessor.connect(this.audioContext.destination)
@ -943,7 +806,7 @@ export default class Player extends Core {
// check if the audio is a live stream when metadata is loaded
instance.audioElement.addEventListener("loadedmetadata", () => {
console.log("loadedmetadata", instance.audioElement.duration)
this.console.log("loadedmetadata", instance.audioElement.duration)
if (instance.audioElement.duration === Infinity) {
instance.manifest.stream = true
@ -962,7 +825,7 @@ export default class Player extends Core {
async startPlaylist(playlist, startIndex = 0, { sync = false } = {}) {
if (this.state.syncModeLocked && !sync) {
console.warn("Sync mode is locked, cannot do this action")
this.console.warn("Sync mode is locked, cannot do this action")
return false
}
@ -971,13 +834,17 @@ export default class Player extends Core {
throw new Error("Playlist is required")
}
console.log("Starting playlist", playlist)
// check if the array has strings, if so its means that is the track id, then fetch the track
if (playlist.some(item => typeof item === "string")) {
if (playlist.some((item) => typeof item === "string")) {
this.console.log("Resolving missing manifests by ids...")
playlist = await this.getTracksByIds(playlist)
}
this.console.log("Starting playlist", playlist)
this.state.loading = true
// !IMPORTANT: abort preloads before destroying current instance
await this.abortPreloads()
@ -985,28 +852,36 @@ export default class Player extends Core {
// clear current queue
this.audioQueue = []
this.audioQueueHistory = []
this.state.loading = true
// sort playlist entries to prioritize instance creating from the startIndex
playlist[startIndex].first = true
for await (const [index, manifest] of playlist.entries()) {
const afterPlaylist = playlist.slice(startIndex)
const beforePlaylist = playlist.slice(0, startIndex).reverse()
for await (const [index, manifest] of afterPlaylist.entries()) {
const instance = await this.createInstance(manifest)
if (index < startIndex) {
this.audioQueueHistory.push(instance)
} else {
this.audioQueue.push(instance)
this.audioQueue.push(instance)
if (index === 0) {
this.play(this.audioQueue[0])
}
}
// play first audio
this.play(this.audioQueue[0])
for await (const [index, manifest] of beforePlaylist.entries()) {
const instance = await this.createInstance(manifest)
this.audioQueueHistory.push(instance)
}
return true
}
async start(manifest, { sync = false, time } = {}) {
if (this.state.syncModeLocked && !sync) {
console.warn("Sync mode is locked, cannot do this action")
this.console.warn("Sync mode is locked, cannot do this action")
return false
}
@ -1034,7 +909,7 @@ export default class Player extends Core {
next({ sync = false } = {}) {
if (this.state.syncModeLocked && !sync) {
console.warn("Sync mode is locked, cannot do this action")
this.console.warn("Sync mode is locked, cannot do this action")
return false
}
@ -1045,7 +920,7 @@ export default class Player extends Core {
// check if there is a next audio in queue
if (this.audioQueue.length === 0) {
console.log("no more audio on queue, stopping playback")
this.console.log("no more audio on queue, stopping playback")
this.destroyCurrentInstance()
@ -1068,7 +943,7 @@ export default class Player extends Core {
previous({ sync = false } = {}) {
if (this.state.syncModeLocked && !sync) {
console.warn("Sync mode is locked, cannot do this action")
this.console.warn("Sync mode is locked, cannot do this action")
return false
}
@ -1090,7 +965,7 @@ export default class Player extends Core {
async pausePlayback() {
return await new Promise((resolve, reject) => {
if (!this.currentAudioInstance) {
console.error("No audio instance")
this.console.error("No audio instance")
return null
}
@ -1112,7 +987,7 @@ export default class Player extends Core {
async resumePlayback() {
return await new Promise((resolve, reject) => {
if (!this.currentAudioInstance) {
console.error("No audio instance")
this.console.error("No audio instance")
return null
}
@ -1155,7 +1030,7 @@ export default class Player extends Core {
toggleMute(to) {
if (app.isMobile) {
console.warn("Cannot mute on mobile")
this.console.warn("Cannot mute on mobile")
return false
}
@ -1180,7 +1055,7 @@ export default class Player extends Core {
}
if (app.isMobile) {
console.warn("Cannot change volume on mobile")
this.console.warn("Cannot change volume on mobile")
return false
}
@ -1216,7 +1091,7 @@ export default class Player extends Core {
}
if (this.state.syncModeLocked && !sync) {
console.warn("Sync mode is locked, cannot do this action")
this.console.warn("Sync mode is locked, cannot do this action")
return false
}
@ -1238,7 +1113,7 @@ export default class Player extends Core {
loop(to) {
if (typeof to !== "boolean") {
console.warn("Loop must be a boolean")
this.console.warn("Loop must be a boolean")
return false
}
@ -1253,12 +1128,12 @@ export default class Player extends Core {
velocity(to) {
if (this.state.syncModeLocked) {
console.warn("Sync mode is locked, cannot do this action")
this.console.warn("Sync mode is locked, cannot do this action")
return false
}
if (typeof to !== "number") {
console.warn("Velocity must be a number")
this.console.warn("Velocity must be a number")
return false
}
@ -1273,7 +1148,7 @@ export default class Player extends Core {
collapse(to) {
if (typeof to !== "boolean") {
console.warn("Collapse must be a boolean")
this.console.warn("Collapse must be a boolean")
return false
}
@ -1284,7 +1159,7 @@ export default class Player extends Core {
toggleSyncMode(to, lock) {
if (typeof to !== "boolean") {
console.warn("Sync mode must be a boolean")
this.console.warn("Sync mode must be a boolean")
return false
}
@ -1292,7 +1167,7 @@ export default class Player extends Core {
this.state.syncModeLocked = lock ?? false
console.log(`Sync mode is now ${this.state.syncMode ? "enabled" : "disabled"} | Locked: ${this.state.syncModeLocked ? "yes" : "no"}`)
this.console.log(`Sync mode is now ${this.state.syncMode ? "enabled" : "disabled"} | Locked: ${this.state.syncModeLocked ? "yes" : "no"}`)
return this.state.syncMode
}
@ -1312,7 +1187,7 @@ export default class Player extends Core {
async getTracksByIds(list) {
if (!Array.isArray(list)) {
console.warn("List must be an array")
this.console.warn("List must be an array")
return false
}
@ -1329,7 +1204,7 @@ export default class Player extends Core {
}
const fetchedTracks = await PlaylistModel.getTracks(ids).catch((err) => {
console.error(err)
this.console.error(err)
return false
})
@ -1352,13 +1227,13 @@ export default class Player extends Core {
async setSampleRate(to) {
// must be a integer
if (typeof to !== "number") {
console.error("Sample rate must be a number")
this.console.error("Sample rate must be a number")
return this.audioContext.sampleRate
}
// must be a integer
if (!Number.isInteger(to)) {
console.error("Sample rate must be a integer")
this.console.error("Sample rate must be a integer")
return this.audioContext.sampleRate
}

View File

@ -37,7 +37,7 @@ export default class ProcessorNode {
if (Array.isArray(this.constructor.dependsOnSettings)) {
// check if the instance has the settings
if (!this.constructor.dependsOnSettings.every((setting) => app.cores.settings.get(setting))) {
console.warn(`Skipping attachment for [${this.constructor.refName ?? this.constructor.name}] node, cause is not passing the settings dependecy > ${this.constructor.dependsOnSettings.join(", ")}`)
this.console.warn(`Skipping attachment for [${this.constructor.refName ?? this.constructor.name}] node, cause is not passing the settings dependecy > ${this.constructor.dependsOnSettings.join(", ")}`)
return instance
}
@ -55,7 +55,7 @@ export default class ProcessorNode {
// check if is already attached
if (currentIndex !== false) {
console.warn(`[${this.constructor.refName ?? this.constructor.name}] node is already attached`)
this.console.warn(`[${this.constructor.refName ?? this.constructor.name}] node is already attached`)
return instance
}
@ -64,14 +64,14 @@ export default class ProcessorNode {
// if has, disconnect it
// if it not has, its means that is the first node, so connect to the media source
if (prevNode && prevNode.processor._last.numberOfOutputs > 0) {
//console.log(`[${this.constructor.refName ?? this.constructor.name}] node is already attached to the previous node, disconnecting...`)
//this.console.log(`[${this.constructor.refName ?? this.constructor.name}] node is already attached to the previous node, disconnecting...`)
// if has outputs, disconnect from the next node
prevNode.processor._last.disconnect()
// now, connect to the processor
prevNode.processor._last.connect(this.processor)
} else {
//console.log(`[${this.constructor.refName ?? this.constructor.name}] node is the first node, connecting to the media source...`)
//this.console.log(`[${this.constructor.refName ?? this.constructor.name}] node is the first node, connecting to the media source...`)
instance.media.connect(this.processor)
}
@ -153,7 +153,7 @@ export default class ProcessorNode {
}
if (!instance) {
console.warn(`Instance is not defined`)
this.console.warn(`Instance is not defined`)
return false
}

View File

@ -123,7 +123,7 @@ export default class EqProcessorNode extends ProcessorNode {
const gainValue = this.state.eqValues[processor.frequency.value].gain
if (processor.gain.value !== gainValue) {
console.debug(`[EQ] Applying values to ${processor.frequency.value} Hz frequency with gain ${gainValue}`)
this.console.debug(`[EQ] Applying values to ${processor.frequency.value} Hz frequency with gain ${gainValue}`)
processor.gain.value = this.state.eqValues[processor.frequency.value].gain
}
})

View File

@ -0,0 +1,21 @@
import SyncModel from "comty.js/models/sync"
export default {
"tidal": async (manifest) => {
const resolvedManifest = await SyncModel.tidalCore.getTrackManifest(manifest.id)
this.console.log(resolvedManifest)
manifest.source = resolvedManifest.playback.url
manifest.title = resolvedManifest.metadata.title
manifest.artist = resolvedManifest.metadata.artists.map(artist => artist.name).join(", ")
manifest.album = resolvedManifest.metadata.album.title
const coverUID = resolvedManifest.metadata.album.cover.replace(/-/g, "/")
manifest.cover = `https://resources.tidal.com/images/${coverUID}/1280x1280.jpg`
return manifest
}
}

View File

@ -0,0 +1,120 @@
import { CapacitorMusicControls } from "capacitor-music-controls-plugin-v3"
export default class MediaSession {
initialize() {
CapacitorMusicControls.addListener("controlsNotification", (info) => {
this.console.log(info)
this.handleControlsEvent(info)
})
// ANDROID (13, see bug above as to why it's necessary)
document.addEventListener("controlsNotification", (event) => {
this.console.log(event)
const info = { message: event.message, position: 0 }
this.handleControlsEvent(info)
})
}
update(manifest) {
if ("mediaSession" in navigator) {
return navigator.mediaSession.metadata = new MediaMetadata({
title: manifest.title,
artist: manifest.artist,
album: manifest.album,
artwork: [
{
src: manifest.cover ?? manifest.thumbnail,
sizes: "512x512",
type: "image/jpeg",
}
],
})
}
return CapacitorMusicControls.create({
track: manifest.title,
artist: manifest.artist,
album: manifest.album,
cover: manifest.cover,
hasPrev: false,
hasNext: false,
hasClose: true,
isPlaying: true,
dismissable: false,
playIcon: "media_play",
pauseIcon: "media_pause",
prevIcon: "media_prev",
nextIcon: "media_next",
closeIcon: "media_close",
notificationIcon: "notification"
})
}
updateIsPlaying(to, timeElapsed = 0) {
if ("mediaSession" in navigator) {
return navigator.mediaSession.playbackState = to ? "playing" : "paused"
}
return CapacitorMusicControls.updateIsPlaying({
isPlaying: to,
elapsed: timeElapsed,
})
}
destroy() {
if ("mediaSession" in navigator) {
navigator.mediaSession.playbackState = "none"
}
this.active = false
return CapacitorMusicControls.destroy()
}
handleControlsEvent(action) {
const message = action.message
switch (message) {
case "music-controls-next": {
return app.cores.player.playback.next()
}
case "music-controls-previous": {
return app.cores.player.playback.previous()
}
case "music-controls-pause": {
return app.cores.player.playback.pause()
}
case "music-controls-play": {
return app.cores.player.playback.play()
}
case "music-controls-destroy": {
return app.cores.player.playback.stop()
}
// External controls (iOS only)
case "music-controls-toggle-play-pause": {
return app.cores.player.playback.toggle()
}
// Headset events (Android only)
// All media button events are listed below
case "music-controls-media-button": {
return app.cores.player.playback.toggle()
}
case "music-controls-headset-unplugged": {
return app.cores.player.playback.pause()
}
case "music-controls-headset-plugged": {
return app.cores.player.playback.play()
}
default:
break;
}
}
}

View File

@ -0,0 +1,935 @@
import Core from "evite/src/core"
import EventEmitter from "evite/src/internals/EventEmitter"
import { Observable } from "object-observer"
import { FastAverageColor } from "fast-average-color"
import PlaylistModel from "comty.js/models/playlists"
import EmbbededMediaPlayer from "components/Player/MediaPlayer"
import BackgroundMediaPlayer from "components/Player/BackgroundMediaPlayer"
import AudioPlayerStorage from "./player.storage"
import defaultAudioProccessors from "./processors"
import MediaSession from "./mediaSession"
import servicesToManifestResolver from "./servicesToManifestResolver"
export default class Player extends Core {
static dependencies = [
"api",
"settings"
]
static namespace = "player"
static bgColor = "aquamarine"
static textColor = "black"
static defaultSampleRate = 48000
static gradualFadeMs = 150
// buffer & precomputation
static maxManifestPrecompute = 3
native_controls = new MediaSession()
currentDomWindow = null
audioContext = new AudioContext({
sampleRate: AudioPlayerStorage.get("sample_rate") ?? Player.defaultSampleRate,
latencyHint: "playback"
})
audioProcessors = []
eventBus = new EventEmitter()
fac = new FastAverageColor()
track_prev_instances = []
track_instance = null
track_next_instances = []
state = Observable.from({
loading: false,
minimized: false,
muted: app.isMobile ? false : (AudioPlayerStorage.get("mute") ?? false),
volume: app.isMobile ? 1 : (AudioPlayerStorage.get("volume") ?? 0.3),
sync_mode: false,
livestream_mode: false,
control_locked: false,
track_manifest: null,
playback_mode: AudioPlayerStorage.get("mode") ?? "repeat",
playback_status: "stopped",
})
public = {
audioContext: this.audioContext,
setSampleRate: this.setSampleRate,
start: this.start.bind(this),
close: this.close.bind(this),
playback: {
mode: this.playbackMode.bind(this),
stop: this.stop.bind(this),
toggle: this.togglePlayback.bind(this),
pause: this.pausePlayback.bind(this),
play: this.resumePlayback.bind(this),
next: this.next.bind(this),
previous: this.previous.bind(this),
seek: this.seek.bind(this),
},
duration: this.duration.bind(this),
volume: this.volume.bind(this),
mute: this.mute.bind(this),
toggleMute: this.toggleMute.bind(this),
seek: this.seek.bind(this),
minimize: this.toggleMinimize.bind(this),
collapse: this.toggleCollapse.bind(this),
state: new Proxy(this.state, {
get: (target, prop) => {
return target[prop]
},
set: (target, prop, value) => {
return false
}
}),
eventBus: new Proxy(this.eventBus, {
get: (target, prop) => {
return target[prop]
},
set: (target, prop, value) => {
return false
}
})
}
async onInitialize() {
this.initializeAudioProcessors()
Observable.observe(this.state, async (changes) => {
try {
changes.forEach((change) => {
if (change.type === "update") {
const stateKey = change.path[0]
this.eventBus.emit(`player.state.update:${stateKey}`, change.object[stateKey])
this.eventBus.emit("player.state.update", change.object)
}
})
} catch (error) {
this.console.error(`Failed to dispatch state updater >`, error)
}
})
}
async initializeBeforeRuntimeInitialize() {
for (const [eventName, eventHandler] of Object.entries(this.wsEvents)) {
app.cores.api.listenEvent(eventName, eventHandler, Player.websocketListen)
}
if (app.isMobile) {
this.state.audioVolume = 1
}
}
async initializeAudioProcessors() {
if (this.audioProcessors.length > 0) {
this.console.log("Destroying audio processors")
this.audioProcessors.forEach((processor) => {
this.console.log(`Destroying audio processor ${processor.constructor.name}`, processor)
processor._destroy()
})
this.audioProcessors = []
}
for await (const defaultProccessor of defaultAudioProccessors) {
this.audioProcessors.push(new defaultProccessor(this))
}
for await (const processor of this.audioProcessors) {
this.console.log(`Initializing audio processor ${processor.constructor.name}`, processor)
if (typeof processor._init === "function") {
try {
await processor._init(this.audioContext)
} catch (error) {
this.console.error(`Failed to initialize audio processor ${processor.constructor.name} >`, error)
continue
}
}
// check if processor has exposed public methods
if (processor.exposeToPublic) {
Object.entries(processor.exposeToPublic).forEach(([key, value]) => {
const refName = processor.constructor.refName
if (typeof this.public[refName] === "undefined") {
// by default create a empty object
this.public[refName] = {}
}
this.public[refName][key] = value
})
}
}
}
//
// UI Methods
//
attachPlayerComponent() {
if (this.currentDomWindow) {
this.console.warn("EmbbededMediaPlayer already attached")
return false
}
if (!app.layout.floatingStack) {
this.console.error("Floating stack not found")
return false
}
this.currentDomWindow = app.layout.floatingStack.add("mediaPlayer", EmbbededMediaPlayer)
}
detachPlayerComponent() {
if (!this.currentDomWindow) {
this.console.warn("EmbbededMediaPlayer not attached")
return false
}
if (!app.layout.floatingStack) {
this.console.error("Floating stack not found")
return false
}
app.layout.floatingStack.remove("mediaPlayer")
this.currentDomWindow = null
}
//
// Instance managing methods
//
async abortPreloads() {
for await (const instance of this.track_next_instances) {
if (instance.abortController?.abort) {
instance.abortController.abort()
}
}
}
async preloadAudioInstance(instance) {
const isIndex = typeof instance === "number"
let index = isIndex ? instance : 0
if (isIndex) {
instance = this.track_next_instances[instance]
}
if (!instance) {
this.console.error("Instance not found to preload")
return false
}
if (!instance.manifest.cover_analysis) {
const img = new Image()
img.crossOrigin = "anonymous"
img.src = `https://cors-anywhere.herokuapp.com/${instance.manifest.cover ?? instance.manifest.thumbnail}`
const cover_analysis = await this.fac.getColorAsync(img)
.catch((err) => {
this.console.error(err)
return false
})
instance.manifest.cover_analysis = cover_analysis
}
if (!instance._preloaded) {
instance.media.preload = "metadata"
instance._preloaded = true
}
if (isIndex) {
this.track_next_instances[index] = instance
}
return instance
}
async destroyCurrentInstance({ sync = false } = {}) {
if (!this.track_instance) {
return false
}
// stop playback
if (this.track_instance.media) {
this.track_instance.media.pause()
}
// reset track_instance
this.track_instance = null
// reset livestream mode
this.state.livestream_mode = false
}
async createInstance(manifest) {
if (!manifest) {
this.console.error("Manifest is required")
return false
}
if (typeof manifest === "string") {
manifest = {
src: manifest,
}
}
// check if manifest has `manifest` property, if is and not inherit or missing source, resolve
if (manifest.service) {
if (manifest.service !== "inherit" && !manifest.source) {
const resolver = servicesToManifestResolver[manifest.service]
if (!resolver) {
this.console.error(`Service ${manifest.service} is not supported`)
return false
}
manifest = await resolver(manifest)
}
}
if (!manifest.src && !manifest.source) {
this.console.error("Manifest source is required")
return false
}
const source = manifest.src ?? manifest.source
if (!manifest.metadata) {
manifest.metadata = {}
}
// if title is not set, use the audio source filename
if (!manifest.metadata.title) {
manifest.metadata.title = source.split("/").pop()
}
let instance = {
manifest: manifest,
attachedProcessors: [],
abortController: new AbortController(),
source: source,
media: new Audio(source),
duration: null,
seek: 0,
track: null,
}
instance.media.signal = instance.abortController.signal
instance.media.crossOrigin = "anonymous"
instance.media.preload = "none"
instance.media.loop = this.state.playback_mode === "repeat"
instance.media.volume = this.state.volume
// handle on end
instance.media.addEventListener("ended", () => {
this.next()
})
instance.media.addEventListener("loadeddata", () => {
this.state.loading = false
})
// update playback status
instance.media.addEventListener("play", () => {
this.state.playback_status = "playing"
})
instance.media.addEventListener("playing", () => {
this.state.loading = false
this.state.playback_status = "playing"
if (this.waitUpdateTimeout) {
clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null
}
})
instance.media.addEventListener("pause", () => {
this.state.playback_status = "paused"
})
instance.media.addEventListener("durationchange", (duration) => {
if (instance.media.paused) {
return false
}
instance.duration = duration
})
instance.media.addEventListener("waiting", () => {
if (instance.media.paused) {
return false
}
if (this.waitUpdateTimeout) {
clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null
}
// if takes more than 150ms to load, update loading state
this.waitUpdateTimeout = setTimeout(() => {
this.state.loading = true
}, 150)
})
instance.media.addEventListener("seeked", () => {
instance.seek = instance.media.currentTime
if (this.state.sync_mode) {
// useMusicSync("music:player:seek", {
// position: instance.seek,
// state: this.state,
// })
}
this.eventBus.emit(`player.seeked`, instance.seek)
})
instance.media.addEventListener("loadedmetadata", () => {
if (instance.media.duration === Infinity) {
instance.manifest.stream = true
this.state.livestream_mode = true
}
}, { once: true })
instance.track = this.audioContext.createMediaElementSource(instance.media)
return instance
}
async attachProcessorsToInstance(instance) {
for await (const [index, processor] of this.audioProcessors.entries()) {
if (processor.constructor.node_bypass === true) {
instance.track.connect(processor.processor)
processor.processor.connect(this.audioContext.destination)
continue
}
if (typeof processor._attach !== "function") {
this.console.error(`Processor ${processor.constructor.refName} not support attach`)
continue
}
instance = await processor._attach(instance, index)
}
const lastProcessor = instance.attachedProcessors[instance.attachedProcessors.length - 1].processor
// now attach to destination
lastProcessor.connect(this.audioContext.destination)
return instance
}
//
// Playback methods
//
async play(instance, params = {}) {
if (typeof instance === "number") {
if (instance < 0) {
instance = this.track_prev_instances[instance]
}
if (instance > 0) {
instance = this.track_instances[instance]
}
if (instance === 0) {
instance = this.track_instance
}
}
if (!instance) {
throw new Error("Audio instance is required")
}
if (this.audioContext.state === "suspended") {
this.audioContext.resume()
}
if (this.track_instance) {
this.track_instance = this.track_instance.attachedProcessors[this.track_instance.attachedProcessors.length - 1]._destroy(this.track_instance)
this.destroyCurrentInstance()
}
// attach processors
instance = await this.attachProcessorsToInstance(instance)
// now set the current instance
this.track_instance = await this.preloadAudioInstance(instance)
// reconstruct audio src if is not set
if (this.track_instance.media.src !== instance.source) {
this.track_instance.media.src = instance.source
}
// set time to 0
this.track_instance.media.currentTime = 0
if (params.time >= 0) {
this.track_instance.media.currentTime = params.time
}
if (params.volume >= 0) {
this.track_instance.gainNode.gain.value = params.volume
} else {
this.track_instance.gainNode.gain.value = this.state.volume
}
this.track_instance.media.muted = this.state.muted
// try to preload next audio
if (this.track_next_instances.length > 0) {
this.preloadAudioInstance(1)
}
// play
await this.track_instance.media.play()
// update manifest
this.state.track_manifest = instance.manifest
this.native_controls.update(instance.manifest)
return this.track_instance
}
async start(manifest, { sync = false, time, startIndex = 0 } = {}) {
if (this.state.control_locked && !sync) {
this.console.warn("Controls are locked, cannot do this action")
return false
}
this.attachPlayerComponent()
// !IMPORTANT: abort preloads before destroying current instance
await this.abortPreloads()
await this.destroyCurrentInstance({
sync
})
this.state.loading = true
this.track_prev_instances = []
this.track_next_instances = []
const isPlaylist = Array.isArray(manifest)
if (isPlaylist) {
let playlist = manifest
if (playlist.length === 0) {
this.console.warn(`[PLAYER] Playlist is empty, aborting...`)
return false
}
if (playlist.some((item) => typeof item === "string")) {
this.console.log("Resolving missing manifests by ids...")
playlist = await this.getTracksByIds(playlist)
}
playlist = playlist.slice(startIndex)
for await (const [index, _manifest] of playlist.entries()) {
const instance = await this.createInstance(_manifest)
this.track_next_instances.push(instance)
if (index === 0) {
this.play(this.track_next_instances[0], {
time: time ?? 0
})
}
}
return playlist
}
const instance = await this.createInstance(manifest)
this.track_next_instances.push(instance)
this.play(this.track_next_instances[0], {
time: time ?? 0
})
return manifest
}
next({ sync = false } = {}) {
if (this.state.control_locked && !sync) {
//this.console.warn("Sync mode is locked, cannot do this action")
return false
}
if (this.track_next_instances.length > 0) {
// move current audio instance to history
this.track_prev_instances.push(this.track_next_instances.shift())
}
if (this.track_next_instances.length === 0) {
this.console.log(`[PLAYER] No more tracks to play, stopping...`)
return this.stop()
}
let nextIndex = 0
if (this.state.playback_mode === "shuffle") {
nextIndex = Math.floor(Math.random() * this.track_next_instances.length)
}
this.play(this.track_next_instances[nextIndex])
}
previous({ sync = false } = {}) {
if (this.state.control_locked && !sync) {
//this.console.warn("Sync mode is locked, cannot do this action")
return false
}
if (this.track_prev_instances.length > 0) {
// move current audio instance to history
this.track_next_instances.unshift(this.track_prev_instances.pop())
return this.play(this.track_next_instances[0])
}
if (this.track_prev_instances.length === 0) {
this.console.log(`[PLAYER] No previous tracks, replying...`)
// replay the current track
return this.play(this.track_instance)
}
}
async togglePlayback() {
if (this.state.playback_status === "paused") {
await this.resumePlayback()
} else {
await this.pausePlayback()
}
}
async pausePlayback() {
return await new Promise((resolve, reject) => {
if (!this.track_instance) {
this.console.error("No audio instance")
return null
}
// set gain exponentially
this.track_instance.gainNode.gain.linearRampToValueAtTime(
0.0001,
this.audioContext.currentTime + (Player.gradualFadeMs / 1000)
)
setTimeout(() => {
this.track_instance.media.pause()
resolve()
}, Player.gradualFadeMs)
this.native_controls.updateIsPlaying(false)
})
}
async resumePlayback() {
return await new Promise((resolve, reject) => {
if (!this.track_instance) {
this.console.error("No audio instance")
return null
}
// ensure audio elemeto starts from 0 volume
this.track_instance.gainNode.gain.value = 0.0001
this.track_instance.media.play().then(() => {
resolve()
})
// set gain exponentially
this.track_instance.gainNode.gain.linearRampToValueAtTime(
this.state.volume,
this.audioContext.currentTime + (Player.gradualFadeMs / 1000)
)
this.native_controls.updateIsPlaying(true)
})
}
stop() {
this.destroyCurrentInstance()
this.abortPreloads()
this.state.playback_status = "stopped"
this.state.track_manifest = null
this.state.livestream_mode = false
this.track_instance = null
this.track_next_instances = []
this.track_prev_instances = []
this.native_controls.destroy()
}
mute(to) {
if (app.isMobile && typeof to !== "boolean") {
this.console.warn("Cannot mute on mobile")
return false
}
if (typeof to === "boolean") {
this.state.muted = to
this.track_instance.media.muted = to
}
return this.state.muted
}
volume(volume) {
if (typeof volume !== "number") {
return this.state.volume
}
if (app.isMobile) {
this.console.warn("Cannot change volume on mobile")
return false
}
if (volume > 1) {
if (!app.cores.settings.get("player.allowVolumeOver100")) {
volume = 1
}
}
if (volume < 0) {
volume = 0
}
this.state.volume = volume
if (this.track_instance) {
if (this.track_instance.gainNode) {
this.track_instance.gainNode.gain.value = this.state.volume
}
}
return this.state.volume
}
seek(time, { sync = false } = {}) {
if (!this.track_instance || !this.track_instance.media) {
return false
}
// if time not provided, return current time
if (typeof time === "undefined") {
return this.track_instance.media.currentTime
}
if (this.state.control_locked && !sync) {
this.console.warn("Sync mode is locked, cannot do this action")
return false
}
// if time is provided, seek to that time
if (typeof time === "number") {
this.track_instance.media.currentTime = time
return time
}
}
playbackMode(mode) {
if (typeof mode !== "string") {
return this.state.playback_mode
}
this.state.playback_mode = mode
}
duration() {
if (!this.track_instance) {
return false
}
return this.track_instance.media.duration
}
loop(to) {
if (typeof to !== "boolean") {
this.console.warn("Loop must be a boolean")
return false
}
this.state.loop = to ?? !this.state.loop
if (this.track_instance.media) {
this.track_instance.media.loop = this.state.loop
}
return this.state.loop
}
close() {
this.stop()
this.detachPlayerComponent()
}
toggleMinimize(to) {
this.state.minimized = to ?? !this.state.minimized
if (this.state.minimized) {
app.layout.sidebar.attachBottomItem("player", BackgroundMediaPlayer, {
noContainer: true
})
} else {
app.layout.sidebar.removeBottomItem("player")
}
return this.state.minimized
}
toggleCollapse(to) {
if (typeof to !== "boolean") {
this.console.warn("Collapse must be a boolean")
return false
}
this.state.collapsed = to ?? !this.state.collapsed
return this.state.collapsed
}
toggleSyncMode(to, lock) {
if (typeof to !== "boolean") {
this.console.warn("Sync mode must be a boolean")
return false
}
this.state.syncMode = to ?? !this.state.syncMode
this.state.syncModeLocked = lock ?? false
this.console.log(`Sync mode is now ${this.state.syncMode ? "enabled" : "disabled"} | Locked: ${this.state.syncModeLocked ? "yes" : "no"}`)
return this.state.syncMode
}
toggleMute(to) {
if (typeof to !== "boolean") {
to = !this.state.muted
}
return this.mute(to)
}
async getTracksByIds(list) {
if (!Array.isArray(list)) {
this.console.warn("List must be an array")
return false
}
let ids = []
list.forEach((item) => {
if (typeof item === "string") {
ids.push(item)
}
})
if (ids.length === 0) {
return list
}
const fetchedTracks = await PlaylistModel.getTracks(ids).catch((err) => {
this.console.error(err)
return false
})
if (!fetchedTracks) {
return list
}
// replace fetched tracks with the ones in the list
fetchedTracks.forEach((fetchedTrack) => {
const index = list.findIndex((item) => item === fetchedTrack._id)
if (index !== -1) {
list[index] = fetchedTrack
}
})
return list
}
async setSampleRate(to) {
// must be a integer
if (typeof to !== "number") {
this.console.error("Sample rate must be a number")
return this.audioContext.sampleRate
}
// must be a integer
if (!Number.isInteger(to)) {
this.console.error("Sample rate must be a integer")
return this.audioContext.sampleRate
}
return await new Promise((resolve, reject) => {
app.confirm({
title: "Change sample rate",
content: `To change the sample rate, the app needs to be reloaded. Do you want to continue?`,
onOk: () => {
try {
this.audioContext = new AudioContext({ sampleRate: to })
AudioPlayerStorage.set("sample_rate", to)
app.navigation.reload()
return resolve(this.audioContext.sampleRate)
} catch (error) {
app.message.error(`Failed to change sample rate, ${error.message}`)
return resolve(this.audioContext.sampleRate)
}
},
onCancel: () => {
return resolve(this.audioContext.sampleRate)
}
})
})
}
}

View File

@ -0,0 +1,25 @@
import store from "store"
export default class AudioPlayerStorage {
static storeKey = "audioPlayer"
static get(key) {
const data = store.get(AudioPlayerStorage.storeKey)
if (data) {
return data[key]
}
return null
}
static set(key, value) {
const data = store.get(AudioPlayerStorage.storeKey) ?? {}
data[key] = value
store.set(AudioPlayerStorage.storeKey, data)
return data
}
}

View File

@ -0,0 +1,79 @@
import ProcessorNode from "../node"
import { createRealTimeBpmProcessor } from "realtime-bpm-analyzer"
import { Observable } from "object-observer"
export default class BPMProcessorNode extends ProcessorNode {
static refName = "bpm"
static node_bypass = true
static lock = true
state = Observable.from({
bpm: 0,
average_bpm: 0,
current_stable_bpm: 0,
})
exposeToPublic = {
state: this.state,
}
async init() {
Observable.observe(this.state, async (changes) => {
try {
changes.forEach((change) => {
if (change.type === "update") {
const stateKey = change.path[0]
if (stateKey === "bpm") {
console.log("bpm update", this.state.bpm)
this.PlayerCore.eventBus.emit(`bpm.change`, this.state.bpm)
}
this.PlayerCore.eventBus.emit(`bpm.state.update:${stateKey}`, change.object[stateKey])
this.PlayerCore.eventBus.emit("bpm.state.update", change.object)
}
})
} catch (error) {
console.error(`Failed to dispatch state updater >`, error)
}
})
this.processor = await createRealTimeBpmProcessor(this.audioContext)
this.processor.port.postMessage({
message: "ASYNC_CONFIGURATION",
parameters: {
continuousAnalysis: true,
stabilizationTime: 5_000,
}
})
this.processor.port.onmessage = (event) => {
if (event.data.message === "BPM") {
const average_bpm = event.data.result.bpm[0]?.tempo ?? 0
if (average_bpm !== this.state.average_bpm) {
this.state.average_bpm = average_bpm
if (average_bpm > 0) {
if (this.state.stable_bpm < average_bpm) {
this.state.bpm = this.state.average_bpm
} else {
this.state.bpm = this.state.stable_bpm
}
} else if (this.state.stable_bpm > 0) {
this.state.bpm = this.state.stable_bpm
}
}
}
if (event.data.message === "BPM_STABLE") {
const stable_bpm = event.data.result.bpm[0]?.tempo ?? 0
this.state.stable_bpm = stable_bpm
}
}
}
}

View File

@ -0,0 +1,55 @@
import AudioPlayerStorage from "../../player.storage"
import ProcessorNode from "../node"
export default class CompressorProcessorNode extends ProcessorNode {
static refName = "compressor"
static dependsOnSettings = ["player.compressor"]
static defaultCompressorValues = {
threshold: -50,
knee: 40,
ratio: 12,
attack: 0.003,
release: 0.25,
}
state = {
compressorValues: AudioPlayerStorage.get("compressor") ?? CompressorProcessorNode.defaultCompressorValues,
}
exposeToPublic = {
modifyValues: function (values) {
this.state.compressorValues = {
...this.state.compressorValues,
...values,
}
AudioPlayerStorage.set("compressor", this.state.compressorValues)
this.applyValues()
}.bind(this),
resetDefaultValues: function () {
this.exposeToPublic.modifyValues(CompressorProcessorNode.defaultCompressorValues)
return this.state.compressorValues
}.bind(this),
detach: this._detach.bind(this),
attach: this._attach.bind(this),
values: this.state.compressorValues,
}
async init(AudioContext) {
if (!AudioContext) {
throw new Error("AudioContext is required")
}
this.processor = AudioContext.createDynamicsCompressor()
this.applyValues()
}
applyValues() {
Object.keys(this.state.compressorValues).forEach((key) => {
this.processor[key].value = this.state.compressorValues[key]
})
}
}

View File

@ -0,0 +1,131 @@
import ProcessorNode from "../node"
import AudioPlayerStorage from "../../player.storage"
export default class EqProcessorNode extends ProcessorNode {
static refName = "eq"
static lock = true
static defaultEqValue = {
32: {
gain: 0,
},
64: {
gain: 0,
},
125: {
gain: 0,
},
250: {
gain: 0,
},
500: {
gain: 0,
},
1000: {
gain: 0,
},
2000: {
gain: 0,
},
4000: {
gain: 0,
},
8000: {
gain: 0,
},
16000: {
gain: 0,
}
}
state = {
eqValues: AudioPlayerStorage.get("eq_values") ?? EqProcessorNode.defaultEqValue,
}
exposeToPublic = {
modifyValues: function (values) {
Object.keys(values).forEach((key) => {
if (isNaN(key)) {
delete values[key]
}
})
this.state.eqValues = {
...this.state.eqValues,
...values,
}
AudioPlayerStorage.set("eq_values", this.state.eqValues)
this.applyValues()
}.bind(this),
resetDefaultValues: function () {
this.exposeToPublic.modifyValues(EqProcessorNode.defaultEqValue)
return this.state
}.bind(this),
values: () => this.state,
}
async init() {
if (!this.audioContext) {
throw new Error("audioContext is required")
}
this.processor = this.audioContext.createGain()
this.processor.gain.value = 1
this.processor.eqNodes = []
const values = Object.entries(this.state.eqValues).map((entry) => {
return {
freq: parseFloat(entry[0]),
gain: parseFloat(entry[1].gain),
}
})
values.forEach((eqValue, index) => {
// chekc if freq and gain is valid
if (isNaN(eqValue.freq)) {
eqValue.freq = 0
}
if (isNaN(eqValue.gain)) {
eqValue.gain = 0
}
this.processor.eqNodes[index] = this.audioContext.createBiquadFilter()
this.processor.eqNodes[index].type = "peaking"
this.processor.eqNodes[index].frequency.value = eqValue.freq
this.processor.eqNodes[index].gain.value = eqValue.gain
})
// connect nodes
for await (let [index, eqNode] of this.processor.eqNodes.entries()) {
const nextNode = this.processor.eqNodes[index + 1]
if (index === 0) {
this.processor.connect(eqNode)
}
if (nextNode) {
eqNode.connect(nextNode)
}
}
// set last processor for processor node can properly connect to the next node
this.processor._last = this.processor.eqNodes.at(-1)
}
applyValues() {
// apply to current instance
this.processor.eqNodes.forEach((processor) => {
const gainValue = this.state.eqValues[processor.frequency.value].gain
if (processor.gain.value !== gainValue) {
this.console.debug(`[EQ] Applying values to ${processor.frequency.value} Hz frequency with gain ${gainValue}`)
processor.gain.value = this.state.eqValues[processor.frequency.value].gain
}
})
}
}

View File

@ -0,0 +1,60 @@
import AudioPlayerStorage from "../../player.storage"
import ProcessorNode from "../node"
export default class GainProcessorNode extends ProcessorNode {
static refName = "gain"
static lock = true
static defaultValues = {
gain: 1,
}
state = {
gain: AudioPlayerStorage.get("gain") ?? GainProcessorNode.defaultValues.gain,
}
exposeToPublic = {
modifyValues: function (values) {
this.state = {
...this.state,
...values,
}
AudioPlayerStorage.set("gain", this.state.gain)
this.applyValues()
}.bind(this),
resetDefaultValues: function () {
this.exposeToPublic.modifyValues(GainProcessorNode.defaultValues)
return this.state
}.bind(this),
values: () => this.state,
}
applyValues() {
// apply to current instance
this.processor.gain.value = app.cores.player.volume() * this.state.gain
}
async init() {
if (!this.audioContext) {
throw new Error("audioContext is required")
}
this.processor = this.audioContext.createGain()
this.applyValues()
}
mutateInstance(instance) {
if (!instance) {
throw new Error("instance is required")
}
instance.gainNode = this.processor
return instance
}
}

View File

@ -0,0 +1,11 @@
import EqProcessorNode from "./eqNode"
import GainProcessorNode from "./gainNode"
import CompressorProcessorNode from "./compressorNode"
import BPMProcessorNode from "./bpmNode"
export default [
BPMProcessorNode,
EqProcessorNode,
GainProcessorNode,
CompressorProcessorNode,
]

View File

@ -0,0 +1,172 @@
export default class ProcessorNode {
constructor(PlayerCore) {
if (!PlayerCore) {
throw new Error("PlayerCore is required")
}
this.PlayerCore = PlayerCore
this.audioContext = PlayerCore.audioContext
}
async _init() {
// check if has init method
if (typeof this.init === "function") {
await this.init(this.audioContext)
}
// check if has declared bus events
if (typeof this.busEvents === "object") {
Object.entries(this.busEvents).forEach((event, fn) => {
app.eventBus.on(event, fn)
})
}
if (typeof this.processor._last === "undefined") {
this.processor._last = this.processor
}
return this
}
_attach(instance, index) {
if (typeof instance !== "object") {
instance = this.PlayerCore.currentAudioInstance
}
// check if has dependsOnSettings
if (Array.isArray(this.constructor.dependsOnSettings)) {
// check if the instance has the settings
if (!this.constructor.dependsOnSettings.every((setting) => app.cores.settings.get(setting))) {
console.warn(`Skipping attachment for [${this.constructor.refName ?? this.constructor.name}] node, cause is not passing the settings dependecy > ${this.constructor.dependsOnSettings.join(", ")}`)
return instance
}
}
// if index is not defined, attach to the last node
if (!index) {
index = instance.attachedProcessors.length
}
const prevNode = instance.attachedProcessors[index - 1]
const nextNode = instance.attachedProcessors[index + 1]
const currentIndex = this._findIndex(instance)
// check if is already attached
if (currentIndex !== false) {
console.warn(`[${this.constructor.refName ?? this.constructor.name}] node is already attached`)
return instance
}
// first check if has prevNode and if is connected to something
// if has, disconnect it
// if it not has, its means that is the first node, so connect to the media source
if (prevNode && prevNode.processor._last.numberOfOutputs > 0) {
//console.log(`[${this.constructor.refName ?? this.constructor.name}] node is already attached to the previous node, disconnecting...`)
// if has outputs, disconnect from the next node
prevNode.processor._last.disconnect()
// now, connect to the processor
prevNode.processor._last.connect(this.processor)
} else {
//console.log(`[${this.constructor.refName ?? this.constructor.name}] node is the first node, connecting to the media source...`)
instance.track.connect(this.processor)
}
// now, check if it has a next node
// if has, connect to it
// if not, connect to the destination
if (nextNode) {
this.processor.connect(nextNode.processor)
}
// add to the attachedProcessors
instance.attachedProcessors.splice(index, 0, this)
// handle instance mutation
if (typeof this.mutateInstance === "function") {
instance = this.mutateInstance(instance)
}
return instance
}
_detach(instance) {
if (typeof instance !== "object") {
instance = this.PlayerCore.currentAudioInstance
}
// find index of the node within the attachedProcessors serching for matching refName
const index = this._findIndex(instance)
if (!index) {
return instance
}
// retrieve the previous and next nodes
const prevNode = instance.attachedProcessors[index - 1]
const nextNode = instance.attachedProcessors[index + 1]
// check if has previous node and if has outputs
if (prevNode && prevNode.processor._last.numberOfOutputs > 0) {
// if has outputs, disconnect from the previous node
prevNode.processor._last.disconnect()
}
// disconnect
instance = this._destroy(instance)
// now, connect the previous node to the next node
if (prevNode && nextNode) {
prevNode.processor._last.connect(nextNode.processor)
} else {
// it means that this is the last node, so connect to the destination
prevNode.processor._last.connect(this.audioContext.destination)
}
return instance
}
_destroy(instance) {
if (typeof instance !== "object") {
instance = this.PlayerCore.currentAudioInstance
}
const index = this._findIndex(instance)
if (!index) {
return instance
}
this.processor.disconnect()
instance.attachedProcessors.splice(index, 1)
return instance
}
_findIndex(instance) {
if (!instance) {
instance = this.PlayerCore.currentAudioInstance
}
if (!instance) {
console.warn(`Instance is not defined`)
return false
}
// find index of the node within the attachedProcessors serching for matching refName
const index = instance.attachedProcessors.findIndex((node) => {
return node.constructor.refName === this.constructor.refName
})
if (index === -1) {
return false
}
return index
}
}

View File

@ -0,0 +1,23 @@
import SyncModel from "comty.js/models/sync"
export default {
"tidal": async (manifest) => {
const resolvedManifest = await SyncModel.tidalCore.getTrackManifest(manifest.id)
manifest.source = resolvedManifest.playback.url
if (!manifest.metadata) {
manifest.metadata = {}
}
manifest.metadata.title = resolvedManifest.metadata.title
manifest.metadata.artist = resolvedManifest.metadata.artists.map(artist => artist.name).join(", ")
manifest.metadata.album = resolvedManifest.metadata.album.title
const coverUID = resolvedManifest.metadata.album.cover.replace(/-/g, "/")
manifest.metadata.cover = `https://resources.tidal.com/images/${coverUID}/1280x1280.jpg`
return manifest
}
}