Volume Controls, Mute/Unmute Video

* Added a top panel to put your video controls in.
* Broadcaster can mute or unmute their own audio input.
* When viewing others' cams, buttons appear to control their video:
  * Their username is displayed in the corner.
  * Mute/unmute button to silence their audio.
  * "X" button to close their camera.
* Button to show what viewers are currently watching your camera.
* Add an "About" page and config for app branding.
* Add dark theme CSS for prefers-dark browsers.
ipad-testing
Noah 2023-02-05 20:26:00 -08:00
parent 1ecff195ac
commit d8de60c990
11 changed files with 9162 additions and 49 deletions

View File

@ -18,6 +18,7 @@ type Config struct {
SecretKey string
}
Title string
WebsiteURL string
PublicChannels []Channel
@ -46,6 +47,7 @@ var Current = DefaultConfig()
// settings.toml file to disk.
func DefaultConfig() Config {
var c = Config{
Title: "BareRTC",
WebsiteURL: "https://www.example.com",
PublicChannels: []Channel{
{

View File

@ -198,3 +198,33 @@ func (s *Server) OnSDP(sub *Subscriber, msg Message) {
Description: msg.Description,
})
}
// OnWatch communicates video watching status between users.
func (s *Server) OnWatch(sub *Subscriber, msg Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
sub.ChatServer(err.Error())
return
}
other.SendJSON(Message{
Action: ActionWatch,
Username: sub.Username,
})
}
// OnUnwatch communicates video Unwatching status between users.
func (s *Server) OnUnwatch(sub *Subscriber, msg Message) {
// Look up the other subscriber.
other, err := s.GetSubscriber(msg.Username)
if err != nil {
sub.ChatServer(err.Error())
return
}
other.SendJSON(Message{
Action: ActionUnwatch,
Username: sub.Username,
})
}

View File

@ -32,6 +32,8 @@ const (
ActionMe = "me" // user self-info sent by FE or BE
ActionOpen = "open" // user wants to view a webcam (open WebRTC)
ActionRing = "ring" // receiver of a WebRTC open request
ActionWatch = "watch" // user has received video and is watching you
ActionUnwatch = "unwatch" // user has closed your video
// Actions sent by server only
ActionPing = "ping"

View File

@ -61,12 +61,9 @@ func IndexPage() http.HandlerFunc {
}
tmpl.Funcs(template.FuncMap{
// Cache busting random string for JS and CSS dependency.
// "CacheHash": func() string {
// return util.RandomString(8)
// },
//
"AsHTML": func(v string) template.HTML {
return template.HTML(v)
},
})
tmpl, err := tmpl.ParseFiles("web/templates/chat.html")
if err != nil {
@ -78,3 +75,32 @@ func IndexPage() http.HandlerFunc {
tmpl.ExecuteTemplate(w, "index", values)
})
}
// AboutPage returns the HTML template for the about page.
func AboutPage() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Load the template, TODO: once on server startup.
tmpl := template.New("index")
// Variables to give to the front-end page.
var values = map[string]interface{}{
// A cache-busting hash for JS and CSS includes.
"CacheHash": util.RandomString(8),
// The current website settings.
"Config": config.Current,
}
tmpl.Funcs(template.FuncMap{
"AsHTML": func(v string) template.HTML {
return template.HTML(v)
},
})
tmpl, err := tmpl.ParseFiles("web/templates/about.html")
if err != nil {
panic(err.Error())
}
tmpl.ExecuteTemplate(w, "index", values)
})
}

View File

@ -31,6 +31,7 @@ func (s *Server) Setup() error {
var mux = http.NewServeMux()
mux.Handle("/", IndexPage())
mux.Handle("/about", AboutPage())
mux.Handle("/ws", s.WebSocket())
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))

View File

@ -73,6 +73,10 @@ func (sub *Subscriber) ReadLoop(s *Server) {
s.OnCandidate(sub, msg)
case ActionSDP:
s.OnSDP(sub, msg)
case ActionWatch:
s.OnWatch(sub, msg)
case ActionUnwatch:
s.OnUnwatch(sub, msg)
default:
sub.ChatServer("Unsupported message type.")
}

File diff suppressed because it is too large Load Diff

View File

