mirror of https://github.com/rsp2k/rtc_demo.git
DjangoCon version
This commit is contained in:
commit
e2bdc2f8ba
|
@ -0,0 +1,14 @@
|
|||
.settings
|
||||
.project
|
||||
.pydevproject
|
||||
.env
|
||||
*.pyc
|
||||
*.pyo
|
||||
.vscode
|
||||
!__init__.py
|
||||
.code-workspace.code-workspace
|
||||
.workspace.code-workspace
|
||||
workspace.code-workspace
|
||||
__pycache__
|
||||
db.sqlite3
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
WebRTC demo using Django, Channels, HTMX, and Coturn
|
||||
|
||||
This is a Proof-of-Concept demonstration of WebRTC sharing video using Django, Channels, and HTMX as a signalling service, with Coturn providing STUN and TURN support.
|
||||
|
||||
Running this:
|
||||
|
||||
If you're just looking to run this in a local environment, running it using runserver does work.
|
||||
However, getting other machines to connect to your server can be problematic due to the requirement that some of the protocols involved will only work across https. Therefore, for anything other than just
|
||||
trying it out on your own system, you'll want to deploy this to a server.
|
||||
|
||||
Here's a summary of what's needed to get this running.
|
||||
|
||||
Keep in mind that this is a full deployment, so everything you're used to doing applies here.
|
||||
(Note: A full detailed description of a deployment is beyond the scope of this readme.)
|
||||
|
||||
- Create your virtual environment
|
||||
(My version was built using Python 3.12)
|
||||
|
||||
- Install packages listed in the requirements.txt file
|
||||
|
||||
- Install and run redis or a true redis-compatible server for the channels layer
|
||||
(I have only ever used redis. This is untested with any of the forks.)
|
||||
|
||||
- Adjust your settings as appropriate. At a minimum, you will probably want to change:
|
||||
- CSRF_TRUSTED_ORIGINS
|
||||
- ALLOWED_HOSTS
|
||||
- STATIC_ROOT
|
||||
|
||||
- Run `manage.py migrate` to initialize the database
|
||||
|
||||
- Configure nginx.
|
||||
- See the sample file `rtc` in the nginx directory. You will need SSL certificates.
|
||||
|
||||
- Set up Daphne and gunicorn to run your project
|
||||
- I use `supervisor` as the process manager. A sample configuration file is in the etc/supervisor/conf.d directory
|
||||
|
||||
- Deploy your Django project
|
||||
- Copy it to an appropriate directory
|
||||
- Run `collectstatic`
|
||||
|
||||
- Configure coturn - sample file in etc/turnserver.conf
|
||||
- You will need a non-local location for your coturn instance if you're behind a NAT
|
||||
- You can use a public STUN server, but TURN should be on a public IP address
|
||||
- (This is not an issue if everyone using this is behind the same NAT)
|
Binary file not shown.
|
@ -0,0 +1,44 @@
|
|||
server {
|
||||
server_name *your.server.dns.name*;
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
default_type text/html;
|
||||
ssi on;
|
||||
ssl_certificate */etc/your/fullchain.pem*;
|
||||
ssl_certificate_key */etc/your/privkey.pem*;
|
||||
|
||||
root /var/www/rtc/;
|
||||
|
||||
location /static/ {
|
||||
alias /var/www/rtc/;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
include /etc/nginx/proxy_params;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://127.0.0.1:8001/ws/;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_redirect off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
server {
|
||||
if ($host = demo.kww.us) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
server_name demo.kww.us;
|
||||
return 404; # managed by Certbot
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
[group:rtc]
|
||||
programs=clear,django,daphne
|
||||
priority=100
|
||||
|
||||
[program:clear]
|
||||
command = /home/rtc/ve/bin/python /home/rtc/rtc/manage.py channel_cleanup
|
||||
numprocs = 1
|
||||
directory = /home/rtc/rtc_demo
|
||||
umask = 002
|
||||
autostart = true
|
||||
startsecs = 2
|
||||
startretries = 3
|
||||
autorestart = unexpected
|
||||
exitcodes = 0
|
||||
stopsignal = QUIT
|
||||
stopwaitsecs = 10
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
user = rtc
|
||||
redirect_stderr = false
|
||||
|
||||
[program:django]
|
||||
command = /home/rtc/ve/bin/gunicorn -b 127.0.0.1:8000 -w 4 rtc_demo.wsgi:application
|
||||
numprocs = 1
|
||||
directory = /home/rtc/rtc_demo
|
||||
umask = 002
|
||||
autostart = true
|
||||
startsecs = 2
|
||||
startretries = 3
|
||||
autorestart = unexpected
|
||||
exitcodes = 0
|
||||
stopsignal = QUIT
|
||||
stopwaitsecs = 10
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
user = rtc
|
||||
redirect_stderr = false
|
||||
|
||||
[program:daphne]
|
||||
command = /home/rtc/ve/bin/daphne -u /run/rtc/rtc-daphne.sock rtc_demo.asgi:application
|
||||
numprocs = 1
|
||||
directory = /home/rtc/rtc_demo
|
||||
umask = 002
|
||||
autostart = true
|
||||
startsecs = 2
|
||||
startretries = 3
|
||||
autorestart = unexpected
|
||||
exitcodes = 0
|
||||
stopsignal = QUIT
|
||||
stopwaitsecs = 10
|
||||
stopasgroup = true
|
||||
killasgroup = true
|
||||
user = rtc
|
||||
redirect_stderr = false
|
|
@ -0,0 +1,19 @@
|
|||
listening-ip=*your IPv4 address*
|
||||
listening-ip=*your IPv6 address*
|
||||
verbose
|
||||
user=dcus:dcus2024
|
||||
lt-cred-mech
|
||||
realm=rtc.kww.us
|
||||
no-tls
|
||||
no-dtls
|
||||
log-file=/var/log/coturn.log
|
||||
simple-log
|
||||
new-log-timestamp
|
||||
proc-user=turnserver
|
||||
proc-group=turnserver
|
||||
cli-password=cli-secure-password
|
||||
web-admin-ip=127.0.0.1
|
||||
web-admin-port=8001
|
||||
no-tlsv1
|
||||
no-tlsv1_1
|
||||
no-tlsv1_2
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rtc_demo.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
|
@ -0,0 +1,32 @@
|
|||
asgiref==3.8.1
|
||||
attrs==24.2.0
|
||||
autobahn==24.4.2
|
||||
Automat==24.8.1
|
||||
cffi==1.17.1
|
||||
channels==4.1.0
|
||||
channels-redis==4.2.0
|
||||
constantly==23.10.4
|
||||
crispy-bootstrap5==2024.2
|
||||
cryptography==43.0.1
|
||||
daphne==4.1.2
|
||||
Django==5.1.1
|
||||
django-channels-presence-4.0==1.1.2
|
||||
django-crispy-forms==2.3
|
||||
gunicorn==23.0.0
|
||||
hyperlink==21.0.0
|
||||
idna==3.8
|
||||
incremental==24.7.2
|
||||
msgpack==1.0.8
|
||||
pyasn1==0.6.1
|
||||
pyasn1_modules==0.4.1
|
||||
pycparser==2.22
|
||||
pyOpenSSL==24.2.1
|
||||
redis==5.0.8
|
||||
service-identity==24.1.0
|
||||
setuptools==74.1.2
|
||||
sqlparse==0.5.1
|
||||
Twisted==24.7.0
|
||||
txaio==23.1.1
|
||||
typing_extensions==4.12.2
|
||||
wheel==0.44.0
|
||||
zope.interface==7.0.3
|
|
@ -0,0 +1,13 @@
|
|||
from channels_presence.models import Presence, Room
|
||||
from django.contrib import admin
|
||||
|
||||
admin.site.register(Room)
|
||||
|
||||
@admin.register(Presence)
|
||||
class PresenceAdmin(admin.ModelAdmin):
|
||||
list_display = ['user', 'room_name', 'channel_name']
|
||||
list_select_related = ['room']
|
||||
|
||||
@admin.display
|
||||
def room_name(self, obj):
|
||||
return obj.room.channel_name
|
|
@ -0,0 +1,210 @@
|
|||
from channels.db import database_sync_to_async
|
||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from channels_presence.models import Presence, Room
|
||||
from django.db.models import Case, F, Q, When, Value
|
||||
from django.db.models.functions import Concat, Right
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
|
||||
class RtcConsumer(AsyncJsonWebsocketConsumer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.function_dict = {
|
||||
'rtc': self._rtc,
|
||||
'join': self._join,
|
||||
'hangup': self._hangup,
|
||||
}
|
||||
|
||||
@database_sync_to_async
|
||||
def _presence_connect(self, rtc_name):
|
||||
# Remove all existing connections to this room for this user.
|
||||
Presence.objects.leave_all(self.channel_name)
|
||||
self.room = Room.objects.add(rtc_name, self.channel_name, self.scope["user"])
|
||||
|
||||
@database_sync_to_async
|
||||
def _presence_disconnect(self, channel_name):
|
||||
Presence.objects.leave_all(channel_name)
|
||||
|
||||
@database_sync_to_async
|
||||
def _presence_touch(self):
|
||||
Presence.objects.touch(self.channel_name)
|
||||
|
||||
@database_sync_to_async
|
||||
def _leave_room(self, room):
|
||||
Room.objects.remove(room, self.channel_name)
|
||||
|
||||
@property
|
||||
def short_name(self):
|
||||
return 'peer-'+self.channel_name[-6:]
|
||||
|
||||
@property
|
||||
def user_name(self):
|
||||
return self.scope['user'].first_name or self.scope['user'].username
|
||||
|
||||
def _create_self_div(self):
|
||||
return render_to_string(
|
||||
'rtc/video_panel.html', {
|
||||
'id': f'{self.short_name}',
|
||||
'user_name': self.user_name
|
||||
}
|
||||
)
|
||||
|
||||
def _create_other_div(self, occupant):
|
||||
return render_to_string(
|
||||
'rtc/video_panel.html', {
|
||||
'id': f'{occupant["short_name"]}',
|
||||
'user_name': occupant['user_name']
|
||||
}
|
||||
)
|
||||
|
||||
async def connect(self):
|
||||
rtc_name = self.scope['url_route']['kwargs']['rtc_name']
|
||||
await self._presence_disconnect(self.channel_name)
|
||||
self.rtc_call = 'rtc_%s' % rtc_name
|
||||
await self._presence_connect(self.rtc_call)
|
||||
|
||||
await self.accept()
|
||||
await self.send_json({
|
||||
'rtc': {'type': 'connect', 'channel_name': self.channel_name}
|
||||
})
|
||||
await self.send_json({
|
||||
'html': render_to_string('rtc/header.html', {'room': rtc_name})
|
||||
})
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
# Leave room group
|
||||
await self._hangup()
|
||||
await self._presence_disconnect(self.channel_name)
|
||||
# Send "remove video" to all_but_me
|
||||
await self._all_but_me(self.rtc_call,
|
||||
{
|
||||
'type': 'rtc_message',
|
||||
'rtc': {
|
||||
'type': 'disconnected',
|
||||
'channel_name': self.channel_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Receive message from WebSocket
|
||||
async def receive_json(self, content):
|
||||
await self._presence_touch()
|
||||
|
||||
for message_key, message_value in content.items():
|
||||
if message_key in self.function_dict:
|
||||
await self.function_dict[message_key](message_value)
|
||||
|
||||
async def _hangup(self, hangup=None):
|
||||
await self._leave_room(self.rtc_call)
|
||||
|
||||
await self._all_but_me(self.rtc_call,
|
||||
{
|
||||
'type': 'rtc_message',
|
||||
'rtc': {
|
||||
'type': 'disconnected',
|
||||
'channel_name': self.channel_name,
|
||||
}
|
||||
}
|
||||
)
|
||||
self.rtc_call = None
|
||||
|
||||
async def _join(self, rtc_call):
|
||||
if self.rtc_call:
|
||||
await self._leave_room(self.rtc_call)
|
||||
|
||||
self.rtc_call = rtc_call
|
||||
await self.send_json({
|
||||
'html': render_to_string('rtc/header.html', {'room': rtc_call})
|
||||
})
|
||||
|
||||
# Send list of connected peers (occupants) to self
|
||||
occupants = await self._room_occupants(self.rtc_call)
|
||||
all_divs = "\n".join([
|
||||
self._create_other_div(occupant)
|
||||
for occupant in occupants
|
||||
if occupant['channel_name'] != self.channel_name
|
||||
])
|
||||
|
||||
#NOTE: The html must be sent before the connections
|
||||
# Otherwise, the connection functions in the client
|
||||
# will be trying to access the div before it exists
|
||||
|
||||
await self.send_json({
|
||||
'html': all_divs
|
||||
})
|
||||
await self.send_json({
|
||||
'rtc': {
|
||||
'type': 'others',
|
||||
'ids': occupants,
|
||||
},
|
||||
})
|
||||
|
||||
await self._all_but_me(self.rtc_call,
|
||||
{
|
||||
'type': 'html_message',
|
||||
'html': self._create_self_div()
|
||||
}
|
||||
)
|
||||
|
||||
# Send self.channel_name to all connected peers
|
||||
await self._all_but_me(self.rtc_call,
|
||||
{
|
||||
'type': 'rtc_message',
|
||||
'rtc': {
|
||||
'type': 'other',
|
||||
'channel_name': self.channel_name,
|
||||
'user_name': self.user_name,
|
||||
'short_name': self.short_name
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await self._presence_connect(self.rtc_call)
|
||||
|
||||
async def _rtc(self, rtc):
|
||||
# If there's a recipient, send to it.
|
||||
if 'recipient' in rtc:
|
||||
await self.channel_layer.send(
|
||||
rtc['recipient'], {
|
||||
'type': 'rtc_message',
|
||||
'rtc': rtc,
|
||||
}
|
||||
)
|
||||
else:
|
||||
await self._all_but_me(
|
||||
self.rtc_call, {
|
||||
'type': 'rtc_message',
|
||||
'rtc': rtc
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@database_sync_to_async
|
||||
def _room_occupants(self, room):
|
||||
return list(Presence.objects.filter(
|
||||
room__channel_name=room
|
||||
).annotate(
|
||||
user_name=Case(
|
||||
When(~Q(user__first_name=''), then=F('user__first_name')),
|
||||
default=F('user__username')
|
||||
),
|
||||
short_name=Concat(Value('peer-'), Right('channel_name', 6))
|
||||
).values('channel_name', 'user_name', 'short_name'))
|
||||
|
||||
async def _all_but_me(self, room, message):
|
||||
occupants = await self._room_occupants(room)
|
||||
for occupant in occupants:
|
||||
if occupant['channel_name'] != self.channel_name:
|
||||
await self.channel_layer.send(
|
||||
occupant['channel_name'], message
|
||||
)
|
||||
|
||||
async def rtc_message(self, event):
|
||||
# Send message to WebSocket
|
||||
await self.send_json({
|
||||
'rtc': event['rtc']
|
||||
})
|
||||
|
||||
async def html_message(self, event):
|
||||
# Send message to WebSocket
|
||||
await self.send_json(event)
|
|
@ -0,0 +1,27 @@
|
|||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
class LoginForm(forms.Form):
|
||||
screen_name = forms.CharField(label="First name (or nickname)", max_length=12)
|
||||
name = forms.CharField(label="Real name", max_length=50)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
screen_name = self.cleaned_data.get('screen_name', '')
|
||||
name = self.cleaned_data.get('name', '')
|
||||
self.user = None
|
||||
|
||||
found_name = User.objects.filter(username=name)
|
||||
if found_name:
|
||||
if found_name.exclude(first_name=screen_name):
|
||||
self.add_error("name",
|
||||
"The name exists but does not match the screen name used"
|
||||
)
|
||||
else:
|
||||
self.user = found_name.first()
|
||||
|
||||
if screen_name == name:
|
||||
self.add_error("screen_name",
|
||||
"Do not use your real name as your screen name"
|
||||
)
|
|
@ -0,0 +1,28 @@
|
|||
import asyncio
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from channels.db import database_sync_to_async
|
||||
from channels_presence.models import Room
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Periodically runs the channel presence clean-up commands"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
asyncio.run(self.do_clean_up())
|
||||
|
||||
async def do_clean_up(self):
|
||||
await asyncio.gather(
|
||||
self.prune_rooms(),
|
||||
self.prune_presences()
|
||||
)
|
||||
|
||||
async def prune_rooms(self):
|
||||
while True:
|
||||
await database_sync_to_async(Room.objects.prune_rooms)()
|
||||
await asyncio.sleep(1800)
|
||||
|
||||
async def prune_presences(self):
|
||||
while True:
|
||||
await database_sync_to_async(Room.objects.prune_presences)()
|
||||
await asyncio.sleep(600)
|
|
@ -0,0 +1,8 @@
|
|||
# chat/routing.py
|
||||
from django.urls import re_path
|
||||
|
||||
from . import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r'ws/(?P<rtc_name>\w+)/$', consumers.RtcConsumer.as_asgi()),
|
||||
]
|
|
@ -0,0 +1,3 @@
|
|||
<div id="header">
|
||||
<h4>{{room}}</h4>
|
||||
</div>
|
|
@ -0,0 +1,40 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% block page_scripts %}
|
||||
<script src="{% static 'lib/htmx.org/dist/htmx.js' %}"></script>
|
||||
<script src="{% static 'lib/htmx.org/dist/ext/ws.js' %}"></script>
|
||||
<script src="{% static 'js/tr.js' %}"></script>
|
||||
<script src="{% static 'lib/adapter/adapter-latest.js' %}"></script>
|
||||
{% endblock page_scripts %}
|
||||
{% block app_content %}
|
||||
<div id="websocket-div" ws-connect="/ws/lobby/" hx-ext="tr-ext, ws">
|
||||
<main class="videoContent">
|
||||
<div id="videos" class="row">
|
||||
<div class="col-sm-4 col-md-3 col-lg-3 col-xl-2">
|
||||
<button class="join" type="button" id="call-button" hx-on:click="handleCallButton(event)">Join Call</button>
|
||||
<div id="header">
|
||||
</div>
|
||||
<div id="self-div">
|
||||
<figure id="self">
|
||||
<video autoplay muted playsinline poster="{% static 'img/placeholder.png' %}"></video>
|
||||
<figcaption>You</figcaption>
|
||||
</figure>
|
||||
<button aria-label="Toggle camera" role="switch" aria-checked="true"
|
||||
type="button" id="toggle-cam" hx-on:click="toggleCam(event)">Cam</button>
|
||||
<div class="row" style="margin-top: 10px;">
|
||||
<form method="post" action="/logout/">
|
||||
{% csrf_token %}
|
||||
<button type="submit">Log out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-8 col-md-9 col-lg-9 col-xl-10">
|
||||
<div id="others" class="row">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="{% static 'js/client.js' %}"></script>
|
||||
{% endblock app_content %}
|
|
@ -0,0 +1,9 @@
|
|||
{% load static %}
|
||||
<div id="others" hx-swap-oob="beforeend">
|
||||
<div id="{{id}}-div" class="col-sm-7 col-md-5 col-lg-4 col-xl-3">
|
||||
<figure id="{{id}}">
|
||||
<video autoplay playsinline poster="{% static 'img/placeholder.png' %}"></video>
|
||||
<figcaption>{{user_name}}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,28 @@
|
|||
from django.contrib.auth import login
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from rtc.forms import LoginForm
|
||||
|
||||
def site_login(request):
|
||||
form = LoginForm(request.POST or None)
|
||||
if request.method == "POST":
|
||||
if form.is_valid():
|
||||
if not form.user:
|
||||
user = User(
|
||||
username=form.cleaned_data['name'],
|
||||
first_name=form.cleaned_data['screen_name'],
|
||||
is_active=True,
|
||||
)
|
||||
user.set_password("no password needed")
|
||||
user.save()
|
||||
else:
|
||||
user = form.user
|
||||
login(request, user)
|
||||
return HttpResponseRedirect("/")
|
||||
return render(request, 'registration/login.html', {'form': form})
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
return render(request, 'rtc/index.html', {})
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
class ChannelsPresence(AppConfig):
|
||||
name = 'channels_presence'
|
||||
default_auto_field = 'django.db.models.AutoField'
|
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
ASGI config for rtc_demo project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rtc_demo.settings')
|
||||
django.setup()
|
||||
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from django.core.asgi import get_asgi_application
|
||||
import rtc.routing
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": get_asgi_application(),
|
||||
# Just HTTP for now. (We can add other protocols later.)
|
||||
"websocket": AuthMiddlewareStack(
|
||||
URLRouter(
|
||||
rtc.routing.websocket_urlpatterns
|
||||
)
|
||||
),
|
||||
})
|
|
@ -0,0 +1,124 @@
|
|||
"""
|
||||
Django settings for rtc_demo project.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'django-insecure-apt*tnv=g5f=0a1qtp-s&doqdks*53&ztw***+snqmse^1ymk%'
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ['demo.kww.us']
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
'http://127.0.0.1', 'http://localhost', 'http://MSI.local'
|
||||
]
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'rtc',
|
||||
'daphne',
|
||||
'channels',
|
||||
'crispy_bootstrap5',
|
||||
'crispy_forms',
|
||||
'rtc_demo.apps.ChannelsPresence',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.staticfiles',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'rtc_demo.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'templates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5'
|
||||
CRISPY_TEMPLATE_PACK = 'bootstrap5'
|
||||
|
||||
WSGI_APPLICATION = 'rtc_demo.wsgi.application'
|
||||
ASGI_APPLICATION = 'rtc_demo.asgi.application'
|
||||
CHANNEL_LAYERS = {
|
||||
'default': {
|
||||
'BACKEND': 'channels_redis.core.RedisChannelLayer',
|
||||
'CONFIG': {
|
||||
"hosts": [('127.0.0.1', 6379)],
|
||||
"capacity": 10000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
'TEST': {
|
||||
'NAME': BASE_DIR / 'db_test.sqlite3'
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = []
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/3.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
LOGIN_REDIRECT_URL = 'index'
|
||||
LOGOUT_REDIRECT_URL = '/login/'
|
||||
LOGIN_URL = '/login/'
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
|
||||
STATIC_ROOT = '/var/www/rtc/'
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / "static"
|
||||
]
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|
@ -0,0 +1,28 @@
|
|||
"""rtc_demo URL Configuration
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/3.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.views import LogoutView
|
||||
from django.urls import path
|
||||
|
||||
from rtc.views import index, site_login
|
||||
|
||||
urlpatterns = [
|
||||
path('', index, name="home"),
|
||||
path('login/', site_login, name="login"),
|
||||
path('logout/', LogoutView.as_view(), name="logout"),
|
||||
path('admin/', admin.site.urls),
|
||||
]
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
WSGI config for rtc_demo project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rtc_demo.settings')
|
||||
|
||||
application = get_wsgi_application()
|
|
@ -0,0 +1,65 @@
|
|||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: "Lucida Grande", Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
body {
|
||||
color: #666666;
|
||||
background-color: #c7d8df;
|
||||
}
|
||||
|
||||
video {
|
||||
background-color: #ddd;
|
||||
display: block;
|
||||
width: 160px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
#appContent {
|
||||
margin-top: 20px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.join {
|
||||
background-color: green;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.leave {
|
||||
background-color: #CA0;
|
||||
color: black;
|
||||
}
|
||||
|
||||
#call-button {
|
||||
width: 143px;
|
||||
margin-right: 11px;
|
||||
}
|
||||
|
||||
#header {
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
button[aria-checked="false"] {
|
||||
text-decoration: line-through;
|
||||
color: white;
|
||||
background: red;
|
||||
}
|
||||
|
||||
figure {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
color: #eee;
|
||||
background: rgba(16,16,16,0.6);
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 160px;
|
||||
}
|
||||
|
Binary file not shown.
After Width: | Height: | Size: 33 KiB |
|
@ -0,0 +1,356 @@
|
|||
'use strict';
|
||||
|
||||
const $self = {
|
||||
user_name: "",
|
||||
rtc_config: {
|
||||
iceServers: [
|
||||
{ urls: 'stun:kww.us:3478' },
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{
|
||||
username: "dcus",
|
||||
credential: "dcus2024",
|
||||
urls: 'turn:kww.us:3478',
|
||||
},
|
||||
],
|
||||
iceTransportPolicy: "all"
|
||||
},
|
||||
media_constraints: {
|
||||
audio: false,
|
||||
video: true,
|
||||
},
|
||||
video_constraints: {
|
||||
height: {max:240, min:48, ideal:120},
|
||||
width: {max:320, min:64, ideal:160}
|
||||
},
|
||||
media_stream: new MediaStream(),
|
||||
media_tracks: {},
|
||||
features: { audio: false },
|
||||
ws: null,
|
||||
ws_json: function(data) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
};
|
||||
|
||||
const $others = new Map();
|
||||
|
||||
function element_id(id='self') {
|
||||
if (id === 'self') return '#self';
|
||||
else return $others.get(id).short_name;
|
||||
}
|
||||
|
||||
function signal(recipient, signal) {
|
||||
$self.ws_json(
|
||||
{ 'rtc': {
|
||||
'type': 'signal', 'recipient': recipient,
|
||||
'sender': $self.id, 'signal': signal
|
||||
}
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
function connected({channel_name}) {
|
||||
if (channel_name) {
|
||||
$self.id = channel_name;
|
||||
}
|
||||
};
|
||||
|
||||
function connected_other(conn_info) {
|
||||
initialize_other(conn_info, true);
|
||||
establish_features(conn_info.channel_name);
|
||||
};
|
||||
|
||||
function connected_others({ids}) {
|
||||
for (let conn_info of ids) {
|
||||
if (conn_info.channel_name === $self.id) continue;
|
||||
initialize_other(conn_info, false);
|
||||
establish_features(conn_info.channel_name);
|
||||
}
|
||||
};
|
||||
|
||||
function disconnected_other({channel_name}) {
|
||||
console.log(`disconnected_other: ${channel_name}`);
|
||||
reset_other(channel_name);
|
||||
};
|
||||
|
||||
async function signalled({sender,
|
||||
signal: {candidate, description} }) {
|
||||
|
||||
const id = sender;
|
||||
const other = $others.get(id);
|
||||
const self_state = other.self_states;
|
||||
|
||||
if (description) {
|
||||
if (description.type === '_reset') {
|
||||
retry_connection(id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Work with incoming description
|
||||
const readyForOffer =
|
||||
!self_state.making_offer &&
|
||||
(other.connection.signalingState === 'stable'
|
||||
|| self_state.remote_answer_pending);
|
||||
|
||||
const offerCollision = description.type === 'offer' && !readyForOffer;
|
||||
|
||||
self_state.ignoring_offer = !self_state.is_polite && offerCollision;
|
||||
|
||||
if (self_state.ignoring_offer) {
|
||||
return;
|
||||
}
|
||||
self_state.remote_answer_pending = description.type === 'answer';
|
||||
|
||||
try {
|
||||
await other.connection.setRemoteDescription(description);
|
||||
} catch(e) {
|
||||
retry_connection(id);
|
||||
return;
|
||||
}
|
||||
|
||||
self_state.remote_answer_pending = false;
|
||||
|
||||
if (description.type === 'offer') {
|
||||
try {
|
||||
await other.connection.setLocalDescription();
|
||||
} catch(e) {
|
||||
const answer = await other.connection.createAnswer();
|
||||
await other.connection.setLocalDescription(answer);
|
||||
} finally {
|
||||
signal(id, {'description': other.connection.localDescription});
|
||||
self_state.suppressing_offer = false;
|
||||
}
|
||||
}
|
||||
|
||||
} else if (candidate) {
|
||||
// Work with incoming ICE
|
||||
try {
|
||||
await other.connection.addIceCandidate(candidate);
|
||||
} catch(e) {
|
||||
// Log error unless $self is ignoring offers
|
||||
// and candidate is not an empty string
|
||||
if (!self_state.ignoring_offer && candidate.candidate.length > 1) {
|
||||
console.error(`Unable to add ICE candidate for other ID: ${id}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apps._add('rtc', 'connect', connected)
|
||||
apps._add('rtc', 'other', connected_other);
|
||||
apps._add('rtc', 'others', connected_others);
|
||||
apps._add('rtc', 'disconnected', disconnected_other);
|
||||
apps._add('rtc', 'signal', signalled);
|
||||
|
||||
|
||||
function display_stream(stream, id = 'self') {
|
||||
var selector = `${element_id(id)} video`;
|
||||
var element = document.querySelector(selector);
|
||||
if (element) { element.srcObject = stream; };
|
||||
}
|
||||
|
||||
async function request_user_media(media_constraints) {
|
||||
$self.media = await navigator.mediaDevices.getUserMedia(media_constraints);
|
||||
$self.media_tracks.video = $self.media.getVideoTracks()[0];
|
||||
$self.media_tracks.video.applyConstraints($self.video_constraints);
|
||||
$self.media_stream.addTrack($self.media_tracks.video);
|
||||
display_stream($self.media_stream);
|
||||
}
|
||||
|
||||
function add_features(id) {
|
||||
const other = $others.get(id);
|
||||
function manage_video(video_feature) {
|
||||
other.features['video'] = video_feature;
|
||||
if (other.media_tracks.video) {
|
||||
if (video_feature) {
|
||||
other.media_stream.addTrack(other.media_tracks.video);
|
||||
} else {
|
||||
other.media_stream.removeTrack(other.media_tracks.video);
|
||||
display_stream(other.media_stream, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
other.features_channel = other.connection.createDataChannel('features', {negotiated: true, id: 500 });
|
||||
other.features_channel.onopen = function(event){
|
||||
other.features_channel.send(JSON.stringify($self.features))
|
||||
};
|
||||
other.features_channel.onmessage = function(event) {
|
||||
const features = JSON.parse(event.data);
|
||||
if ('video' in features) {
|
||||
manage_video(features['video']);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function share_features(id, ...features) {
|
||||
const other = $others.get(id);
|
||||
|
||||
const shared_features = {};
|
||||
|
||||
if (!other.features_channel) return;
|
||||
|
||||
for (let f of features) {
|
||||
shared_features[f] = $self.features[f];
|
||||
}
|
||||
|
||||
try {
|
||||
other.features_channel.send(JSON.stringify(shared_features));
|
||||
} catch(e) {
|
||||
console.error('Error sending features:', e);
|
||||
}
|
||||
}
|
||||
|
||||
request_user_media($self.media_constraints);
|
||||
|
||||
function establish_features(id) {
|
||||
register_rtc_callbacks(id);
|
||||
add_features(id);
|
||||
const other = $others.get(id);
|
||||
for (let track in $self.media_tracks) {
|
||||
other.connection.addTrack($self.media_tracks[track]);
|
||||
}
|
||||
}
|
||||
|
||||
function initialize_other({channel_name, user_name, short_name}, polite) {
|
||||
$others.set(channel_name, {
|
||||
user_name: user_name,
|
||||
short_name: '#' + short_name,
|
||||
connection: new RTCPeerConnection($self.rtc_config),
|
||||
media_stream: new MediaStream(),
|
||||
media_tracks: {},
|
||||
features: { 'connection_count': 0},
|
||||
self_states: {
|
||||
is_polite: polite,
|
||||
making_offer: false,
|
||||
ignoring_offer: false,
|
||||
remote_answer_pending: false,
|
||||
suppressing_offer: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function reset_other(channel_name, preserve) {
|
||||
const other = $others.get(channel_name);
|
||||
display_stream(null, channel_name);
|
||||
if (other) {
|
||||
if (other.connection) {
|
||||
other.connection.close();
|
||||
}
|
||||
}
|
||||
if (!preserve) {
|
||||
let qs = document.querySelector(`${other.short_name}-div`);
|
||||
if (qs) { qs.remove(); };
|
||||
$others.delete(channel_name);
|
||||
}
|
||||
}
|
||||
|
||||
function retry_connection(channel_name) {
|
||||
const polite = $others.get(channel_name).self_states.is_polite;
|
||||
reset_other(channel_name, true);
|
||||
//TODO bundle id with username for this call
|
||||
initialize_other({channel_name, user_name}, polite);
|
||||
$others.get(channel_name).self_states.suppressing_offer = polite;
|
||||
|
||||
establish_features(channel_name);
|
||||
|
||||
if (polite) {
|
||||
signal(channel_name,
|
||||
{'description': {'type': '_reset'}}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function register_rtc_callbacks(id) {
|
||||
const other = $others.get(id);
|
||||
other.connection.onconnectionstatechange = conn_state_change(id);
|
||||
other.connection.onnegotiationneeded = conn_negotiation(id);
|
||||
other.connection.onicecandidate = ice_candidate(id);
|
||||
other.connection.ontrack = other_track(id);
|
||||
}
|
||||
|
||||
function conn_state_change(id) {
|
||||
return function() {
|
||||
const other = $others.get(id);
|
||||
const otherElement = document.querySelector(`${other.short_name}`);
|
||||
if (otherElement) {
|
||||
otherElement.dataset.connectionState = other.connection.connectionState;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function conn_negotiation(id) {
|
||||
return async function() {
|
||||
const other = $others.get(id);
|
||||
const self_state = other.self_states;
|
||||
if (self_state.suppressing_offer) return;
|
||||
try {
|
||||
self_state.making_offer = true;
|
||||
await other.connection.setLocalDescription();
|
||||
} catch(e) {
|
||||
const offer = await other.connection.createOffer();
|
||||
await other.connection.setLocalDescription(offer);
|
||||
} finally {
|
||||
signal(id, {'description': other.connection.localDescription});
|
||||
self_state.making_offer = false;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function ice_candidate(id) {
|
||||
return function({candidate}) {
|
||||
signal(id, {candidate});
|
||||
}
|
||||
}
|
||||
|
||||
function other_track(id) {
|
||||
return function({track}) {
|
||||
const other = $others.get(id);
|
||||
other.media_tracks[track.kind] = track;
|
||||
other.media_stream.addTrack(track);
|
||||
display_stream(other.media_stream, id);
|
||||
};
|
||||
}
|
||||
|
||||
htmx.on('htmx:wsOpen', function(e) {
|
||||
$self.ws = e.detail.socketWrapper;
|
||||
$self.ws_json({'room': 'lobby'});
|
||||
});
|
||||
|
||||
function handleCallButton(event) {
|
||||
const call_button = event.target;
|
||||
if (call_button.className === 'join') {
|
||||
call_button.className = 'leave';
|
||||
call_button.innerText = 'Leave Call';
|
||||
if ($self.ws) {
|
||||
$self.ws_json({'join': 'video'});
|
||||
}
|
||||
} else {
|
||||
// Leave the call
|
||||
call_button.className = 'join';
|
||||
call_button.innerText = 'Join Call';
|
||||
$self.ws_json({'hangup': true});
|
||||
for (let channel_name of $others.keys()) {
|
||||
reset_other(channel_name);
|
||||
}
|
||||
let node_list = document.querySelectorAll('[id^="other-"][id$="-div"]');
|
||||
for (let node of node_list) { node.remove(); }
|
||||
}
|
||||
};
|
||||
|
||||
function toggleCam(event) {
|
||||
const button = event.target;
|
||||
const video = $self.media_tracks.video;
|
||||
const state = video.enabled = !video.enabled;
|
||||
$self.features.video = state;
|
||||
button.setAttribute('aria-checked', state);
|
||||
|
||||
for (let id of $others.keys()) {
|
||||
share_features(id, 'video');
|
||||
}
|
||||
|
||||
if (state) {
|
||||
$self.media_stream.addTrack($self.media_tracks.video);
|
||||
} else {
|
||||
$self.media_stream.removeTrack($self.media_tracks.video);
|
||||
display_stream($self.media_stream);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
(function() {
|
||||
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api;
|
||||
|
||||
/* data is the parsed JSON response
|
||||
data.html is the html to add to the page
|
||||
*/
|
||||
htmx.defineExtension('tr-ext', {
|
||||
transformResponse : function(text, xhr, elt) {
|
||||
const data = JSON.parse(text);
|
||||
for (var [app, message] of Object.entries(data)) {
|
||||
var event = message.type;
|
||||
apps._forward(app, event, message)
|
||||
}
|
||||
if ('remove' in data) {
|
||||
htmx.remove(htmx.find(data.remove));
|
||||
}
|
||||
if (data.html === undefined) { data.html = ""; };
|
||||
return data.html;
|
||||
}
|
||||
})
|
||||
})();
|
||||
|
||||
htmx.on('htmx:wsOpen', function(e) {
|
||||
// Send heartbeat at least every 30 seconds
|
||||
setInterval(function() {
|
||||
$self.ws_json({"signal": "hb"});
|
||||
}, 25000 + (Math.random() * 5000) );
|
||||
});
|
||||
|
||||
const apps = {
|
||||
'_add': function(app, name, target) {
|
||||
if (!apps[app]) {
|
||||
apps[app] = new Map();
|
||||
}
|
||||
if (!apps[app].has(name)) {
|
||||
apps[app].set(name, new Array());
|
||||
}
|
||||
if (!apps[app].get(name).includes(target)) {
|
||||
apps[app].get(name).push(target);
|
||||
}
|
||||
},
|
||||
'_remove': function(app, name, target) {
|
||||
if (apps[app]) {
|
||||
idx = apps[app].get(name).indexOf(target)
|
||||
if (idx > -1) {
|
||||
apps[app].get(name).splice(idx, 1);
|
||||
}
|
||||
if (apps[app].get(name).length == 0) {
|
||||
apps[app].delete(name);
|
||||
}
|
||||
}
|
||||
},
|
||||
'_forward': function(app, name, message) {
|
||||
if (apps[app] && apps[app].has(name)) {
|
||||
for (target of apps[app].get(name)) {
|
||||
target(message);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,467 @@
|
|||
/*
|
||||
WebSockets Extension
|
||||
============================
|
||||
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api
|
||||
|
||||
htmx.defineExtension('ws', {
|
||||
|
||||
/**
|
||||
* init is called once, when this extension is first registered.
|
||||
* @param {import("../htmx").HtmxInternalApi} apiRef
|
||||
*/
|
||||
init: function(apiRef) {
|
||||
// Store reference to internal API
|
||||
api = apiRef
|
||||
|
||||
// Default function for creating new EventSource objects
|
||||
if (!htmx.createWebSocket) {
|
||||
htmx.createWebSocket = createWebSocket
|
||||
}
|
||||
|
||||
// Default setting for reconnect delay
|
||||
if (!htmx.config.wsReconnectDelay) {
|
||||
htmx.config.wsReconnectDelay = 'full-jitter'
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
*/
|
||||
onEvent: function(name, evt) {
|
||||
var parent = evt.target || evt.detail.elt
|
||||
switch (name) {
|
||||
// Try to close the socket when elements are removed
|
||||
case 'htmx:beforeCleanupElement':
|
||||
|
||||
var internalData = api.getInternalData(parent)
|
||||
|
||||
if (internalData.webSocket) {
|
||||
internalData.webSocket.close()
|
||||
}
|
||||
return
|
||||
|
||||
// Try to create websockets when elements are processed
|
||||
case 'htmx:beforeProcessNode':
|
||||
|
||||
forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), function(child) {
|
||||
ensureWebSocket(child)
|
||||
})
|
||||
forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), function(child) {
|
||||
ensureWebSocketSend(child)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function splitOnWhitespace(trigger) {
|
||||
return trigger.trim().split(/\s+/)
|
||||
}
|
||||
|
||||
function getLegacyWebsocketURL(elt) {
|
||||
var legacySSEValue = api.getAttributeValue(elt, 'hx-ws')
|
||||
if (legacySSEValue) {
|
||||
var values = splitOnWhitespace(legacySSEValue)
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var value = values[i].split(/:(.+)/)
|
||||
if (value[0] === 'connect') {
|
||||
return value[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocket creates a new WebSocket on the designated element, using
|
||||
* the element's "ws-connect" attribute.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @returns
|
||||
*/
|
||||
function ensureWebSocket(socketElt) {
|
||||
// If the element containing the WebSocket connection no longer exists, then
|
||||
// do not connect/reconnect the WebSocket.
|
||||
if (!api.bodyContains(socketElt)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the source straight from the element's value
|
||||
var wssSource = api.getAttributeValue(socketElt, 'ws-connect')
|
||||
|
||||
if (wssSource == null || wssSource === '') {
|
||||
var legacySource = getLegacyWebsocketURL(socketElt)
|
||||
if (legacySource == null) {
|
||||
return
|
||||
} else {
|
||||
wssSource = legacySource
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee that the wssSource value is a fully qualified URL
|
||||
if (wssSource.indexOf('/') === 0) {
|
||||
var base_part = location.hostname + (location.port ? ':' + location.port : '')
|
||||
if (location.protocol === 'https:') {
|
||||
wssSource = 'wss://' + base_part + wssSource
|
||||
} else if (location.protocol === 'http:') {
|
||||
wssSource = 'ws://' + base_part + wssSource
|
||||
}
|
||||
}
|
||||
|
||||
var socketWrapper = createWebsocketWrapper(socketElt, function() {
|
||||
return htmx.createWebSocket(wssSource)
|
||||
})
|
||||
|
||||
socketWrapper.addEventListener('message', function(event) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return
|
||||
}
|
||||
|
||||
var response = event.data
|
||||
if (!api.triggerEvent(socketElt, 'htmx:wsBeforeMessage', {
|
||||
message: response,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
api.withExtensions(socketElt, function(extension) {
|
||||
response = extension.transformResponse(response, null, socketElt)
|
||||
})
|
||||
|
||||
var settleInfo = api.makeSettleInfo(socketElt)
|
||||
var fragment = api.makeFragment(response)
|
||||
|
||||
if (fragment.children.length) {
|
||||
var children = Array.from(fragment.children)
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
api.oobSwap(api.getAttributeValue(children[i], 'hx-swap-oob') || 'true', children[i], settleInfo)
|
||||
}
|
||||
}
|
||||
|
||||
api.settleImmediately(settleInfo.tasks)
|
||||
api.triggerEvent(socketElt, 'htmx:wsAfterMessage', { message: response, socketWrapper: socketWrapper.publicInterface })
|
||||
})
|
||||
|
||||
// Put the WebSocket into the HTML Element's custom data.
|
||||
api.getInternalData(socketElt).webSocket = socketWrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} WebSocketWrapper
|
||||
* @property {WebSocket} socket
|
||||
* @property {Array<{message: string, sendElt: Element}>} messageQueue
|
||||
* @property {number} retryCount
|
||||
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
|
||||
* @property {(message: string, sendElt: Element) => void} send
|
||||
* @property {(event: string, handler: Function) => void} addEventListener
|
||||
* @property {() => void} handleQueuedMessages
|
||||
* @property {() => void} init
|
||||
* @property {() => void} close
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* @param socketElt
|
||||
* @param socketFunc
|
||||
* @returns {WebSocketWrapper}
|
||||
*/
|
||||
function createWebsocketWrapper(socketElt, socketFunc) {
|
||||
var wrapper = {
|
||||
socket: null,
|
||||
messageQueue: [],
|
||||
retryCount: 0,
|
||||
|
||||
/** @type {Object<string, Function[]>} */
|
||||
events: {},
|
||||
|
||||
addEventListener: function(event, handler) {
|
||||
if (this.socket) {
|
||||
this.socket.addEventListener(event, handler)
|
||||
}
|
||||
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = []
|
||||
}
|
||||
|
||||
this.events[event].push(handler)
|
||||
},
|
||||
|
||||
sendImmediately: function(message, sendElt) {
|
||||
if (!this.socket) {
|
||||
api.triggerErrorEvent()
|
||||
}
|
||||
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
|
||||
message,
|
||||
socketWrapper: this.publicInterface
|
||||
})) {
|
||||
this.socket.send(message)
|
||||
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
|
||||
message,
|
||||
socketWrapper: this.publicInterface
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
send: function(message, sendElt) {
|
||||
if (this.socket.readyState !== this.socket.OPEN) {
|
||||
this.messageQueue.push({ message, sendElt })
|
||||
} else {
|
||||
this.sendImmediately(message, sendElt)
|
||||
}
|
||||
},
|
||||
|
||||
handleQueuedMessages: function() {
|
||||
while (this.messageQueue.length > 0) {
|
||||
var queuedItem = this.messageQueue[0]
|
||||
if (this.socket.readyState === this.socket.OPEN) {
|
||||
this.sendImmediately(queuedItem.message, queuedItem.sendElt)
|
||||
this.messageQueue.shift()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
init: function() {
|
||||
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
||||
// Close discarded socket
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
// Create a new WebSocket and event handlers
|
||||
/** @type {WebSocket} */
|
||||
var socket = socketFunc()
|
||||
|
||||
// The event.type detail is added for interface conformance with the
|
||||
// other two lifecycle events (open and close) so a single handler method
|
||||
// can handle them polymorphically, if required.
|
||||
api.triggerEvent(socketElt, 'htmx:wsConnecting', { event: { type: 'connecting' } })
|
||||
|
||||
this.socket = socket
|
||||
|
||||
socket.onopen = function(e) {
|
||||
wrapper.retryCount = 0
|
||||
api.triggerEvent(socketElt, 'htmx:wsOpen', { event: e, socketWrapper: wrapper.publicInterface })
|
||||
wrapper.handleQueuedMessages()
|
||||
}
|
||||
|
||||
socket.onclose = function(e) {
|
||||
// If socket should not be connected, stop further attempts to establish connection
|
||||
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
|
||||
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
|
||||
var delay = getWebSocketReconnectDelay(wrapper.retryCount)
|
||||
setTimeout(function() {
|
||||
wrapper.retryCount += 1
|
||||
wrapper.init()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// Notify client code that connection has been closed. Client code can inspect `event` field
|
||||
// to determine whether closure has been valid or abnormal
|
||||
api.triggerEvent(socketElt, 'htmx:wsClose', { event: e, socketWrapper: wrapper.publicInterface })
|
||||
}
|
||||
|
||||
socket.onerror = function(e) {
|
||||
api.triggerErrorEvent(socketElt, 'htmx:wsError', { error: e, socketWrapper: wrapper })
|
||||
maybeCloseWebSocketSource(socketElt)
|
||||
}
|
||||
|
||||
var events = this.events
|
||||
Object.keys(events).forEach(function(k) {
|
||||
events[k].forEach(function(e) {
|
||||
socket.addEventListener(k, e)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
close: function() {
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
wrapper.init()
|
||||
|
||||
wrapper.publicInterface = {
|
||||
send: wrapper.send.bind(wrapper),
|
||||
sendImmediately: wrapper.sendImmediately.bind(wrapper),
|
||||
queue: wrapper.messageQueue
|
||||
}
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocketSend attaches trigger handles to elements with
|
||||
* "ws-send" attribute
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function ensureWebSocketSend(elt) {
|
||||
var legacyAttribute = api.getAttributeValue(elt, 'hx-ws')
|
||||
if (legacyAttribute && legacyAttribute !== 'send') {
|
||||
return
|
||||
}
|
||||
|
||||
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
|
||||
processWebSocketSend(webSocketParent, elt)
|
||||
}
|
||||
|
||||
/**
|
||||
* hasWebSocket function checks if a node has webSocket instance attached
|
||||
* @param {HTMLElement} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasWebSocket(node) {
|
||||
return api.getInternalData(node).webSocket != null
|
||||
}
|
||||
|
||||
/**
|
||||
* processWebSocketSend adds event listeners to the <form> element so that
|
||||
* messages can be sent to the WebSocket server when the form is submitted.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @param {HTMLElement} sendElt
|
||||
*/
|
||||
function processWebSocketSend(socketElt, sendElt) {
|
||||
var nodeData = api.getInternalData(sendElt)
|
||||
var triggerSpecs = api.getTriggerSpecs(sendElt)
|
||||
triggerSpecs.forEach(function(ts) {
|
||||
api.addTriggerHandler(sendElt, ts, nodeData, function(elt, evt) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return
|
||||
}
|
||||
|
||||
/** @type {WebSocketWrapper} */
|
||||
var socketWrapper = api.getInternalData(socketElt).webSocket
|
||||
var headers = api.getHeaders(sendElt, api.getTarget(sendElt))
|
||||
var results = api.getInputValues(sendElt, 'post')
|
||||
var errors = results.errors
|
||||
var rawParameters = Object.assign({}, results.values)
|
||||
var expressionVars = api.getExpressionVars(sendElt)
|
||||
var allParameters = api.mergeObjects(rawParameters, expressionVars)
|
||||
var filteredParameters = api.filterValues(allParameters, sendElt)
|
||||
|
||||
var sendConfig = {
|
||||
parameters: filteredParameters,
|
||||
unfilteredParameters: allParameters,
|
||||
headers,
|
||||
errors,
|
||||
|
||||
triggeringEvent: evt,
|
||||
messageBody: undefined,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
}
|
||||
|
||||
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (errors && errors.length > 0) {
|
||||
api.triggerEvent(elt, 'htmx:validation:halted', errors)
|
||||
return
|
||||
}
|
||||
|
||||
var body = sendConfig.messageBody
|
||||
if (body === undefined) {
|
||||
var toSend = Object.assign({}, sendConfig.parameters)
|
||||
if (sendConfig.headers) { toSend.HEADERS = headers }
|
||||
body = JSON.stringify(toSend)
|
||||
}
|
||||
|
||||
socketWrapper.send(body, elt)
|
||||
|
||||
if (evt && api.shouldCancel(evt, elt)) {
|
||||
evt.preventDefault()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
||||
* @param {number} retryCount // The number of retries that have already taken place
|
||||
* @returns {number}
|
||||
*/
|
||||
function getWebSocketReconnectDelay(retryCount) {
|
||||
/** @type {"full-jitter" | ((retryCount:number) => number)} */
|
||||
var delay = htmx.config.wsReconnectDelay
|
||||
if (typeof delay === 'function') {
|
||||
return delay(retryCount)
|
||||
}
|
||||
if (delay === 'full-jitter') {
|
||||
var exp = Math.min(retryCount, 6)
|
||||
var maxDelay = 1000 * Math.pow(2, exp)
|
||||
return maxDelay * Math.random()
|
||||
}
|
||||
|
||||
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
|
||||
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
|
||||
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
|
||||
* returns FALSE.
|
||||
*
|
||||
* @param {*} elt
|
||||
* @returns
|
||||
*/
|
||||
function maybeCloseWebSocketSource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
api.getInternalData(elt).webSocket.close()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* createWebSocket is the default method for creating new WebSocket objects.
|
||||
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns WebSocket
|
||||
*/
|
||||
function createWebSocket(url) {
|
||||
var sock = new WebSocket(url, [])
|
||||
sock.binaryType = htmx.config.wsBinaryType
|
||||
return sock
|
||||
}
|
||||
|
||||
/**
|
||||
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} attributeName
|
||||
*/
|
||||
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
||||
var result = []
|
||||
|
||||
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, 'hx-ws')) {
|
||||
result.push(elt)
|
||||
}
|
||||
|
||||
// Search all child nodes that match the requested attribute
|
||||
elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + '], [data-hx-ws], [hx-ws]').forEach(function(node) {
|
||||
result.push(node)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} arr
|
||||
* @param {(T) => void} func
|
||||
*/
|
||||
function forEach(arr, func) {
|
||||
if (arr) {
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
func(arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,20 @@
|
|||
<!DOCTYPE html>
|
||||
{% load static %}
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WebRTC Demo</title>
|
||||
<link href="{% static 'lib/bootstrap/css/bootstrap.min.css' %}" type="text/css" rel="stylesheet">
|
||||
<link href="{% static 'css/main.css' %}" type="text/css" rel="stylesheet">
|
||||
{% block page_scripts %}
|
||||
{% endblock page_scripts %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container" id="appContent">
|
||||
<div class="row"><div class="offset-sm-2 col-sm-9"><h2>WebRTC Demo</h2></div></div>
|
||||
{% block app_content %}
|
||||
{% endblock app_content %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,28 @@
|
|||
{% extends "base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block app_content %}
|
||||
There is no real security on this site.<br/>
|
||||
<br/>
|
||||
Pick any screen name you wish to use for the "First name" field.<br/>
|
||||
<br/>
|
||||
Please supply your real name in the "Real name" field.<br/>
|
||||
<br/>
|
||||
If you get disconnected and wish to reconnect, the "First name" you use<br/>
|
||||
must be the same "First name" that you previously used with your "Real name".<br/>
|
||||
(In other words, the "First name" must match the "Real name" when initially created.)<br/>
|
||||
<br/><br/>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="col-sm-4">
|
||||
{{ form | crispy }}
|
||||
</div>
|
||||
<br>
|
||||
<input type="submit" value="login">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
</form>
|
||||
<br>
|
||||
<br>
|
||||
|
||||
{% endblock %}
|
Loading…
Reference in New Issue