From e05384a8937a920fee7236143e9cadd815aa7c56 Mon Sep 17 00:00:00 2001 From: Keon Kim Date: Sat, 30 May 2026 06:36:56 +0900 Subject: [PATCH] Add Agora video room flow --- README.md | 16 ++++ base/templates/base/lobby.html | 11 ++- base/templates/base/main.html | 10 +- base/templates/base/room.html | 33 +++++-- base/urls.py | 4 +- base/views.py | 12 ++- mychart/settings.py | 4 + requirements.txt | 1 + static/js/stream.js | 139 +++++++++++++++++++++++++++ static/styles/main.css | 168 ++++++++++++++++++++++++++++++++- 10 files changed, 383 insertions(+), 15 deletions(-) create mode 100644 README.md create mode 100644 requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2822c3 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# MyChart Calls + +A Django video-call demo that lets multiple users join the same room through the Agora Web SDK. + +## Setup + +```bash +python -m pip install -r requirements.txt +export AGORA_APP_ID="your-agora-app-id" +export AGORA_TEMP_TOKEN="optional-room-token" +python manage.py runserver +``` + +Open `http://127.0.0.1:8000`, enter a room name and display name, then share the same room name with other participants. + +`AGORA_TEMP_TOKEN` can be empty for tokenless Agora projects. If your Agora project requires tokens, generate a temporary token for the channel and set it before starting Django. diff --git a/base/templates/base/lobby.html b/base/templates/base/lobby.html index a390c32..9cce5b4 100644 --- a/base/templates/base/lobby.html +++ b/base/templates/base/lobby.html @@ -9,6 +9,15 @@

Welcome to MyChart

A group calling application for you

+
+ + + + + + + +
-{% endblock content %} \ No newline at end of file +{% endblock content %} diff --git a/base/templates/base/main.html b/base/templates/base/main.html index 95642cb..2cf3aae 100644 --- a/base/templates/base/main.html +++ b/base/templates/base/main.html @@ -9,11 +9,17 @@ + {% block head %} + {% endblock head %} -

Main Template

+ {% block content %} {% endblock content %} + {% block scripts %} + {% endblock scripts %} - \ No newline at end of file + diff --git a/base/templates/base/room.html b/base/templates/base/room.html index 2061dec..55dd0ec 100644 --- a/base/templates/base/room.html +++ b/base/templates/base/room.html @@ -1,18 +1,37 @@ {% extends 'base/main.html' %} +{% load static %} + +{% block head %} + +{% endblock head %} {% block content %} -
-
-

Room Name:

+
+
+
+

Room

+

{{ room_name }}

