euterpe-source/packages/euterpe/src/index.ts

475 lines
19 KiB
TypeScript

import * as Player from "@euterpe.js/player"
import * as Library from "@euterpe.js/music-library"
export { Euterpe, EuterpeBuilder }
/**
* Avoid Writing directly to any fields in this class!
*/
class Euterpe extends Player.MusicPlayer {
current_song: Library.Song | undefined
current_song_id = 0
queue: Library.Song[] = []
played_history: Library.Song[] = []
constructor(
public db: Library.DB,
audio_context: AudioContext,
audio_element: HTMLAudioElement,
track: MediaElementAudioSourceNode,
gain: GainNode,
volume: number,
current_song_path?: string) {
super(audio_context, audio_element, track, gain, volume, current_song_path,)
}
/**
* Use to load song on page load.
*/
preload_song_async(id: number) {
return new Promise((resolve, reject) => {
const next = this.db.songs.find((song) => song!.id == id)
if (!next) reject(new Error(`Song with id ${id} doesn't exist`))
else {
this.try_new_song_async(next.url.pathname).then((s) => {
this.current_song = next
resolve(s)
}, (e) => reject(e))
}
})
}
/**
* 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
*/
try_next_song_async() {
return new Promise((resolve, reject) => {
let new_song: Library.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) reject(new Error("Won't go past the last song"))
new_song = this.db.songs.find((song) => song.id == next_id)!
}
this.try_new_song_async(new_song.url.href).then(
() => {
this.try_play_async().then((s) => {
if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
resolve(s)
}, (e) => reject(e))
},
(e) => reject(e)
)
})
}
/**
* 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
*/
next_song_async() {
return new Promise((resolve, reject) => {
let new_song: Library.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)!
}
this.try_new_song_async(new_song.url.href).then(
() => {
this.try_play_async().then((s) => {
if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
resolve(s)
}, (e) => reject(e))
},
(e) => reject(e)
)
})
}
/**
* Won't tell you if the playback was successsful & wil loop back if already on last song. Best use try_next_song_async()
* If queue present, uses that, if not, relies on Song ID directly from DB
*/
next_song() {
let new_song: Library.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)!
}
this.new_song(new_song.url.href)
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!
}
/**
* Won't tell you if the playback was successsful, won't loop back if already on last song and will throw error if attempted. Best use next_song_async()
* If queue present, uses that, if not, relies on Song ID directly from DB
*/
try_next_song() {
let new_song: Library.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)!
}
this.new_song(new_song.url.href)
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!
}
/**
* Uses safer try_play_async. Normal play / play_async will try to start the player even if the track hasn't started yet, or was previously suspended/closed
*/
try_specific_song_async(new_song_id: number) {
return new Promise((resolve, reject) => {
const new_song = this.db.songs.find((song) => song.id! == new_song_id)
if (!new_song) reject(new Error(`No song with id "${new_song_id}" found`))
else {
this.try_new_song_async(new_song.url.href).then(
() => {
this.try_play_async().then((s) => {
if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
resolve(s)
}, (e) => reject(e))
},
(e) => reject(e)
)
}
})
}
/**
* uses play_async. Will try to play even if the audio context was suspended or closed.
*/
specific_song_async(new_song_id: number) {
return new Promise((resolve, reject) => {
const new_song = this.db.songs.find((song) => song.id! == new_song_id)
if (!new_song) reject(new Error(`No song with id "${new_song_id}" found`))
else {
this.try_new_song_async(new_song.url.href).then(
() => {
this.play_async().then((s) => {
if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
resolve(s)
}, (e) => reject(e))
},
(e) => reject(e)
)
}
})
}
/**
* Will throw an error if new ID not found. Won't tell you if the play was successful, best use specific_song_async() or try_specific_song_async()
*/
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.new_song(new_song.url.href)
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!
}
}
/**
* 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
*/
try_previous_song_async() {
return new Promise((resolve, reject) => {
let new_song: Library.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) reject(new Error("Won't roll backwards to last song"))
new_song = this.db.songs.find((song) => song.id == next_id)!
}
this.try_new_song_async(new_song.url.href).then(
() => {
this.try_play_async().then((s) => {
//if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
resolve(s)
}, (e) => reject(e))
},
(e) => reject(e)
)
})
}
/**
* 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
*/
previous_song_async() {
return new Promise((resolve, reject) => {
let new_song: Library.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)!
}
this.try_new_song_async(new_song.url.href).then(
() => {
this.try_play_async().then((s) => {
//if (this.current_song) this.played_history.push(this.current_song)
this.current_song = new_song
this.current_song_id = new_song.id!
resolve(s)
}, (e) => reject(e))
},
(e) => reject(e)
)
})
}
/**
* 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
*/
try_previous_song() {
let new_song: Library.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)!
}
this.new_song(new_song.url.href)
this.play()
//if (this.current_song) this.played_history.push(this.current_song)
this.current_song_id = new_song.id!
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() {
let new_song: Library.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)!
}
this.new_song(new_song.url.href)
this.play()
//if (this.current_song) this.played_history.push(this.current_song)
this.current_song_id = new_song.id!
this.current_song = new_song
}
/**
* 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 Library.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 Library.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)
}
}
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: Library.DB) {
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)
this.audio_element.preload = "metadata"
return new Euterpe(this.db, this.#audio_context, this.audio_element, this.#track, this.#gain, this.#volume)
}
}