diff --git a/package.json b/package.json index 5cfddb6..59cedeb 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "publish-player": "nx build player && cd dist/packages/player && npm publish --access=public", "publish-visualizer": "nx build visualizer && cd dist/packages/visualizer && npm publish --access=public", - "publish-library": "nx build music-library && cd dist/packages/music-library && npm publish --access=public" + "publish-library": "nx build music-library && cd dist/packages/music-library && npm publish --access=public", + "publish-euterpe": "nx build euterpe && cd dist/packages/euterpe && npm publish --access=public" }, "private": false, "devDependencies": { @@ -39,4 +40,4 @@ "dependencies": { "tslib": "^2.3.0" } -} \ No newline at end of file +} diff --git a/packages/euterpe-web-test/.babelrc b/packages/euterpe-web-test/.babelrc new file mode 100644 index 0000000..19ebd10 --- /dev/null +++ b/packages/euterpe-web-test/.babelrc @@ -0,0 +1,5 @@ +{ + "presets": [ + "@nx/js/babel" + ] +} diff --git a/packages/euterpe-web-test/.eslintrc.json b/packages/euterpe-web-test/.eslintrc.json new file mode 100644 index 0000000..5313267 --- /dev/null +++ b/packages/euterpe-web-test/.eslintrc.json @@ -0,0 +1,33 @@ +{ + "extends": [ + "../../.eslintrc.json" + ], + "ignorePatterns": [ + "!**/*" + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx" + ], + "rules": {} + }, + { + "files": [ + "*.ts", + "*.tsx" + ], + "rules": {} + }, + { + "files": [ + "*.js", + "*.jsx" + ], + "rules": {} + } + ] +} diff --git a/packages/euterpe-web-test/index.html b/packages/euterpe-web-test/index.html new file mode 100644 index 0000000..d5b8f1a --- /dev/null +++ b/packages/euterpe-web-test/index.html @@ -0,0 +1,46 @@ + + + + + + EuterpeTest + + + + + + + + +
+
+
+

playing:   +

ID - ID

+

+
+
+ + + + + + +

-:--

+ +

-:--

+ + + + + + +
+
+ queue:

+ history:

+
+ + + + \ No newline at end of file diff --git a/packages/euterpe-web-test/project.json b/packages/euterpe-web-test/project.json new file mode 100644 index 0000000..152ae65 --- /dev/null +++ b/packages/euterpe-web-test/project.json @@ -0,0 +1,70 @@ +{ + "name": "euterpe-web-test", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "packages/euterpe-web-test/src", + "tags": [], + "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": [ + "{options.outputPath}" + ], + "defaultConfiguration": "production", + "options": { + "outputPath": "dist/packages/euterpe-web-test" + }, + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "mode": "production" + } + } + }, + "serve": { + "executor": "@nx/vite:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "euterpe-web-test:build" + }, + "configurations": { + "development": { + "buildTarget": "euterpe-web-test:build:development", + "hmr": true + }, + "production": { + "buildTarget": "euterpe-web-test:build:production", + "hmr": false + } + } + }, + "preview": { + "executor": "@nx/vite:preview-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "euterpe-web-test:build" + }, + "configurations": { + "development": { + "buildTarget": "euterpe-web-test:build:development" + }, + "production": { + "buildTarget": "euterpe-web-test:build:production" + } + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "lintFilePatterns": [ + "packages/euterpe-web-test/**/*.ts" + ] + } + } + } +} diff --git a/packages/euterpe-web-test/public/16.サニーボーイ・ラプソディ.ogg b/packages/euterpe-web-test/public/16.サニーボーイ・ラプソディ.ogg new file mode 100644 index 0000000..54389a4 Binary files /dev/null and b/packages/euterpe-web-test/public/16.サニーボーイ・ラプソディ.ogg differ diff --git a/packages/euterpe-web-test/public/Jamie xx - Sleep Sound.mp3 b/packages/euterpe-web-test/public/Jamie xx - Sleep Sound.mp3 new file mode 100644 index 0000000..15e7832 Binary files /dev/null and b/packages/euterpe-web-test/public/Jamie xx - Sleep Sound.mp3 differ diff --git a/packages/euterpe-web-test/public/Machinedrum, Tanerelle & Mono Poly - Star (IMANU Remix) final.mp3 b/packages/euterpe-web-test/public/Machinedrum, Tanerelle & Mono Poly - Star (IMANU Remix) final.mp3 new file mode 100644 index 0000000..e4ffc91 Binary files /dev/null and b/packages/euterpe-web-test/public/Machinedrum, Tanerelle & Mono Poly - Star (IMANU Remix) final.mp3 differ diff --git a/packages/euterpe-web-test/public/favicon.ico b/packages/euterpe-web-test/public/favicon.ico new file mode 100644 index 0000000..317ebcb Binary files /dev/null and b/packages/euterpe-web-test/public/favicon.ico differ diff --git a/packages/euterpe-web-test/public/janz - wish.mp3 b/packages/euterpe-web-test/public/janz - wish.mp3 new file mode 100644 index 0000000..d98d63c Binary files /dev/null and b/packages/euterpe-web-test/public/janz - wish.mp3 differ diff --git a/packages/euterpe-web-test/src/assets/.gitkeep b/packages/euterpe-web-test/src/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/euterpe-web-test/src/db.ts b/packages/euterpe-web-test/src/db.ts new file mode 100644 index 0000000..fa0a636 --- /dev/null +++ b/packages/euterpe-web-test/src/db.ts @@ -0,0 +1,63 @@ +import { EuterpeBuilder, Library, Player } from "@euterpe.js/euterpe"; +//export const euterpe = new +export const db = new Library.DB + +db.add([ + //The IDs are added incrementally & are 0 based., so first artists ID added is 0, next 1 etc... + //You can specify the ID manually if you want + new Library.Artist({ + name: "Jamie xx", + }), + new Library.Artist({ + name: "janz", + }), + new Library.Artist({ + name: "Machinedrum", + }), + new Library.Artist({ + name: "Tanerélle", + }), + new Library.Artist({ + name: "Mono/Poly", + }), + new Library.Artist({ + name: "IMANU", + links: [ + [Library.Platforms.Spotify, new URL("https://open.spotify.com/artist/5Y7rFm0tiJTVDzGLMzz0W1?si=DRaZyugTTIqlBHDkMGKVqA&nd=1")] + ] + }), + new Library.Artist({ + name: "toe", + id: 10 + }), +]) +db.add([ + new Library.Song({ + //Refrences are constructed as such. This allows to get to the artist from either collection or song + artists: [new Library.Ref(Library.RefTo.Artists, 2), new Library.Ref(Library.RefTo.Artists, 3), new Library.Ref(Library.RefTo.Artists, 4)], + duration: 252, + name: "Star", + remix_artists: [new Library.Ref(Library.RefTo.Artists, 5)], + url: new URL("http://127.0.0.1:4200/Machinedrum, Tanerelle & Mono Poly - Star (IMANU Remix) final.mp3") + }), + new Library.Song({ + //If you don't like guessing the IDs, then this is also a way to do it + artists: [new Library.Ref(Library.RefTo.Artists, db.artists.find((a) => a.name == "Jamie xx")!.id!)], + duration: 331, + name: "Sleep Sound", + url: new URL("http://127.0.0.1:4200/Jamie xx - Sleep Sound.mp3") + }), + new Library.Song({ + artists: [new Library.Ref(Library.RefTo.Artists, 1)], + duration: 75, + name: "wish", + url: new URL("http://127.0.0.1:4200/janz - wish.mp3") + }), + new Library.Song({ + artists: [new Library.Ref(Library.RefTo.Artists, 10)], + duration: 4 * 60 + 5, + name: "サニーボーイ・ラプソディ", + url: new URL("http://127.0.0.1:4200/16.サニーボーイ・ラプソディ.ogg") + }) +]) + diff --git a/packages/euterpe-web-test/src/main.ts b/packages/euterpe-web-test/src/main.ts new file mode 100644 index 0000000..634432d --- /dev/null +++ b/packages/euterpe-web-test/src/main.ts @@ -0,0 +1,142 @@ +import { db } from "./db"; +import { EuterpeBuilder, Euterpe } from "@euterpe.js/euterpe"; + +let is_seeking = false +// document.addEventListener("click", start, { once: true }) +let euterpe: Euterpe +maybe_start() +function maybe_start() { + if (euterpe) return + + euterpe = new EuterpeBuilder(document.querySelector("#audio")!, db) + .build() + add_library_to_dom() + euterpe.preload_song_async(0).then(() => { + document.querySelector("#text-playing")!.innerHTML = euterpe.format_current_song() + }, (e) => console.log(e + " Failed to preload")) + + document.querySelector("#seek")?.addEventListener("mouseup", (e) => { + euterpe.try_seek_async(e.target?.valueAsNumber).then(() => { console.log("seeked to " + e.target?.valueAsNumber) }, () => { + alert("Failed seeking! " + e) + }) + is_seeking = false + }) + + // Subscriptions to AudioContext changes, eg. time.. + euterpe.on_duration_formatted((time) => { + document.querySelector("#duration")!.innerHTML = time + document.querySelector("#seek")!.max = "" + euterpe.current_song_duration + }) + + euterpe.on_time_tick_formatted((time) => { + document.querySelector("#current")!.innerHTML = time + }) + euterpe.on_time_tick((time) => { + if (is_seeking) return + document.querySelector("#seek")!.value = "" + time + dev_queue_update() + dev_history_update() + }) + + +} + +document.querySelector("#previous")?.addEventListener("click", () => { + maybe_start() + euterpe.previous_song_async().then(() => { + document.querySelector("#text-playing")!.innerHTML = euterpe.format_current_song() + }, (e) => alert(e + "Failed to change song")) +}) +document.querySelector("#next")?.addEventListener("click", () => { + maybe_start() + euterpe.next_song_async().then(() => { + document.querySelector("#text-playing")!.innerHTML = euterpe.format_current_song() + }, (e) => alert(e + "Failed to change song")) +}) + +document.querySelector("#play")?.addEventListener("click", () => { + maybe_start() + euterpe.play_async().catch((e) => alert("Failed to play, " + e)) +}) +document.querySelector("#pause")?.addEventListener("click", () => { + maybe_start() + euterpe.pause() +}) +document.querySelector("#mute")?.addEventListener("click", () => { + maybe_start() + euterpe.mute() +}) +document.querySelector("#unmute")?.addEventListener("click", () => { + maybe_start() + euterpe.unmute() +}) +document.querySelector("#toggle-mute")?.addEventListener("click", () => { + maybe_start() + euterpe.mute_toggle() +}) +document.querySelector("#toggle-play")?.addEventListener("click", () => { + maybe_start() + euterpe.play_toggle_async().catch((e) => alert("failed to toggle pause/play!" + e)) +}) +document.querySelector("#volume")?.addEventListener("input", (e) => { + maybe_start() + euterpe.change_volume(e.target?.valueAsNumber) +}) +//disables time updates so the time slider doesn't slip away from user +document.querySelector("#seek")?.addEventListener("mousedown", () => { + is_seeking = true; +}) + +function add_library_to_dom() { + const lib_dom = document.querySelector(".library-wrapper") as HTMLDivElement + for (const song of euterpe.db.songs) { + const div = document.createElement("div") + const p = document.createElement("p") + const button_play = document.createElement("button") + const button_queue = document.createElement("button") + const span = document.createElement("span") + p.innerHTML = `${euterpe.format_current_song(song.id)}` + + button_play.innerHTML = "play" + button_play.dataset["id"] = `${song.id}` + button_play.onclick = library_play + + button_queue.innerHTML = "queue" + button_queue.dataset["id"] = `${song.id}` + button_queue.onclick = library_queue + + div.appendChild(p) + span.appendChild(button_play) + span.appendChild(button_queue) + div.appendChild(span) + + lib_dom.appendChild(div) + } +} +function library_play(e: MouseEvent) { + const b = e.currentTarget as HTMLButtonElement + euterpe.try_specific_song_async(parseInt(b.dataset["id"]!)).then( + () => document.querySelector("#text-playing")!.innerHTML = euterpe.format_current_song(), + (e) => alert(e) + ) +} +function library_queue(e: MouseEvent) { + const b = e.currentTarget as HTMLButtonElement + euterpe.queue_append(parseInt(b.dataset["id"]!)) +} +function dev_queue_update() { + const p = document.querySelector("#queue-info") as HTMLParagraphElement + const dev_arr = [] + for (const song of euterpe.queue) { + dev_arr.push(`Name: ${song.name}, ID: ${song.id} |`) + } + p.innerHTML = dev_arr.toString() +} +function dev_history_update() { + const p = document.querySelector("#history-info") as HTMLParagraphElement + const dev_arr = [] + for (const song of euterpe.played_history) { + dev_arr.push(`Name: ${song.name}, ID: ${song.id} |`) + } + p.innerHTML = dev_arr.toString() +} \ No newline at end of file diff --git a/packages/euterpe-web-test/src/styles.css b/packages/euterpe-web-test/src/styles.css new file mode 100644 index 0000000..9c81066 --- /dev/null +++ b/packages/euterpe-web-test/src/styles.css @@ -0,0 +1,38 @@ +#volume{ + transform: rotate(270deg); +} +body { + width: 100vw; + height:100vh; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} +.wrapper{ + width:80vw; + display: flex; + justify-content: space-between; +} +.name-wrapper{ + width:80vw; + display: flex; + justify-content: center; +} +.library-wrapper > div { + display: flex; + margin: 0 5rem; + justify-content: space-between; +} +.library-wrapper { + width:60vw; +} +.library-wrapper button { + height: 100%; +} +.dev-wrapper{ + width: 80vw; + margin: 6rem 0; +} \ No newline at end of file diff --git a/packages/euterpe-web-test/tsconfig.app.json b/packages/euterpe-web-test/tsconfig.app.json new file mode 100644 index 0000000..3253fc8 --- /dev/null +++ b/packages/euterpe-web-test/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["node"] + }, + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/packages/euterpe-web-test/tsconfig.json b/packages/euterpe-web-test/tsconfig.json new file mode 100644 index 0000000..af79c85 --- /dev/null +++ b/packages/euterpe-web-test/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "strict": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "include": ["src"], + "references": [ + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/packages/euterpe-web-test/vite.config.ts b/packages/euterpe-web-test/vite.config.ts new file mode 100644 index 0000000..1eed4fd --- /dev/null +++ b/packages/euterpe-web-test/vite.config.ts @@ -0,0 +1,33 @@ +/// +import { defineConfig } from "vite" + +import viteTsConfigPaths from "vite-tsconfig-paths" + +export default defineConfig({ + cacheDir: "../../node_modules/.vite/euterpe-web-test", + + server: { + port: 4200, + host: "localhost" + }, + + preview: { + port: 4300, + host: "localhost" + }, + + plugins: [ + viteTsConfigPaths({ + root: "../../" + }) + ] + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ + // viteTsConfigPaths({ + // root: '../../', + // }), + // ], + // }, +}) diff --git a/packages/euterpe/.eslintrc.json b/packages/euterpe/.eslintrc.json new file mode 100644 index 0000000..5313267 --- /dev/null +++ b/packages/euterpe/.eslintrc.json @@ -0,0 +1,33 @@ +{ + "extends": [ + "../../.eslintrc.json" + ], + "ignorePatterns": [ + "!**/*" + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx" + ], + "rules": {} + }, + { + "files": [ + "*.ts", + "*.tsx" + ], + "rules": {} + }, + { + "files": [ + "*.js", + "*.jsx" + ], + "rules": {} + } + ] +} diff --git a/packages/euterpe/README.md b/packages/euterpe/README.md new file mode 100644 index 0000000..31725b2 --- /dev/null +++ b/packages/euterpe/README.md @@ -0,0 +1,22 @@ +# Euterpe + +Fully featured AudioContext music player for the web. + +Features: + - "Local" library/Database for songs, collections etc. + - Queue + - History + - Easy way to create Vector based audio visuals + - Safe. Provides wrappers for all functions that are either unsafe or don't give a success return. (very Rust inspired, yes.) + - Async / Await or simple funcions. + +## How to use: + +#### Simple demo [here](https://github.com/euterpe-js/euterpe-source/tree/master/packages/euterpe-web-test) + +Since this package is just a compilation of our smaller modules, you can read individual modules' tutorials on their respective npm page: + - [Euterpe Player](https://www.npmjs.com/package/@euterpe.js/player) + - [Euterpe Visualizer](https://www.npmjs.com/package/@euterpe.js/visualizer) + - [Euterpe Music Library](https://www.npmjs.com/package/@euterpe.js/music-library) + +This module builds on those, and further adds functions for playing backwards, forwards and managing the queue. \ No newline at end of file diff --git a/packages/euterpe/package.json b/packages/euterpe/package.json new file mode 100644 index 0000000..9ed7172 --- /dev/null +++ b/packages/euterpe/package.json @@ -0,0 +1,33 @@ +{ + "name": "@euterpe.js/euterpe", + "version": "1.0.0", + "type": "module", + "description": "Fully featured solution for playing music on the web. Support for local library, audio visuals and more!", + "main": "./src/index.js", + "author": { + "name": "Djkáťo", + "email": "djkatovfx@gmail.com" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/euterpe-js/euterpe-source.git" + }, + "homepage": "https://github.com/euterpe-js/euterpe-source/tree/master/packages/euterpe#readme", + "keywords": [ + "audio", + "library", + "music-database", + "audio-player", + "webaudio", + "database", + "db" + ], + "exports": { + ".": { + "types": "./src/index.d.ts", + "import": "./src/index.js", + "require": "./src/lib/euterpe.js" + } + } +} \ No newline at end of file diff --git a/packages/euterpe/project.json b/packages/euterpe/project.json new file mode 100644 index 0000000..4a9a193 --- /dev/null +++ b/packages/euterpe/project.json @@ -0,0 +1,40 @@ +{ + "name": "euterpe", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/euterpe/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": [ + "{options.outputPath}" + ], + "options": { + "outputPath": "dist/packages/euterpe", + "main": "packages/euterpe/src/index.ts", + "tsConfig": "packages/euterpe/tsconfig.lib.json", + "assets": [ + "packages/euterpe/*.md" + ] + } + }, + "publish": { + "command": "node tools/scripts/publish.mjs euterpe {args.ver} {args.tag}", + "dependsOn": [ + "build" + ] + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "lintFilePatterns": [ + "packages/euterpe/**/*.ts" + ] + } + } + }, + "tags": [] +} diff --git a/packages/euterpe/src/index.ts b/packages/euterpe/src/index.ts new file mode 100644 index 0000000..086e4c5 --- /dev/null +++ b/packages/euterpe/src/index.ts @@ -0,0 +1,484 @@ +import * as Player from "@euterpe.js/player" +import * as Library from "@euterpe.js/music-library" +import * as Visualizer from "@euterpe.js/visualizer" + +export { Player, Library, Visualizer, 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; + this.db.songs.sort((a, b) => a.id! - b.id!) + 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; + this.db.songs.sort((a, b) => a.id! - b.id!) + 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; + this.db.songs.sort((a, b) => a.id! - b.id!) + 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; + this.db.songs.sort((a, b) => a.id! - b.id!) + 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; + this.db.songs.sort((a, b) => a.id! - b.id!) + 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; + this.db.songs.sort((a, b) => a.id! - b.id!) + 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; + this.db.songs.sort((a, b) => a.id! - b.id!) + 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; + this.db.songs.sort((a, b) => a.id! - b.id!) + 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) + } +} diff --git a/packages/euterpe/src/lib/euterpe.ts b/packages/euterpe/src/lib/euterpe.ts new file mode 100644 index 0000000..d1d16af --- /dev/null +++ b/packages/euterpe/src/lib/euterpe.ts @@ -0,0 +1,3 @@ +export function euterpe(): string { + return "euterpe" +} diff --git a/packages/euterpe/tsconfig.json b/packages/euterpe/tsconfig.json new file mode 100644 index 0000000..7758f7b --- /dev/null +++ b/packages/euterpe/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} \ No newline at end of file diff --git a/packages/euterpe/tsconfig.lib.json b/packages/euterpe/tsconfig.lib.json new file mode 100644 index 0000000..33eca2c --- /dev/null +++ b/packages/euterpe/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/music-library-web-test/src/db.ts b/packages/music-library-web-test/src/db.ts index dfa24e3..16cb9f7 100644 --- a/packages/music-library-web-test/src/db.ts +++ b/packages/music-library-web-test/src/db.ts @@ -1,4 +1,4 @@ -import { DB, Artist, Song, RefTo, Ref, Platforms } from "@euterpe/music-library"; +import { DB, Artist, Song, RefTo, Ref, Platforms } from "@euterpe.js/music-library"; export const db = new DB db.add([ diff --git a/packages/music-library-web-test/src/main.ts b/packages/music-library-web-test/src/main.ts index eed8d5b..b0bb067 100644 --- a/packages/music-library-web-test/src/main.ts +++ b/packages/music-library-web-test/src/main.ts @@ -1,10 +1,9 @@ -import { MusicPlayerBuilder } from "@euterpe/player"; +import { MusicPlayerBuilder } from "@euterpe.js/player"; import { db } from "./db"; -import { Artist } from "@euterpe/music-library"; -import { DB, Platforms } from "@euterpe/music-library"; +import { Artist } from "@euterpe.js/music-library"; +import { DB, Platforms } from "@euterpe.js/music-library"; const audio_el = document.querySelector("#audio") as HTMLAudioElement -const music_player_builder = MusicPlayerBuilder(audio_el) -music_player_builder.start() +const music_player_builder = new MusicPlayerBuilder(audio_el) const music_player = music_player_builder.build() music_player.change_volume(1) diff --git a/packages/music-library/tsconfig.json b/packages/music-library/tsconfig.json index db7b566..7758f7b 100644 --- a/packages/music-library/tsconfig.json +++ b/packages/music-library/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "commonjs", + "module": "ESNext", "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitOverride": true, @@ -16,4 +16,4 @@ "path": "./tsconfig.lib.json" } ] -} +} \ No newline at end of file diff --git a/packages/player-web-test/src/main.ts b/packages/player-web-test/src/main.ts index 18c14cd..115557f 100644 --- a/packages/player-web-test/src/main.ts +++ b/packages/player-web-test/src/main.ts @@ -1,7 +1,6 @@ -import { MusicPlayerBuilder } from "@euterpe/player"; +import { MusicPlayerBuilder } from "@euterpe.js/player"; const audio_el = document.querySelector("#audio") as HTMLAudioElement -const music_player_builder = MusicPlayerBuilder(audio_el) -music_player_builder.start() +const music_player_builder = new MusicPlayerBuilder(audio_el) const music_player = music_player_builder.build() music_player.change_volume(1) diff --git a/packages/player/README.md b/packages/player/README.md index 276dd80..472dcf4 100644 --- a/packages/player/README.md +++ b/packages/player/README.md @@ -55,19 +55,19 @@ document.querySelector("#volume")?.addEventListener("input", (e) => { Euterpe Player also provides functions to easily track the status of playback. It does this via Subscription/Publisher pattern which publishes every frame ( Using `requestAnimationFrame()`). This allows for always up todate values reflecting on the UI. ```js // Subscriptions to AudioContext changes, eg. time.. -music_player.subscribe_to_formatted_duration_time((time) => { +music_player.on_duration_formatted((time) => { //time == "4:53, "15:59", "1756:15:59"... document.querySelector("#duration-text").innerHTML = time //duration but in "0","1.2", "1223.21668181"... format document.querySelector("#input-seek-range").max = "" + music_player.get_current_duration() }) //Keep the current time uptodate but formatted. -music_player.subscribe_to_formatted_current_time_tick((time) => { +music_player.on_time_tick_formatted((time) => { //time == "2:52", "10:59:59"... document.querySelector("#current-text").innerHTML = time }) //Keep slider uptodate -music_player.subscribe_to_time_tick((time) => { +music_player.on_time_tick((time) => { //time == "0","1.2", "1223.21668181"... document.querySelector("#input-seek-range").value = "" + time }) diff --git a/packages/player/src/index.ts b/packages/player/src/index.ts index 2e1645b..12ceb74 100644 --- a/packages/player/src/index.ts +++ b/packages/player/src/index.ts @@ -3,80 +3,72 @@ export enum SubscribeEvents { FormattedDurationTick, FormattedCurrentTimeTick, } -const PubSub = () => { +class 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> = [] + el_current_time_tick: Array<(data: any) => void> = [] + el_formatted_duration_tick: Array<(data: any) => void> = [] + el_formatted_current_time_tick: Array<(data: any) => void> = [] - function subscribe(event_name: SubscribeEvents, func: (data: any) => void) { + subscribe(event_name: SubscribeEvents, func: (data: any) => void) { switch (event_name) { case SubscribeEvents.CurrentTimeTick: { - el_current_time_tick.push(func) + this.el_current_time_tick.push(func) break } case SubscribeEvents.FormattedDurationTick: { - el_formatted_duration_tick.push(func) + this.el_formatted_duration_tick.push(func) break } case SubscribeEvents.FormattedCurrentTimeTick: { - el_formatted_current_time_tick.push(func) + this.el_formatted_current_time_tick.push(func) break } } } - function unsubscribe(event_name: SubscribeEvents, func: (data: any) => void) { + 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) + if (this.el_current_time_tick.includes(func)) { + this.el_current_time_tick.splice(this.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) + if (this.el_formatted_duration_tick.includes(func)) { + this.el_formatted_duration_tick.splice(this.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) + if (this.el_formatted_duration_tick.includes(func)) { + this.el_formatted_duration_tick.splice(this.el_formatted_duration_tick.indexOf(func), 1) } break } } } - function emit(event_name: SubscribeEvents, data: any) { + emit(event_name: SubscribeEvents, data: any) { switch (event_name) { case SubscribeEvents.CurrentTimeTick: { - el_current_time_tick.forEach((func) => { + this.el_current_time_tick.forEach((func) => { func(data) }) break } case SubscribeEvents.FormattedDurationTick: { - el_formatted_duration_tick.forEach((func) => { + this.el_formatted_duration_tick.forEach((func) => { func(data) }) break } case SubscribeEvents.FormattedCurrentTimeTick: { - el_formatted_current_time_tick.forEach((func) => { + this.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 */ @@ -87,51 +79,54 @@ declare global { } -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() +export class MusicPlayer { + current_song_duration = 0 + #volume_cache: number + is_playing = false + time = 0 + #pub_sub = new PubSub + constructor( + private audio_context: AudioContext, + private audio_element: HTMLAudioElement, + public track: MediaElementAudioSourceNode, + private gain: GainNode, + public volume: number, + private current_song_path?: string) { + this.#volume_cache = volume + } - function mute_toggle() { - if (gain.gain.value == 0) { - unmute() + mute_toggle() { + if (this.gain.gain.value == 0) { + this.unmute() } else { - mute() + this.mute() } } - function mute() { - volume_cache = gain.gain.value + mute() { + this.#volume_cache = this.gain.gain.value /* Gentler mute, doesn't pop gain.gain.linearRampToValueAtTime( 0, audio_context.currentTime + 0.1 );*/ - volume = gain.gain.value = 0 + this.volume = this.gain.gain.value = 0 } - function unmute() { - volume = gain.gain.value = volume_cache + unmute() { + this.volume = this.gain.gain.value = this.#volume_cache } - function change_volume(volume_i: number) { - volume = gain.gain.value = volume_i + change_volume(volume_i: number) { + this.volume = this.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) { + try_seek_async(new_time: number) { return new Promise((resolve, reject) => { - if (track.context.state == "closed" || track.context.state == "suspended") { - is_playing = false + if (this.track.context.state == "closed" || this.track.context.state == "suspended") { + this.is_playing = false reject("Can't seek - track not playing") } - audio_element.currentTime = new_time + this.audio_element.currentTime = new_time resolve(null) /*audio_element.play().then((s) => resolve(s), (r) => { is_playing = false @@ -139,45 +134,47 @@ export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTML })*/ }) } - /** - * 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 }) + // THIS MIGHT BE UNNECESSARY? CUZ SEEKING DOESN'T REQUIRE PLAY + // /** + // * Can try to seek even if the audio context was suspended or closed. Best to use try_seek_async() + // */ + // seek_async(new_time: number) { + // return new Promise((resolve, reject) => { + // this.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. + // */ + + seek(new_time: number) { + this.audio_element.currentTime = new_time + //this.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() { + try_play_toggle_async() { return new Promise((resolve, reject) => { - if (audio_context.state === "suspended" || audio_context.state === "closed") { + if (this.audio_context.state === "suspended" || this.audio_context.state === "closed") { reject("Context closed or suspended") } - if (audio_element.paused) { - audio_element.play().then((s) => { - is_playing = true + if (this.audio_element.paused) { + this.audio_element.play().then((s) => { + this.is_playing = true resolve(s) }, (r) => { - is_playing = false + this.is_playing = false reject(r) }) } else { - audio_element.pause() - is_playing = false + this.audio_element.pause() + this.is_playing = false resolve(null) } }) @@ -185,22 +182,22 @@ export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTML /** * Can try to play even if the audio context was suspended or closed. Best to use try_play_toggle_async() */ - function play_toggle_async() { + play_toggle_async() { return new Promise((resolve, reject) => { - if (audio_context.state === "suspended" || audio_context.state === "closed") { - audio_context.resume() + if (this.audio_context.state === "suspended" || this.audio_context.state === "closed") { + this.audio_context.resume() } - if (audio_element.paused) { - audio_element.play().then((s) => { - is_playing = true + if (this.audio_element.paused) { + this.audio_element.play().then((s) => { + this.is_playing = true resolve(s) }, (r) => { - is_playing = false + this.is_playing = false reject(r) }) } else { - audio_element.pause() - is_playing = false + this.audio_element.pause() + this.is_playing = false resolve(null) } }) @@ -208,32 +205,32 @@ export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTML /** * 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 + play_toggle() { + if (this.audio_element.paused) { + this.is_playing = true + this.audio_element.play().catch((r) => { + this.is_playing = false throw r }) } else { - is_playing = false - audio_element.pause() + this.is_playing = false + this.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() { + try_play_async() { return new Promise((resolve, reject) => { - if (is_playing) reject(Error("Already playing")) - if (audio_context.state === "suspended" || audio_context.state === "closed") { + if (this.is_playing) reject(Error("Already playing")) + if (this.audio_context.state === "suspended" || this.audio_context.state === "closed") { reject("Context closed or suspended") } - audio_element.play().then((s) => { - is_playing = true + this.audio_element.play().then((s) => { + this.is_playing = true resolve(s) }, (r) => { - is_playing = false + this.is_playing = false reject(r) }) }) @@ -241,17 +238,17 @@ export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTML /** * Will try to play even if the audio context was suspended or closed. Best to use try_play_async() */ - function play_async() { + play_async() { return new Promise((resolve, reject) => { - if (is_playing) resolve(null) - if (audio_context.state === "suspended" || audio_context.state === "closed") { - audio_context.resume() + if (this.is_playing) resolve(null) + if (this.audio_context.state === "suspended" || this.audio_context.state === "closed") { + this.audio_context.resume() } - audio_element.play().then((s) => { - is_playing = true + this.audio_element.play().then((s) => { + this.is_playing = true resolve(s) }, (r) => { - is_playing = false + this.is_playing = false reject(r) }) }) @@ -259,67 +256,61 @@ export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTML /** * 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 + play() { + if (this.is_playing) return + this.audio_element.play().catch((r) => { + this.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 + pause() { + this.audio_element.pause() + this.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) { + try_new_song_async(path: string) { return new Promise((resolve, reject) => { - audio_element.src = current_song_path = path + this.audio_element.src = this.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 + this.audio_element.addEventListener("canplaythrough", function canplay_listener(s) { controller.abort() resolve(s) }, { signal: controller.signal }) - audio_element.addEventListener("error", function error_listener(e) { - controller.abort() - reject(e) - }, { signal: controller.signal }) - /* - audio_element.addEventListener("abort", function abort_listener(e) { + this.audio_element.addEventListener("error", function error_listener(e) { controller.abort() reject(e) }, { signal: controller.signal }) - audio_element.addEventListener("stalled", function stalled_listener(e) { + this.audio_element.addEventListener("stalled", function stalled_listener(e) { controller.abort() reject(e) }, { signal: controller.signal }) - */ - is_playing = false + + this.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 + new_song(path: string) { + this.audio_element.src = this.current_song_path = path + this.current_song_duration = this.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 + get_formatted_duration() { + const dur = this.audio_element.duration + this.current_song_duration = this.audio_element.duration if (dur == 0 || !dur) return "0:00" @@ -341,8 +332,8 @@ export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTML * 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 + get_formatted_current_time() { + const curr = this.audio_element.currentTime if (curr == 0 || !curr) return "0:00" // ~~ is Bitwise OR, equivalent to Math.floor() @@ -359,170 +350,133 @@ export const MusicPlayer = (audio_context_i: AudioContext, audio_element_i: HTML ret += "" + secs; return ret; } + #emit_time() { + const request_id = requestAnimationFrame(this.#emit_time.bind(this)) + if (this.audio_element.ended) this.is_playing = false + if (this.audio_element.paused) this.is_playing == false + // if use reactively changes volume directly + this.gain.gain.value = this.volume + + this.time = this.audio_element.currentTime + if (this.#pub_sub.el_current_time_tick.length == 0) cancelAnimationFrame(request_id) + this.#pub_sub.emit(SubscribeEvents.CurrentTimeTick, this.time) + } + #emit_duration_fmt() { + const request_id = requestAnimationFrame(this.#emit_duration_fmt.bind(this)) + const time = this.get_formatted_duration() + if (this.#pub_sub.el_formatted_duration_tick.length == 0) cancelAnimationFrame(request_id) + this.#pub_sub.emit(SubscribeEvents.FormattedDurationTick, time) + } + #emit_time_fmt() { + const request_id = requestAnimationFrame(this.#emit_time_fmt.bind(this)) + const time = this.get_formatted_current_time() + if (this.#pub_sub.el_formatted_current_time_tick.length == 0) cancelAnimationFrame(request_id) + this.#pub_sub.emit(SubscribeEvents.FormattedCurrentTimeTick, time) + } /** * 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() + on_time_tick(callback: (data: any) => void) { + this.#pub_sub.subscribe(SubscribeEvents.CurrentTimeTick, callback) + this.#emit_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) + on_time_tick_formatted(callback: (data: any) => void) { + this.#pub_sub.subscribe(SubscribeEvents.FormattedCurrentTimeTick, callback) + this.#emit_time_fmt() } /** * 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, + on_duration_formatted(callback: (data: any) => void) { + this.#pub_sub.subscribe(SubscribeEvents.FormattedDurationTick, callback) + this.#emit_duration_fmt() } } -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 +export class MusicPlayerBuilder { + #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 ) + * 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() { + constructor(private audio_element: HTMLAudioElement) { 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() + 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} */ - function add_analyser() { - const analyser = audio_context.createAnalyser() - !prev_node ? track.connect(analyser) : prev_node.connect(analyser) - prev_node = analyser + 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} */ - function add_stereo_panner_node() { - const panner = audio_context.createStereoPanner() - !prev_node ? track.connect(panner) : prev_node.connect(panner) - prev_node = panner + 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} */ - function add_wave_shaper_node() { - const shaper = audio_context.createWaveShaper() - !prev_node ? track.connect(shaper) : prev_node.connect(shaper) - prev_node = shaper + 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. */ - function connect_custom_node(node: AudioNode) { - !prev_node ? track.connect(node) : prev_node.connect(node) - prev_node = 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 + * 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 + 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 {MusicPlayer: () => void} + * @returns {Euterpe} */ - function build() { - if (!is_gain_connected) { - !prev_node ? track.connect(gain) : prev_node.connect(gain) - prev_node = gain + 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 } - 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 + this.#prev_node.connect(this.#audio_context.destination) + this.audio_element.preload = "metadata" + return new MusicPlayer(this.#audio_context, this.audio_element, this.#track, this.#gain, this.#volume) } } \ No newline at end of file diff --git a/packages/visualizer-web-test/src/main.ts b/packages/visualizer-web-test/src/main.ts index fdda253..fb7df7f 100644 --- a/packages/visualizer-web-test/src/main.ts +++ b/packages/visualizer-web-test/src/main.ts @@ -1,8 +1,7 @@ -import { MusicPlayerBuilder } from "@euterpe/player"; -import { AudioVisualBuilder, SmoothingAlgorythm, ShapeType } from "@euterpe/visualizer" +import { MusicPlayerBuilder } from "@euterpe.js/player"; +import { AudioVisualBuilder, SmoothingAlgorythm, ShapeType } from "@euterpe.js/visualizer" const audio_el = document.querySelector("#audio") as HTMLAudioElement -const music_player_builder = MusicPlayerBuilder(audio_el) -music_player_builder.start() +const music_player_builder = new MusicPlayerBuilder(audio_el) const trapnation_analyser_node = music_player_builder.add_analyser() const bar_analyser_node = music_player_builder.add_analyser() const music_player = music_player_builder.build() @@ -70,14 +69,14 @@ music_player.try_new_song_async(encodeURI("http://127.0.0.1:4200/nuphory - NVISI is_seeking = false }) // Subscriptions to AudioContext changes, eg. time.. - music_player.subscribe_to_formatted_duration_time((time) => { + music_player.on_duration_formatted((time) => { document.querySelector("#duration").innerHTML = time - document.querySelector("#seek").max = "" + music_player.get_current_duration() + document.querySelector("#seek").max = "" + music_player.current_song_duration }) - music_player.subscribe_to_formatted_current_time_tick((time) => { + music_player.on_time_tick_formatted((time) => { document.querySelector("#current").innerHTML = time }) - music_player.subscribe_to_time_tick((time) => { + music_player.on_time_tick((time) => { if (is_seeking) return document.querySelector("#seek").value = "" + time }) diff --git a/packages/visualizer/src/index.ts b/packages/visualizer/src/index.ts index bc12b30..5fd6676 100644 --- a/packages/visualizer/src/index.ts +++ b/packages/visualizer/src/index.ts @@ -37,6 +37,7 @@ export class AudioVisual { #canvas_height #canvas_width #fft_data + #subscriber_fns = new Array<(data: Float32Array) => void>() constructor( analyzer_node: AnalyserNode, svg_injecting_element: SVGSVGElement, @@ -256,9 +257,14 @@ export class AudioVisual { return `` } + on_data(fn: ((data: Float32Array) => void)) { + this.#subscriber_fns.push(fn) + } + draw() { this.#analyzer_node.getFloatFrequencyData(this.#fft_data) this.#svg_injecting_element.innerHTML = this.#create_svg_element() + this.#subscriber_fns.forEach((fn) => fn(this.#fft_data)) requestAnimationFrame(this.draw.bind(this)) } } diff --git a/tsconfig.base.json b/tsconfig.base.json index 7aef2bc..bf68c10 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -18,13 +18,16 @@ "skipDefaultLibCheck": true, "baseUrl": ".", "paths": { - "@euterpe/music-library": [ + "@euterpe.js/euterpe": [ + "packages/euterpe/src/index.ts" + ], + "@euterpe.js/music-library": [ "packages/music-library/src/index.ts" ], - "@euterpe/player": [ + "@euterpe.js/player": [ "packages/player/src/index.ts" ], - "@euterpe/visualizer": [ + "@euterpe.js/visualizer": [ "packages/visualizer/src/index.ts" ] } @@ -33,4 +36,4 @@ "node_modules", "tmp" ] -} \ No newline at end of file +}