diff --git a/packages/app/src/cores/api/index.js b/packages/app/src/cores/api/index.js index 8630e91f..803a3622 100755 --- a/packages/app/src/cores/api/index.js +++ b/packages/app/src/cores/api/index.js @@ -1,7 +1,8 @@ import Core from "evite/src/core" -import config from "config" import { Bridge } from "linebridge/dist/client" -import { Session } from "models" + +import config from "config" +import { SessionModel } from "models" function generateWSFunctionHandler(socket, type = "listen") { if (!socket) { @@ -47,15 +48,23 @@ function generateWSFunctionHandler(socket, type = "listen") { } export default class ApiCore extends Core { - constructor(props) { - super(props) + static namespace = "api" - this.namespaces = Object() + excludedExpiredExceptionURL = ["/session/regenerate"] - this.onExpiredExceptionEvent = false - this.excludedExpiredExceptionURL = ["/regenerate_session_token"] + onExpiredExceptionEvent = false - this.ctx.registerPublicMethod("api", this) + namespaces = Object() + + public = { + namespaces: this.namespaces, + customRequest: this.customRequest, + request: this.request, + withEndpoints: this.withEndpoints, + attachBridge: this.attachBridge, + detachBridge: this.detachBridge, + createBridge: this.createBridge, + autenticateWS: this.autenticateWS, } async customRequest( @@ -83,7 +92,7 @@ export default class ApiCore extends Core { payload.headers = {} } - const sessionToken = await Session.token + const sessionToken = await SessionModel.token if (sessionToken) { payload.headers["Authorization"] = `Bearer ${sessionToken}` @@ -95,7 +104,7 @@ export default class ApiCore extends Core { return await this.namespaces[namepace].httpInterface(payload, ...args) } - request = (namespace = "main", method, endpoint, ...args) => { + request(namespace = "main", method, endpoint, ...args) { if (!this.namespaces[namespace]) { throw new Error(`Namespace ${namespace} not found`) } @@ -111,7 +120,7 @@ export default class ApiCore extends Core { return this.namespaces[namespace].endpoints[method][endpoint](...args) } - withEndpoints = (namespace = "main") => { + withEndpoints(namespace = "main") { if (!this.namespaces[namespace]) { throw new Error(`Namespace ${namespace} not found`) } @@ -119,7 +128,7 @@ export default class ApiCore extends Core { return this.namespaces[namespace].endpoints } - handleBeforeRequest = async (request) => { + async handleBeforeRequest(request) { if (this.onExpiredExceptionEvent) { if (this.excludedExpiredExceptionURL.includes(request.url)) return @@ -132,19 +141,21 @@ export default class ApiCore extends Core { } } - handleRegenerationEvent = async (refreshToken, makeRequest) => { + async handleRegenerationEvent(refreshToken, makeRequest) { window.app.eventBus.emit("session.expiredExceptionEvent", refreshToken) this.onExpiredExceptionEvent = true - const expiredToken = await Session.token - - // exclude regeneration endpoint + const expiredToken = await SessionModel.token // send request to regenerate token - const response = await this.request("main", "post", "regenerateSessionToken", { - expiredToken: expiredToken, - refreshToken, + const response = await this.customRequest("main", { + method: "POST", + url: "/session/regenerate", + data: { + expiredToken: expiredToken, + refreshToken, + } }).catch((error) => { console.error(`Failed to regenerate token: ${error.message}`) return false @@ -155,7 +166,7 @@ export default class ApiCore extends Core { } // set new token - Session.token = response.token + SessionModel.token = response.token //this.namespaces["main"].internalAbortController.abort() @@ -165,18 +176,18 @@ export default class ApiCore extends Core { window.app.eventBus.emit("session.regenerated") } - attachBridge = (key, params) => { + attachBridge(key, params) { return this.namespaces[key] = this.createBridge(params) } - detachBridge = (key) => { + detachBridge(key) { return delete this.namespaces[key] } createBridge(params = {}) { const getSessionContext = async () => { const obj = {} - const token = await Session.token + const token = await SessionModel.token if (token) { // append token to context @@ -224,7 +235,7 @@ export default class ApiCore extends Core { const bridge = new Bridge(bridgeOptions) - // handle main ws events + // handle main ws onEvents const mainWSSocket = bridge.wsInterface.sockets["main"] mainWSSocket.on("authenticated", () => { @@ -236,16 +247,23 @@ export default class ApiCore extends Core { }) mainWSSocket.on("connect", () => { - this.ctx.eventBus.emit(`api.ws.main.connect`) + if (this.ctx.eventBus) { + this.ctx.eventBus.emit(`api.ws.main.connect`) + } + this.autenticateWS(mainWSSocket) }) mainWSSocket.on("disconnect", (...context) => { - this.ctx.eventBus.emit(`api.ws.main.disconnect`, ...context) + if (this.ctx.eventBus) { + this.ctx.eventBus.emit(`api.ws.main.disconnect`, ...context) + } }) mainWSSocket.on("connect_error", (...context) => { - this.ctx.eventBus.emit(`api.ws.main.connect_error`, ...context) + if (this.ctx.eventBus) { + this.ctx.eventBus.emit(`api.ws.main.connect_error`, ...context) + } }) // generate functions @@ -256,8 +274,8 @@ export default class ApiCore extends Core { return bridge } - autenticateWS = async (socket) => { - const token = await Session.token + async autenticateWS(socket) { + const token = await SessionModel.token if (token) { socket.emit("authenticate", { diff --git a/packages/app/src/cores/audioPlayer/index.jsx b/packages/app/src/cores/audioPlayer/index.jsx deleted file mode 100755 index 9827ee82..00000000 --- a/packages/app/src/cores/audioPlayer/index.jsx +++ /dev/null @@ -1,271 +0,0 @@ -import Core from "evite/src/core" - -import React from "react" -import { Howl } from "howler" - -import { EmbbededMediaPlayer } from "components" -import { DOMWindow } from "components/RenderWindow" - -export default class AudioPlayerCore extends Core { - audioMuted = false - audioVolume = 1 - - audioQueueHistory = [] - audioQueue = [] - currentAudio = null - - currentDomWindow = null - - preloadAudioDebounce = null - - publicMethods = { - AudioPlayer: this, - } - - async initialize() { - app.eventBus.on("audioPlayer.end", () => { - this.nextAudio() - }) - } - - toogleMute() { - this.audioMuted = !this.audioMuted - - if (this.currentAudio) { - this.currentAudio.instance.mute(this.audioMuted) - } - - // apply to all audio in queue - this.audioQueue.forEach((audio) => { - audio.instance.mute(this.audioMuted) - }) - - app.eventBus.emit("audioPlayer.muted", this.audioMuted) - - return this.audioMuted - } - - setVolume(volume) { - if (typeof volume !== "number") { - console.warn("Volume must be a number") - return false - } - - if (volume > 1) { - volume = 1 - } - - if (volume < 0) { - volume = 0 - } - - this.audioVolume = volume - - if (this.currentAudio) { - this.currentAudio.instance.volume(volume) - } - - // apply to all audio in queue - this.audioQueue.forEach((audio) => { - audio.instance.volume(volume) - }) - - app.eventBus.emit("audioPlayer.volumeChanged", volume) - - return volume - } - - async preloadAudio() { - // debounce to prevent multiple preload - if (this.preloadAudioDebounce) { - clearTimeout(this.preloadAudioDebounce) - } - - this.preloadAudioDebounce = setTimeout(async () => { - // load the first 2 audio in queue - const audioToLoad = this.audioQueue.slice(0, 2) - - // filter undefined - const audioToLoadFiltered = audioToLoad.filter((audio) => audio.instance) - - audioToLoad.forEach(async (audio) => { - const audioState = audio.instance.state() - - if (audioState !== "loaded" && audioState !== "loading") { - await audio.instance.load() - } - }) - }, 600) - } - - startPlaylist = async (data) => { - if (typeof data === "undefined") { - console.warn("No data provided") - return false - } - - if (!Array.isArray(data)) { - data = [data] - } - - await this.clearAudioQueues() - - this.attachEmbbededMediaPlayer() - - for await (const item of data) { - const audioInstance = await this.createAudioInstance(item) - - await this.audioQueue.push({ - data: item, - instance: audioInstance, - }) - } - await this.preloadAudio() - - this.currentAudio = this.audioQueue.shift() - - this.playCurrentAudio() - } - - clearAudioQueues() { - if (this.currentAudio) { - this.currentAudio.instance.stop() - } - - this.audioQueueHistory = [] - this.audioQueue = [] - this.currentAudio = null - } - - async playCurrentAudio() { - if (!this.currentAudio) { - console.warn("No audio playing") - return false - } - - const audioState = this.currentAudio.instance.state() - - console.log(`Current Audio State: ${audioState}`) - - // check if the instance is loaded - if (audioState !== "loaded") { - console.warn("Audio not loaded") - - app.eventBus.emit("audioPlayer.loading", this.currentAudio) - - await this.currentAudio.instance.load() - - app.eventBus.emit("audioPlayer.loaded", this.currentAudio) - } - - this.currentAudio.instance.play() - } - - pauseAudioQueue() { - if (!this.currentAudio) { - console.warn("No audio playing") - return false - } - - this.currentAudio.instance.pause() - } - - previousAudio() { - // check if there is audio playing - if (this.currentAudio) { - this.currentAudio.instance.stop() - } - - // check if there is audio in queue - if (!this.audioQueueHistory[0]) { - console.warn("No audio in queue") - return false - } - - // move current audio to queue - this.audioQueue.unshift(this.currentAudio) - - this.currentAudio = this.audioQueueHistory.pop() - - this.playCurrentAudio() - } - - nextAudio() { - // check if there is audio playing - if (this.currentAudio) { - this.currentAudio.instance.stop() - } - - // check if there is audio in queue - if (!this.audioQueue[0]) { - console.warn("No audio in queue") - - this.currentAudio = null - - // if there is no audio in queue, close the embbeded media player - this.destroyPlayer() - - return false - } - - // move current audio to history - this.audioQueueHistory.push(this.currentAudio) - - this.currentAudio = this.audioQueue.shift() - - this.playCurrentAudio() - - this.preloadAudio() - } - - destroyPlayer() { - this.currentDomWindow.destroy() - this.currentDomWindow = null - } - - async createAudioInstance(data) { - const audio = new Howl({ - src: data.src, - preload: false, - //html5: true, - mute: this.audioMuted, - volume: this.audioVolume, - onplay: () => { - app.eventBus.emit("audioPlayer.playing", data) - }, - onend: () => { - app.eventBus.emit("audioPlayer.end", data) - }, - onload: () => { - app.eventBus.emit("audioPlayer.preloaded", data) - }, - onpause: () => { - app.eventBus.emit("audioPlayer.paused", data) - }, - onstop: () => { - app.eventBus.emit("audioPlayer.stopped", data) - }, - onseek: () => { - app.eventBus.emit("audioPlayer.seeked", data) - }, - onvolume: () => { - app.eventBus.emit("audioPlayer.volumeChanged", data) - }, - }) - - return audio - } - - attachEmbbededMediaPlayer() { - if (this.currentDomWindow) { - console.warn("EmbbededMediaPlayer already attached") - return false - } - - this.currentDomWindow = new DOMWindow({ - id: "mediaPlayer" - }) - - this.currentDomWindow.render() - } -} \ No newline at end of file diff --git a/packages/app/src/cores/contextMenu/components/contextMenu/index.less b/packages/app/src/cores/contextMenu/components/contextMenu/index.less index 21607e0f..19ff4dfe 100755 --- a/packages/app/src/cores/contextMenu/components/contextMenu/index.less +++ b/packages/app/src/cores/contextMenu/components/contextMenu/index.less @@ -42,9 +42,7 @@ align-items: center; justify-content: space-between; - width: 100%; - - padding: 5px 10px 5px 20px; + padding: 10px 10px 10px 20px; transition: all 50ms ease-in-out; @@ -58,7 +56,7 @@ } &:active { - background-color: var(--background-color-primary2); + background-color: var(--background-color-primary-2); transform: scale(0.95); } } diff --git a/packages/app/src/cores/contextMenu/index.js b/packages/app/src/cores/contextMenu/index.js index 6a665012..610f272f 100755 --- a/packages/app/src/cores/contextMenu/index.js +++ b/packages/app/src/cores/contextMenu/index.js @@ -18,7 +18,7 @@ export default class ContextMenuCore extends Core { clickOutsideToClose: true, }) - async initialize() { + async onInitialize() { document.addEventListener("contextmenu", this.handleEvent) } diff --git a/packages/app/src/cores/i18n/index.js b/packages/app/src/cores/i18n/index.js index 5b42b8d2..b366f01e 100755 --- a/packages/app/src/cores/i18n/index.js +++ b/packages/app/src/cores/i18n/index.js @@ -15,14 +15,14 @@ export function extractLocaleFromPath(path = "") { const messageImports = import.meta.glob("schemas/translations/*.json") export default class I18nCore extends Core { - events = { + onEvents = { "changeLanguage": (locale) => { this.loadAsyncLanguage(locale) } } - initialize = async () => { - let locale = app.settings.get("language") ?? DEFAULT_LOCALE + onInitialize = async () => { + let locale = app.cores.settings.get("language") ?? DEFAULT_LOCALE if (!SUPPORTED_LOCALES.includes(locale)) { locale = DEFAULT_LOCALE diff --git a/packages/app/src/cores/index.js b/packages/app/src/cores/index.js index 493b78be..d1471307 100755 --- a/packages/app/src/cores/index.js +++ b/packages/app/src/cores/index.js @@ -2,7 +2,6 @@ import SettingsCore from "./settings" import APICore from "./api" import StyleCore from "./style" import PermissionsCore from "./permissions" -import SearchCore from "./search" import ContextMenuCore from "./contextMenu" import I18nCore from "./i18n" @@ -10,13 +9,12 @@ import NotificationsCore from "./notifications" import ShortcutsCore from "./shortcuts" import SoundCore from "./sound" -import AudioPlayer from "./audioPlayer" +import Player from "./player" // DEFINE LOAD ORDER HERE export default [ SettingsCore, APICore, - SearchCore, PermissionsCore, StyleCore, I18nCore, @@ -24,6 +22,6 @@ export default [ NotificationsCore, ShortcutsCore, - AudioPlayer, + Player, ContextMenuCore, ] \ No newline at end of file diff --git a/packages/app/src/cores/notifications/index.jsx b/packages/app/src/cores/notifications/index.jsx index b6be8f89..6b624d03 100755 --- a/packages/app/src/cores/notifications/index.jsx +++ b/packages/app/src/cores/notifications/index.jsx @@ -6,7 +6,7 @@ import { Translation } from "react-i18next" import { Haptics } from "@capacitor/haptics" export default class NotificationCore extends Core { - events = { + onEvents = { "changeNotificationsSoundVolume": (value) => { this.playAudio({ soundVolume: value }) }, @@ -17,12 +17,12 @@ export default class NotificationCore extends Core { } } - publicMethods = { + registerToApp = { notification: this } getSoundVolume = () => { - return (window.app.settings.get("notifications_sound_volume") ?? 50) / 100 + return (window.app.cores.settings.get("notifications_sound_volume") ?? 50) / 100 } new = (notification, options = {}) => { @@ -31,7 +31,9 @@ export default class NotificationCore extends Core { this.playAudio(options) } - notify = (notification, options = {}) => { + notify = (notification, options = { + type: "info" + }) => { if (typeof notification === "string") { notification = { title: "New notification", @@ -39,20 +41,69 @@ export default class NotificationCore extends Core { } } - Notf.open({ - message: - {(t) => t(notification.title)} - , - description: - {(t) => t(notification.description)} - , + const notfObj = { duration: notification.duration ?? 4, - icon: React.isValidElement(notification.icon) ? notification.icon : (createIconRender(notification.icon) ?? ), - }) + } + + if (notification.message) { + switch (typeof notification.message) { + case "function": { + notfObj.message = React.createElement(notification.message) + + break + } + case "object": { + notfObj.message = notification.message + + break + } + default: { + notfObj.message = + {(t) => t(notification.message)} + + + break + } + } + } + + if (notification.description) { + switch (typeof notification.description) { + case "function": { + notfObj.description = React.createElement(notification.description) + + break + } + + case "object": { + notfObj.description = notification.description + + break + } + + default: { + notfObj.description = + {(t) => t(notification.description)} + + + break + } + } + } + + if (notification.icon) { + notfObj.icon = React.isValidElement(notification.icon) ? notification.icon : (createIconRender(notification.icon) ?? ) + } + + if (typeof Notf[options.type] !== "function") { + options.type = "info" + } + + return Notf[options.type](notfObj) } playHaptic = async (options = {}) => { - const vibrationEnabled = options.vibrationEnabled ?? window.app.settings.get("notifications_vibrate") + const vibrationEnabled = options.vibrationEnabled ?? window.app.cores.settings.get("notifications_vibrate") if (vibrationEnabled) { await Haptics.vibrate() @@ -60,7 +111,7 @@ export default class NotificationCore extends Core { } playAudio = (options = {}) => { - const soundEnabled = options.soundEnabled ?? window.app.settings.get("notifications_sound") + const soundEnabled = options.soundEnabled ?? window.app.cores.settings.get("notifications_sound") const soundVolume = options.soundVolume ? options.soundVolume / 100 : this.getSoundVolume() if (soundEnabled) { diff --git a/packages/app/src/cores/permissions/index.js b/packages/app/src/cores/permissions/index.js index 320f7184..927ffc21 100755 --- a/packages/app/src/cores/permissions/index.js +++ b/packages/app/src/cores/permissions/index.js @@ -6,20 +6,22 @@ import SessionModel from "models/session" export default class PermissionsCore extends Core { static namespace = "permissions" static dependencies = ["api"] - static public = ["hasAdmin", "checkUserIdIsSelf", "hasPermission"] - userData = null - isUserAdmin = null + public = { + hasAdmin: this.hasAdmin, + checkUserIdIsSelf: this.checkUserIdIsSelf, + hasPermission: this.hasPermission, + } - hasAdmin = async () => { + async hasAdmin() { return await UserModel.hasAdmin() } - checkUserIdIsSelf = (userId) => { - return SessionModel.user_id === userId + checkUserIdIsSelf(user_id) { + return SessionModel.user_id === user_id } - hasPermission = async (permission) => { + async hasPermission(permission) { let query = [] if (Array.isArray(permission)) { diff --git a/packages/app/src/cores/player/index.jsx b/packages/app/src/cores/player/index.jsx new file mode 100644 index 00000000..c85edc1b --- /dev/null +++ b/packages/app/src/cores/player/index.jsx @@ -0,0 +1,715 @@ +import Core from "evite/src/core" +import { Observable } from "object-observer" +import store from "store" +// import { createRealTimeBpmProcessor } from "realtime-bpm-analyzer" + +import { EmbbededMediaPlayer } from "components" +import { DOMWindow } from "components/RenderWindow" + +class AudioPlayerStorage { + static storeKey = "audioPlayer" + + static get(key) { + const data = store.get(AudioPlayerStorage.storeKey) + + if (data) { + return data[key] + } + + return null + } + + static set(key, value) { + const data = store.get(AudioPlayerStorage.storeKey) ?? {} + + data[key] = value + + store.set(AudioPlayerStorage.storeKey, data) + + return data + } +} + +export default class Player extends Core { + static namespace = "player" + + currentDomWindow = null + + audioContext = new AudioContext() + + audioQueueHistory = [] + audioQueue = [] + audioProcessors = [] + + currentAudioInstance = null + + state = Observable.from({ + loading: false, + audioMuted: AudioPlayerStorage.get("mute") ?? false, + playbackMode: AudioPlayerStorage.get("mode") ?? "repeat", + audioVolume: AudioPlayerStorage.get("volume") ?? 0.3, + velocity: AudioPlayerStorage.get("velocity") ?? 1, + + currentAudioManifest: null, + playbackStatus: "stopped", + crossfading: false, + trackBPM: 0, + }) + + public = { + audioContext: this.audioContext, + attachPlayerComponent: this.attachPlayerComponent.bind(this), + detachPlayerComponent: this.detachPlayerComponent.bind(this), + toogleMute: this.toogleMute.bind(this), + volume: this.volume.bind(this), + start: this.start.bind(this), + startPlaylist: this.startPlaylist.bind(this), + playback: { + mode: function (mode) { + if (mode) { + this.state.playbackMode = mode + } + + return this.state.playbackMode + }.bind(this), + toogle: function () { + if (!this.currentAudioInstance) { + console.error("No audio instance") + return null + } + + if (this.currentAudioInstance.audioElement.paused) { + this.public.playback.play() + } else { + this.public.playback.pause() + } + }.bind(this), + play: function () { + if (!this.currentAudioInstance) { + console.error("No audio instance") + return null + } + + // set gain exponentially + this.currentAudioInstance.gainNode.gain.linearRampToValueAtTime( + this.state.audioVolume, + this.audioContext.currentTime + 0.1 + ) + + setTimeout(() => { + this.currentAudioInstance.audioElement.play() + }, 100) + + }.bind(this), + pause: function () { + if (!this.currentAudioInstance) { + console.error("No audio instance") + return null + } + + // set gain exponentially + this.currentAudioInstance.gainNode.gain.linearRampToValueAtTime( + 0.0001, + this.audioContext.currentTime + 0.1 + ) + + setTimeout(() => { + this.currentAudioInstance.audioElement.pause() + }, 100) + }.bind(this), + next: this.next.bind(this), + previous: this.previous.bind(this), + stop: this.stop.bind(this), + status: function () { + return this.state.playbackStatus + }.bind(this), + }, + getState: function (key) { + if (key) { + return this.state[key] + } + + return this.state + }.bind(this), + seek: this.seek.bind(this), + duration: this.duration.bind(this), + velocity: this.velocity.bind(this), + } + + async onInitialize() { + Observable.observe(this.state, (changes) => { + changes.forEach((change) => { + if (change.type === "update") { + switch (change.path[0]) { + case "trackBPM": { + app.eventBus.emit("player.bpm.update", change.object.trackBPM) + + break + } + case "crossfading": { + app.eventBus.emit("player.crossfading.update", change.object.crossfading) + + console.log("crossfading", change.object.crossfading) + + break + } + case "loading": { + app.eventBus.emit("player.loading.update", change.object.loading) + + break + } + case "currentAudioManifest": { + app.eventBus.emit("player.current.update", change.object.currentAudioManifest) + + break + } + case "audioMuted": { + AudioPlayerStorage.set("muted", change.object.audioMuted) + + app.eventBus.emit("player.mute.update", change.object.audioMuted) + + break + } + case "audioVolume": { + AudioPlayerStorage.set("volume", change.object.audioVolume) + + app.eventBus.emit("player.volume.update", change.object.audioVolume) + + break + } + case "velocity": { + AudioPlayerStorage.set("velocity", change.object.velocity) + + app.eventBus.emit("player.velocity.update", change.object.velocity) + + break + } + case "playbackMode": { + AudioPlayerStorage.set("mode", change.object.playbackMode) + + this.currentAudioInstance.audioElement.loop = change.object.playbackMode === "repeat" + + app.eventBus.emit("player.mode.update", change.object.playbackMode) + + break + } + case "playbackStatus": { + app.eventBus.emit("player.status.update", change.object.playbackStatus) + + break + } + } + } + }) + }) + } + + // async instanciateRealtimeAnalyzerNode() { + // if (this.realtimeAnalyzerNode) { + // return false + // } + + // this.realtimeAnalyzerNode = await createRealTimeBpmProcessor(this.audioContext) + + // this.realtimeAnalyzerNode.port.onmessage = (event) => { + // if (event.data.result.bpm[0]) { + // if (this.state.trackBPM != event.data.result.bpm[0].tempo) { + // this.state.trackBPM = event.data.result.bpm[0].tempo + // } + // } + + // if (event.data.message === "BPM_STABLE") { + // console.log("BPM STABLE", event.data.result) + // } + // } + // } + + attachPlayerComponent() { + if (this.currentDomWindow) { + console.warn("EmbbededMediaPlayer already attached") + return false + } + + this.currentDomWindow = new DOMWindow({ + id: "mediaPlayer" + }) + + this.currentDomWindow.render() + } + + detachPlayerComponent() { + if (!this.currentDomWindow) { + console.warn("EmbbededMediaPlayer not attached") + return false + } + + this.currentDomWindow.close() + this.currentDomWindow = null + } + + destroyCurrentInstance() { + if (!this.currentAudioInstance) { + return false + } + + // stop playback + if (this.currentAudioInstance.audioElement) { + this.currentAudioInstance.audioElement.pause() + } + + this.currentAudioInstance = null + } + + async createInstance(manifest) { + if (!manifest) { + console.error("Manifest is required") + return false + } + + if (typeof manifest === "string") { + manifest = { + src: manifest, + } + } + + if (!manifest.src && !manifest.source) { + console.error("Manifest source is required") + return false + } + + const audioSource = manifest.src ?? manifest.source + + if (!manifest.title) { + manifest.title = audioSource.split("/").pop() + } + + let instanceObj = { + audioElement: new Audio(audioSource), + audioSource: audioSource, + manifest: manifest, + track: null, + gainNode: null, + crossfadeInterval: null, + crossfading: false + } + + instanceObj.audioElement.loop = this.state.playbackMode === "repeat" + instanceObj.audioElement.crossOrigin = "anonymous" + instanceObj.audioElement.preload = "metadata" + + const createCrossfadeInterval = () => { + console.warn("Crossfader is not supported yet") + return false + + const crossfadeDuration = app.cores.settings.get("player.crossfade") + + if (crossfadeDuration === 0) { + return false + } + + if (instanceObj.crossfadeInterval) { + clearInterval(instanceObj.crossfadeInterval) + } + + // fix audioElement.duration to be the duration of the audio minus the crossfade time + const crossfadeTime = Number.parseFloat(instanceObj.audioElement.duration).toFixed(0) - crossfadeDuration + + const crossfaderTick = () => { + // check the if current audio has reached the crossfade time + if (instanceObj.audioElement.currentTime >= crossfadeTime) { + instanceObj.crossfading = true + + this.next({ + crossfading: crossfadeDuration, + instance: instanceObj + }) + + clearInterval(instanceObj.crossfadeInterval) + } + } + + crossfaderTick() + + instanceObj.crossfadeInterval = setInterval(() => { + crossfaderTick() + }, 1000) + } + + // handle on end + instanceObj.audioElement.addEventListener("ended", () => { + // cancel if is crossfading + if (this.state.crossfading || instanceObj.crossfading) { + return false + } + + this.next() + }) + + instanceObj.audioElement.addEventListener("play", () => { + this.state.loading = false + + this.state.playbackStatus = "playing" + + instanceObj.audioElement.loop = this.state.playbackMode === "repeat" + }) + + instanceObj.audioElement.addEventListener("playing", () => { + this.state.loading = false + + this.state.playbackStatus = "playing" + + if (this.waitUpdateTimeout) { + clearTimeout(this.waitUpdateTimeout) + this.waitUpdateTimeout = null + } + + createCrossfadeInterval() + }) + + instanceObj.audioElement.addEventListener("pause", () => { + if (this.state.crossfading || instanceObj.crossfading) { + return false + } + + this.state.playbackStatus = "paused" + + if (instanceObj.crossfadeInterval) { + clearInterval(instanceObj.crossfadeInterval) + } + }) + + instanceObj.audioElement.addEventListener("durationchange", (duration) => { + if (instanceObj.audioElement.paused) { + return + } + + app.eventBus.emit("player.duration.update", duration) + }) + + instanceObj.audioElement.addEventListener("waiting", () => { + if (instanceObj.audioElement.paused) { + return + } + + if (this.waitUpdateTimeout) { + clearTimeout(this.waitUpdateTimeout) + this.waitUpdateTimeout = null + } + + // if takes more than 200ms to load, update loading state + this.waitUpdateTimeout = setTimeout(() => { + this.state.loading = true + }, 200) + }) + + instanceObj.audioElement.addEventListener("seeked", () => { + app.eventBus.emit("player.seek.update", instanceObj.audioElement.currentTime) + createCrossfadeInterval() + }) + + //await this.instanciateRealtimeAnalyzerNode() + + instanceObj.track = this.audioContext.createMediaElementSource(instanceObj.audioElement) + + instanceObj.gainNode = this.audioContext.createGain() + + instanceObj.gainNode.gain.value = this.state.audioVolume + + const processorsList = [ + instanceObj.gainNode, + ...this.audioProcessors, + ] + + let lastProcessor = null + + processorsList.forEach((processor) => { + if (lastProcessor) { + lastProcessor.connect(processor) + } else { + instanceObj.track.connect(processor) + } + + lastProcessor = processor + }) + + lastProcessor.connect(this.audioContext.destination) + + return instanceObj + } + + play(instance, params = {}) { + if (typeof instance === "number") { + instance = this.audioQueue[instance] + } + + if (!instance) { + throw new Error("Audio instance is required") + } + + if (this.audioContext.state === "suspended") { + this.audioContext.resume() + } + + this.currentAudioInstance = instance + this.state.currentAudioManifest = instance.manifest + + // set time to 0 + this.currentAudioInstance.audioElement.currentTime = 0 + + if (params.time >= 0) { + this.currentAudioInstance.audioElement.currentTime = params.time + } + + if (params.volume >= 0) { + this.currentAudioInstance.gainNode.gain.value = params.volume + } + + if (this.realtimeAnalyzerNode) { + const filter = this.audioContext.createBiquadFilter() + + filter.type = "lowpass" + + this.currentAudioInstance.track.connect(filter).connect(this.realtimeAnalyzerNode) + } + + instance.audioElement.play() + + if (!this.currentDomWindow) { + // FIXME: i gonna attach the player component after 500ms to avoid error calculating the player position and duration on the first play + setTimeout(() => { + this.attachPlayerComponent() + }, 300) + } + } + + async startPlaylist(playlist, startIndex = 0) { + // playlist is an array of audio manifests + if (!playlist || !Array.isArray(playlist)) { + throw new Error("Playlist is required") + } + + this.destroyCurrentInstance() + + // clear current queue + this.audioQueue = [] + + this.audioQueueHistory = [] + + this.state.loading = true + + for await (const [index, manifest] of playlist.entries()) { + const instance = await this.createInstance(manifest) + + if (index < startIndex) { + this.audioQueueHistory.push(instance) + } else { + this.audioQueue.push(instance) + } + } + + // play first audio + this.play(this.audioQueue[0]) + } + + async start(manifest) { + this.destroyCurrentInstance() + + const instance = await this.createInstance(manifest) + + this.audioQueue = [instance] + + this.audioQueueHistory = [] + + this.state.loading = true + + this.play(this.audioQueue[0]) + } + + next(params = {}) { + if (this.audioQueue.length > 0) { + // move current audio instance to history + this.audioQueueHistory.push(this.audioQueue.shift()) + } + + // check if there is a next audio in queue + if (this.audioQueue.length === 0) { + console.log("no more audio on queue, stopping playback") + + this.destroyCurrentInstance() + + this.state.playbackStatus = "stopped" + this.state.currentAudioManifest = null + + return false + } + + const nextParams = {} + let nextIndex = 0 + + if (params.crossfading && params.crossfading > 0 && this.state.playbackStatus === "playing" && params.instance) { + this.state.crossfading = true + + // calculate the current audio context time with the current audio duration (subtracting time offset) + const linearFadeoutTime = Number( + this.audioContext.currentTime + + Number(params.crossfading.toFixed(2)) + ) + + console.log("linearFadeoutTime", this.audioContext.currentTime, linearFadeoutTime) + + console.log("crossfading offset", ( this.currentAudioInstance.audioElement.duration - this.currentAudioInstance.audioElement.currentTime) - Number(params.crossfading.toFixed(2))) + + params.instance.gainNode.gain.linearRampToValueAtTime(0.00001, linearFadeoutTime) + + nextParams.volume = 0 + + setTimeout(() => { + this.state.crossfading = false + }, params.crossfading) + } else { + this.destroyCurrentInstance() + } + + // if is in shuffle mode, play a random audio + if (this.state.playbackMode === "shuffle") { + nextIndex = Math.floor(Math.random() * this.audioQueue.length) + } + + // play next audio + this.play(this.audioQueue[nextIndex], nextParams) + + if (this.state.crossfading) { + // calculate the current audio context time (fixing times) with the crossfading duration + const linearFadeinTime = Number(this.audioContext.currentTime + Number(params.crossfading.toFixed(2))) + + console.log("linearFadeinTime", this.audioContext.currentTime, linearFadeinTime) + + // set a linear ramp to 1 + this.currentAudioInstance.gainNode.gain.linearRampToValueAtTime( + this.state.audioVolume, + linearFadeinTime + ) + } + } + + previous() { + this.destroyCurrentInstance() + + if (this.audioQueueHistory.length > 0) { + // move current audio instance to queue + this.audioQueue.unshift(this.audioQueueHistory.pop()) + + // play previous audio + this.play(this.audioQueue[0]) + } + + // check if there is a previous audio in history + if (this.audioQueueHistory.length === 0) { + // if there is no previous audio, start again from the first audio + this.play(this.audioQueue[0]) + } + } + + stop() { + this.destroyCurrentInstance() + + this.state.playbackStatus = "stopped" + this.state.currentAudioManifest = null + + this.audioQueue = [] + } + + toogleMute(to) { + this.state.audioMuted = to ?? !this.state.audioMuted + + if (this.currentAudioInstance) { + this.currentAudioInstance.audioElement.muted = this.state.audioMuted + } + + return this.state.audioMuted + } + + volume(volume) { + if (typeof volume !== "number") { + return this.state.audioVolume + } + + if (volume > 1) { + console.log(app.cores.settings.get("player.allowVolumeOver100")) + + if (!app.cores.settings.get("player.allowVolumeOver100")) { + volume = 1 + } + } + + if (volume < 0) { + volume = 0 + } + + this.state.audioVolume = volume + + if (this.currentAudioInstance) { + if (this.currentAudioInstance.gainNode) { + this.currentAudioInstance.gainNode.gain.value = this.state.audioVolume + } + } + + return this.state.audioVolume + } + + seek(time) { + if (!this.currentAudioInstance) { + return false + } + + // if time not provided, return current time + if (typeof time === "undefined") { + return this.currentAudioInstance.audioElement.currentTime + } + + // if time is provided, seek to that time + if (typeof time === "number") { + this.currentAudioInstance.audioElement.currentTime = time + + return time + } + } + + duration() { + if (!this.currentAudioInstance) { + return false + } + + return this.currentAudioInstance.audioElement.duration + } + + loop(to) { + if (typeof to !== "boolean") { + console.warn("Loop must be a boolean") + return false + } + + this.state.loop = to ?? !this.state.loop + + if (this.currentAudioInstance) { + this.currentAudioInstance.audioElement.loop = this.state.loop + } + + return this.state.loop + } + + velocity(to) { + if (typeof to !== "number") { + console.warn("Velocity must be a number") + return false + } + + this.state.velocity = to + + if (this.currentAudioInstance) { + this.currentAudioInstance.audioElement.playbackRate = this.state.velocity + } + + return this.state.velocity + } +} \ No newline at end of file diff --git a/packages/app/src/cores/remoteStorage/index.js b/packages/app/src/cores/remoteStorage/index.js index d18df5b8..845da79b 100755 --- a/packages/app/src/cores/remoteStorage/index.js +++ b/packages/app/src/cores/remoteStorage/index.js @@ -8,7 +8,7 @@ export default class RemoteStorage extends Core { connection = null - async initialize() { + async onInitialize() { } diff --git a/packages/app/src/cores/search/index.js b/packages/app/src/cores/search/index.js deleted file mode 100755 index a43db953..00000000 --- a/packages/app/src/cores/search/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import Core from "evite/src/core" - -export default class Search extends Core { - static namespace = "searchEngine" - static dependencies = ["api"] - static public = ["search"] - - apiBridge = null - - search = async (keywords, params = {}) => { - if (!this.apiBridge) { - this.apiBridge = app.api.withEndpoints("main") - } - - return await this.apiBridge.get.search(undefined, { keywords: keywords, params }) - } -} \ No newline at end of file diff --git a/packages/app/src/cores/settings/index.js b/packages/app/src/cores/settings/index.js index 6f264f12..b8fee9b8 100755 --- a/packages/app/src/cores/settings/index.js +++ b/packages/app/src/cores/settings/index.js @@ -4,52 +4,60 @@ import defaultSettings from "schemas/defaultSettings.json" import { Observable } from "rxjs" export default class SettingsCore extends Core { - storeKey = "app_settings" + static namespace = "settings" - settings = store.get(this.storeKey) ?? {} + static storeKey = "app_settings" - publicMethods = { - settings: this + public = { + is: this.is, + set: this.set, + get: this.get, + getDefaults: this.getDefaults, + withEvent: this.withEvent, } - initialize() { - this.fulfillUndefinedWithDefaults() - } + onInitialize() { + const settings = this.get() - fulfillUndefinedWithDefaults = () => { + // fulfillUndefinedWithDefaults Object.keys(defaultSettings).forEach((key) => { const value = defaultSettings[key] // Only set default if value is undefined - if (typeof this.settings[key] === "undefined") { - this.settings[key] = value + if (typeof settings[key] === "undefined") { + this.set(key, value) } }) } - is = (key, value) => { - return this.settings[key] === value + is(key, value) { + return this.get(key) === value } - set = (key, value) => { - this.settings[key] = value - store.set(this.storeKey, this.settings) + set(key, value) { + const settings = this.get() + + settings[key] = value + + store.set(SettingsCore.storeKey, settings) window.app.eventBus.emit("setting.update", { key, value }) window.app.eventBus.emit(`setting.update.${key}`, value) - return this.settings + return settings } - get = (key) => { + get(key) { + const settings = store.get(SettingsCore.storeKey) ?? {} + if (typeof key === "undefined") { - return this.settings + return settings } - return this.settings[key] + return settings[key] } - getDefaults = (key) => { + getDefaults(key) { if (typeof key === "undefined") { return defaultSettings } @@ -57,8 +65,8 @@ export default class SettingsCore extends Core { return defaultSettings[key] } - withEvent = (listenEvent, defaultValue) => { - let value = defaultValue ?? this.settings[key] ?? false + withEvent(listenEvent, defaultValue) { + let value = defaultValue ?? false const observable = new Observable((subscriber) => { subscriber.next(value) diff --git a/packages/app/src/cores/shortcuts/index.js b/packages/app/src/cores/shortcuts/index.js index f6678b1b..3b730418 100755 --- a/packages/app/src/cores/shortcuts/index.js +++ b/packages/app/src/cores/shortcuts/index.js @@ -3,7 +3,7 @@ import Core from "evite/src/core" export default class ShortcutsCore extends Core { shortcutsRegister = [] - publicMethods = { + registerToApp = { shortcuts: this } diff --git a/packages/app/src/cores/sound/index.js b/packages/app/src/cores/sound/index.js index cc1e3f8d..2fb2cf11 100755 --- a/packages/app/src/cores/sound/index.js +++ b/packages/app/src/cores/sound/index.js @@ -3,36 +3,29 @@ import { Howl } from "howler" import config from "config" export default class SoundCore extends Core { - sounds = {} + static namespace = "sound" - publicMethods = { - sound: this, + public = { + play: this.play, + getSounds: this.getSounds, } - async initialize() { - this.sounds = await this.getSounds() - } - - getSounds = async () => { + async getSounds() { // TODO: Load custom soundpacks manifests let soundPack = config.defaultSoundPack ?? {} - Object.keys(soundPack).forEach((key) => { - const src = soundPack[key] - - soundPack[key] = (options) => new Howl({ - volume: window.app.settings.get("generalAudioVolume") ?? 0.5, - ...options, - src: [src], - }) - }) - return soundPack } - play = (name, options) => { - if (this.sounds[name]) { - return this.sounds[name](options).play() + async play(name, options) { + let soundPack = await this.getSounds() + + if (soundPack[name]) { + return new Howl({ + volume: window.app.cores.settings.get("generalAudioVolume") ?? 0.5, + ...options, + src: [soundPack[name]], + }).play() } else { console.error(`Sound [${name}] not found or is not available.`) return false diff --git a/packages/app/src/cores/style/index.jsx b/packages/app/src/cores/style/index.jsx index 1bf8f4a2..e6fd53bc 100755 --- a/packages/app/src/cores/style/index.jsx +++ b/packages/app/src/cores/style/index.jsx @@ -1,87 +1,118 @@ +import React from "react" +import ReactDOM from "react-dom" +import SVG from "react-inlinesvg" + import Core from "evite/src/core" import config from "config" import store from "store" -import { ConfigProvider } from "antd" +import { ConfigProvider, theme } from "antd" +import RemoteSVGToComponent from "components/RemoteSVGToComponent" + +const variantToAlgorithm = { + light: theme.defaultAlgorithm, + dark: theme.darkAlgorithm, +} + +export class ThemeProvider extends React.Component { + state = { + useAlgorigthm: app.cores.style.currentVariant ?? "dark", + useCompactMode: app.cores.style.getValue("compact-mode"), + } + + handleUpdate = (update) => { + console.log("[THEME] Update", update) + + if (update.themeVariant) { + this.setState({ + useAlgorigthm: update.themeVariant + }) + } + + this.setState({ + useCompactMode: update["compact-mode"] + }) + } + + componentDidMount() { + app.eventBus.on("style.update", this.handleUpdate) + } + + componentWillUnmount() { + app.eventBus.off("style.update", this.handleUpdate) + } + + render() { + const themeAlgorithms = [ + variantToAlgorithm[this.state.useAlgorigthm ?? "dark"], + ] + + if (this.state.useCompactMode) { + themeAlgorithms.push(theme.compactAlgorithm) + } + + return + {this.props.children} + + } +} export default class StyleCore extends Core { - themeManifestStorageKey = "theme" - modificationStorageKey = "themeModifications" + static namespace = "style" - theme = null - mutation = null - currentVariant = null + static themeManifestStorageKey = "theme" + static modificationStorageKey = "themeModifications" - events = { - "style.compactMode": (value = !window.app.settings.get("style.compactMode")) => { - if (value) { - return this.update({ - layoutMargin: 0, - layoutPadding: 0, - }) - } + static get rootVariables() { + let attributes = document.documentElement.getAttribute("style").trim().split(";") - return this.update({ - layoutMargin: this.getValue("layoutMargin"), - layoutPadding: this.getValue("layoutPadding"), - }) - }, - "style.autoDarkModeToogle": (value) => { - if (value === true) { - this.handleAutoColorScheme() - } else { - this.applyVariant(this.getStoragedVariant()) - } - }, - "theme.applyVariant": (value) => { - this.applyVariant(value) - this.setVariant(value) - }, - "modifyTheme": (value) => { - this.update(value) - this.setModifications(this.mutation) - }, - "resetTheme": () => { - this.resetDefault() - } + attributes = attributes.slice(0, (attributes.length - 1)) + + attributes = attributes.map((variable) => { + let [key, value] = variable.split(":") + key = key.split("--")[1] + + return [key, value] + }) + + return Object.fromEntries(attributes) } - publicMethods = { - style: this + static get storagedTheme() { + return store.get(StyleCore.themeManifestStorageKey) } - static get currentVariant() { - return document.documentElement.style.getPropertyValue("--themeVariant") + static get storagedVariant() { + return app.cores.settings.get("style.darkMode") ? "dark" : "light" } - handleAutoColorScheme() { - const prefered = window.matchMedia("(prefers-color-scheme: light)") + static set storagedModifications(modifications) { + return store.set(StyleCore.modificationStorageKey, modifications) + } - if (!prefered.matches) { - this.applyVariant("dark") + static get storagedModifications() { + return store.get(StyleCore.modificationStorageKey) ?? {} + } + + async onInitialize() { + if (StyleCore.storagedTheme) { + // TODO: Start remote theme loader } else { - this.applyVariant("light") - } - } - - initialize = async () => { - let theme = this.getStoragedTheme() - - const modifications = this.getStoragedModifications() - const variantKey = this.getStoragedVariant() - - if (!theme) { - // load default theme - theme = this.getDefaultTheme() - } else { - // load URL and initialize theme + this.public.theme = config.defaultTheme } - // set global theme - this.theme = theme + const modifications = StyleCore.storagedModifications + const variantKey = StyleCore.storagedVariant // override with static vars - if (theme.staticVars) { - this.update(theme.staticVars) + if (this.public.theme.defaultVars) { + this.update(this.public.theme.defaultVars) } // override theme with modifications @@ -96,104 +127,130 @@ export default class StyleCore extends Core { window.matchMedia("(prefers-color-scheme: light)").addListener(() => { console.log(`[THEME] Auto color scheme changed`) - if (window.app.settings.get("auto_darkMode")) { + if (window.app.cores.settings.get("auto_darkMode")) { this.handleAutoColorScheme() } }) - if (window.app.settings.get("auto_darkMode")) { + if (window.app.cores.settings.get("auto_darkMode")) { this.handleAutoColorScheme() } } - getRootVariables = () => { - let attributes = document.documentElement.getAttribute("style").trim().split(";") - attributes = attributes.slice(0, (attributes.length - 1)) - attributes = attributes.map((variable) => { - let [key, value] = variable.split(":") - key = key.split("--")[1] - - return [key, value] - }) - - return Object.fromEntries(attributes) + onEvents = { + "style.autoDarkModeToogle": (value) => { + if (value === true) { + this.handleAutoColorScheme() + } else { + this.applyVariant(StyleCore.storagedVariant) + } + } } - getDefaultTheme = () => { - // TODO: Use evite CONSTANTS_API - return config.defaultTheme + public = { + theme: null, + mutation: null, + currentVariant: "dark", + + getValue: (...args) => this.getValue(...args), + setDefault: () => this.setDefault(), + update: (...args) => this.update(...args), + applyVariant: (...args) => this.applyVariant(...args), + compactMode: (value = !window.app.cores.settings.get("style.compactMode")) => { + if (value) { + return this.update({ + layoutMargin: 0, + layoutPadding: 0, + }) + } + + return this.update({ + layoutMargin: this.getValue("layoutMargin"), + layoutPadding: this.getValue("layoutPadding"), + }) + }, + modify: (value) => { + this.public.update(value) + + this.applyVariant(this.public.mutation.themeVariant ?? this.public.currentVariant) + + StyleCore.storagedModifications = this.public.mutation + }, + defaultVar: (key) => { + if (!key) { + return this.public.theme.defaultVars + } + + return this.public.theme.defaultVars[key] + }, + storagedVariant: StyleCore.storagedVariant, + storagedModifications: StyleCore.storagedModifications, } - getStoragedTheme = () => { - return store.get(this.themeManifestStorageKey) - } - - getStoragedModifications = () => { - return store.get(this.modificationStorageKey) ?? {} - } - - getStoragedVariant = () => { - return app.settings.get("themeVariant") - } - - getValue = (key) => { - const storagedModifications = this.getStoragedModifications() - const staticValues = this.theme.staticVars - + getValue(key) { if (typeof key === "undefined") { return { - ...staticValues, - ...storagedModifications + ...this.public.theme.defaultVars, + ...StyleCore.storagedModifications } } - return storagedModifications[key] || staticValues[key] + return StyleCore.storagedModifications[key] || this.public.theme.defaultVars[key] } - setVariant = (variationKey) => { - return app.settings.set("themeVariant", variationKey) + setDefault() { + store.remove(StyleCore.themeManifestStorageKey) + store.remove(StyleCore.modificationStorageKey) + + app.cores.settings.set("colorPrimary", this.public.theme.defaultVars.colorPrimary) + + this.onInitialize() } - setModifications = (modifications) => { - return store.set(this.modificationStorageKey, modifications) - } - - resetDefault = () => { - store.remove(this.themeManifestStorageKey) - store.remove(this.modificationStorageKey) - - window.app.settings.set("primaryColor", this.theme.staticVars.primaryColor) - - this.initialize() - } - - update = (update) => { + update(update) { if (typeof update !== "object") { return false } - this.mutation = { - ...this.theme.staticVars, - ...this.mutation, + this.public.mutation = { + ...this.public.theme.defaultVars, + ...this.public.mutation, ...update } - Object.keys(this.mutation).forEach(key => { - document.documentElement.style.setProperty(`--${key}`, this.mutation[key]) + Object.keys(this.public.mutation).forEach(key => { + document.documentElement.style.setProperty(`--${key}`, this.public.mutation[key]) }) - document.documentElement.className = `theme-${this.currentVariant}` - document.documentElement.style.setProperty(`--themeVariant`, this.currentVariant) + app.eventBus.emit("style.update", { + ...this.public.mutation, + }) - ConfigProvider.config({ theme: this.mutation }) + ConfigProvider.config({ theme: this.public.mutation }) } - applyVariant = (variant = (this.theme.defaultVariant ?? "light")) => { - const values = this.theme.variants[variant] + applyVariant(variant = (this.public.theme.defaultVariant ?? "light")) { + const values = this.public.theme.variants[variant] - if (values) { - this.currentVariant = variant - this.update(values) + if (!values) { + console.error(`Variant [${variant}] not found`) + return false + } + + values.themeVariant = variant + + this.public.currentVariant = variant + + this.update(values) + } + + handleAutoColorScheme() { + const prefered = window.matchMedia("(prefers-color-scheme: light)") + + if (!prefered.matches) { + this.applyVariant("dark") + } else { + this.applyVariant("light") } } } \ No newline at end of file