476 lines
16 KiB
TypeScript
476 lines
16 KiB
TypeScript
import { MusicPlayer } from "@euterpe.js/player"
|
|
import { DB, Song, Artist } from "@euterpe.js/music-library"
|
|
|
|
export { Euterpe, EuterpeBuilder }
|
|
/**
|
|
* Avoid Writing directly to any fields in this class!
|
|
*/
|
|
class Euterpe extends MusicPlayer {
|
|
current_song: Song | undefined
|
|
current_song_id = 0
|
|
queue: Song[] = []
|
|
played_history: Song[] = []
|
|
#sub_list: Array<(song_id: number, song_name: string) => void> = []
|
|
constructor(
|
|
public db: DB,
|
|
audio_context: AudioContext,
|
|
audio_element: HTMLAudioElement,
|
|
track: MediaElementAudioSourceNode,
|
|
gain: GainNode,
|
|
volume: number,
|
|
current_song_path?: string,
|
|
private options?: BuilderOptions
|
|
) {
|
|
super(audio_context, audio_element, track, gain, volume, current_song_path)
|
|
|
|
audio_element.addEventListener("ended", async () => {
|
|
try {
|
|
await this.try_next_song()
|
|
} catch (e) {
|
|
audio_element.currentTime = 0
|
|
this.pause()
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Unsafe, throws error if failed. Use try_seek or seek unless you don't care about the result.
|
|
*/
|
|
override seek(new_time: number) {
|
|
super.seek(new_time)
|
|
if (this.options?.auto_play_after_seek) this.play()
|
|
}
|
|
|
|
/**
|
|
* Safer seek. Normal seek will try to start the player even if the track hasn't started yet, or was previously suspended/closed.
|
|
* won't resume playback by default unless `EuterpeBuilderOptions.auto_play_after_seek == true`
|
|
* @throws if "Can't seek - Audiocontext is not running"
|
|
*/
|
|
override async try_seek(new_time: number) {
|
|
await super.try_seek(new_time)
|
|
if (this.options?.auto_play_after_seek) this.play()
|
|
}
|
|
|
|
/**
|
|
* Use to load song on page load.
|
|
* @throws if song with ID doesn't exist
|
|
*/
|
|
async try_preload_song(id: number) {
|
|
const next = this.db.songs.find((song) => song!.id == id)
|
|
if (!next) throw new Error(`Song with id ${id} doesn't exist`)
|
|
else {
|
|
this.current_song = next
|
|
this.#emit_on_song_change()
|
|
await this.try_new_song(next.url.pathname)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Won't loop back to first song if already on the last.
|
|
* If queue present, uses that, if not, relies on Song ID directly from DB
|
|
* @throws if on last song or song fails to start
|
|
*/
|
|
async try_next_song() {
|
|
let new_song: Song
|
|
if (this.queue.length > 0) {
|
|
new_song = this.queue.shift()!
|
|
} else {
|
|
let id_i = this.db.songs.length
|
|
while (this.db.songs[--id_i].id! > this.current_song_id);
|
|
const next_id = ++id_i
|
|
|
|
if (next_id == this.db.songs.length) throw new Error("Won't go past the last song")
|
|
new_song = this.db.songs.find((song) => song.id == next_id)!
|
|
}
|
|
const url = this.options?.use_only_pathname_url ? new_song.url.pathname : new_song.url.toString()
|
|
await this.try_new_song(url)
|
|
if (this.options?.auto_play_after_changing_song) await this.try_play()
|
|
if (this.current_song) this.played_history.push(this.current_song)
|
|
this.current_song = new_song
|
|
this.current_song_id = new_song.id!
|
|
this.#emit_on_song_change()
|
|
}
|
|
|
|
/**
|
|
* Will loop back to first song if already on last song,
|
|
* If queue present, uses that, if not, relies on Song ID directly from DB
|
|
*/
|
|
async try_next_song_looping() {
|
|
let new_song: Song
|
|
if (this.queue.length > 0) {
|
|
new_song = this.queue.shift()!
|
|
} else {
|
|
let id_i = this.db.songs.length
|
|
while (this.db.songs[--id_i].id! > this.current_song_id);
|
|
let next_id = ++id_i
|
|
|
|
if (next_id == this.db.songs.length) next_id = this.db.songs[0].id!
|
|
new_song = this.db.songs.find((song) => song.id == next_id)!
|
|
}
|
|
const url = this.options?.use_only_pathname_url ? new_song.url.pathname : new_song.url.toString()
|
|
await this.try_new_song(url)
|
|
if (this.options?.auto_play_after_changing_song) await this.try_play()
|
|
if (this.current_song) this.played_history.push(this.current_song)
|
|
this.current_song = new_song
|
|
this.current_song_id = new_song.id!
|
|
this.#emit_on_song_change()
|
|
}
|
|
|
|
/**
|
|
* Won't tell you if the playback was successsful & will loop back if already on last song. Best use try_next_song()
|
|
* If queue present, uses that, if not, relies on Song ID directly from DB
|
|
*/
|
|
next_song_looping() {
|
|
let new_song: Song
|
|
if (this.queue.length > 0) {
|
|
new_song = this.queue.shift()!
|
|
} else {
|
|
let id_i = this.db.songs.length
|
|
while (this.db.songs[--id_i].id! > this.current_song_id);
|
|
let next_id = ++id_i
|
|
|
|
if (next_id == this.db.songs.length) next_id = this.db.songs[0].id!
|
|
new_song = this.db.songs.find((song) => song.id == next_id)!
|
|
}
|
|
const url = this.options?.use_only_pathname_url ? new_song.url.pathname : new_song.url.toString()
|
|
this.new_song(url)
|
|
if (this.options?.auto_play_after_changing_song) this.play()
|
|
if (this.current_song) this.played_history.push(this.current_song)
|
|
this.current_song = new_song
|
|
this.current_song_id = new_song.id!
|
|
this.#emit_on_song_change()
|
|
}
|
|
|
|
/**
|
|
* Won't tell you if the playback was successsful, won't loop back if already on last song and won't throw error if attempted. Best use next_song()
|
|
* If queue present, uses that, if not, relies on Song ID directly from DB
|
|
*/
|
|
next_song() {
|
|
let new_song: Song
|
|
if (this.queue.length > 0) {
|
|
new_song = this.queue.shift()!
|
|
} else {
|
|
let id_i = this.db.songs.length
|
|
while (this.db.songs[--id_i].id! > this.current_song_id);
|
|
const next_id = ++id_i
|
|
new_song = this.db.songs.find((song) => song.id == next_id)!
|
|
}
|
|
const url = this.options?.use_only_pathname_url ? new_song.url.pathname : new_song.url.toString()
|
|
this.new_song(url)
|
|
if (this.options?.auto_play_after_changing_song) this.play()
|
|
if (this.current_song) this.played_history.push(this.current_song)
|
|
this.current_song = new_song
|
|
this.current_song_id = new_song.id!
|
|
this.#emit_on_song_change()
|
|
}
|
|
|
|
/**
|
|
* Uses safer try_play. Normal play / play will try to start the player even if the track hasn't started yet, or was previously suspended/closed
|
|
*/
|
|
async try_specific_song(new_song_id: number) {
|
|
const new_song = this.db.songs.find((song) => song.id! == new_song_id)
|
|
if (!new_song) throw new Error(`No song with id "${new_song_id}" found`)
|
|
else {
|
|
this.try_new_song(new_song.url.pathname)
|
|
if (this.options?.auto_play_after_changing_song) await this.try_play()
|
|
if (this.current_song) this.played_history.push(this.current_song)
|
|
this.current_song = new_song
|
|
this.current_song_id = new_song.id!
|
|
this.#emit_on_song_change()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Won't throw an error if new ID not found. Won't tell you if the play was successful, best use specific_song() or try_specific_song()
|
|
*/
|
|
specific_song(new_song_id: number) {
|
|
const new_song = this.db.songs.find((song) => song.id! == new_song_id)
|
|
if (!new_song) return
|
|
const url = this.options?.use_only_pathname_url ? new_song.url.pathname : new_song.url.toString()
|
|
this.new_song(url)
|
|
if (this.options?.auto_play_after_changing_song) this.play()
|
|
if (this.current_song) this.played_history.push(this.current_song)
|
|
this.current_song = new_song
|
|
this.current_song_id = new_song.id!
|
|
this.#emit_on_song_change()
|
|
}
|
|
|
|
/**
|
|
* Won't loop back to first song if already on the last.
|
|
* If played_history is present, uses that, if not, relies on Song ID directly from DB
|
|
* @throws if playback was unsuccessful or at first song/ can't go more previous
|
|
*/
|
|
async try_previous_song() {
|
|
let new_song: Song
|
|
if (this.played_history.length > 0) {
|
|
new_song = this.played_history.pop()!
|
|
} else {
|
|
let id_i = 0
|
|
while (this.db.songs[++id_i].id! < this.current_song_id);
|
|
const next_id = --id_i
|
|
|
|
if (next_id == this.db.songs.length) throw new Error("Won't roll backwards to last song")
|
|
new_song = this.db.songs.find((song) => song.id == next_id)!
|
|
}
|
|
const url = this.options?.use_only_pathname_url ? new_song.url.pathname : new_song.url.toString()
|
|
await this.try_new_song(url)
|
|
if (this.options?.auto_play_after_changing_song) await this.try_play()
|
|
this.current_song = new_song
|
|
this.current_song_id = new_song.id!
|
|
this.#emit_on_song_change()
|
|
}
|
|
|
|
/**
|
|
* Will loop back to first song if already on the last.
|
|
* If history present, uses that, if not, relies on Song ID directly from DB
|
|
*/
|
|
async try_previous_song_looping() {
|
|
let new_song: Song
|
|
if (this.played_history.length > 0) {
|
|
new_song = this.played_history.pop()!
|
|
} else {
|
|
let id_i = -1
|
|
while (this.db.songs[++id_i].id! < this.current_song_id);
|
|
let next_id = --id_i
|
|
|
|
if (next_id == -1) next_id = this.db.songs[this.db.songs.length - 1].id!
|
|
new_song = this.db.songs.find((song) => song.id == next_id)!
|
|
}
|
|
const url = this.options?.use_only_pathname_url ? new_song.url.pathname : new_song.url.toString()
|
|
await this.try_new_song(url)
|
|
if (this.options?.auto_play_after_changing_song) await this.try_play()
|
|
this.current_song = new_song
|
|
this.current_song_id = new_song.id!
|
|
this.#emit_on_song_change()
|
|
}
|
|
|
|
/**
|
|
* won't tell you if the play was successful, won't loop back to last song if already on the first and will throw error if attempted.
|
|
* If history present, uses that, if not, relies on Song ID directly from DB
|
|
*/
|
|
previous_song() {
|
|
let new_song: Song
|
|
if (this.played_history.length > 0) {
|
|
new_song = this.played_history.pop()!
|
|
} else {
|
|
let id_i = 0
|
|
while (this.db.songs[++id_i].id! < this.current_song_id);
|
|
const next_id = -id_i
|
|
|
|
if (next_id == this.db.songs.length) throw new Error("Won't go past the last song")
|
|
new_song = this.db.songs.find((song) => song.id == next_id)!
|
|
}
|
|
const url = this.options?.use_only_pathname_url ? new_song.url.pathname : new_song.url.toString()
|
|
this.new_song(url)
|
|
if (this.options?.auto_play_after_changing_song) this.play()
|
|
this.current_song_id = new_song.id!
|
|
this.#emit_on_song_change()
|
|
this.current_song = new_song
|
|
}
|
|
|
|
/**
|
|
* won't tell you if the play was successful & will loop back to last song if already on the first.
|
|
* If queue present, uses that, if not, relies on Song ID directly from DB
|
|
*/
|
|
previous_song_looping() {
|
|
let new_song: Song
|
|
if (this.played_history.length > 0) {
|
|
new_song = this.played_history.pop()!
|
|
} else {
|
|
let id_i = 0
|
|
while (this.db.songs[++id_i].id! < this.current_song_id);
|
|
let next_id = -id_i
|
|
|
|
if (next_id == this.db.songs.length) next_id = this.db.songs[this.db.songs.length].id!
|
|
new_song = this.db.songs.find((song) => song.id == next_id)!
|
|
}
|
|
const url = this.options?.use_only_pathname_url ? new_song.url.pathname : new_song.url.toString()
|
|
this.new_song(url)
|
|
if (this.options?.auto_play_after_changing_song) this.play()
|
|
this.current_song_id = new_song.id!
|
|
this.current_song = new_song
|
|
this.#emit_on_song_change()
|
|
}
|
|
|
|
/**
|
|
* Takes the song data from current song if no song ID is specified. Will return "ID - ID" if ID and current song doesn't exist
|
|
* @returns {ARTIST}, {ARTIST2}... - {SONG NAME} ({REMIX ARTIST}, {REMIX ARTIST2}... remix)
|
|
*/
|
|
format_current_song(id = this.current_song?.id) {
|
|
const curr_song = this.db.songs.find((song) => song.id == id)
|
|
if (!curr_song) {
|
|
return "ID - ID"
|
|
}
|
|
let final_text = ""
|
|
|
|
for (const artist of curr_song.artists) {
|
|
const curr_artist = artist.get(this.db) as Artist
|
|
final_text += curr_artist.name + ", "
|
|
}
|
|
|
|
final_text = final_text.slice(0, final_text.length - 2) // remove trailing ", "
|
|
final_text += " - " + curr_song.name
|
|
|
|
if (curr_song.remix_artists.length > 0) {
|
|
final_text += " ("
|
|
|
|
for (const artist of curr_song.remix_artists) {
|
|
const curr_artist = artist.get(this.db) as Artist
|
|
if (curr_artist.links && curr_artist.links.length > 0) {
|
|
final_text += curr_artist.name
|
|
} else {
|
|
final_text += curr_artist.name + ", "
|
|
}
|
|
}
|
|
|
|
final_text = final_text.slice(0, final_text.length - 2) // remove trailing ", "
|
|
final_text += " Remix)"
|
|
}
|
|
|
|
return final_text
|
|
}
|
|
|
|
/**
|
|
* Will add to queue, if ID is undefined nothing will happen. If ID already is in queue, nothing will happen. For more control use `try_queue_add()`
|
|
*/
|
|
queue_add(id: number) {
|
|
const curr_song = this.db.songs.find((song) => song.id == id)
|
|
if (!curr_song) return
|
|
if (this.queue.find((song) => song.id == id)) return
|
|
this.queue.push(curr_song)
|
|
}
|
|
|
|
/**
|
|
* Will add to queue. If ID is undefined throws error. if ID is already in queue, throws error.
|
|
*/
|
|
try_queue_add(id: number) {
|
|
const curr_song = this.db.songs.find((song) => song.id == id)
|
|
if (!curr_song) throw new Error(`Song of id "${id}" doesn't exist`)
|
|
if (this.queue.find((song) => song.id == id)) throw new Error(`Song of id "${id}" already queued`)
|
|
this.queue.push(curr_song)
|
|
}
|
|
/**
|
|
* Will add to queue. Unlike queue_add, if given ID is already in queue, it will move it to the end of the queue. Throws error if ID doesn't exist.
|
|
*/
|
|
try_queue_append(id: number) {
|
|
const curr_song = this.db.songs.find((song) => song.id == id)
|
|
if (!curr_song) throw new Error(`Song of id "${id}" doesn't exist`)
|
|
const i = this.queue.findIndex((song) => song.id == id)
|
|
if (i != -1) this.queue.push(this.queue.splice(i, 1)[0])
|
|
else this.queue.push(curr_song)
|
|
}
|
|
/**
|
|
* Will add to queue. Unlike queue_add, if given ID is already in queue, it will move it to the end of the queue. If ID Doesn't exist, does nothing. For more control use try_queue_append()
|
|
*/
|
|
queue_append(id: number) {
|
|
const curr_song = this.db.songs.find((song) => song.id == id)
|
|
if (!curr_song) return
|
|
const i = this.queue.findIndex((song) => song.id == id)
|
|
if (i != -1) this.queue.push(this.queue.splice(i, 1)[0])
|
|
else this.queue.push(curr_song)
|
|
}
|
|
/**
|
|
* Removes song of ID from queue and returns it. Does and returns nothing if song already not found.
|
|
*/
|
|
queue_remove(id: number) {
|
|
const i = this.queue.findIndex((song) => song.id == id)
|
|
if (i == -1) return
|
|
return this.queue.splice(i, 1)
|
|
}
|
|
|
|
on_song_change(callback: (song_id: number, song_name: string) => void) {
|
|
this.#sub_list.push(callback)
|
|
}
|
|
|
|
#emit_on_song_change() {
|
|
for (const func of this.#sub_list) {
|
|
func(this.current_song_id, this.format_current_song(this.current_song_id))
|
|
}
|
|
}
|
|
}
|
|
type BuilderOptions = {
|
|
use_only_pathname_url?: boolean
|
|
auto_play_after_seek?: boolean
|
|
auto_play_after_changing_song?: boolean
|
|
}
|
|
|
|
class EuterpeBuilder {
|
|
#audio_context: AudioContext
|
|
#gain: GainNode
|
|
#track: MediaElementAudioSourceNode
|
|
#volume = 1
|
|
#prev_node: any
|
|
|
|
#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)
|
|
*/
|
|
constructor(private audio_element: HTMLAudioElement, private db: DB, private options?: BuilderOptions) {
|
|
if (audio_element === undefined) throw Error("audio_element was undefined")
|
|
// ↓ For old browsers
|
|
const AudioContext = window.AudioContext || window.webkitAudioContext
|
|
this.#audio_context = new AudioContext()
|
|
this.#track = this.#audio_context.createMediaElementSource(audio_element)
|
|
this.#gain = this.#audio_context.createGain()
|
|
}
|
|
/**
|
|
* For external use, not kept inside player after connection.
|
|
* @returns {AnalyserNode}
|
|
*/
|
|
add_analyser() {
|
|
const analyser = this.#audio_context.createAnalyser()
|
|
!this.#prev_node ? this.#track.connect(analyser) : this.#prev_node.connect(analyser)
|
|
this.#prev_node = analyser
|
|
return analyser
|
|
}
|
|
/**
|
|
* For external use, not kept inside player after connection.
|
|
* @returns {StereoPannerNode}
|
|
*/
|
|
add_stereo_panner_node() {
|
|
const panner = this.#audio_context.createStereoPanner()
|
|
!this.#prev_node ? this.#track.connect(panner) : this.#prev_node.connect(panner)
|
|
this.#prev_node = panner
|
|
return panner
|
|
}
|
|
/**
|
|
* For external use, not kept inside player after connection.
|
|
* @returns {StereoPannerNode}
|
|
*/
|
|
add_wave_shaper_node() {
|
|
const shaper = this.#audio_context.createWaveShaper()
|
|
!this.#prev_node ? this.#track.connect(shaper) : this.#prev_node.connect(shaper)
|
|
this.#prev_node = shaper
|
|
return shaper
|
|
}
|
|
/**
|
|
* For additional trickery, you can connect your own node.
|
|
*/
|
|
connect_custom_node(node: AudioNode) {
|
|
!this.#prev_node ? this.#track.connect(node) : this.#prev_node.connect(node)
|
|
this.#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
|
|
*/
|
|
connect_gain() {
|
|
!this.#prev_node ? this.#track.connect(this.#gain) : this.#prev_node.connect(this.#gain)
|
|
this.#prev_node = this.#gain
|
|
this.#is_gain_connected = true
|
|
}
|
|
/**
|
|
* Finishes the build
|
|
* @returns {Euterpe}
|
|
*/
|
|
build() {
|
|
if (!this.#is_gain_connected) {
|
|
!this.#prev_node ? this.#track.connect(this.#gain) : this.#prev_node.connect(this.#gain)
|
|
this.#prev_node = this.#gain
|
|
}
|
|
this.#prev_node.connect(this.#audio_context.destination)
|
|
return new Euterpe(this.db, this.#audio_context, this.audio_element, this.#track, this.#gain, this.#volume, undefined, this.options)
|
|
}
|
|
}
|