support huddles (#106)

* add token endpoint for livekit

* fix: invalid type passed to hook

* fix: inject livekit env variables for orion

* fix: show controls indicator above stream # shortcut

* return livekit server url from api

* simple huddle implementation with streams

* set human name in livekit room context

* simplify deployment tooling

* join huddle with audio automatically

* feat: show when there is an active huddle

This introduces a webhook which listens to events from livekit and
updates our firestore stream particle. It keeps the client simple,
reacting to changes to firestore docs.

* use headphones icon for huddles

* fix: screenshare not working in electron

The default VideoConference component from livekit doesn't support
screenshare in electron. This attempts to compose our own layout with
livekit components ourselves and introduces our own flow for
screenshare.
This commit was merged in pull request #106.
This commit is contained in:
Arjun Patel
2026-04-01 10:32:15 -07:00
committed by GitHub
parent 47e2a9baa4
commit 4d1ad717ad
32 changed files with 1358 additions and 243 deletions
+145 -13
View File
@@ -4,37 +4,46 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"cloud.google.com/go/firestore"
"github.com/flowy-live/llink/internal/auth"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/livekit"
"github.com/flowy-live/llink/internal/middleware"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/utils"
"github.com/flowy-live/llink/internal/waitlist"
"github.com/livekit/protocol/webhook"
)
type Handler struct {
authSvc auth.AuthService
humanSvc human.Service
networkSvc network.Service
particleSvc particle.Service
depotSvc depot.Service
waitlistSvc waitlist.Service
authSvc auth.AuthService
humanSvc human.Service
networkSvc network.Service
particleSvc particle.Service
depotSvc depot.Service
waitlistSvc waitlist.Service
livekitClient livekit.Client
firestoreClient *firestore.Client
}
func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc network.Service, particleSvc particle.Service, depotSvc depot.Service, waitlistSvc waitlist.Service) *Handler {
func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc network.Service, particleSvc particle.Service, depotSvc depot.Service, waitlistSvc waitlist.Service, livekitClient livekit.Client, firestoreClient *firestore.Client) *Handler {
return &Handler{
authSvc: authSvc,
humanSvc: humanSvc,
networkSvc: networkSvc,
particleSvc: particleSvc,
depotSvc: depotSvc,
waitlistSvc: waitlistSvc,
authSvc: authSvc,
humanSvc: humanSvc,
networkSvc: networkSvc,
particleSvc: particleSvc,
depotSvc: depotSvc,
waitlistSvc: waitlistSvc,
livekitClient: livekitClient,
firestoreClient: firestoreClient,
}
}
@@ -109,6 +118,18 @@ type RevokeInvitationRequest struct {
Email string `json:"email"`
}
// LiveKit DTOs
type GetLivekitTokenRequest struct {
NetworkId string `json:"network_id"`
StreamId string `json:"stream_id"`
}
type GetLivekitTokenResponse struct {
Token string `json:"token"`
ServerUrl string `json:"server_url"`
}
// Depot DTOs
type PrepareUploadRequest struct {
@@ -1040,6 +1061,117 @@ func (h *Handler) networkToDTO(ctx context.Context, n *network.Network) (Network
}, nil
}
// ============================================================================
// LiveKit Handlers
// ============================================================================
func (h *Handler) GetLivekitToken(w http.ResponseWriter, r *http.Request) {
humanId, ok := middleware.HumanIdFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
humanEmail, ok := middleware.EmailFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req GetLivekitTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.NetworkId == "" {
http.Error(w, "network_id is required", http.StatusBadRequest)
return
}
if req.StreamId == "" {
http.Error(w, "stream_id is required", http.StatusBadRequest)
return
}
// Compose room name encoding both network and stream IDs for webhook resolution
roomName := req.NetworkId + "/" + req.StreamId
token, err := h.livekitClient.GetJoinToken(roomName, humanId, humanEmail)
if err != nil {
slog.Error("failed to generate livekit token", "error", err, "humanId", humanId, "roomName", roomName)
http.Error(w, "failed to generate token", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(GetLivekitTokenResponse{Token: token, ServerUrl: h.livekitClient.ServerUrl()})
}
// HandleLivekitWebhook processes LiveKit webhook events for huddle presence.
// It verifies the webhook signature (not user auth), then updates the stream
// particle's huddle_active_participants field in Firestore.
func (h *Handler) HandleLivekitWebhook(w http.ResponseWriter, r *http.Request) {
event, err := webhook.ReceiveWebhookEvent(r, h.livekitClient.KeyProvider())
if err != nil {
slog.Error("failed to verify livekit webhook", "error", err)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
eventType := event.GetEvent()
slog.Info("received livekit webhook", "event", eventType, "room", event.GetRoom().GetName())
switch eventType {
case "participant_joined", "participant_left", "room_finished":
// Handle these events
default:
w.WriteHeader(http.StatusOK)
return
}
// Parse room name to extract networkId and streamId
roomName := event.GetRoom().GetName()
parts := strings.SplitN(roomName, "/", 2)
if len(parts) != 2 {
slog.Error("invalid room name format", "room", roomName)
http.Error(w, "invalid room name", http.StatusBadRequest)
return
}
networkId, streamId := parts[0], parts[1]
docPath := fmt.Sprintf("networks/%s/children/%s", networkId, streamId)
docRef := h.firestoreClient.Doc(docPath)
ctx := r.Context()
var participantIds []string
if eventType == "room_finished" {
// Room is done — clear the participants
participantIds = []string{}
} else {
// Use ListParticipants for authoritative state (avoids drift from missed webhooks)
participants, err := h.livekitClient.ListParticipants(ctx, roomName)
if err != nil {
slog.Error("failed to list participants", "error", err, "room", roomName)
// Return 200 so LiveKit doesn't retry
w.WriteHeader(http.StatusOK)
return
}
participantIds = make([]string, 0, len(participants))
for _, p := range participants {
participantIds = append(participantIds, p.Identity)
}
}
_, err = docRef.Update(ctx, []firestore.Update{
{Path: "huddle_active_participants", Value: participantIds},
})
if err != nil {
slog.Error("failed to update huddle participants in firestore", "error", err, "path", docPath)
}
w.WriteHeader(http.StatusOK)
}
func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {