diff --git a/packages/app/src/components/Music/PlaylistView/index.less b/packages/app/src/components/Music/PlaylistView/index.less index 1a7952f2..746de021 100755 --- a/packages/app/src/components/Music/PlaylistView/index.less +++ b/packages/app/src/components/Music/PlaylistView/index.less @@ -1,4 +1,4 @@ -#root { +html { &.mobile { .playlist_view { display: flex; diff --git a/packages/app/src/components/PagePanels/index.less b/packages/app/src/components/PagePanels/index.less index 3fa3cfdc..bdea1f1f 100755 --- a/packages/app/src/components/PagePanels/index.less +++ b/packages/app/src/components/PagePanels/index.less @@ -1,6 +1,6 @@ @import "theme/vars.less"; -#root { +html { &.mobile { &.page-panel-spacer { .pagePanels { diff --git a/packages/app/src/components/Player/Controls/index.less b/packages/app/src/components/Player/Controls/index.less index ba5367dd..6a7c1f25 100644 --- a/packages/app/src/components/Player/Controls/index.less +++ b/packages/app/src/components/Player/Controls/index.less @@ -1,4 +1,4 @@ -#root { +html { &.mobile { .player-controls { svg { diff --git a/packages/app/src/components/Player/MediaPlayer/index.less b/packages/app/src/components/Player/MediaPlayer/index.less index 65326e83..6072f916 100755 --- a/packages/app/src/components/Player/MediaPlayer/index.less +++ b/packages/app/src/components/Player/MediaPlayer/index.less @@ -1,6 +1,6 @@ @top_controls_height: 55px; -#root { +html { &.mobile { .embbededMediaPlayerWrapper { .player { diff --git a/packages/app/src/components/PostsList/index.less b/packages/app/src/components/PostsList/index.less index 81b93bea..26af603c 100755 --- a/packages/app/src/components/PostsList/index.less +++ b/packages/app/src/components/PostsList/index.less @@ -1,6 +1,6 @@ @import "theme/vars.less"; -#root { +html { &.mobile { .post-list { overflow: unset; diff --git a/packages/app/src/components/UserBadges/index.less b/packages/app/src/components/UserBadges/index.less index a04649be..6f62f1c0 100644 --- a/packages/app/src/components/UserBadges/index.less +++ b/packages/app/src/components/UserBadges/index.less @@ -1,19 +1,3 @@ -#root { - &.mobile { - .badges { - .ant-tag { - padding: 10px; - font-size: 0.9rem; - width: 100%; - - svg { - font-size: 1.2rem; - } - } - } - } -} - .badges { display: grid; grid-template-columns: repeat(2, 1fr); diff --git a/packages/app/src/components/UserCard/index.less b/packages/app/src/components/UserCard/index.less index e66c13b3..31ac1b9a 100755 --- a/packages/app/src/components/UserCard/index.less +++ b/packages/app/src/components/UserCard/index.less @@ -1,6 +1,6 @@ @import "theme/vars.less"; -#root { +html { &.mobile { .userCard { width: 100%; diff --git a/packages/app/src/cores/stream/BufferedStreamReader.js b/packages/app/src/cores/stream/BufferedStreamReader.js new file mode 100644 index 00000000..3de83853 --- /dev/null +++ b/packages/app/src/cores/stream/BufferedStreamReader.js @@ -0,0 +1,157 @@ +/* Audio file chunks read must be buffered before sending to decoder. + * Otherwise, decoder returns white noise for odd (not even) chunk size). + * Skipping/hissing occurs if buffer is too small or if network isn't fast enough. + * Users must wait too long to hear audio if buffer is too large. + * + * Returns Promise that resolves when entire stream is read and bytes queued for decoding + */ +export default class BufferedStreamReader { + onRead; // callback on every read. useful for speed calcs + onBufferFull; // callback when buffer fills or read completes + request; // HTTP request we're reading + buffer; // buffer we're filling + bufferPos = 0; // last filled position in buffer + isRunning; + abortController; + + constructor(request, readBufferSize) { + if (!(parseInt(readBufferSize) > 0)) + throw Error('readBufferSize not provided'); + + this.request = request; + this.buffer = new Uint8Array(readBufferSize); + } + + abort() { + if (this.abortController) { + this.abortController.abort(); + } + this.request = null; + } + + seek(time) { + if (!this._controller) { + return; + } + + const targetByteOffset = Math.floor((time / this._audioContext.sampleRate) * this._bufferSize); + const targetPosition = Math.floor(targetByteOffset / this._chunkSize); + const targetChunkByteOffset = targetByteOffset % this._chunkSize; + + // Set the reader's position to the target position + this._controller.reader.seek(targetPosition); + + // Update the internal buffer to start reading from the target chunk and byte offset + this._buffer = this._controller.reader.readChunk(targetChunkByteOffset, this._chunkSize); + + // Reset the read position and buffer position + this._readPosition = targetByteOffset; + this._bufferPosition = targetChunkByteOffset; + + // Emit an event or invoke a callback to notify the AudioStreamPlayer that the seek operation is complete + // You can define an `onSeek` event or callback in BufferedStreamReader and invoke it here. + } + + async read() { + if (this.isRunning) { + return console.warn('cannot start - read in progess.'); + } + + this.isRunning = true; + + return this._start() + .catch(e => { + if (e.name === 'AbortError') { + return; + } + this.abort(); + throw e; + }) + .finally(_ => this.isRunning = false); + } + + async _start() { + this.abortController = ('AbortController' in window) ? new AbortController() : null; + const signal = this.abortController ? this.abortController.signal : null; + + const response = await fetch(this.request, { signal }); + if (!response.ok) throw Error(response.status + ' ' + response.statusText); + if (!response.body) throw Error('ReadableStream not yet supported in this browser - browser compatibility'); + + const reader = response.body.getReader(), + contentLength = response.headers.get('content-length'), // requires CORS access-control-expose-headers: content-length + totalBytes = contentLength ? parseInt(contentLength, 10) : 0; + + let totalRead = 0, byte, readBufferPos = 0; + + const read = async () => { + const { value, done } = await reader.read(); + const byteLength = value ? value.byteLength : 0; + totalRead += byteLength; + + if (this.onRead) { + this.onRead({ bytes: value, totalRead, totalBytes, done }); + } + + // avoid blocking read() + setTimeout(_ => this._readIntoBuffer({ value, done, request: this.request })); + // console.log(this.request); + // this._readIntoBuffer({ value, done }); + + if (!done) { + return read(); + } + }; + + return read(); + } + + _requestIsAborted({ request }) { + return this.request !== request; + } + + _reset() { + this.bufferPos = 0 + this.buffer.fill(0) + } + + _flushBuffer({ end, done, request }) { + if (this._requestIsAborted({ request })) { + return + } + + this.onBufferFull({ bytes: this.buffer.slice(0, end), done }); + } + + /* read value into buffer and call onBufferFull when reached */ + _readIntoBuffer({ value, done, request }) { + if (this._requestIsAborted({ request })) { + return + } + + if (done) { + this._flushBuffer({ end: this.bufferPos, done, request }); + return; + } + + const src = value, + srcLen = src.byteLength, + bufferLen = this.buffer.byteLength; + let srcStart = 0, + bufferPos = this.bufferPos; + + while (srcStart < srcLen) { + const len = Math.min(bufferLen - bufferPos, srcLen - srcStart); + const end = srcStart + len; + this.buffer.set(src.subarray(srcStart, end), bufferPos); + srcStart += len; + bufferPos += len; + if (bufferPos === bufferLen) { + bufferPos = 0; + this._flushBuffer({ end: Infinity, done, request }); + } + } + + this.bufferPos = bufferPos; + } +} \ No newline at end of file diff --git a/packages/app/src/cores/stream/stream.core.js b/packages/app/src/cores/stream/stream.core.js new file mode 100644 index 00000000..9fd78201 --- /dev/null +++ b/packages/app/src/cores/stream/stream.core.js @@ -0,0 +1,87 @@ +import Core from "evite/src/core" + +import remotes from "comty.js/remotes" +import PlaylistModel from "comty.js/models/playlists" + +export default class StreamPlayer extends Core { + static refName = "stream" + static namespace = "stream" + static dependencies = [ + "settings" + ] + + queue = [] + prevQueue = [] + + public = { + start: this.start.bind(this), + playback: { + play: this.playback_play.bind(this), + pause: this.playback_pause.bind(this), + seek: this.playback_seek.bind(this), + }, + } + + async onInitialize() { + //this.audioContext.resume() + } + + playback_play() { + this.streamPlayer.resume() + } + + playback_pause() { + this.streamPlayer.pause() + } + + playback_seek(time) { + this.streamPlayer.seek(time) + } + + async createInstance(payload) { + // if payload is a string its means is a track_id, try to fetch it + if (typeof payload === "string") { + payload = await PlaylistModel.getTrack(payload) + } + + const instanceObj = { + streamSource: payload.source.split("//")[1].split("/").slice(2).join("/"), + source: payload.source, + metadata: { + title: payload.title, + album: payload.album, + artist: payload.artist, + cover: payload.cover ?? payload.thumbnail, + ...payload.metadata, + }, + audioBuffer: this.audioContext.createBuffer(2, this.audioContext.sampleRate * 2, 48000), + media: this.audioContext.createBufferSource(), + } + + console.log(instanceObj) + + return instanceObj + } + + async start( + instance, + { + time = 0 + } = {} + ) { + instance = await this.createInstance(instance) + + this.queue = [instance] + + this.prevQueue = [] + + this.play(this.queue[0], { + time: time, + }) + } + + + async play(instance) { + + } +} \ No newline at end of file diff --git a/packages/app/src/cores/stream/streamPlayer.js b/packages/app/src/cores/stream/streamPlayer.js new file mode 100644 index 00000000..26cf1d8e --- /dev/null +++ b/packages/app/src/cores/stream/streamPlayer.js @@ -0,0 +1,146 @@ +import BufferedStreamReader from "./BufferedStreamReader.js" + +export default class AudioStreamPlayer { + // these shouldn't change once set + _worker = null + _readBufferSize = 1024 * 100 + + // these are reset + _sessionId = null // used to prevent race conditions between cancel/starts + _reader = null + + buffers = [] + + constructor(audioContext, readBufferSize) { + this.audioContext = audioContext + + this._worker = new Worker("/workers/wav-decoder.js") + + this._worker.onerror = (event) => { + this.reset() + } + + this._worker.onmessage = this._onWorkerMessage.bind(this) + + if (readBufferSize) { + this._readBufferSize = readBufferSize + } + + this.instanceSource = this.audioContext.createBufferSource() + + this.reset() + } + + reset() { + this.audioContext.suspend() + + if (this._reader) { + this._reader.abort() + this._reader._reset() + } + + if (this._sessionId) { + performance.clearMarks(this._downloadMarkKey); + } + + this._sessionId = null; + this._reader = null; + + this.buffer = null + } + + start(url) { + this.reset() + + this._sessionId = performance.now() + + performance.mark(this._downloadMarkKey) + + const reader = new BufferedStreamReader(new Request(url), this._readBufferSize) + + reader.onRead = this._downloadProgress.bind(this) + reader.onBufferFull = this.decode.bind(this) + + reader.read().catch((e) => { + console.error(e) + }) + + this._reader = reader + + this.resume() + + this.instanceSource.connect(this.audioContext.destination) + + this.instanceSource.start(0) + } + + pause() { + this.audioContext.suspend() + } + resume() { + this.audioContext.resume() + } + + seek(time) { + + } + + decode({ bytes, done }) { + const sessionId = this._sessionId + + this._worker.postMessage({ decode: bytes.buffer, sessionId }, [bytes.buffer]) + } + + // prevent race condition by checking sessionId + _onWorkerMessage(event) { + const { decoded, sessionId } = event.data; + + if (decoded.channelData) { + if (!(this._sessionId && this._sessionId === sessionId)) { + console.log("race condition detected for closed session"); + return; + } + + this.pushToBuffer(decoded); + } + } + + _downloadProgress({ bytes, totalRead, totalBytes, done }) { + + //console.log(done, (totalRead/totalBytes*100).toFixed(2) ); + } + + get _downloadMarkKey() { + return `download-start-${this._sessionId}`; + } + _getDownloadStartTime() { + return performance.getEntriesByName(this._downloadMarkKey)[0].startTime; + } + + // outputBuffer() { + // const { src, buffer, numberOfChannels, channelData } = this.audioBufferNodes[this._bufferPosition] + + // src.onended = () => { + // this._bufferPosition++ + + // this.outputBuffer() + // } + + // for (let c = 0; c < numberOfChannels; c++) { + // buffer.copyToChannel(channelData[c], c); + // } + + // src.buffer = buffer + // src.connect(this.audioContext.destination) + + // src.start(0) + // } + + pushToBuffer({ channelData, length, numberOfChannels, sampleRate }) { + const buffer = this.audioContext.createBuffer(numberOfChannels, length, sampleRate) + + for (let c = 0; c < numberOfChannels; c++) { + buffer.copyToChannel(channelData[c], c); + } + } +} \ No newline at end of file diff --git a/packages/app/src/layout.jsx b/packages/app/src/layout.jsx index 2ed1614f..b7164895 100755 --- a/packages/app/src/layout.jsx +++ b/packages/app/src/layout.jsx @@ -135,7 +135,7 @@ export default class Layout extends React.PureComponent { return this.layoutInterface.toggleRootContainerClassname("page-panel-spacer", to) }, toggleRootContainerClassname: (classname, to) => { - const root = document.getElementById("root") + const root = document.documentElement if (!root) { console.error("root not found") diff --git a/packages/app/src/pages/live/index.less b/packages/app/src/pages/live/index.less index 6c211340..5c6b42a7 100755 --- a/packages/app/src/pages/live/index.less +++ b/packages/app/src/pages/live/index.less @@ -1,7 +1,7 @@ @panel-width: 500px; @chatbox-header-height: 50px; -#root { +html { &.mobile { .livestream { flex-direction: column; diff --git a/packages/app/src/pages/play/index.less b/packages/app/src/pages/play/index.less index a9e0d91e..cd7b0b72 100755 --- a/packages/app/src/pages/play/index.less +++ b/packages/app/src/pages/play/index.less @@ -1,4 +1,4 @@ -#root { +html { &.mobile { .playlist_view { display: flex; diff --git a/packages/app/src/theme/mobile.less b/packages/app/src/theme/mobile.less index 84ac4910..2ea74870 100755 --- a/packages/app/src/theme/mobile.less +++ b/packages/app/src/theme/mobile.less @@ -1,6 +1,6 @@ @import "theme/vars.less"; -#root { +html { &.mobile { &.centered-content { .app_layout {