From aa8e0864cc59d2284e4b516c4dd744b69a0b2525 Mon Sep 17 00:00:00 2001 From: SrGooglo Date: Wed, 2 Aug 2023 20:38:28 +0000 Subject: [PATCH] migrate `player` to `playerv2` --- .../src/components/Layout/bottomBar/index.jsx | 8 +- .../components/Music/PlaylistItem/index.jsx | 2 +- .../Music/PlaylistTimelineEntry/index.jsx | 7 +- .../Music/PlaylistTimelineEntry/index.less | 2 +- .../components/Music/PlaylistView/index.jsx | 9 +- .../app/src/components/Music/Track/index.jsx | 16 +- .../Player/BackgroundMediaPlayer/index.jsx | 26 +- .../components/Player/MediaPlayer/index.jsx | 96 +- .../components/Player/MediaPlayer/index.less | 12 + .../src/components/Player/SeekBar/index.jsx | 40 +- .../src/contexts/WithPlayerContext/index.jsx | 92 +- .../player/{player.core.js => player_dep.js} | 271 ++--- .../app/src/cores/player/processorNode.js | 10 +- .../cores/player/processors/eqNode/index.js | 2 +- .../player/servicesToManifestResolver.js | 21 + .../app/src/cores/playerv2/mediaSession.js | 120 +++ .../app/src/cores/playerv2/player.core.js | 935 ++++++++++++++++++ .../app/src/cores/playerv2/player.storage.js | 25 + .../playerv2/processors/bpmNode/index.js | 79 ++ .../processors/compressorNode/index.js | 55 ++ .../cores/playerv2/processors/eqNode/index.js | 131 +++ .../playerv2/processors/gainNode/index.js | 60 ++ .../src/cores/playerv2/processors/index.js | 11 + .../app/src/cores/playerv2/processors/node.js | 172 ++++ .../playerv2/servicesToManifestResolver.js | 23 + 25 files changed, 1892 insertions(+), 333 deletions(-) rename packages/app/src/cores/player/{player.core.js => player_dep.js} (82%) create mode 100644 packages/app/src/cores/player/servicesToManifestResolver.js create mode 100644 packages/app/src/cores/playerv2/mediaSession.js create mode 100755 packages/app/src/cores/playerv2/player.core.js create mode 100644 packages/app/src/cores/playerv2/player.storage.js create mode 100644 packages/app/src/cores/playerv2/processors/bpmNode/index.js create mode 100644 packages/app/src/cores/playerv2/processors/compressorNode/index.js create mode 100644 packages/app/src/cores/playerv2/processors/eqNode/index.js create mode 100644 packages/app/src/cores/playerv2/processors/gainNode/index.js create mode 100644 packages/app/src/cores/playerv2/processors/index.js create mode 100644 packages/app/src/cores/playerv2/processors/node.js create mode 100644 packages/app/src/cores/playerv2/servicesToManifestResolver.js diff --git a/packages/app/src/components/Layout/bottomBar/index.jsx b/packages/app/src/components/Layout/bottomBar/index.jsx index ad1aae3d..75631e42 100755 --- a/packages/app/src/components/Layout/bottomBar/index.jsx +++ b/packages/app/src/components/Layout/bottomBar/index.jsx @@ -377,13 +377,13 @@ export class BottomBar extends React.Component { { - this.context.currentManifest &&
} diff --git a/packages/app/src/components/Music/PlaylistItem/index.jsx b/packages/app/src/components/Music/PlaylistItem/index.jsx index 7347190a..7aeeedf5 100644 --- a/packages/app/src/components/Music/PlaylistItem/index.jsx +++ b/packages/app/src/components/Music/PlaylistItem/index.jsx @@ -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
{ 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
+ return
{ 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) => { diff --git a/packages/app/src/components/Music/Track/index.jsx b/packages/app/src/components/Music/Track/index.jsx index fae2f00d..9d84bbc4 100644 --- a/packages/app/src/components/Music/Track/index.jsx +++ b/packages/app/src/components/Music/Track/index.jsx @@ -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" && } - +
{ props.track.metadata?.duration diff --git a/packages/app/src/components/Player/BackgroundMediaPlayer/index.jsx b/packages/app/src/components/Player/BackgroundMediaPlayer/index.jsx index 527b3f7a..767f0a51 100644 --- a/packages/app/src/components/Player/BackgroundMediaPlayer/index.jsx +++ b/packages/app/src/components/Player/BackgroundMediaPlayer/index.jsx @@ -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, }} >
@@ -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" ? : + this.context.playback_status === "playing" ? : }
@@ -113,14 +113,14 @@ export class BackgroundMediaPlayer extends React.Component { > { !this.state.expanded &&

{ - 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"} }

@@ -131,8 +131,8 @@ export class BackgroundMediaPlayer extends React.Component { { - 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"} } @@ -165,7 +165,7 @@ export class BackgroundMediaPlayer extends React.Component { size="small" type="ghost" shape="circle" - icon={this.context.playbackStatus === "playing" ? : } + icon={this.context.playback_status === "playing" ? : } onClick={app.cores.player.playback.toggle} /> diff --git a/packages/app/src/components/Player/MediaPlayer/index.jsx b/packages/app/src/components/Player/MediaPlayer/index.jsx index b55ab6da..69c64bef 100755 --- a/packages/app/src/components/Player/MediaPlayer/index.jsx +++ b/packages/app/src/components/Player/MediaPlayer/index.jsx @@ -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
+ +
+} + // 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
{ - !this.context.syncModeLocked && !this.context.syncMode && } onClick={this.inviteSync} shape="circle" @@ -139,40 +191,44 @@ export class AudioPlayer extends React.Component { }
+ { + this.state.showDancer && + } +
-

+

{ - 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")) }

{ - this.context.currentManifest?.artist &&
+ this.context.track_manifest?.metadata?.artist &&

- {this.context.currentManifest?.artist ?? "Unknown"} + {this.context.track_manifest?.metadata?.artist ?? "Unknown"}

} { - !app.isMobile && this.context.playbackStatus !== "stopped" && }
@@ -180,20 +236,20 @@ export class AudioPlayer extends React.Component {
{ + 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() } } diff --git a/packages/app/src/contexts/WithPlayerContext/index.jsx b/packages/app/src/contexts/WithPlayerContext/index.jsx index 9d784b30..245e4da0 100644 --- a/packages/app/src/contexts/WithPlayerContext/index.jsx +++ b/packages/app/src/contexts/WithPlayerContext/index.jsx @@ -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) } } diff --git a/packages/app/src/cores/player/player.core.js b/packages/app/src/cores/player/player_dep.js similarity index 82% rename from packages/app/src/cores/player/player.core.js rename to packages/app/src/cores/player/player_dep.js index aae91ed9..0841cc10 100755 --- a/packages/app/src/cores/player/player.core.js +++ b/packages/app/src/cores/player/player_dep.js @@ -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 } diff --git a/packages/app/src/cores/player/processorNode.js b/packages/app/src/cores/player/processorNode.js index cb596287..6e902076 100644 --- a/packages/app/src/cores/player/processorNode.js +++ b/packages/app/src/cores/player/processorNode.js @@ -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 } diff --git a/packages/app/src/cores/player/processors/eqNode/index.js b/packages/app/src/cores/player/processors/eqNode/index.js index aa646edd..2843a75f 100644 --- a/packages/app/src/cores/player/processors/eqNode/index.js +++ b/packages/app/src/cores/player/processors/eqNode/index.js @@ -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 } }) diff --git a/packages/app/src/cores/player/servicesToManifestResolver.js b/packages/app/src/cores/player/servicesToManifestResolver.js new file mode 100644 index 00000000..38136c29 --- /dev/null +++ b/packages/app/src/cores/player/servicesToManifestResolver.js @@ -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 + } +} \ No newline at end of file diff --git a/packages/app/src/cores/playerv2/mediaSession.js b/packages/app/src/cores/playerv2/mediaSession.js new file mode 100644 index 00000000..8171a1db --- /dev/null +++ b/packages/app/src/cores/playerv2/mediaSession.js @@ -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; + } + } +} \ No newline at end of file diff --git a/packages/app/src/cores/playerv2/player.core.js b/packages/app/src/cores/playerv2/player.core.js new file mode 100755 index 00000000..80648f56 --- /dev/null +++ b/packages/app/src/cores/playerv2/player.core.js @@ -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) + } + }) + }) + } +} \ No newline at end of file diff --git a/packages/app/src/cores/playerv2/player.storage.js b/packages/app/src/cores/playerv2/player.storage.js new file mode 100644 index 00000000..b21a1d5f --- /dev/null +++ b/packages/app/src/cores/playerv2/player.storage.js @@ -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 + } +} \ No newline at end of file diff --git a/packages/app/src/cores/playerv2/processors/bpmNode/index.js b/packages/app/src/cores/playerv2/processors/bpmNode/index.js new file mode 100644 index 00000000..ef502a0d --- /dev/null +++ b/packages/app/src/cores/playerv2/processors/bpmNode/index.js @@ -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 + } + } + } +} \ No newline at end of file diff --git a/packages/app/src/cores/playerv2/processors/compressorNode/index.js b/packages/app/src/cores/playerv2/processors/compressorNode/index.js new file mode 100644 index 00000000..14aa0083 --- /dev/null +++ b/packages/app/src/cores/playerv2/processors/compressorNode/index.js @@ -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] + }) + } +} \ No newline at end of file diff --git a/packages/app/src/cores/playerv2/processors/eqNode/index.js b/packages/app/src/cores/playerv2/processors/eqNode/index.js new file mode 100644 index 00000000..3b27f230 --- /dev/null +++ b/packages/app/src/cores/playerv2/processors/eqNode/index.js @@ -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 + } + }) + } +} \ No newline at end of file diff --git a/packages/app/src/cores/playerv2/processors/gainNode/index.js b/packages/app/src/cores/playerv2/processors/gainNode/index.js new file mode 100644 index 00000000..1098d753 --- /dev/null +++ b/packages/app/src/cores/playerv2/processors/gainNode/index.js @@ -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 + } +} \ No newline at end of file diff --git a/packages/app/src/cores/playerv2/processors/index.js b/packages/app/src/cores/playerv2/processors/index.js new file mode 100644 index 00000000..d263ec3a --- /dev/null +++ b/packages/app/src/cores/playerv2/processors/index.js @@ -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, +] \ No newline at end of file diff --git a/packages/app/src/cores/playerv2/processors/node.js b/packages/app/src/cores/playerv2/processors/node.js new file mode 100644 index 00000000..1fd83628 --- /dev/null +++ b/packages/app/src/cores/playerv2/processors/node.js @@ -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 + } +} \ No newline at end of file diff --git a/packages/app/src/cores/playerv2/servicesToManifestResolver.js b/packages/app/src/cores/playerv2/servicesToManifestResolver.js new file mode 100644 index 00000000..ba68cd39 --- /dev/null +++ b/packages/app/src/cores/playerv2/servicesToManifestResolver.js @@ -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 + } +} \ No newline at end of file