Initial commit: Replacing repo with local files
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
|
||||
// to get started and then uncomment the line below.
|
||||
// import "./user_socket.js"
|
||||
|
||||
// You can include dependencies in two ways.
|
||||
//
|
||||
// The simplest option is to put them in assets/vendor and
|
||||
// import them using relative paths:
|
||||
//
|
||||
// import "../vendor/some-package.js"
|
||||
//
|
||||
// Alternatively, you can `npm install some-package --prefix assets` and import
|
||||
// them using a path starting with the package name:
|
||||
//
|
||||
// import "some-package"
|
||||
//
|
||||
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
|
||||
// To load it, simply add a second `<link>` to your `root.html.heex` file.
|
||||
|
||||
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
|
||||
import "phoenix_html"
|
||||
// Establish Phoenix Socket and LiveView configuration.
|
||||
import { Socket } from "phoenix"
|
||||
import { LiveSocket } from "phoenix_live_view"
|
||||
import { hooks as colocatedHooks } from "phoenix-colocated/river_connect"
|
||||
import topbar from "../vendor/topbar"
|
||||
|
||||
import { AudioRecorder } from "./hooks/audio_recorder"
|
||||
|
||||
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: { _csrf_token: csrfToken },
|
||||
hooks: { ...colocatedHooks, AudioRecorder },
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" })
|
||||
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
||||
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
|
||||
|
||||
// connect if there are any LiveViews on the page
|
||||
liveSocket.connect()
|
||||
|
||||
// expose liveSocket on window for web console debug logs and latency simulation:
|
||||
// >> liveSocket.enableDebug()
|
||||
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
|
||||
// >> liveSocket.disableLatencySim()
|
||||
window.liveSocket = liveSocket
|
||||
|
||||
// The lines below enable quality of life phoenix_live_reload
|
||||
// development features:
|
||||
//
|
||||
// 1. stream server logs to the browser console
|
||||
// 2. click on elements to jump to their definitions in your code editor
|
||||
//
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
window.addEventListener("phx:live_reload:attached", ({ detail: reloader }) => {
|
||||
// Enable server log streaming to client.
|
||||
// Disable with reloader.disableServerLogs()
|
||||
reloader.enableServerLogs()
|
||||
|
||||
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
|
||||
//
|
||||
// * click with "c" key pressed to open at caller location
|
||||
// * click with "d" key pressed to open at function component definition location
|
||||
let keyDown
|
||||
window.addEventListener("keydown", e => keyDown = e.key)
|
||||
window.addEventListener("keyup", _e => keyDown = null)
|
||||
window.addEventListener("click", e => {
|
||||
if (keyDown === "c") {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
reloader.openEditorAtCaller(e.target)
|
||||
} else if (keyDown === "d") {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
reloader.openEditorAtDef(e.target)
|
||||
}
|
||||
}, true)
|
||||
|
||||
window.liveReloader = reloader
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import socket from "../user_socket"
|
||||
|
||||
export const AudioRecorder = {
|
||||
mounted() {
|
||||
this.mediaRecorder = null;
|
||||
this.isRecording = false;
|
||||
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this.queue = [];
|
||||
this.sourceBuffer = null;
|
||||
this.mediaSource = null;
|
||||
this.liveAudio = new Audio();
|
||||
this.liveAudio.autoplay = true;
|
||||
|
||||
// Join channel
|
||||
this.channel = socket.channel("audio:lobby", {})
|
||||
this.channel.join()
|
||||
.receive("ok", resp => { console.log("Joined audio lobby", resp) })
|
||||
.receive("error", resp => { console.error("Unable to join audio lobby", resp) })
|
||||
|
||||
// Listen for start of others' audio
|
||||
this.channel.on("audio_start", (payload) => {
|
||||
this.prepareLivePlayback();
|
||||
});
|
||||
|
||||
// Listen for incoming chunks (others talking)
|
||||
this.channel.on("audio_chunk", (payload) => {
|
||||
this.queueChunk(payload.data)
|
||||
})
|
||||
|
||||
// UI Events
|
||||
const btn = this.el.querySelector("button#record-btn");
|
||||
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (this.isRecording) {
|
||||
this.stopRecording();
|
||||
} else {
|
||||
this.startRecording(crypto.randomUUID());
|
||||
}
|
||||
});
|
||||
|
||||
// External event to play live stream (from UI button)
|
||||
this.el.addEventListener("play_live", () => {
|
||||
console.log("Play Live triggered");
|
||||
console.log("MediaSource state:", this.mediaSource ? this.mediaSource.readyState : "null");
|
||||
console.log("SourceBuffer state:", this.sourceBuffer ? (this.sourceBuffer.updating ? "updating" : "ready") : "null");
|
||||
console.log("Buffer Queue length:", this.queue.length);
|
||||
|
||||
if (this.liveAudio) {
|
||||
console.log("Attempting to play live audio...");
|
||||
this.liveAudio.play()
|
||||
.then(() => console.log("Live audio playing started"))
|
||||
.catch(err => console.error("Error playing live audio", err));
|
||||
} else {
|
||||
console.error("Live audio object not initialized");
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
prepareLivePlayback() {
|
||||
console.log("Preparing live playback MediaSource...");
|
||||
this.queue = [];
|
||||
this.mediaSource = new MediaSource();
|
||||
this.liveAudio.src = URL.createObjectURL(this.mediaSource);
|
||||
|
||||
this.mediaSource.addEventListener('sourceopen', () => {
|
||||
console.log("MediaSource open");
|
||||
try {
|
||||
if (this.mediaSource.sourceBuffers.length > 0) return;
|
||||
|
||||
this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/webm; codecs="opus"');
|
||||
this.sourceBuffer.mode = 'sequence';
|
||||
|
||||
this.sourceBuffer.addEventListener('updateend', () => {
|
||||
this.processQueue();
|
||||
});
|
||||
console.log("SourceBuffer added and mode set to sequence");
|
||||
} catch (e) {
|
||||
console.error("Error adding SourceBuffer", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
queueChunk(base64) {
|
||||
// console.log("Received chunk, queueing...");
|
||||
const binaryString = window.atob(base64);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
this.queue.push(bytes);
|
||||
this.processQueue();
|
||||
},
|
||||
|
||||
processQueue() {
|
||||
if (!this.sourceBuffer) {
|
||||
// console.warn("No SourceBuffer available to process queue");
|
||||
return;
|
||||
}
|
||||
if (this.sourceBuffer.updating) {
|
||||
// console.log("SourceBuffer updating, waiting...");
|
||||
return;
|
||||
}
|
||||
if (this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = this.queue.shift();
|
||||
try {
|
||||
this.sourceBuffer.appendBuffer(chunk);
|
||||
// console.log("Appended buffer. Queue size:", this.queue.length);
|
||||
} catch (e) {
|
||||
console.error("Error appending to SourceBuffer", e);
|
||||
}
|
||||
},
|
||||
|
||||
startRecording(id) {
|
||||
console.log("Starting recording...", id);
|
||||
if (this.isRecording) return;
|
||||
this.isRecording = true;
|
||||
this.currentId = id;
|
||||
this.startTime = Date.now();
|
||||
this.el.setAttribute("data-recording", "true");
|
||||
|
||||
// Notify channel we are starting
|
||||
this.channel.push("audio_start", { id: this.currentId });
|
||||
|
||||
navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
.then(stream => {
|
||||
console.log("Microphone access granted");
|
||||
this.mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
|
||||
|
||||
this.mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0 && this.channel) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
this.channel.push("audio_chunk", reader.result);
|
||||
};
|
||||
reader.readAsArrayBuffer(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.mediaRecorder.start(100); // chunk every 100ms
|
||||
this.pushEvent("recording_started", { id });
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error accessing microphone", err);
|
||||
this.isRecording = false;
|
||||
});
|
||||
},
|
||||
|
||||
stopRecording() {
|
||||
console.log("Stopping recording...");
|
||||
if (!this.isRecording) return;
|
||||
this.isRecording = false;
|
||||
this.el.removeAttribute("data-recording");
|
||||
|
||||
if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
|
||||
this.mediaRecorder.stop();
|
||||
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
const duration = Date.now() - this.startTime;
|
||||
this.channel.push("audio_end", { id: this.currentId, duration_ms: duration });
|
||||
this.pushEvent("recording_stopped", { id: this.currentId, duration: duration });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// NOTE: The contents of this file will only be executed if
|
||||
// you uncomment its entry in "assets/js/app.js".
|
||||
|
||||
// Bring in the socket library
|
||||
import {Socket} from "phoenix"
|
||||
|
||||
// And connect to the path in "lib/river_connect_web/endpoint.ex". We pass the
|
||||
// token for authentication. Read below how it should be used.
|
||||
let params = {user_id: window.userId || "guest-" + Math.floor(Math.random() * 1000)}
|
||||
let socket = new Socket("/socket", {params: params})
|
||||
|
||||
// When you connect, you'll often need to authenticate the client.
|
||||
// For example, imagine you have an authentication plug, `MyAuth`,
|
||||
// which authenticates the session and assigns a `:current_user`.
|
||||
// If the current user exists you can set a own_id in the window
|
||||
// object in root.html.heex:
|
||||
//
|
||||
// <script>window.userToken = "<%= assigns[:user_token] %>";</script>
|
||||
//
|
||||
// Now you need to pass this token to JavaScript. You can do so
|
||||
// inside the socket implementation in "lib/river_connect_web/channels/user_socket.ex":
|
||||
//
|
||||
// def connect(%{"token" => token}, socket, _connect_info) do
|
||||
// # max_age: 1209600 is equivalent to two weeks in seconds
|
||||
// case Phoenix.Token.verify(socket, "user socket", token, max_age: 1_209_600) do
|
||||
// {:ok, user_id} ->
|
||||
// {:ok, assign(socket, :user, user_id)}
|
||||
// {:error, reason} ->
|
||||
// :error
|
||||
// end
|
||||
// end
|
||||
//
|
||||
// Finally, connect to the socket:
|
||||
socket.connect()
|
||||
|
||||
// Now that you are connected, you can join channels with a topic.
|
||||
// Let's assume you have a channel with a topic named `room` and the
|
||||
// subtopic is its id - in this case 42:
|
||||
//
|
||||
// let channel = socket.channel("room:42", {})
|
||||
// channel.join()
|
||||
// .receive("ok", resp => { console.log("Joined successfully", resp) })
|
||||
// .receive("error", resp => { console.log("Unable to join", resp) })
|
||||
//
|
||||
// export default socket to import it in other files
|
||||
export default socket
|
||||
Reference in New Issue
Block a user