2023-05-21 21:10:32 +00:00
|
|
|
export enum SubscribeEvents {
|
|
|
|
CurrentTimeTick,
|
|
|
|
FormattedDurationTick,
|
|
|
|
FormattedCurrentTimeTick,
|
|
|
|
}
|
|
|
|
const PubSub = () => {
|
|
|
|
//el = event listener
|
|
|
|
const el_current_time_tick: Array<(data: any) => void> = []
|
|
|
|
const el_formatted_duration_tick: Array<(data: any) => void> = []
|
|
|
|
const el_formatted_current_time_tick: Array<(data: any) => void> = []
|
|
|
|
|
|
|
|
function subscribe(event_name: SubscribeEvents, func: (data: any) => void) {
|
|
|
|
switch (event_name) {
|
|
|
|
case SubscribeEvents.CurrentTimeTick: {
|
|
|
|
el_current_time_tick.push(func)
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case SubscribeEvents.FormattedDurationTick: {
|
|
|
|
el_formatted_duration_tick.push(func)
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case SubscribeEvents.FormattedCurrentTimeTick: {
|
|
|
|
el_formatted_current_time_tick.push(func)
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function unsubscribe(event_name: SubscribeEvents, func: (data: any) => void) {
|
|
|
|
switch (event_name) {
|
|
|
|
case SubscribeEvents.CurrentTimeTick: {
|
|
|
|
if (el_current_time_tick.includes(func)) {
|
|
|
|
el_current_time_tick.splice(el_current_time_tick.indexOf(func), 1)
|
|
|
|
}
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case SubscribeEvents.FormattedDurationTick: {
|
|
|
|
if (el_formatted_duration_tick.includes(func)) {
|
|
|
|
el_formatted_duration_tick.splice(el_formatted_duration_tick.indexOf(func), 1)
|
|
|
|
}
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case SubscribeEvents.FormattedCurrentTimeTick: {
|
|
|
|
if (el_formatted_duration_tick.includes(func)) {
|
|
|
|
el_formatted_duration_tick.splice(el_formatted_duration_tick.indexOf(func), 1)
|
|
|
|
}
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function emit(event_name: SubscribeEvents, data: any) {
|
|
|
|
switch (event_name) {
|
|
|
|
case SubscribeEvents.CurrentTimeTick: {
|
|
|
|
el_current_time_tick.forEach((func) => {
|
|
|
|
func(data)
|
|
|
|
})
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case SubscribeEvents.FormattedDurationTick: {
|
|
|
|
el_formatted_duration_tick.forEach((func) => {
|
|
|
|
func(data)
|
|
|
|
})
|
|
|
|
break
|
|
|
|
}
|
|
|
|
case SubscribeEvents.FormattedCurrentTimeTick: {
|
|
|
|
el_formatted_current_time_tick.forEach((func) => {
|
|
|
|
func(data)
|
|
|
|
})
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
el_current_time_tick,
|
|
|
|
el_formatted_duration_tick,
|
|
|
|
el_formatted_current_time_tick,
|
|
|
|
subscribe,
|
|
|
|
unsubscribe,
|
|
|
|
emit
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/* For old browsers */
|
|
|
|
declare global {
|
|
|
|
interface Window {
|
|
|
|
webkitAudioContext: typeof AudioContext
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTMLAudioElement, track_i: MediaElementAudioSourceNode, gain_i: GainNode, volume_i: number, current_song_path_i?: string) => {
|
|
|
|
const audio_element: HTMLAudioElement = audio_element_i
|
|
|
|
const audio_context: AudioContext = audio_context_i
|
|
|
|
const track: MediaElementAudioSourceNode = track_i
|
|
|
|
const gain: GainNode = gain_i
|
|
|
|
let current_song_path: string | undefined = current_song_path_i
|
|
|
|
let current_song_duration: number
|
|
|
|
let volume_cache: number = volume_i
|
|
|
|
let volume: number = volume_i
|
|
|
|
let is_playing = false
|
|
|
|
let time = 0
|
|
|
|
const pub_sub = PubSub()
|
|
|
|
|
|
|
|
function mute_toggle() {
|
|
|
|
if (gain.gain.value == 0) {
|
|
|
|
unmute()
|
|
|
|
} else {
|
|
|
|
mute()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function mute() {
|
|
|
|
volume_cache = gain.gain.value
|
|
|
|
/* Gentler mute, doesn't pop
|
|
|
|
gain.gain.linearRampToValueAtTime(
|
|
|
|
0,
|
|
|
|
audio_context.currentTime + 0.1
|
|
|
|
);*/
|
|
|
|
volume = gain.gain.value = 0
|
|
|
|
}
|
|
|
|
function unmute() {
|
|
|
|
volume = gain.gain.value = volume_cache
|
|
|
|
}
|
|
|
|
function change_volume(volume_i: number) {
|
|
|
|
volume = gain.gain.value = volume_i
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Safer seek_async. Normal seek will try to start the player even if the track hasn't started yet, or was previously suspended/closed
|
|
|
|
*/
|
|
|
|
function try_seek_async(new_time: number) {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
if (track.context.state == "closed" || track.context.state == "suspended") {
|
|
|
|
is_playing = false
|
|
|
|
reject("Can't seek - track not playing")
|
|
|
|
}
|
|
|
|
audio_element.currentTime = new_time
|
|
|
|
resolve(null)
|
|
|
|
/*audio_element.play().then((s) => resolve(s), (r) => {
|
|
|
|
is_playing = false
|
|
|
|
reject(r)
|
|
|
|
})*/
|
|
|
|
})
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Can try to seek even if the audio context was suspended or closed. Best to use try_seek_async()
|
|
|
|
*/
|
|
|
|
function seek_async(new_time: number) {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
audio_element.currentTime = new_time
|
|
|
|
resolve(null)
|
|
|
|
/* audio_element.play().then((s) => resolve(s), (r) => {
|
|
|
|
is_playing = false
|
|
|
|
reject(r)
|
|
|
|
})*/
|
|
|
|
})
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Unsafe, throws error if failed. Use try_seek_async or seek_async unless you don't care about the result.
|
|
|
|
*/
|
|
|
|
function seek(new_time: number) {
|
|
|
|
audio_element.currentTime = new_time
|
|
|
|
audio_element.play().catch((e) => { throw e })
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Safer play_toggle_async. Normal play_toggle will try to start the player even if the track hasn't started yet, or was previously suspended/closed
|
|
|
|
*/
|
|
|
|
function try_play_toggle_async() {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
if (audio_context.state === "suspended" || audio_context.state === "closed") {
|
|
|
|
reject("Context closed or suspended")
|
|
|
|
}
|
|
|
|
if (audio_element.paused) {
|
|
|
|
audio_element.play().then((s) => {
|
|
|
|
is_playing = true
|
|
|
|
resolve(s)
|
|
|
|
}, (r) => {
|
|
|
|
is_playing = false
|
|
|
|
reject(r)
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
audio_element.pause()
|
|
|
|
is_playing = false
|
|
|
|
resolve(null)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Can try to play even if the audio context was suspended or closed. Best to use try_play_toggle_async()
|
|
|
|
*/
|
|
|
|
function play_toggle_async() {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
if (audio_context.state === "suspended" || audio_context.state === "closed") {
|
|
|
|
audio_context.resume()
|
|
|
|
}
|
|
|
|
if (audio_element.paused) {
|
|
|
|
audio_element.play().then((s) => {
|
|
|
|
is_playing = true
|
|
|
|
resolve(s)
|
|
|
|
}, (r) => {
|
|
|
|
is_playing = false
|
|
|
|
reject(r)
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
audio_element.pause()
|
|
|
|
is_playing = false
|
|
|
|
resolve(null)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Unsafe, throws error if failed. Use play_toggle_async or try_play_toggle_async unless you don't care about the result.
|
|
|
|
*/
|
|
|
|
function play_toggle() {
|
|
|
|
if (audio_element.paused) {
|
|
|
|
is_playing = true
|
|
|
|
audio_element.play().catch((r) => {
|
|
|
|
is_playing = false
|
|
|
|
throw r
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
is_playing = false
|
|
|
|
audio_element.pause()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Safer play_async. Normal play will try to start the player even if the track hasn't started yet, or was previously suspended/closed
|
|
|
|
*/
|
|
|
|
function try_play_async() {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
if (is_playing) reject(Error("Already playing"))
|
|
|
|
if (audio_context.state === "suspended" || audio_context.state === "closed") {
|
|
|
|
reject("Context closed or suspended")
|
|
|
|
}
|
|
|
|
audio_element.play().then((s) => {
|
|
|
|
is_playing = true
|
|
|
|
resolve(s)
|
|
|
|
}, (r) => {
|
|
|
|
is_playing = false
|
|
|
|
reject(r)
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Will try to play even if the audio context was suspended or closed. Best to use try_play_async()
|
|
|
|
*/
|
|
|
|
function play_async() {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
if (is_playing) resolve(null)
|
|
|
|
if (audio_context.state === "suspended" || audio_context.state === "closed") {
|
|
|
|
audio_context.resume()
|
|
|
|
}
|
|
|
|
audio_element.play().then((s) => {
|
|
|
|
is_playing = true
|
|
|
|
resolve(s)
|
|
|
|
}, (r) => {
|
|
|
|
is_playing = false
|
|
|
|
reject(r)
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Unsafe, throws error if failed. Use play_async or try_play_async unless you don't care about the result.
|
|
|
|
*/
|
|
|
|
function play() {
|
|
|
|
if (is_playing) return
|
|
|
|
audio_element.play().catch((r) => {
|
|
|
|
is_playing = false
|
|
|
|
throw r
|
|
|
|
})
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Safe technically. Even if audioContext is suspended or closed it will pretend that it paused.
|
|
|
|
*/
|
|
|
|
function pause() {
|
|
|
|
audio_element.pause()
|
|
|
|
is_playing = false
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Will only load metadata of the upcoming song. Need to call try_play_async() afterwards to start the playback
|
|
|
|
*/
|
|
|
|
function try_new_song_async(path: string) {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
audio_element.src = current_song_path = path
|
|
|
|
//Found out today about this. Such a nice new way to mass remove event listeners!
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
|
|
audio_element.addEventListener("canplaythrough", function canplay_listener(s) {
|
|
|
|
//current_song_duration = audio_element.duration
|
|
|
|
controller.abort()
|
|
|
|
resolve(s)
|
|
|
|
}, { signal: controller.signal })
|
|
|
|
|
|
|
|
audio_element.addEventListener("error", function error_listener(e) {
|
|
|
|
controller.abort()
|
|
|
|
reject(e)
|
|
|
|
}, { signal: controller.signal })
|
2023-05-29 19:42:35 +00:00
|
|
|
/*
|
2023-05-21 21:10:32 +00:00
|
|
|
audio_element.addEventListener("abort", function abort_listener(e) {
|
|
|
|
controller.abort()
|
|
|
|
reject(e)
|
|
|
|
}, { signal: controller.signal })
|
|
|
|
|
|
|
|
audio_element.addEventListener("stalled", function stalled_listener(e) {
|
|
|
|
controller.abort()
|
|
|
|
reject(e)
|
|
|
|
}, { signal: controller.signal })
|
2023-05-29 19:42:35 +00:00
|
|
|
*/
|
2023-05-21 21:10:32 +00:00
|
|
|
is_playing = false
|
|
|
|
})
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Won't tell if you if the song actually got loaded or if it failed. For a safer version use try_new_song_async() unless you don't care about the result
|
|
|
|
*/
|
|
|
|
function new_song(path: string) {
|
|
|
|
audio_element.src = current_song_path = path
|
|
|
|
//current_song_duration = audio_element.duration
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Will parse the duration of the song to make it easy to display in UI
|
|
|
|
* If somethings undefined it returns "0:00"
|
|
|
|
*/
|
|
|
|
function get_formatted_duration() {
|
|
|
|
const dur = audio_element.duration
|
|
|
|
current_song_duration = audio_element.duration
|
|
|
|
|
|
|
|
if (dur == 0 || !dur) return "0:00"
|
|
|
|
|
|
|
|
// ~ is Bitwise NOT, equivalent to Math.floor()
|
|
|
|
const hrs = ~~(dur / 3600);
|
|
|
|
const mins = ~~((dur % 3600) / 60);
|
|
|
|
const secs = ~~dur % 60;
|
|
|
|
|
|
|
|
let ret = ""
|
|
|
|
if (hrs > 0) {
|
|
|
|
ret += "" + hrs + ":" + (mins < 10 ? "0" : "");
|
|
|
|
}
|
|
|
|
|
|
|
|
ret += "" + mins + ":" + (secs < 10 ? "0" : "");
|
|
|
|
ret += "" + secs;
|
|
|
|
return ret;
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Will parse the current time of the song to make it easy to display in UI
|
|
|
|
* If somethings undefined it returns "0:00"
|
|
|
|
*/
|
|
|
|
function get_formatted_current_time() {
|
|
|
|
const curr = audio_element.currentTime
|
|
|
|
|
|
|
|
if (curr == 0 || !curr) return "0:00"
|
|
|
|
// ~~ is Bitwise OR, equivalent to Math.floor()
|
|
|
|
const hrs = ~~(curr / 3600);
|
|
|
|
const mins = ~~((curr % 3600) / 60);
|
|
|
|
const secs = ~~curr % 60;
|
|
|
|
|
|
|
|
let ret = ""
|
|
|
|
if (hrs > 0) {
|
|
|
|
ret += "" + hrs + ":" + (mins < 10 ? "0" : "");
|
|
|
|
}
|
|
|
|
|
|
|
|
ret += "" + mins + ":" + (secs < 10 ? "0" : "");
|
|
|
|
ret += "" + secs;
|
|
|
|
return ret;
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Will give current time every animation frame
|
|
|
|
*/
|
|
|
|
function subscribe_to_time_tick(callback: (data: any) => void) {
|
|
|
|
pub_sub.subscribe(SubscribeEvents.CurrentTimeTick, callback)
|
|
|
|
emit_current_time()
|
|
|
|
}
|
|
|
|
function emit_current_time() {
|
|
|
|
const request_id = requestAnimationFrame(emit_current_time.bind(MusicPlayer))
|
|
|
|
if (audio_element.ended) is_playing = false
|
|
|
|
if (audio_element.paused) is_playing == false
|
|
|
|
// if use reactively changes volume directly
|
|
|
|
gain.gain.value = volume
|
|
|
|
|
|
|
|
time = audio_element.currentTime
|
|
|
|
if (pub_sub.el_current_time_tick.length == 0) cancelAnimationFrame(request_id)
|
|
|
|
pub_sub.emit(SubscribeEvents.CurrentTimeTick, time)
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Will give formatted current time via get_formatted_current_time() every animation frame
|
|
|
|
*/
|
|
|
|
function subscribe_to_formatted_current_time_tick(callback: (data: any) => void) {
|
|
|
|
pub_sub.subscribe(SubscribeEvents.FormattedCurrentTimeTick, callback)
|
|
|
|
emit_formatted_current_time()
|
|
|
|
}
|
|
|
|
function emit_formatted_current_time() {
|
|
|
|
const request_id = requestAnimationFrame(emit_formatted_current_time.bind(MusicPlayer))
|
|
|
|
const time = get_formatted_current_time()
|
|
|
|
//if (pub_sub.el_formatted_current_time_tick.length == 0) cancelAnimationFrame(request_id)
|
|
|
|
pub_sub.emit(SubscribeEvents.FormattedCurrentTimeTick, time)
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Will give formatted duration time via get_formatted_duration() every animation frame
|
|
|
|
*/
|
|
|
|
function subscribe_to_formatted_duration_time(callback: (data: any) => void) {
|
|
|
|
pub_sub.subscribe(SubscribeEvents.FormattedDurationTick, callback)
|
|
|
|
emit_formatted_duration_time()
|
|
|
|
}
|
|
|
|
function emit_formatted_duration_time() {
|
|
|
|
const request_id = requestAnimationFrame(emit_formatted_duration_time.bind(MusicPlayer))
|
|
|
|
const time = get_formatted_duration()
|
|
|
|
//if (pub_sub.el_formatted_duration_tick.length == 0) cancelAnimationFrame(request_id)
|
|
|
|
pub_sub.emit(SubscribeEvents.FormattedDurationTick, time)
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
track,
|
|
|
|
get_is_playing: () => is_playing,
|
|
|
|
get_current_path: () => current_song_path,
|
|
|
|
get_current_duration: () => current_song_duration,
|
|
|
|
volume,
|
|
|
|
time,
|
|
|
|
mute,
|
|
|
|
unmute,
|
|
|
|
mute_toggle,
|
|
|
|
change_volume,
|
|
|
|
try_seek_async,
|
|
|
|
seek_async,
|
|
|
|
seek,
|
|
|
|
play,
|
|
|
|
pause,
|
|
|
|
play_toggle,
|
|
|
|
play_toggle_async,
|
|
|
|
try_play_toggle_async,
|
|
|
|
try_new_song_async,
|
|
|
|
new_song,
|
|
|
|
get_formatted_duration,
|
|
|
|
get_formatted_current_time,
|
|
|
|
subscribe_to_formatted_current_time_tick,
|
|
|
|
subscribe_to_formatted_duration_time,
|
|
|
|
subscribe_to_time_tick,
|
|
|
|
try_play_async,
|
|
|
|
play_async,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function MusicPlayerBuilder(audio_element: HTMLAudioElement) {
|
|
|
|
let audio_context: AudioContext
|
|
|
|
let gain: GainNode
|
|
|
|
let track: MediaElementAudioSourceNode
|
|
|
|
const volume = 1
|
|
|
|
let prev_node: any;
|
|
|
|
let is_gain_connected = false
|
|
|
|
/**
|
|
|
|
* Creates a context and gain( Gets connected at the end )
|
|
|
|
* will throw if audio_element is undefined (stupid vue setup amirite?)
|
|
|
|
* will throw if user has not interacted with the page yet (Can't initiate AudioContext)
|
|
|
|
*/
|
|
|
|
function start() {
|
|
|
|
if (audio_element === undefined) throw Error("audio_element was undefined")
|
|
|
|
// ↓ For old browsers
|
|
|
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
|
|
audio_context = new AudioContext()
|
|
|
|
track = audio_context.createMediaElementSource(audio_element)
|
|
|
|
gain = audio_context.createGain()
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* For external use, not kept inside player after connection.
|
|
|
|
* @returns {AnalyserNode}
|
|
|
|
*/
|
|
|
|
function add_analyser() {
|
|
|
|
const analyser = audio_context.createAnalyser()
|
|
|
|
!prev_node ? track.connect(analyser) : prev_node.connect(analyser)
|
|
|
|
prev_node = analyser
|
|
|
|
return analyser
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* For external use, not kept inside player after connection.
|
|
|
|
* @returns {StereoPannerNode}
|
|
|
|
*/
|
|
|
|
function add_stereo_panner_node() {
|
|
|
|
const panner = audio_context.createStereoPanner()
|
|
|
|
!prev_node ? track.connect(panner) : prev_node.connect(panner)
|
|
|
|
prev_node = panner
|
|
|
|
return panner
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* For external use, not kept inside player after connection.
|
|
|
|
* @returns {StereoPannerNode}
|
|
|
|
*/
|
|
|
|
function add_wave_shaper_node() {
|
|
|
|
const shaper = audio_context.createWaveShaper()
|
|
|
|
!prev_node ? track.connect(shaper) : prev_node.connect(shaper)
|
|
|
|
prev_node = shaper
|
|
|
|
return shaper
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* For additional trickery, you can connect your own node.
|
|
|
|
*/
|
|
|
|
function connect_custom_node(node: AudioNode) {
|
|
|
|
!prev_node ? track.connect(node) : prev_node.connect(node)
|
|
|
|
prev_node = node
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Only use if you need to connect the gain before another node,
|
|
|
|
* eg. if you want the analyser nodes output to be affected by user gain
|
|
|
|
*/
|
|
|
|
function connect_gain() {
|
|
|
|
!prev_node ? track.connect(gain) : prev_node.connect(gain)
|
|
|
|
prev_node = gain
|
|
|
|
is_gain_connected = true
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Finishes the build
|
|
|
|
* @returns {MusicPlayer: () => void}
|
|
|
|
*/
|
|
|
|
function build() {
|
|
|
|
if (!is_gain_connected) {
|
|
|
|
!prev_node ? track.connect(gain) : prev_node.connect(gain)
|
|
|
|
prev_node = gain
|
|
|
|
}
|
|
|
|
prev_node.connect(audio_context.destination)
|
|
|
|
audio_element.preload = "metadata"
|
|
|
|
return MusicPlayer(audio_context, audio_element, track, gain, volume)
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
start,
|
|
|
|
add_analyser,
|
|
|
|
add_stereo_panner_node,
|
|
|
|
add_wave_shaper_node,
|
|
|
|
connect_gain,
|
|
|
|
connect_custom_node,
|
|
|
|
build
|
|
|
|
}
|
|
|
|
}
|