+
+
Connecting...
-
-
My Name
-
-
+
+ +
+ + + Leave
{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/base/urls.py b/base/urls.py index eee9bea..f9fa4d2 100644 --- a/base/urls.py +++ b/base/urls.py @@ -2,6 +2,6 @@ from . import views urlpatterns = [ - path("", views.lobby), - path("room/", views.room), + path("", views.lobby, name="lobby"), + path("room/", views.room, name="room"), ] diff --git a/base/views.py b/base/views.py index bf673c6..7dc2766 100644 --- a/base/views.py +++ b/base/views.py @@ -1,7 +1,17 @@ +from django.conf import settings from django.shortcuts import render def lobby(request): return render(request, 'base/lobby.html') def room(request): - return render(request, 'base/room.html') + room_name = request.GET.get('room', 'default-room').strip() or 'default-room' + user_name = request.GET.get('name', 'Guest').strip() or 'Guest' + + context = { + 'room_name': room_name[:80], + 'user_name': user_name[:80], + 'agora_app_id': settings.AGORA_APP_ID, + 'agora_token': settings.AGORA_TEMP_TOKEN, + } + return render(request, 'base/room.html', context) diff --git a/mychart/settings.py b/mychart/settings.py index 087e094..f0882e6 100644 --- a/mychart/settings.py +++ b/mychart/settings.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/4.2/ref/settings/ """ +import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -122,6 +123,9 @@ BASE_DIR / 'static' ] +AGORA_APP_ID = os.environ.get('AGORA_APP_ID', '') +AGORA_TEMP_TOKEN = os.environ.get('AGORA_TEMP_TOKEN', '') + # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8039482 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Django==4.2.1 diff --git a/static/js/stream.js b/static/js/stream.js index e69de29..d7d991d 100644 --- a/static/js/stream.js +++ b/static/js/stream.js @@ -0,0 +1,139 @@ +const roomElement = document.getElementById("room"); +const streamsElement = document.getElementById("video-streams"); +const statusElement = document.getElementById("room-status"); +const cameraButton = document.getElementById("camera-btn"); +const micButton = document.getElementById("mic-btn"); + +const appId = roomElement?.dataset.appId || ""; +const channel = roomElement?.dataset.channel || "default-room"; +const token = roomElement?.dataset.token || null; +const displayName = roomElement?.dataset.userName || "Guest"; + +let client; +let localTracks = []; +let localUid; +let cameraEnabled = true; +let micEnabled = true; + +function setStatus(message, isError = false) { + statusElement.textContent = message; + statusElement.classList.toggle("error", isError); +} + +function createPlayer(uid, name) { + const container = document.createElement("div"); + container.className = "video-container"; + container.id = `user-container-${uid}`; + + const label = document.createElement("div"); + label.className = "username-wrapper"; + const labelText = document.createElement("span"); + labelText.className = "user-name"; + labelText.textContent = name; + label.appendChild(labelText); + + const player = document.createElement("div"); + player.className = "video-player"; + player.id = `user-${uid}`; + + container.append(label, player); + streamsElement.appendChild(container); + return player; +} + +function removePlayer(uid) { + document.getElementById(`user-container-${uid}`)?.remove(); +} + +function updateParticipantCount() { + const count = streamsElement.querySelectorAll(".video-container").length; + setStatus(`${count} participant${count === 1 ? "" : "s"} connected`); +} + +async function handleUserPublished(user, mediaType) { + await client.subscribe(user, mediaType); + + if (!document.getElementById(`user-container-${user.uid}`)) { + createPlayer(user.uid, `Guest ${user.uid}`); + } + + if (mediaType === "video") { + user.videoTrack.play(`user-${user.uid}`); + } + + if (mediaType === "audio") { + user.audioTrack.play(); + } + + updateParticipantCount(); +} + +function handleUserLeft(user) { + removePlayer(user.uid); + updateParticipantCount(); +} + +async function joinRoom() { + if (!roomElement) return; + + if (!appId) { + setStatus("Set AGORA_APP_ID and optional AGORA_TEMP_TOKEN to enable live video.", true); + return; + } + + if (!window.AgoraRTC) { + setStatus("Agora Web SDK failed to load.", true); + return; + } + + client = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" }); + client.on("user-published", handleUserPublished); + client.on("user-left", handleUserLeft); + + localUid = await client.join(appId, channel, token, null); + localTracks = await AgoraRTC.createMicrophoneAndCameraTracks(); + + createPlayer(localUid, displayName); + localTracks[1].play(`user-${localUid}`); + + await client.publish(localTracks); + updateParticipantCount(); +} + +async function leaveRoom() { + for (const track of localTracks) { + track.stop(); + track.close(); + } + + if (client) { + await client.leave(); + } +} + +cameraButton?.addEventListener("click", async () => { + const videoTrack = localTracks[1]; + if (!videoTrack) return; + + cameraEnabled = !cameraEnabled; + await videoTrack.setEnabled(cameraEnabled); + cameraButton.textContent = cameraEnabled ? "Camera on" : "Camera off"; + cameraButton.classList.toggle("is-off", !cameraEnabled); +}); + +micButton?.addEventListener("click", async () => { + const audioTrack = localTracks[0]; + if (!audioTrack) return; + + micEnabled = !micEnabled; + await audioTrack.setEnabled(micEnabled); + micButton.textContent = micEnabled ? "Mic on" : "Mic off"; + micButton.classList.toggle("is-off", !micEnabled); +}); + +window.addEventListener("beforeunload", leaveRoom); + +joinRoom().catch((error) => { + console.error(error); + setStatus("Could not join the video room. Check Agora credentials and browser permissions.", true); +}); diff --git a/static/styles/main.css b/static/styles/main.css index 698e4b0..a8fa9ec 100644 --- a/static/styles/main.css +++ b/static/styles/main.css @@ -1,10 +1,32 @@ :root { - --shadow:0 4px 6px -1px rgb(0,0,0,0.1), 0 2px 4px -1px rgb(0,0,0,0,06); + --shadow: 0 4px 20px rgba(32, 42, 71, 0.12); + --blue: #4b5fac; + --blue-dark: #24386f; + --surface: #ffffff; + --muted: #69748a; } body { background-color: rgba(232,233,239,1); + color: #1f2733; + font-family: Arial, sans-serif; + margin: 0; +} + +.site-header { + align-items: center; + background: var(--surface); + box-shadow: var(--shadow); + display: flex; + height: 64px; + padding: 0 32px; +} + +.brand-link { + color: var(--blue-dark); + font-weight: 700; + text-decoration: none; } #logo { @@ -25,6 +47,92 @@ body { transform: translate(-50%, -50%); } +#form-container h1 { + margin-bottom: 6px; + text-align: center; +} + +#form-container p { + color: var(--muted); + margin-top: 0; + text-align: center; +} + +#room-form { + display: grid; + gap: 12px; + margin-top: 24px; +} + +#room-form label { + color: var(--blue-dark); + font-size: 14px; + font-weight: 700; +} + +#room-form input { + border: 1px solid rgba(75, 95, 172, 0.35); + border-radius: 5px; + font: inherit; + padding: 12px; +} + +#room-form button, +#controls button, +#leave-btn { + background: var(--blue); + border: 0; + border-radius: 5px; + color: #fff; + cursor: pointer; + display: inline-block; + font: inherit; + font-weight: 700; + padding: 12px 18px; + text-align: center; + text-decoration: none; +} + +#room-form button:hover, +#controls button:hover, +#leave-btn:hover { + background: var(--blue-dark); +} + +#room-header { + align-items: center; + display: flex; + justify-content: space-between; + margin: 24px auto 16px; + width: 75%; +} + +#room-header h1 { + margin: 0; +} + +.eyebrow { + color: var(--muted); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + margin: 0 0 4px; + text-transform: uppercase; +} + +#room-status { + background: var(--surface); + border-radius: 5px; + box-shadow: var(--shadow); + color: var(--blue-dark); + font-weight: 700; + padding: 12px 16px; +} + +#room-status.error { + color: #b42318; +} + #video-streams { display: flex; flex-wrap: wrap; @@ -43,5 +151,61 @@ body { border-radius: 5px; margin: 2px; background-color: rgba(198,202,219,1); + overflow: hidden; + position: relative; +} + +.username-wrapper { + background: rgba(31, 39, 51, 0.75); + border-radius: 4px; + color: #fff; + left: 12px; + padding: 6px 10px; + position: absolute; + top: 12px; + z-index: 2; +} -} \ No newline at end of file +.video-player { + height: 100%; + width: 100%; +} + +#controls { + display: flex; + gap: 12px; + justify-content: center; + margin: 16px auto 32px; + width: 75%; +} + +#controls button.is-off { + background: #69748a; +} + +@media (max-width: 700px) { + #form-container, + #room-header, + #video-streams, + #controls { + width: calc(100% - 32px); + } + + #form-container { + position: static; + transform: none; + margin: 32px auto; + } + + #room-header, + #controls { + align-items: stretch; + flex-direction: column; + } + + .video-container { + flex-basis: 100%; + min-height: 260px; + } +} +}