Initial commit: Replacing repo with local files

This commit is contained in:
2026-02-13 09:22:48 -06:00
commit 4970133b83
62 changed files with 4934 additions and 0 deletions
+85
View File
@@ -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 });
}
}
+46
View File
@@ -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