mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-09 10:34:17 +00:00
Add audio system foundation with player architecture refactor
This commit is contained in:
parent
29ae54fe03
commit
369803534b
123
packages/app/src/cores/player/classes/AudioBase.js
Normal file
123
packages/app/src/cores/player/classes/AudioBase.js
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { MediaPlayer } from "dashjs"
|
||||||
|
import PlayerProcessors from "./PlayerProcessors"
|
||||||
|
import AudioPlayerStorage from "../player.storage"
|
||||||
|
|
||||||
|
export default class AudioBase {
|
||||||
|
constructor(player) {
|
||||||
|
this.player = player
|
||||||
|
}
|
||||||
|
|
||||||
|
audio = new Audio()
|
||||||
|
context = null
|
||||||
|
demuxer = null
|
||||||
|
elementSource = null
|
||||||
|
|
||||||
|
processorsManager = new PlayerProcessors(this)
|
||||||
|
processors = {}
|
||||||
|
|
||||||
|
waitUpdateTimeout = null
|
||||||
|
|
||||||
|
initialize = async () => {
|
||||||
|
// create a audio context
|
||||||
|
this.context = new AudioContext({
|
||||||
|
sampleRate:
|
||||||
|
AudioPlayerStorage.get("sample_rate") ??
|
||||||
|
this.player.constructor.defaultSampleRate,
|
||||||
|
latencyHint: "playback",
|
||||||
|
})
|
||||||
|
|
||||||
|
// configure some settings for audio
|
||||||
|
this.audio.crossOrigin = "anonymous"
|
||||||
|
this.audio.preload = "metadata"
|
||||||
|
|
||||||
|
// listen all events
|
||||||
|
for (const [key, value] of Object.entries(this.audioEvents)) {
|
||||||
|
this.audio.addEventListener(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup demuxer for mpd
|
||||||
|
this.createDemuxer()
|
||||||
|
|
||||||
|
// create element source
|
||||||
|
this.elementSource = this.context.createMediaElementSource(this.audio)
|
||||||
|
|
||||||
|
// initialize audio processors
|
||||||
|
await this.processorsManager.initialize()
|
||||||
|
await this.processorsManager.attachAllNodes()
|
||||||
|
}
|
||||||
|
|
||||||
|
createDemuxer() {
|
||||||
|
this.demuxer = MediaPlayer().create()
|
||||||
|
|
||||||
|
this.demuxer.updateSettings({
|
||||||
|
streaming: {
|
||||||
|
buffer: {
|
||||||
|
resetSourceBuffersForTrackSwitch: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.demuxer.initialize(this.audio, null, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
this.audio.pause()
|
||||||
|
this.audio.src = null
|
||||||
|
this.audio.currentTime = 0
|
||||||
|
|
||||||
|
this.demuxer.destroy()
|
||||||
|
this.createDemuxer()
|
||||||
|
}
|
||||||
|
|
||||||
|
audioEvents = {
|
||||||
|
ended: () => {
|
||||||
|
this.player.next()
|
||||||
|
},
|
||||||
|
loadeddata: () => {
|
||||||
|
this.player.state.loading = false
|
||||||
|
},
|
||||||
|
loadedmetadata: () => {
|
||||||
|
if (this.audio.duration === Infinity) {
|
||||||
|
this.player.state.live = true
|
||||||
|
} else {
|
||||||
|
this.player.state.live = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
play: () => {
|
||||||
|
this.player.state.playback_status = "playing"
|
||||||
|
},
|
||||||
|
playing: () => {
|
||||||
|
this.player.state.loading = false
|
||||||
|
|
||||||
|
this.player.state.playback_status = "playing"
|
||||||
|
|
||||||
|
if (typeof this.waitUpdateTimeout !== "undefined") {
|
||||||
|
clearTimeout(this.waitUpdateTimeout)
|
||||||
|
this.waitUpdateTimeout = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pause: () => {
|
||||||
|
this.player.state.playback_status = "paused"
|
||||||
|
},
|
||||||
|
durationchange: () => {
|
||||||
|
this.player.eventBus.emit(
|
||||||
|
`player.durationchange`,
|
||||||
|
this.audio.duration,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
waiting: () => {
|
||||||
|
if (this.waitUpdateTimeout) {
|
||||||
|
clearTimeout(this.waitUpdateTimeout)
|
||||||
|
this.waitUpdateTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// if takes more than 150ms to load, update loading state
|
||||||
|
this.waitUpdateTimeout = setTimeout(() => {
|
||||||
|
this.player.state.loading = true
|
||||||
|
}, 150)
|
||||||
|
},
|
||||||
|
seeked: () => {
|
||||||
|
this.player.eventBus.emit(`player.seeked`, this.audio.currentTime)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
56
packages/app/src/cores/player/classes/MediaSession.js
Normal file
56
packages/app/src/cores/player/classes/MediaSession.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
export default class MediaSession {
|
||||||
|
constructor(player) {
|
||||||
|
this.player = player
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
for (const [action, handler] of this.handlers) {
|
||||||
|
navigator.mediaSession.setActionHandler(action, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers = [
|
||||||
|
[
|
||||||
|
"play",
|
||||||
|
() => {
|
||||||
|
console.log("media session play event", "play")
|
||||||
|
this.player.resumePlayback()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"pause",
|
||||||
|
() => {
|
||||||
|
console.log("media session pause event", "pause")
|
||||||
|
this.player.pausePlayback()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"seekto",
|
||||||
|
(seek) => {
|
||||||
|
console.log("media session seek event", seek)
|
||||||
|
this.player.seek(seek.seekTime)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
update = (manifest) => {
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: manifest.title,
|
||||||
|
artist: manifest.artist,
|
||||||
|
album: manifest.album,
|
||||||
|
artwork: [
|
||||||
|
{
|
||||||
|
src: manifest.cover,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
flush = () => {
|
||||||
|
navigator.mediaSession.metadata = null
|
||||||
|
}
|
||||||
|
|
||||||
|
updateIsPlaying = (isPlaying) => {
|
||||||
|
navigator.mediaSession.playbackState = isPlaying ? "playing" : "paused"
|
||||||
|
}
|
||||||
|
}
|
@ -1,83 +1,96 @@
|
|||||||
import defaultAudioProccessors from "../processors"
|
import defaultAudioProccessors from "../processors"
|
||||||
|
|
||||||
export default class PlayerProcessors {
|
export default class PlayerProcessors {
|
||||||
constructor(player) {
|
constructor(base) {
|
||||||
this.player = player
|
this.base = base
|
||||||
}
|
}
|
||||||
|
|
||||||
processors = []
|
nodes = []
|
||||||
|
attached = []
|
||||||
|
|
||||||
public = {}
|
public = {}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
// if already exists audio processors, destroy all before create new
|
// if already exists audio processors, destroy all before create new
|
||||||
if (this.processors.length > 0) {
|
if (this.nodes.length > 0) {
|
||||||
this.player.console.log("Destroying audio processors")
|
this.base.player.console.log("Destroying audio processors")
|
||||||
|
|
||||||
this.processors.forEach((processor) => {
|
this.nodes.forEach((node) => {
|
||||||
this.player.console.log(`Destroying audio processor ${processor.constructor.name}`, processor)
|
this.base.player.console.log(
|
||||||
processor._destroy()
|
`Destroying audio processor node ${node.constructor.name}`,
|
||||||
})
|
node,
|
||||||
|
)
|
||||||
|
node._destroy()
|
||||||
|
})
|
||||||
|
|
||||||
this.processors = []
|
this.nodes = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// instanciate default audio processors
|
// instanciate default audio processors
|
||||||
for await (const defaultProccessor of defaultAudioProccessors) {
|
for await (const defaultProccessor of defaultAudioProccessors) {
|
||||||
this.processors.push(new defaultProccessor(this.player))
|
this.nodes.push(new defaultProccessor(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialize audio processors
|
// initialize audio processors
|
||||||
for await (const processor of this.processors) {
|
for await (const node of this.nodes) {
|
||||||
if (typeof processor._init === "function") {
|
if (typeof node._init === "function") {
|
||||||
try {
|
try {
|
||||||
await processor._init(this.player.audioContext)
|
await node._init()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.player.console.error(`Failed to initialize audio processor ${processor.constructor.name} >`, error)
|
this.base.player.console.error(
|
||||||
continue
|
`Failed to initialize audio processor node ${node.constructor.name} >`,
|
||||||
}
|
error,
|
||||||
}
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// check if processor has exposed public methods
|
// check if processor has exposed public methods
|
||||||
if (processor.exposeToPublic) {
|
if (node.exposeToPublic) {
|
||||||
Object.entries(processor.exposeToPublic).forEach(([key, value]) => {
|
Object.entries(node.exposeToPublic).forEach(([key, value]) => {
|
||||||
const refName = processor.constructor.refName
|
const refName = node.constructor.refName
|
||||||
|
|
||||||
if (typeof this.player.public[refName] === "undefined") {
|
if (typeof this.base.processors[refName] === "undefined") {
|
||||||
// by default create a empty object
|
// by default create a empty object
|
||||||
this.player.public[refName] = {}
|
this.base.processors[refName] = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.player.public[refName][key] = value
|
this.base.processors[refName][key] = value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async attachProcessorsToInstance(instance) {
|
attachAllNodes = async () => {
|
||||||
for await (const [index, processor] of this.processors.entries()) {
|
for await (const [index, node] of this.nodes.entries()) {
|
||||||
if (processor.constructor.node_bypass === true) {
|
if (node.constructor.node_bypass === true) {
|
||||||
instance.contextElement.connect(processor.processor)
|
this.base.context.elementSource.connect(node.processor)
|
||||||
|
|
||||||
processor.processor.connect(this.player.audioContext.destination)
|
node.processor.connect(this.base.context.destination)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof processor._attach !== "function") {
|
if (typeof node._attach !== "function") {
|
||||||
this.player.console.error(`Processor ${processor.constructor.refName} not support attach`)
|
this.base.console.error(
|
||||||
|
`Processor ${node.constructor.refName} not support attach`,
|
||||||
|
)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
instance = await processor._attach(instance, index)
|
await node._attach(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastProcessor = instance.attachedProcessors[instance.attachedProcessors.length - 1].processor
|
const lastProcessor = this.attached[this.attached.length - 1].processor
|
||||||
|
|
||||||
// now attach to destination
|
// now attach to destination
|
||||||
lastProcessor.connect(this.player.audioContext.destination)
|
lastProcessor.connect(this.base.context.destination)
|
||||||
|
}
|
||||||
|
|
||||||
return instance
|
detachAllNodes = async () => {
|
||||||
}
|
for (const [index, node] of this.attached.entries()) {
|
||||||
}
|
await node._detach()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,206 +1,131 @@
|
|||||||
import TrackManifest from "./TrackManifest"
|
import TrackManifest from "./TrackManifest"
|
||||||
import { MediaPlayer } from "dashjs"
|
|
||||||
|
|
||||||
export default class TrackInstance {
|
export default class TrackInstance {
|
||||||
constructor(player, manifest) {
|
constructor(manifest, player) {
|
||||||
|
if (typeof manifest === "undefined") {
|
||||||
|
throw new Error("Manifest is required")
|
||||||
|
}
|
||||||
|
|
||||||
if (!player) {
|
if (!player) {
|
||||||
throw new Error("Player core is required")
|
throw new Error("Player core is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof manifest === "undefined") {
|
if (!(manifest instanceof TrackManifest)) {
|
||||||
throw new Error("Manifest is required")
|
manifest = new TrackManifest(manifest, player)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manifest.source) {
|
||||||
|
throw new Error("Manifest must have a source")
|
||||||
}
|
}
|
||||||
|
|
||||||
this.player = player
|
this.player = player
|
||||||
this.manifest = manifest
|
this.manifest = manifest
|
||||||
|
|
||||||
this.id = this.manifest.id ?? this.manifest._id
|
this.id = this.manifest.id ?? this.manifest._id
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_initialized = false
|
play = async (params = {}) => {
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
audio = null
|
if (!this.manifest.source.endsWith(".mpd")) {
|
||||||
|
this.player.base.demuxer.destroy()
|
||||||
contextElement = null
|
this.player.base.audio.src = this.manifest.source
|
||||||
|
|
||||||
abortController = new AbortController()
|
|
||||||
|
|
||||||
attachedProcessors = []
|
|
||||||
|
|
||||||
waitUpdateTimeout = null
|
|
||||||
|
|
||||||
mediaEvents = {
|
|
||||||
ended: () => {
|
|
||||||
this.player.next()
|
|
||||||
},
|
|
||||||
loadeddata: () => {
|
|
||||||
this.player.state.loading = false
|
|
||||||
},
|
|
||||||
loadedmetadata: () => {
|
|
||||||
if (this.audio.duration === Infinity) {
|
|
||||||
this.player.state.live = true
|
|
||||||
} else {
|
|
||||||
this.player.state.live = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
play: () => {
|
|
||||||
this.player.state.playback_status = "playing"
|
|
||||||
},
|
|
||||||
playing: () => {
|
|
||||||
this.player.state.loading = false
|
|
||||||
|
|
||||||
this.player.state.playback_status = "playing"
|
|
||||||
|
|
||||||
if (typeof this.waitUpdateTimeout !== "undefined") {
|
|
||||||
clearTimeout(this.waitUpdateTimeout)
|
|
||||||
this.waitUpdateTimeout = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
pause: () => {
|
|
||||||
this.player.state.playback_status = "paused"
|
|
||||||
},
|
|
||||||
durationchange: () => {
|
|
||||||
this.player.eventBus.emit(
|
|
||||||
`player.durationchange`,
|
|
||||||
this.audio.duration,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
waiting: () => {
|
|
||||||
if (this.waitUpdateTimeout) {
|
|
||||||
clearTimeout(this.waitUpdateTimeout)
|
|
||||||
this.waitUpdateTimeout = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// if takes more than 150ms to load, update loading state
|
|
||||||
this.waitUpdateTimeout = setTimeout(() => {
|
|
||||||
this.player.state.loading = true
|
|
||||||
}, 150)
|
|
||||||
},
|
|
||||||
seeked: () => {
|
|
||||||
this.player.eventBus.emit(`player.seeked`, this.audio.currentTime)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
initialize = async () => {
|
|
||||||
this.manifest = await this.resolveManifest()
|
|
||||||
|
|
||||||
this.audio = new Audio()
|
|
||||||
|
|
||||||
this.audio.signal = this.abortController.signal
|
|
||||||
this.audio.crossOrigin = "anonymous"
|
|
||||||
this.audio.preload = "metadata"
|
|
||||||
|
|
||||||
// support for dash audio streaming
|
|
||||||
if (this.manifest.source.endsWith(".mpd")) {
|
|
||||||
this.muxerPlayer = MediaPlayer().create()
|
|
||||||
this.muxerPlayer.updateSettings({
|
|
||||||
streaming: {
|
|
||||||
buffer: {
|
|
||||||
resetSourceBuffersForTrackSwitch: true,
|
|
||||||
useChangeTypeForTrackSwitch: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
this.muxerPlayer.initialize(this.audio, null, false)
|
|
||||||
|
|
||||||
this.muxerPlayer.attachSource(this.manifest.source)
|
|
||||||
} else {
|
} else {
|
||||||
this.audio.src = this.manifest.source
|
if (!this.player.base.demuxer) {
|
||||||
}
|
this.player.base.createDemuxer()
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(this.mediaEvents)) {
|
|
||||||
this.audio.addEventListener(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.contextElement = this.player.audioContext.createMediaElementSource(
|
|
||||||
this.audio,
|
|
||||||
)
|
|
||||||
|
|
||||||
this._initialized = true
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
stop = () => {
|
|
||||||
if (this.audio) {
|
|
||||||
this.audio.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.muxerPlayer) {
|
|
||||||
this.muxerPlayer.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastProcessor =
|
|
||||||
this.attachedProcessors[this.attachedProcessors.length - 1]
|
|
||||||
|
|
||||||
if (lastProcessor) {
|
|
||||||
this.attachedProcessors[
|
|
||||||
this.attachedProcessors.length - 1
|
|
||||||
]._destroy(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.attachedProcessors = []
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveManifest = async () => {
|
|
||||||
if (typeof this.manifest === "string") {
|
|
||||||
this.manifest = {
|
|
||||||
src: this.manifest,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.manifest = new TrackManifest(this.manifest, {
|
|
||||||
serviceProviders: this.player.serviceProviders,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (this.manifest.service) {
|
|
||||||
if (!this.player.serviceProviders.has(this.manifest.service)) {
|
|
||||||
throw new Error(
|
|
||||||
`Service ${this.manifest.service} is not supported`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// try to resolve source file
|
await this.player.base.demuxer.attachSource(
|
||||||
if (!this.manifest.source) {
|
`${this.manifest.source}?t=${Date.now()}`,
|
||||||
console.log("Resolving manifest cause no source defined")
|
|
||||||
|
|
||||||
this.manifest = await this.player.serviceProviders.resolve(
|
|
||||||
this.manifest.service,
|
|
||||||
this.manifest,
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log("Manifest resolved", this.manifest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.manifest.source) {
|
|
||||||
throw new Error("Manifest `source` is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// set empty metadata if not provided
|
|
||||||
if (!this.manifest.metadata) {
|
|
||||||
this.manifest.metadata = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// auto name if a title is not provided
|
|
||||||
if (!this.manifest.metadata.title) {
|
|
||||||
this.manifest.metadata.title = this.manifest.source.split("/").pop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// process overrides
|
|
||||||
const override = await this.manifest.serviceOperations.fetchOverride()
|
|
||||||
|
|
||||||
if (override) {
|
|
||||||
console.log(
|
|
||||||
`Override found for track ${this.manifest._id}`,
|
|
||||||
override,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
this.manifest.overrides = override
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.manifest
|
this.player.base.audio.currentTime = params.time ?? 0
|
||||||
|
|
||||||
|
if (this.player.base.audio.paused) {
|
||||||
|
await this.player.base.audio.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset audio volume and gain
|
||||||
|
this.player.base.audio.volume = 1
|
||||||
|
this.player.base.processors.gain.set(this.player.state.volume)
|
||||||
|
|
||||||
|
const endTime = performance.now()
|
||||||
|
|
||||||
|
this._loadMs = endTime - startTime
|
||||||
|
|
||||||
|
console.log(`[INSTANCE] Playing >`, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pause = async () => {
|
||||||
|
console.log("[INSTANCE] Pausing >", this)
|
||||||
|
|
||||||
|
this.player.base.audio.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
resume = async () => {
|
||||||
|
console.log("[INSTANCE] Resuming >", this)
|
||||||
|
|
||||||
|
this.player.base.audio.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveManifest = async () => {
|
||||||
|
// if (typeof this.manifest === "string") {
|
||||||
|
// this.manifest = {
|
||||||
|
// src: this.manifest,
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// this.manifest = new TrackManifest(this.manifest, {
|
||||||
|
// serviceProviders: this.player.serviceProviders,
|
||||||
|
// })
|
||||||
|
|
||||||
|
// if (this.manifest.service) {
|
||||||
|
// if (!this.player.serviceProviders.has(this.manifest.service)) {
|
||||||
|
// throw new Error(
|
||||||
|
// `Service ${this.manifest.service} is not supported`,
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // try to resolve source file
|
||||||
|
// if (!this.manifest.source) {
|
||||||
|
// console.log("Resolving manifest cause no source defined")
|
||||||
|
|
||||||
|
// this.manifest = await this.player.serviceProviders.resolve(
|
||||||
|
// this.manifest.service,
|
||||||
|
// this.manifest,
|
||||||
|
// )
|
||||||
|
|
||||||
|
// console.log("Manifest resolved", this.manifest)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (!this.manifest.source) {
|
||||||
|
// throw new Error("Manifest `source` is required")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // set empty metadata if not provided
|
||||||
|
// if (!this.manifest.metadata) {
|
||||||
|
// this.manifest.metadata = {}
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // auto name if a title is not provided
|
||||||
|
// if (!this.manifest.metadata.title) {
|
||||||
|
// this.manifest.metadata.title = this.manifest.source.split("/").pop()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // process overrides
|
||||||
|
// const override = await this.manifest.serviceOperations.fetchOverride()
|
||||||
|
|
||||||
|
// if (override) {
|
||||||
|
// console.log(
|
||||||
|
// `Override found for track ${this.manifest._id}`,
|
||||||
|
// override,
|
||||||
|
// )
|
||||||
|
|
||||||
|
// this.manifest.overrides = override
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return this.manifest
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import jsmediatags from "jsmediatags/dist/jsmediatags.min.js"
|
import { parseBlob } from "music-metadata"
|
||||||
import { FastAverageColor } from "fast-average-color"
|
import { FastAverageColor } from "fast-average-color"
|
||||||
|
|
||||||
export default class TrackManifest {
|
export default class TrackManifest {
|
||||||
@ -33,13 +33,6 @@ export default class TrackManifest {
|
|||||||
this.artist = params.artist
|
this.artist = params.artist
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
typeof params.artists !== "undefined" ||
|
|
||||||
Array.isArray(params.artists)
|
|
||||||
) {
|
|
||||||
this.artistStr = params.artists.join(", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof params.source !== "undefined") {
|
if (typeof params.source !== "undefined") {
|
||||||
this.source = params.source
|
this.source = params.source
|
||||||
}
|
}
|
||||||
@ -48,8 +41,8 @@ export default class TrackManifest {
|
|||||||
this.metadata = params.metadata
|
this.metadata = params.metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof params.lyrics_enabled !== "undefined") {
|
if (typeof params.liked !== "undefined") {
|
||||||
this.lyrics_enabled = params.lyrics_enabled
|
this.liked = params.liked
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
@ -64,59 +57,54 @@ export default class TrackManifest {
|
|||||||
album = "Unknown"
|
album = "Unknown"
|
||||||
artist = "Unknown"
|
artist = "Unknown"
|
||||||
source = null
|
source = null
|
||||||
metadata = null
|
metadata = {}
|
||||||
|
|
||||||
// set default service to default
|
// set default service to default
|
||||||
service = "default"
|
service = "default"
|
||||||
|
|
||||||
// Extended from db
|
// Extended from db
|
||||||
lyrics_enabled = false
|
|
||||||
liked = null
|
liked = null
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
if (this.params.file) {
|
if (!this.params.file) {
|
||||||
this.metadata = await this.analyzeMetadata(
|
return this
|
||||||
this.params.file.originFileObj,
|
|
||||||
)
|
|
||||||
|
|
||||||
this.metadata.format = this.metadata.type.toUpperCase()
|
|
||||||
|
|
||||||
if (this.metadata.tags) {
|
|
||||||
if (this.metadata.tags.title) {
|
|
||||||
this.title = this.metadata.tags.title
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.metadata.tags.artist) {
|
|
||||||
this.artist = this.metadata.tags.artist
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.metadata.tags.album) {
|
|
||||||
this.album = this.metadata.tags.album
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.metadata.tags.picture) {
|
|
||||||
this.cover = app.cores.remoteStorage.binaryArrayToFile(
|
|
||||||
this.metadata.tags.picture,
|
|
||||||
"cover",
|
|
||||||
)
|
|
||||||
|
|
||||||
const coverUpload =
|
|
||||||
await app.cores.remoteStorage.uploadFile(this.cover)
|
|
||||||
|
|
||||||
this.cover = coverUpload.url
|
|
||||||
|
|
||||||
delete this.metadata.tags.picture
|
|
||||||
}
|
|
||||||
|
|
||||||
this.handleChanges({
|
|
||||||
cover: this.cover,
|
|
||||||
title: this.title,
|
|
||||||
artist: this.artist,
|
|
||||||
album: this.album,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const analyzedMetadata = await parseBlob(
|
||||||
|
this.params.file.originFileObj,
|
||||||
|
{
|
||||||
|
skipPostHeaders: true,
|
||||||
|
},
|
||||||
|
).catch(() => ({}))
|
||||||
|
|
||||||
|
this.metadata.format = analyzedMetadata.format.codec
|
||||||
|
|
||||||
|
if (analyzedMetadata.common) {
|
||||||
|
this.title = analyzedMetadata.common.title ?? this.title
|
||||||
|
this.artist = analyzedMetadata.common.artist ?? this.artist
|
||||||
|
this.album = analyzedMetadata.common.album ?? this.album
|
||||||
|
}
|
||||||
|
|
||||||
|
if (analyzedMetadata.common.picture) {
|
||||||
|
const cover = analyzedMetadata.common.picture[0]
|
||||||
|
|
||||||
|
const coverFile = new File([cover.data], "cover", {
|
||||||
|
type: cover.format,
|
||||||
|
})
|
||||||
|
|
||||||
|
const coverUpload =
|
||||||
|
await app.cores.remoteStorage.uploadFile(coverFile)
|
||||||
|
|
||||||
|
this.cover = coverUpload.url
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleChanges({
|
||||||
|
cover: this.cover,
|
||||||
|
title: this.title,
|
||||||
|
artist: this.artist,
|
||||||
|
album: this.album,
|
||||||
|
})
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,19 +114,6 @@ export default class TrackManifest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzeMetadata = async (file) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
jsmediatags.read(file, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
return resolve(data)
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
return reject(error)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzeCoverColor = async () => {
|
analyzeCoverColor = async () => {
|
||||||
const fac = new FastAverageColor()
|
const fac = new FastAverageColor()
|
||||||
|
|
||||||
@ -169,8 +144,6 @@ export default class TrackManifest {
|
|||||||
this,
|
this,
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(this.overrides)
|
|
||||||
|
|
||||||
if (this.overrides) {
|
if (this.overrides) {
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
@ -210,6 +183,7 @@ export default class TrackManifest {
|
|||||||
return {
|
return {
|
||||||
_id: this._id,
|
_id: this._id,
|
||||||
uid: this.uid,
|
uid: this.uid,
|
||||||
|
cover: this.cover,
|
||||||
title: this.title,
|
title: this.title,
|
||||||
album: this.album,
|
album: this.album,
|
||||||
artist: this.artist,
|
artist: this.artist,
|
||||||
|
@ -3,11 +3,11 @@ import { Core } from "@ragestudio/vessel"
|
|||||||
import ActivityEvent from "@classes/ActivityEvent"
|
import ActivityEvent from "@classes/ActivityEvent"
|
||||||
import QueueManager from "@classes/QueueManager"
|
import QueueManager from "@classes/QueueManager"
|
||||||
import TrackInstance from "./classes/TrackInstance"
|
import TrackInstance from "./classes/TrackInstance"
|
||||||
//import MediaSession from "./classes/MediaSession"
|
import MediaSession from "./classes/MediaSession"
|
||||||
import ServiceProviders from "./classes/Services"
|
import ServiceProviders from "./classes/Services"
|
||||||
import PlayerState from "./classes/PlayerState"
|
import PlayerState from "./classes/PlayerState"
|
||||||
import PlayerUI from "./classes/PlayerUI"
|
import PlayerUI from "./classes/PlayerUI"
|
||||||
import PlayerProcessors from "./classes/PlayerProcessors"
|
import AudioBase from "./classes/AudioBase"
|
||||||
|
|
||||||
import setSampleRate from "./helpers/setSampleRate"
|
import setSampleRate from "./helpers/setSampleRate"
|
||||||
|
|
||||||
@ -22,27 +22,18 @@ export default class Player extends Core {
|
|||||||
|
|
||||||
// player config
|
// player config
|
||||||
static defaultSampleRate = 48000
|
static defaultSampleRate = 48000
|
||||||
static gradualFadeMs = 150
|
|
||||||
static maxManifestPrecompute = 3
|
|
||||||
|
|
||||||
state = new PlayerState(this)
|
state = new PlayerState(this)
|
||||||
ui = new PlayerUI(this)
|
ui = new PlayerUI(this)
|
||||||
serviceProviders = new ServiceProviders()
|
serviceProviders = new ServiceProviders()
|
||||||
//nativeControls = new MediaSession()
|
nativeControls = new MediaSession(this)
|
||||||
audioContext = new AudioContext({
|
|
||||||
sampleRate:
|
|
||||||
AudioPlayerStorage.get("sample_rate") ?? Player.defaultSampleRate,
|
|
||||||
latencyHint: "playback",
|
|
||||||
})
|
|
||||||
|
|
||||||
audioProcessors = new PlayerProcessors(this)
|
base = new AudioBase(this)
|
||||||
|
|
||||||
queue = new QueueManager({
|
queue = new QueueManager({
|
||||||
loadFunction: this.createInstance,
|
loadFunction: this.createInstance,
|
||||||
})
|
})
|
||||||
|
|
||||||
currentTrackInstance = null
|
|
||||||
|
|
||||||
public = {
|
public = {
|
||||||
start: this.start,
|
start: this.start,
|
||||||
close: this.close,
|
close: this.close,
|
||||||
@ -74,10 +65,11 @@ export default class Player extends Core {
|
|||||||
eventBus: () => {
|
eventBus: () => {
|
||||||
return this.eventBus
|
return this.eventBus
|
||||||
},
|
},
|
||||||
|
base: () => {
|
||||||
|
return this.base
|
||||||
|
},
|
||||||
state: this.state,
|
state: this.state,
|
||||||
ui: this.ui.public,
|
ui: this.ui.public,
|
||||||
audioContext: this.audioContext,
|
|
||||||
gradualFadeMs: Player.gradualFadeMs,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async afterInitialize() {
|
async afterInitialize() {
|
||||||
@ -85,8 +77,8 @@ export default class Player extends Core {
|
|||||||
this.state.volume = 1
|
this.state.volume = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
//await this.nativeControls.initialize()
|
await this.nativeControls.initialize()
|
||||||
await this.audioProcessors.initialize()
|
await this.base.initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
@ -100,10 +92,6 @@ export default class Player extends Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createInstance(manifest) {
|
|
||||||
return new TrackInstance(this, manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// Playback methods
|
// Playback methods
|
||||||
//
|
//
|
||||||
@ -112,46 +100,21 @@ export default class Player extends Core {
|
|||||||
throw new Error("Audio instance is required")
|
throw new Error("Audio instance is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
this.console.log("Initializing instance", instance)
|
|
||||||
|
|
||||||
// resume audio context if needed
|
// resume audio context if needed
|
||||||
if (this.audioContext.state === "suspended") {
|
if (this.base.context.state === "suspended") {
|
||||||
this.audioContext.resume()
|
this.base.context.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialize instance if is not
|
|
||||||
if (this.queue.currentItem._initialized === false) {
|
|
||||||
this.queue.currentItem = await instance.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.console.log("Instance", this.queue.currentItem)
|
|
||||||
|
|
||||||
// update manifest
|
// update manifest
|
||||||
this.state.track_manifest = this.queue.currentItem.manifest
|
this.state.track_manifest =
|
||||||
|
this.queue.currentItem.manifest.toSeriableObject()
|
||||||
// attach processors
|
|
||||||
this.queue.currentItem =
|
|
||||||
await this.audioProcessors.attachProcessorsToInstance(
|
|
||||||
this.queue.currentItem,
|
|
||||||
)
|
|
||||||
|
|
||||||
// set audio properties
|
|
||||||
this.queue.currentItem.audio.currentTime = params.time ?? 0
|
|
||||||
this.queue.currentItem.audio.muted = this.state.muted
|
|
||||||
this.queue.currentItem.audio.loop =
|
|
||||||
this.state.playback_mode === "repeat"
|
|
||||||
this.queue.currentItem.gainNode.gain.value = Math.pow(
|
|
||||||
this.state.volume,
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
|
|
||||||
// play
|
// play
|
||||||
await this.queue.currentItem.audio.play()
|
//await this.queue.currentItem.audio.play()
|
||||||
|
await this.queue.currentItem.play(params)
|
||||||
this.console.log(`Playing track >`, this.queue.currentItem)
|
|
||||||
|
|
||||||
// update native controls
|
// update native controls
|
||||||
//this.nativeControls.update(this.queue.currentItem.manifest)
|
this.nativeControls.update(this.queue.currentItem.manifest)
|
||||||
|
|
||||||
return this.queue.currentItem
|
return this.queue.currentItem
|
||||||
}
|
}
|
||||||
@ -160,10 +123,10 @@ export default class Player extends Core {
|
|||||||
this.ui.attachPlayerComponent()
|
this.ui.attachPlayerComponent()
|
||||||
|
|
||||||
if (this.queue.currentItem) {
|
if (this.queue.currentItem) {
|
||||||
await this.queue.currentItem.stop()
|
await this.queue.currentItem.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.abortPreloads()
|
//await this.abortPreloads()
|
||||||
await this.queue.flush()
|
await this.queue.flush()
|
||||||
|
|
||||||
this.state.loading = true
|
this.state.loading = true
|
||||||
@ -187,8 +150,8 @@ export default class Player extends Core {
|
|||||||
playlist = await this.serviceProviders.resolveMany(playlist)
|
playlist = await this.serviceProviders.resolveMany(playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
for await (const [index, _manifest] of playlist.entries()) {
|
for await (let [index, _manifest] of playlist.entries()) {
|
||||||
let instance = await this.createInstance(_manifest)
|
let instance = new TrackInstance(_manifest, this)
|
||||||
|
|
||||||
this.queue.add(instance)
|
this.queue.add(instance)
|
||||||
}
|
}
|
||||||
@ -229,10 +192,6 @@ export default class Player extends Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
next() {
|
next() {
|
||||||
if (this.queue.currentItem) {
|
|
||||||
this.queue.currentItem.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
//const isRandom = this.state.playback_mode === "shuffle"
|
//const isRandom = this.state.playback_mode === "shuffle"
|
||||||
const item = this.queue.next()
|
const item = this.queue.next()
|
||||||
|
|
||||||
@ -244,10 +203,6 @@ export default class Player extends Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
previous() {
|
previous() {
|
||||||
if (this.queue.currentItem) {
|
|
||||||
this.queue.currentItem.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = this.queue.previous()
|
const item = this.queue.previous()
|
||||||
|
|
||||||
return this.play(item)
|
return this.play(item)
|
||||||
@ -275,18 +230,14 @@ export default class Player extends Core {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// set gain exponentially
|
this.base.processors.gain.fade(0)
|
||||||
this.queue.currentItem.gainNode.gain.linearRampToValueAtTime(
|
|
||||||
0.0001,
|
|
||||||
this.audioContext.currentTime + Player.gradualFadeMs / 1000,
|
|
||||||
)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.queue.currentItem.audio.pause()
|
this.queue.currentItem.pause()
|
||||||
resolve()
|
resolve()
|
||||||
}, Player.gradualFadeMs)
|
}, Player.gradualFadeMs)
|
||||||
|
|
||||||
//this.nativeControls.updateIsPlaying(false)
|
this.nativeControls.updateIsPlaying(false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -302,19 +253,12 @@ export default class Player extends Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ensure audio elemeto starts from 0 volume
|
// ensure audio elemeto starts from 0 volume
|
||||||
this.queue.currentItem.gainNode.gain.value = 0.0001
|
this.queue.currentItem.resume().then(() => {
|
||||||
|
|
||||||
this.queue.currentItem.audio.play().then(() => {
|
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
this.base.processors.gain.fade(this.state.volume)
|
||||||
|
|
||||||
// set gain exponentially
|
this.nativeControls.updateIsPlaying(true)
|
||||||
this.queue.currentItem.gainNode.gain.linearRampToValueAtTime(
|
|
||||||
Math.pow(this.state.volume, 2),
|
|
||||||
this.audioContext.currentTime + Player.gradualFadeMs / 1000,
|
|
||||||
)
|
|
||||||
|
|
||||||
//this.nativeControls.updateIsPlaying(true)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -325,10 +269,7 @@ export default class Player extends Core {
|
|||||||
|
|
||||||
this.state.playback_mode = mode
|
this.state.playback_mode = mode
|
||||||
|
|
||||||
if (this.queue.currentItem) {
|
this.base.audio.loop = this.state.playback_mode === "repeat"
|
||||||
this.queue.currentItem.audio.loop =
|
|
||||||
this.state.playback_mode === "repeat"
|
|
||||||
}
|
|
||||||
|
|
||||||
AudioPlayerStorage.set("mode", mode)
|
AudioPlayerStorage.set("mode", mode)
|
||||||
|
|
||||||
@ -336,22 +277,15 @@ export default class Player extends Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stopPlayback() {
|
stopPlayback() {
|
||||||
if (this.queue.currentItem) {
|
this.base.flush()
|
||||||
this.queue.currentItem.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.queue.flush()
|
this.queue.flush()
|
||||||
|
|
||||||
this.abortPreloads()
|
|
||||||
|
|
||||||
this.state.playback_status = "stopped"
|
this.state.playback_status = "stopped"
|
||||||
this.state.track_manifest = null
|
this.state.track_manifest = null
|
||||||
|
|
||||||
this.queue.currentItem = null
|
this.queue.currentItem = null
|
||||||
this.track_next_instances = []
|
|
||||||
this.track_prev_instances = []
|
|
||||||
|
|
||||||
//this.nativeControls.destroy()
|
//this.abortPreloads()
|
||||||
|
this.nativeControls.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
@ -369,7 +303,7 @@ export default class Player extends Core {
|
|||||||
|
|
||||||
if (typeof to === "boolean") {
|
if (typeof to === "boolean") {
|
||||||
this.state.muted = to
|
this.state.muted = to
|
||||||
this.queue.currentItem.audio.muted = to
|
this.base.audio.muted = to
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.state.muted
|
return this.state.muted
|
||||||
@ -395,65 +329,42 @@ export default class Player extends Core {
|
|||||||
volume = 0
|
volume = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
this.state.volume = volume
|
|
||||||
|
|
||||||
AudioPlayerStorage.set("volume", volume)
|
AudioPlayerStorage.set("volume", volume)
|
||||||
|
|
||||||
if (this.queue.currentItem) {
|
this.state.volume = volume
|
||||||
if (this.queue.currentItem.gainNode) {
|
this.base.processors.gain.set(volume)
|
||||||
this.queue.currentItem.gainNode.gain.value = Math.pow(
|
|
||||||
this.state.volume,
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.state.volume
|
return this.state.volume
|
||||||
}
|
}
|
||||||
|
|
||||||
seek(time) {
|
seek(time) {
|
||||||
if (!this.queue.currentItem || !this.queue.currentItem.audio) {
|
if (!this.base.audio) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// if time not provided, return current time
|
// if time not provided, return current time
|
||||||
if (typeof time === "undefined") {
|
if (typeof time === "undefined") {
|
||||||
return this.queue.currentItem.audio.currentTime
|
return this.base.audio.currentTime
|
||||||
}
|
}
|
||||||
|
|
||||||
// if time is provided, seek to that time
|
// if time is provided, seek to that time
|
||||||
if (typeof time === "number") {
|
if (typeof time === "number") {
|
||||||
this.console.log(
|
this.console.log(
|
||||||
`Seeking to ${time} | Duration: ${this.queue.currentItem.audio.duration}`,
|
`Seeking to ${time} | Duration: ${this.base.audio.duration}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
this.queue.currentItem.audio.currentTime = time
|
this.base.audio.currentTime = time
|
||||||
|
|
||||||
return time
|
return time
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
duration() {
|
duration() {
|
||||||
if (!this.queue.currentItem || !this.queue.currentItem.audio) {
|
if (!this.base.audio) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.queue.currentItem.audio.duration
|
return this.base.audio.duration
|
||||||
}
|
|
||||||
|
|
||||||
loop(to) {
|
|
||||||
if (typeof to !== "boolean") {
|
|
||||||
this.console.warn("Loop must be a boolean")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.loop = to ?? !this.state.loop
|
|
||||||
|
|
||||||
if (this.queue.currentItem.audio) {
|
|
||||||
this.queue.currentItem.audio.loop = this.state.loop
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.state.loop
|
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
|
@ -2,44 +2,40 @@ import ProcessorNode from "../node"
|
|||||||
import Presets from "../../classes/Presets"
|
import Presets from "../../classes/Presets"
|
||||||
|
|
||||||
export default class CompressorProcessorNode extends ProcessorNode {
|
export default class CompressorProcessorNode extends ProcessorNode {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props)
|
super(props)
|
||||||
|
|
||||||
this.presets = new Presets({
|
this.presets = new Presets({
|
||||||
storage_key: "compressor",
|
storage_key: "compressor",
|
||||||
defaultPresetValue: {
|
defaultPresetValue: {
|
||||||
threshold: -50,
|
threshold: -50,
|
||||||
knee: 40,
|
knee: 40,
|
||||||
ratio: 12,
|
ratio: 12,
|
||||||
attack: 0.003,
|
attack: 0.003,
|
||||||
release: 0.25,
|
release: 0.25,
|
||||||
},
|
},
|
||||||
onApplyValues: this.applyValues.bind(this),
|
onApplyValues: this.applyValues.bind(this),
|
||||||
})
|
})
|
||||||
|
|
||||||
this.exposeToPublic = {
|
this.exposeToPublic = {
|
||||||
presets: this.presets,
|
presets: this.presets,
|
||||||
detach: this._detach,
|
detach: this._detach,
|
||||||
attach: this._attach,
|
attach: this._attach,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static refName = "compressor"
|
static refName = "compressor"
|
||||||
static dependsOnSettings = ["player.compressor"]
|
static dependsOnSettings = ["player.compressor"]
|
||||||
|
|
||||||
async init(AudioContext) {
|
async init() {
|
||||||
if (!AudioContext) {
|
this.processor = this.audioContext.createDynamicsCompressor()
|
||||||
throw new Error("AudioContext is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
this.processor = AudioContext.createDynamicsCompressor()
|
this.applyValues()
|
||||||
|
}
|
||||||
|
|
||||||
this.applyValues()
|
applyValues() {
|
||||||
}
|
Object.keys(this.presets.currentPresetValues).forEach((key) => {
|
||||||
|
this.processor[key].value = this.presets.currentPresetValues[key]
|
||||||
applyValues() {
|
})
|
||||||
Object.keys(this.presets.currentPresetValues).forEach((key) => {
|
}
|
||||||
this.processor[key].value = this.presets.currentPresetValues[key]
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -2,93 +2,98 @@ import ProcessorNode from "../node"
|
|||||||
import Presets from "../../classes/Presets"
|
import Presets from "../../classes/Presets"
|
||||||
|
|
||||||
export default class EqProcessorNode extends ProcessorNode {
|
export default class EqProcessorNode extends ProcessorNode {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props)
|
super(props)
|
||||||
|
|
||||||
this.presets = new Presets({
|
this.presets = new Presets({
|
||||||
storage_key: "eq",
|
storage_key: "eq",
|
||||||
defaultPresetValue: {
|
defaultPresetValue: {
|
||||||
32: 0,
|
32: 0,
|
||||||
64: 0,
|
64: 0,
|
||||||
125: 0,
|
125: 0,
|
||||||
250: 0,
|
250: 0,
|
||||||
500: 0,
|
500: 0,
|
||||||
1000: 0,
|
1000: 0,
|
||||||
2000: 0,
|
2000: 0,
|
||||||
4000: 0,
|
4000: 0,
|
||||||
8000: 0,
|
8000: 0,
|
||||||
16000: 0,
|
16000: 0,
|
||||||
},
|
},
|
||||||
onApplyValues: this.applyValues.bind(this),
|
onApplyValues: this.applyValues.bind(this),
|
||||||
})
|
})
|
||||||
|
|
||||||
this.exposeToPublic = {
|
this.exposeToPublic = {
|
||||||
presets: this.presets,
|
presets: this.presets,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static refName = "eq"
|
static refName = "eq"
|
||||||
static lock = true
|
|
||||||
|
|
||||||
applyValues() {
|
applyValues() {
|
||||||
// apply to current instance
|
// apply to current instance
|
||||||
this.processor.eqNodes.forEach((processor) => {
|
this.processor.eqNodes.forEach((processor) => {
|
||||||
const gainValue = this.presets.currentPresetValues[processor.frequency.value]
|
const gainValue =
|
||||||
|
this.presets.currentPresetValues[processor.frequency.value]
|
||||||
|
|
||||||
if (processor.gain.value !== gainValue) {
|
if (processor.gain.value !== gainValue) {
|
||||||
console.debug(`[EQ] Applying values to ${processor.frequency.value} Hz frequency with gain ${gainValue}`)
|
console.debug(
|
||||||
processor.gain.value = gainValue
|
`[EQ] Applying values to ${processor.frequency.value} Hz frequency with gain ${gainValue}`,
|
||||||
}
|
)
|
||||||
})
|
processor.gain.value = gainValue
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
if (!this.audioContext) {
|
if (!this.audioContext) {
|
||||||
throw new Error("audioContext is required")
|
throw new Error("audioContext is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
this.processor = this.audioContext.createGain()
|
this.processor = this.audioContext.createGain()
|
||||||
|
|
||||||
this.processor.gain.value = 1
|
this.processor.gain.value = 1
|
||||||
|
|
||||||
this.processor.eqNodes = []
|
this.processor.eqNodes = []
|
||||||
|
|
||||||
const values = Object.entries(this.presets.currentPresetValues).map((entry) => {
|
const values = Object.entries(this.presets.currentPresetValues).map(
|
||||||
return {
|
(entry) => {
|
||||||
freq: parseFloat(entry[0]),
|
return {
|
||||||
gain: parseFloat(entry[1]),
|
freq: parseFloat(entry[0]),
|
||||||
}
|
gain: parseFloat(entry[1]),
|
||||||
})
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
values.forEach((eqValue, index) => {
|
values.forEach((eqValue, index) => {
|
||||||
// chekc if freq and gain is valid
|
// chekc if freq and gain is valid
|
||||||
if (isNaN(eqValue.freq)) {
|
if (isNaN(eqValue.freq)) {
|
||||||
eqValue.freq = 0
|
eqValue.freq = 0
|
||||||
}
|
}
|
||||||
if (isNaN(eqValue.gain)) {
|
if (isNaN(eqValue.gain)) {
|
||||||
eqValue.gain = 0
|
eqValue.gain = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
this.processor.eqNodes[index] = this.audioContext.createBiquadFilter()
|
this.processor.eqNodes[index] =
|
||||||
this.processor.eqNodes[index].type = "peaking"
|
this.audioContext.createBiquadFilter()
|
||||||
this.processor.eqNodes[index].frequency.value = eqValue.freq
|
this.processor.eqNodes[index].type = "peaking"
|
||||||
this.processor.eqNodes[index].gain.value = eqValue.gain
|
this.processor.eqNodes[index].frequency.value = eqValue.freq
|
||||||
})
|
this.processor.eqNodes[index].gain.value = eqValue.gain
|
||||||
|
})
|
||||||
|
|
||||||
// connect nodes
|
// connect nodes
|
||||||
for await (let [index, eqNode] of this.processor.eqNodes.entries()) {
|
for await (let [index, eqNode] of this.processor.eqNodes.entries()) {
|
||||||
const nextNode = this.processor.eqNodes[index + 1]
|
const nextNode = this.processor.eqNodes[index + 1]
|
||||||
|
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
this.processor.connect(eqNode)
|
this.processor.connect(eqNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextNode) {
|
if (nextNode) {
|
||||||
eqNode.connect(nextNode)
|
eqNode.connect(nextNode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// set last processor for processor node can properly connect to the next node
|
// set last processor for processor node can properly connect to the next node
|
||||||
this.processor._last = this.processor.eqNodes.at(-1)
|
this.processor._last = this.processor.eqNodes.at(-1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,60 +1,49 @@
|
|||||||
import AudioPlayerStorage from "../../player.storage"
|
|
||||||
import ProcessorNode from "../node"
|
import ProcessorNode from "../node"
|
||||||
|
|
||||||
export default class GainProcessorNode extends ProcessorNode {
|
export default class GainProcessorNode extends ProcessorNode {
|
||||||
static refName = "gain"
|
static refName = "gain"
|
||||||
|
static gradualFadeMs = 150
|
||||||
|
|
||||||
static lock = true
|
exposeToPublic = {
|
||||||
|
set: this.setGain.bind(this),
|
||||||
|
linearRampToValueAtTime: this.linearRampToValueAtTime.bind(this),
|
||||||
|
fade: this.fade.bind(this),
|
||||||
|
}
|
||||||
|
|
||||||
static defaultValues = {
|
setGain(gain) {
|
||||||
gain: 1,
|
gain = this.processGainValue(gain)
|
||||||
}
|
|
||||||
|
|
||||||
state = {
|
return (this.processor.gain.value = gain)
|
||||||
gain: AudioPlayerStorage.get("gain") ?? GainProcessorNode.defaultValues.gain,
|
}
|
||||||
}
|
|
||||||
|
|
||||||
exposeToPublic = {
|
linearRampToValueAtTime(gain, time) {
|
||||||
modifyValues: function (values) {
|
gain = this.processGainValue(gain)
|
||||||
this.state = {
|
return this.processor.gain.linearRampToValueAtTime(gain, time)
|
||||||
...this.state,
|
}
|
||||||
...values,
|
|
||||||
}
|
|
||||||
|
|
||||||
AudioPlayerStorage.set("gain", this.state.gain)
|
fade(gain) {
|
||||||
|
if (gain <= 0) {
|
||||||
|
gain = 0.0001
|
||||||
|
} else {
|
||||||
|
gain = this.processGainValue(gain)
|
||||||
|
}
|
||||||
|
|
||||||
this.applyValues()
|
const currentTime = this.audioContext.currentTime
|
||||||
}.bind(this),
|
const fadeTime = currentTime + this.constructor.gradualFadeMs / 1000
|
||||||
resetDefaultValues: function () {
|
|
||||||
this.exposeToPublic.modifyValues(GainProcessorNode.defaultValues)
|
|
||||||
|
|
||||||
return this.state
|
this.processor.gain.linearRampToValueAtTime(gain, fadeTime)
|
||||||
}.bind(this),
|
}
|
||||||
values: () => this.state,
|
|
||||||
}
|
|
||||||
|
|
||||||
applyValues() {
|
processGainValue(gain) {
|
||||||
// apply to current instance
|
return Math.pow(gain, 2)
|
||||||
this.processor.gain.value = app.cores.player.state.volume * this.state.gain
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
if (!this.audioContext) {
|
if (!this.audioContext) {
|
||||||
throw new Error("audioContext is required")
|
throw new Error("audioContext is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
this.processor = this.audioContext.createGain()
|
this.processor = this.audioContext.createGain()
|
||||||
|
this.processor.gain.value = this.player.state.volume
|
||||||
this.applyValues()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateInstance(instance) {
|
|
||||||
if (!instance) {
|
|
||||||
throw new Error("instance is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
instance.gainNode = this.processor
|
|
||||||
|
|
||||||
return instance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -2,13 +2,12 @@ import EqProcessorNode from "./eqNode"
|
|||||||
import GainProcessorNode from "./gainNode"
|
import GainProcessorNode from "./gainNode"
|
||||||
import CompressorProcessorNode from "./compressorNode"
|
import CompressorProcessorNode from "./compressorNode"
|
||||||
//import BPMProcessorNode from "./bpmNode"
|
//import BPMProcessorNode from "./bpmNode"
|
||||||
|
//import SpatialNode from "./spatialNode"
|
||||||
import SpatialNode from "./spatialNode"
|
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
//BPMProcessorNode,
|
//BPMProcessorNode,
|
||||||
EqProcessorNode,
|
EqProcessorNode,
|
||||||
GainProcessorNode,
|
GainProcessorNode,
|
||||||
CompressorProcessorNode,
|
CompressorProcessorNode,
|
||||||
SpatialNode,
|
//SpatialNode,
|
||||||
]
|
]
|
||||||
|
@ -1,172 +1,147 @@
|
|||||||
export default class ProcessorNode {
|
export default class ProcessorNode {
|
||||||
constructor(PlayerCore) {
|
constructor(manager) {
|
||||||
if (!PlayerCore) {
|
if (!manager) {
|
||||||
throw new Error("PlayerCore is required")
|
throw new Error("processorManager is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
this.PlayerCore = PlayerCore
|
this.manager = manager
|
||||||
this.audioContext = PlayerCore.audioContext
|
this.audioContext = manager.base.context
|
||||||
}
|
this.elementSource = manager.base.elementSource
|
||||||
|
this.player = manager.base.player
|
||||||
|
}
|
||||||
|
|
||||||
async _init() {
|
async _init() {
|
||||||
// check if has init method
|
// check if has init method
|
||||||
if (typeof this.init === "function") {
|
if (typeof this.init === "function") {
|
||||||
await this.init(this.audioContext)
|
await this.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if has declared bus events
|
// check if has declared bus events
|
||||||
if (typeof this.busEvents === "object") {
|
if (typeof this.busEvents === "object") {
|
||||||
Object.entries(this.busEvents).forEach((event, fn) => {
|
Object.entries(this.busEvents).forEach((event, fn) => {
|
||||||
app.eventBus.on(event, fn)
|
app.eventBus.on(event, fn)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof this.processor._last === "undefined") {
|
if (typeof this.processor._last === "undefined") {
|
||||||
this.processor._last = this.processor
|
this.processor._last = this.processor
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
_attach(instance, index) {
|
_attach(index) {
|
||||||
if (typeof instance !== "object") {
|
// check if has dependsOnSettings
|
||||||
instance = this.PlayerCore.currentAudioInstance
|
if (Array.isArray(this.constructor.dependsOnSettings)) {
|
||||||
}
|
// check if the instance has the settings
|
||||||
|
if (
|
||||||
|
!this.constructor.dependsOnSettings.every((setting) =>
|
||||||
|
app.cores.settings.get(setting),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`Skipping attachment for [${this.constructor.refName ?? this.constructor.name}] node, cause is not passing the settings dependecy > ${this.constructor.dependsOnSettings.join(", ")}`,
|
||||||
|
)
|
||||||
|
|
||||||
// check if has dependsOnSettings
|
return null
|
||||||
if (Array.isArray(this.constructor.dependsOnSettings)) {
|
}
|
||||||
// check if the instance has the settings
|
}
|
||||||
if (!this.constructor.dependsOnSettings.every((setting) => app.cores.settings.get(setting))) {
|
|
||||||
console.warn(`Skipping attachment for [${this.constructor.refName ?? this.constructor.name}] node, cause is not passing the settings dependecy > ${this.constructor.dependsOnSettings.join(", ")}`)
|
|
||||||
|
|
||||||
return instance
|
// if index is not defined, attach to the last node
|
||||||
}
|
if (!index) {
|
||||||
}
|
index = this.manager.attached.length
|
||||||
|
}
|
||||||
|
|
||||||
// if index is not defined, attach to the last node
|
const prevNode = this.manager.attached[index - 1]
|
||||||
if (!index) {
|
const nextNode = this.manager.attached[index + 1]
|
||||||
index = instance.attachedProcessors.length
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevNode = instance.attachedProcessors[index - 1]
|
const currentIndex = this._findIndex()
|
||||||
const nextNode = instance.attachedProcessors[index + 1]
|
|
||||||
|
|
||||||
const currentIndex = this._findIndex(instance)
|
// check if is already attached
|
||||||
|
if (currentIndex !== false) {
|
||||||
|
console.warn(
|
||||||
|
`[${this.constructor.refName ?? this.constructor.name}] node is already attached`,
|
||||||
|
)
|
||||||
|
|
||||||
// check if is already attached
|
return null
|
||||||
if (currentIndex !== false) {
|
}
|
||||||
console.warn(`[${this.constructor.refName ?? this.constructor.name}] node is already attached`)
|
|
||||||
|
|
||||||
return instance
|
// first check if has prevNode and if is connected to something
|
||||||
}
|
// if has, disconnect it
|
||||||
|
// if it not has, its means that is the first node, so connect to the media source
|
||||||
|
if (prevNode && prevNode.processor._last.numberOfOutputs > 0) {
|
||||||
|
//console.log(`[${this.constructor.refName ?? this.constructor.name}] node is already attached to the previous node, disconnecting...`)
|
||||||
|
// if has outputs, disconnect from the next node
|
||||||
|
prevNode.processor._last.disconnect()
|
||||||
|
|
||||||
// first check if has prevNode and if is connected to something
|
// now, connect to the processor
|
||||||
// if has, disconnect it
|
prevNode.processor._last.connect(this.processor)
|
||||||
// if it not has, its means that is the first node, so connect to the media source
|
} else {
|
||||||
if (prevNode && prevNode.processor._last.numberOfOutputs > 0) {
|
//console.log(`[${this.constructor.refName ?? this.constructor.name}] node is the first node, connecting to the media source...`)
|
||||||
//console.log(`[${this.constructor.refName ?? this.constructor.name}] node is already attached to the previous node, disconnecting...`)
|
this.elementSource.connect(this.processor)
|
||||||
// if has outputs, disconnect from the next node
|
}
|
||||||
prevNode.processor._last.disconnect()
|
|
||||||
|
|
||||||
// now, connect to the processor
|
// now, check if it has a next node
|
||||||
prevNode.processor._last.connect(this.processor)
|
// if has, connect to it
|
||||||
} else {
|
// if not, connect to the destination
|
||||||
//console.log(`[${this.constructor.refName ?? this.constructor.name}] node is the first node, connecting to the media source...`)
|
if (nextNode) {
|
||||||
instance.contextElement.connect(this.processor)
|
this.processor.connect(nextNode.processor)
|
||||||
}
|
}
|
||||||
|
|
||||||
// now, check if it has a next node
|
// add to the attachedProcessors
|
||||||
// if has, connect to it
|
this.manager.attached.splice(index, 0, this)
|
||||||
// if not, connect to the destination
|
|
||||||
if (nextNode) {
|
|
||||||
this.processor.connect(nextNode.processor)
|
|
||||||
}
|
|
||||||
|
|
||||||
// add to the attachedProcessors
|
// // handle instance mutation
|
||||||
instance.attachedProcessors.splice(index, 0, this)
|
// if (typeof this.mutateInstance === "function") {
|
||||||
|
// instance = this.mutateInstance(instance)
|
||||||
|
// }
|
||||||
|
|
||||||
// handle instance mutation
|
return this
|
||||||
if (typeof this.mutateInstance === "function") {
|
}
|
||||||
instance = this.mutateInstance(instance)
|
|
||||||
}
|
|
||||||
|
|
||||||
return instance
|
_detach() {
|
||||||
}
|
// find index of the node within the attachedProcessors serching for matching refName
|
||||||
|
const index = this._findIndex()
|
||||||
|
|
||||||
_detach(instance) {
|
if (!index) {
|
||||||
if (typeof instance !== "object") {
|
return null
|
||||||
instance = this.PlayerCore.currentAudioInstance
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// find index of the node within the attachedProcessors serching for matching refName
|
// retrieve the previous and next nodes
|
||||||
const index = this._findIndex(instance)
|
const prevNode = this.manager.attached[index - 1]
|
||||||
|
const nextNode = this.manager.attached[index + 1]
|
||||||
|
|
||||||
if (!index) {
|
// check if has previous node and if has outputs
|
||||||
return instance
|
if (prevNode && prevNode.processor._last.numberOfOutputs > 0) {
|
||||||
}
|
// if has outputs, disconnect from the previous node
|
||||||
|
prevNode.processor._last.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
// retrieve the previous and next nodes
|
// disconnect
|
||||||
const prevNode = instance.attachedProcessors[index - 1]
|
this.processor.disconnect()
|
||||||
const nextNode = instance.attachedProcessors[index + 1]
|
this.manager.attached.splice(index, 1)
|
||||||
|
|
||||||
// check if has previous node and if has outputs
|
// now, connect the previous node to the next node
|
||||||
if (prevNode && prevNode.processor._last.numberOfOutputs > 0) {
|
if (prevNode && nextNode) {
|
||||||
// if has outputs, disconnect from the previous node
|
prevNode.processor._last.connect(nextNode.processor)
|
||||||
prevNode.processor._last.disconnect()
|
} else {
|
||||||
}
|
// it means that this is the last node, so connect to the destination
|
||||||
|
prevNode.processor._last.connect(this.audioContext.destination)
|
||||||
|
}
|
||||||
|
|
||||||
// disconnect
|
return this
|
||||||
instance = this._destroy(instance)
|
}
|
||||||
|
|
||||||
// now, connect the previous node to the next node
|
_findIndex() {
|
||||||
if (prevNode && nextNode) {
|
// find index of the node within the attachedProcessors serching for matching refName
|
||||||
prevNode.processor._last.connect(nextNode.processor)
|
const index = this.manager.attached.findIndex((node) => {
|
||||||
} else {
|
return node.constructor.refName === this.constructor.refName
|
||||||
// it means that this is the last node, so connect to the destination
|
})
|
||||||
prevNode.processor._last.connect(this.audioContext.destination)
|
|
||||||
}
|
|
||||||
|
|
||||||
return instance
|
if (index === -1) {
|
||||||
}
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
_destroy(instance) {
|
return index
|
||||||
if (typeof instance !== "object") {
|
}
|
||||||
instance = this.PlayerCore.currentAudioInstance
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const index = this._findIndex(instance)
|
|
||||||
|
|
||||||
if (!index) {
|
|
||||||
return instance
|
|
||||||
}
|
|
||||||
|
|
||||||
this.processor.disconnect()
|
|
||||||
|
|
||||||
instance.attachedProcessors.splice(index, 1)
|
|
||||||
|
|
||||||
return instance
|
|
||||||
}
|
|
||||||
|
|
||||||
_findIndex(instance) {
|
|
||||||
if (!instance) {
|
|
||||||
instance = this.PlayerCore.currentAudioInstance
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!instance) {
|
|
||||||
console.warn(`Instance is not defined`)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// find index of the node within the attachedProcessors serching for matching refName
|
|
||||||
const index = instance.attachedProcessors.findIndex((node) => {
|
|
||||||
return node.constructor.refName === this.constructor.refName
|
|
||||||
})
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user