@ -28,14 +28,22 @@ body {
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 0, 0, 0.2);
padding: 10px;
background: rgb(190,190,190);
background: linear-gradient(0deg, rgb(172, 172, 172) 0%, rgb(214, 214, 214) 100%);
display: grid;
column-gap: 10px;
row-gap: 10px;
grid-template-columns: 260px 1fr 280px;
grid-template-rows: 1fr auto;
grid-template-rows: auto 1fr auto;
}
/* Header row */
.chat-container > .chat-header {
grid-column: 1 / 4;
grid-row: 1;
}
/* Left column: DMs and channels */
@ -47,7 +55,7 @@ body {
/* Main column: chat history */
.chat-container > .chat-column {
grid-column: 2;
grid-row: 1;
grid-row: 2;
background-color: yellow;
overflow: hidden;
}
@ -55,7 +63,7 @@ body {
/* Footer row: message entry box */
.chat-container > .chat-footer {
grid-column: 1 / 4;
grid-row: 2 / 2;
grid-row: 3;
}
/* Right column: Who List */
@ -118,7 +126,7 @@ body {
*******************/
.video-feeds {
background-color: yellow;
background-color: #222;
width: 100%;
max-width: 100%;
overflow-x: scroll;
@ -128,9 +136,38 @@ body {
}
.video-feeds > .feed {
flex: 10 0 auto;
width: 120px;
height: 80px;
position: relative;
flex: 0 0 144px;
width: 168px;
height: 112px;
background-color: black;
margin: 5px;
}
.video-feeds > .feed > video {
width: 100%;
height: 100%;
}
.video-feeds > .feed > .controls {
position: absolute;
background: rgba(0, 0, 0, 0.75);
right: 4px;
bottom: 4px;
}
.video-feeds > .feed > .close {
position: absolute;
right: 4px;
top: 0;
}
.video-feeds > .feed > .caption {
position: absolute;
background: rgba(0, 0, 0, 0.75);
color: #fff;
top: 4px;
left: 4px;
font-size: small;
padding: 2px 4px;
}

View File

@ -47,12 +47,17 @@ const app = Vue.createApp({
active: false,
elem: null, // <video id="localVideo"> element
stream: null, // MediaStream object
muted: false, // our outgoing mic is muted, not by default
// Who all is watching me? map of users.
watching: {},
},
// WebRTC sessions with other users.
WebRTC: {
// Streams per username.
streams: {},
muted: {}, // muted bool per username
// RTCPeerConnections per username.
pc: {},
@ -113,7 +118,7 @@ const app = Vue.createApp({
this.initHistory(channel.ID);
}
this.ChatServer("Welcome to BareRTC!")
this.ChatClient("Welcome to BareRTC!");
// Auto login with JWT token?
// TODO: JWT validation on the WebSocket as well.
@ -237,6 +242,12 @@ const app = Vue.createApp({
this.closeVideo(row.username);
}
}
// Has the back-end server forgotten we are on video? This can
// happen if we disconnect/reconnect while we were streaming.
if (this.webcam.active && !this.whoMap[this.username]?.videoActive) {
this.sendMe();
}
},
// Send a video request to access a user's camera.
@ -361,6 +372,12 @@ const app = Vue.createApp({
case "sdp":
this.onSDP(msg);
break;
case "watch":
this.onWatch(msg);
break;
case "unwatch":
this.onUnwatch(msg);
break;
case "error":
this.pushHistory({
channel: msg.channel,
@ -414,7 +431,7 @@ const app = Vue.createApp({
// this.ChatClient("We are the offerer - set up onNegotiationNeeded");
pc.onnegotiationneeded = () => {
console.error("WebRTC OnNegotiationNeeded called!");
this.ChatClient("Negotiation Needed, creating WebRTC offer.");
// this.ChatClient("Negotiation Needed, creating WebRTC offer.");
pc.createOffer().then(this.localDescCreated(pc, username)).catch(this.ChatClient);
};
}
@ -433,12 +450,14 @@ const app = Vue.createApp({
}
window.requestAnimationFrame(() => {
this.ChatServer("Setting <video> srcObject for " + username);
let $ref = document.getElementById(`videofeed-${username}`);
console.log("Video elem:", $ref);
$ref.srcObject = stream;
// this.$refs[`videofeed-${username}`].srcObject = stream;
});
// Inform them they are being watched.
this.sendWatch(username, true);
};
// If we were already broadcasting video, send our stream to
@ -535,6 +554,21 @@ const app = Vue.createApp({
}
}, console.error);
},
onWatch(msg) {
// The user has our video feed open now.
this.webcam.watching[msg.username] = true;
},
onUnwatch(msg) {
// The user has closed our video feed.
delete(this.webcam.watching[msg.username]);
},
sendWatch(username, watching) {
// Send the watch or unwatch message to backend.
this.ws.conn.send(JSON.stringify({
action: watching ? "watch" : "unwatch",
username: username,
}));
},
/**
* Front-end web app concerns.
@ -689,27 +723,37 @@ const app = Vue.createApp({
// Camera is already open? Then disconnect the connection.
if (this.WebRTC.pc[user.username] != undefined) {
// TODO: this breaks the connection both ways :(
// this.closeVideo(user.username);
// return;
}
// We need to broadcast video to connect to another.
// TODO: because if the offerer doesn't add video tracks they
// won't request video support so the answerer's video isn't sent
if (!this.webcam.active) {
this.ChatServer("You will need to turn your own camera on first before you can connect to " + user.username + ".");
// return;
this.closeVideo(user.username);
return;
}
this.sendOpen(user.username);
// Responsive CSS -> go to chat panel to see the camera
this.openChatPanel();
},
closeVideo(username) {
// A user has logged off the server. Clean up any WebRTC connections.
delete (this.WebRTC.streams[username]);
delete (this.webcam.watching[username]);
if (this.WebRTC.pc[username] != undefined) {
this.WebRTC.pc[username].close();
delete (this.WebRTC.pc[username]);
}
// Inform backend we have closed it.
this.sendWatch(username, false);
},
// Show who watches our video.
showViewers() {
// TODO: for now, ChatClient is our bro.
let users = Object.keys(this.webcam.watching);
if (users.length === 0) {
this.ChatClient("There is currently nobody viewing your camera.");
} else {
this.ChatClient("Your current webcam viewers are:<br><br>" + users.join(", "));
}
},
// Stop broadcasting.
@ -727,6 +771,29 @@ const app = Vue.createApp({
this.sendMe();
},
// Mute my microphone if broadcasting.
muteMe() {
this.webcam.muted = !this.webcam.muted;
this.webcam.stream.getAudioTracks().forEach(track => {
console.error("Set audio track enabled=%s", !this.webcam.muted, track);
track.enabled = !this.webcam.muted;
});
},
isMuted(username) {
return this.WebRTC.muted[username] === true;
},
muteVideo(username) {
this.WebRTC.muted[username] = !this.isMuted(username);
console.error("muteVideo(%s) is not muted=%s", username, this.WebRTC.muted[username]);
// Find the <video> tag to mute it.
let $ref = document.getElementById(`videofeed-${username}`);
console.log("Video elem:", $ref);
if ($ref) {
$ref.muted = this.WebRTC.muted[username];
}
},
initHistory(channel) {
if (this.channels[channel] == undefined) {
this.channels[channel] = {

55
web/templates/about.html Normal file
View File

@ -0,0 +1,55 @@
{{define "index"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css">
<link rel="stylesheet" type="text/css" href="/static/css/bulma-prefers-dark.css">
<link rel="stylesheet" href="/static/fontawesome-free-6.1.2-web/css/all.css">
<link rel="stylesheet" type="text/css" href="/static/css/chat.css?{{.CacheHash}}">
<title>About BareRTC</title>
</head>
<body>
<div class="container is-fullhd">
<div class="content mt-5">
<h1>About BareRTC</h1>
<p>
This chat room software is called <strong>BareRTC</strong> and this
page contains information about the software and how to use it.
</p>
<p>
BareRTC is an open source project released under the GNU General Public
License with code available
<a href="https://git.kirsle.net/apps/BareRTC" target="_blank">here</a>.
</p>
<h1>About {{AsHTML .Config.Title}}</h1>
<p>
<strong>{{AsHTML .Config.Title}}</strong> is the name of this particular
BareRTC server. The administrator may have left some links to more
info below:
</p>
<ul>
<li><strong>Website:</strong>
<a href="{{.Config.WebsiteURL}}" target="_blank">{{.Config.WebsiteURL}}</a>
</li>
</ul>
<hr>
<p>
This page will be fleshed out later with help and tips for using this
chat room.
</p>
</div>
</div>
</body>
</html>
{{end}}

View File

@ -5,6 +5,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css">
<link rel="stylesheet" type="text/css" href="/static/css/bulma-prefers-dark.css">
<link rel="stylesheet" href="/static/fontawesome-free-6.1.2-web/css/all.css">
<link rel="stylesheet" type="text/css" href="/static/css/chat.css?{{.CacheHash}}">
<title>BareRTC</title>
@ -46,6 +47,61 @@
<div class="chat-container">
<!-- Top header panel -->
<header class="chat-header">
<div class="columns is-mobile">
<div class="column is-narrow">
<strong class="is-6">{{AsHTML .Config.Title}}</strong>
</div>
<div class="column">
<!-- Stop/Start video buttons -->
<button type="button"
v-if="webcam.active"
class="button is-small is-danger"
@click="stopVideo()">
<i class="fa fa-camera mr-2"></i>
Stop
</button>
<button type="button"
v-else
class="button is-small is-success"
@click="startVideo()"
:disabled="webcam.busy">
<i class="fa fa-camera mr-2"></i>
Start
</button>
<!-- Mute/Unmute my mic buttons (if streaming)-->
<button type="button"
v-if="webcam.active && !webcam.muted"
class="button is-small is-danger is-outlined ml-2"
@click="muteMe()">
<i class="fa fa-microphone mr-2"></i>
Mute
</button>
<button type="button"
v-if="webcam.active && webcam.muted"
class="button is-small is-danger ml-2"
@click="muteMe()">
<i class="fa fa-microphone mr-2"></i>
Unmute
</button>
<!-- Watchers button -->
<button type="button"
v-if="webcam.active"
class="button is-small is-info is-outlined ml-2"
@click="showViewers()">
<i class="fa fa-eye mr-2"></i>
[[Object.keys(webcam.watching).length]]
</button>
</div>
<div class="column is-narrow">
<a href="/about" target="_blank" class="button is-small is-link">About</a>
</div>
</div>
</header>
<!-- Left Column: Channels & DMs -->
<div class="left-column">
<div class="card grid-card">
@ -107,6 +163,7 @@
</div>
</div>
<!-- Middle Column: Chat Room/History -->
<div class="chat-column">
<div class="card grid-card">
<header class="card-header has-background-link">
@ -154,18 +211,49 @@
</div>
</header>
<div class="video-feeds" v-show="webcam.active || Object.keys(WebRTC.streams).length > 0">
<!-- Video Feeds-->
<!-- My video -->
<video class="feed"
v-show="webcam.active"
id="localVideo"
autoplay muted>
x
</video>
<video class="feed"
v-for="(stream, username) in WebRTC.streams"
v-bind:key="username"
:id="'videofeed-'+username"
autoplay muted>
</video>
<!-- Others' videos -->
<div class="feed" v-for="(stream, username) in WebRTC.streams" v-bind:key="username">
<video class="feed"
:id="'videofeed-'+username"
autoplay>
</video>
<div class="caption">
[[username]]
</div>
<div class="close">
<a href="#"
class="has-text-danger"
title="Close video"
@click="closeVideo(username)">
<i class="fa fa-close"></i>
</a>
</div>
<div class="controls">
<button type="button"
v-if="isMuted(username)"
class="button is-small is-danger is-outlined p-1"
title="Unmute this video"
@click="muteVideo(username)">
<i class="fa fa-volume-xmark"></i>
</button>
<button type="button"
v-else
class="button is-small is-danger is-outlined p-1"
title="Mute this video"
@click="muteVideo(username)">
<i class="fa fa-volume-high"></i>
</button>
</div>
</div>
</div>
<div class="card-content" id="chatHistory">
@ -243,22 +331,7 @@
</form>
</div>
<div class="column is-narrow">
<button type="button"
v-if="webcam.active"
class="button is-danger"
@click="stopVideo()">
<i class="fa fa-camera mr-2"></i>
Stop
</button>
<button type="button"
v-else
class="button is-success"
@click="startVideo()"
:disabled="webcam.busy">
<i class="fa fa-camera mr-2"></i>
Start
</button>
</div>
</div>