diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7975f3e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,64 @@ +# Skald Project Instructions + +Skald is a hard fork of Galene into an audio-only, hall-based conferencing +server. + +## Direction + +- Treat Skald as incompatible with Galene unless compatibility is explicitly + requested. +- Do not add legacy Galene routes, config fallbacks, protocol shims, migration + behavior, or compatibility aliases by default. +- Replace Galene branding and group/room terminology with Skald and hall + terminology everywhere practical. +- Apply renames and removals thoroughly. When changing a concept, route, + command, UI label, config key, file name, or behavior, search for every + related surface and update even small details such as tests, examples, help + text, comments, docs, error messages, CSS selectors, and generated/static + asset references. +- Remove video behavior rather than hiding it. +- Reject incoming video intentionally. +- Recordings should become single mixed Ogg Opus audio files. + +## Workflow + +- Use `todo.txt` as the source of truth for staged work. +- Preserve the todo status markers: + - `-` means open. + - `+` means implemented, needs testing. + - `X` means tested and done. +- Work in small batches and update `todo.txt` only for items actually completed + or tested. +- Before beginning a new phase or section, review all earlier `+` and `X` + sections in `todo.txt`; confirm their status still matches the code, tests, + docs, and user-facing surfaces, and downgrade stale `X` markers when evidence + no longer supports them. +- Keep changes scoped to the current batch. Do not mix in unrelated cleanup. +- Before behavior changes, inspect the current code path and tests rather than + assuming Galene behavior still applies. + +## Verification + +- Run the narrowest useful verification for each change. +- For Go changes, prefer focused package tests first, then broader + `go test ./...` when the change crosses packages. +- For frontend changes, verify renamed assets load and keyboard-accessible + controls still work where practical. +- For shell script changes, run `shellcheck`. +- If verification is not possible, state that plainly. + +## Accessibility + +- The browser UI must be fully screen-reader accessible, not merely visually + simplified. +- Dynamic conference events must be announced through appropriate live regions + so screen readers read important changes automatically. This includes chat + messages, participant joins and leaves, raised hands, recording state changes, + moderation actions, connection status changes, and errors that require user + attention. +- Use polite live regions for routine updates and assertive live regions only + for urgent or blocking errors. +- Avoid duplicate announcements when updating visible text and live-region text. +- The browser UI must be audio-only and keyboard accessible. +- Preserve labels or accessible names when removing or reworking controls. +- Do not rely on visual layout, sound, or color alone to communicate state. diff --git a/codecs/codecs.go b/codecs/codecs.go index e4849cd..5d6b6b0 100644 --- a/codecs/codecs.go +++ b/codecs/codecs.go @@ -2,312 +2,30 @@ package codecs import ( "errors" - "strings" - - "github.com/pion/rtp" - "github.com/pion/rtp/codecs" ) var errTruncated = errors.New("truncated packet") -// Keyframe determines if packet is the start of a keyframe. -// It returns (true, true) if that is the case, (false, true) if that is -// definitely not the case, and (false, false) if the information cannot -// be determined. -func Keyframe(codec string, packet *rtp.Packet) (bool, bool) { - if strings.EqualFold(codec, "video/vp8") { - var vp8 codecs.VP8Packet - _, err := vp8.Unmarshal(packet.Payload) - if err != nil || len(vp8.Payload) < 1 { - return false, false - } - - if vp8.S != 0 && vp8.PID == 0 && (vp8.Payload[0]&0x1) == 0 { - return true, true - } - return false, true - } else if strings.EqualFold(codec, "video/vp9") { - var vp9 codecs.VP9Packet - _, err := vp9.Unmarshal(packet.Payload) - if err != nil || len(vp9.Payload) < 1 { - return false, false - } - if !vp9.B { - return false, true - } - - if (vp9.Payload[0] & 0xc0) != 0x80 { - return false, false - } - - profile := (vp9.Payload[0] >> 4) & 0x3 - if profile != 3 { - return (vp9.Payload[0] & 0xC) == 0, true - } - return (vp9.Payload[0] & 0x6) == 0, true - } else if strings.EqualFold(codec, "video/av1") { - if len(packet.Payload) < 2 { - return false, true - } - // Z=0, N=1 - if (packet.Payload[0] & 0x88) != 0x08 { - return false, true - } - w := (packet.Payload[0] & 0x30) >> 4 - - getObu := func(data []byte, last bool) ([]byte, int, bool) { - if last { - return data, len(data), false - } - offset := 0 - length := 0 - for { - if len(data) <= offset { - return nil, offset, offset > 0 - } - if offset >= 4 { - return nil, offset, true - } - l := data[offset] - length |= int(l&0x7f) << (offset * 7) - offset++ - if (l & 0x80) == 0 { - break - } - } - if len(data) < offset+length { - return data[offset:], len(data), true - } - return data[offset : offset+length], - offset + length, false - } - offset := 1 - i := 0 - for { - obu, length, truncated := - getObu(packet.Payload[offset:], int(w) == i+1) - if len(obu) < 1 { - return false, false - } - tpe := (obu[0] & 0x38) >> 3 - switch i { - case 0: - // OBU_SEQUENCE_HEADER - if tpe != 1 { - return false, true - } - default: - // OBU_FRAME_HEADER or OBU_FRAME - if tpe == 3 || tpe == 6 { - if len(obu) < 2 { - return false, false - } - // show_existing_frame == 0 - if (obu[1] & 0x80) != 0 { - return false, true - } - // frame_type == KEY_FRAME - return (obu[1] & 0x60) == 0, true - } - } - if truncated || i >= int(w) { - // the first frame header is in a second - // packet, give up. - return false, false - } - offset += length - i++ - } - } else if strings.EqualFold(codec, "video/h264") { - if len(packet.Payload) < 1 { - return false, false - } - nalu := packet.Payload[0] & 0x1F - if nalu == 0 { - // reserved - return false, false - } else if nalu <= 23 { - // simple NALU - return nalu == 7, true - } else if nalu == 24 || nalu == 25 || nalu == 26 || nalu == 27 { - // STAP-A, STAP-B, MTAP16 or MTAP24 - i := 1 - if nalu == 25 || nalu == 26 || nalu == 27 { - // skip DON - i += 2 - } - for i < len(packet.Payload) { - if i+2 > len(packet.Payload) { - return false, false - } - length := uint16(packet.Payload[i])<<8 | - uint16(packet.Payload[i+1]) - i += 2 - if i+int(length) > len(packet.Payload) { - return false, false - } - offset := 0 - if nalu == 26 { - offset = 3 - } else if nalu == 27 { - offset = 4 - } - if offset >= int(length) { - return false, false - } - n := packet.Payload[i+offset] & 0x1F - if n == 7 { - return true, true - } else if n >= 24 { - // is this legal? - return false, false - } - i += int(length) - } - if i == len(packet.Payload) { - return false, true - } - return false, false - } else if nalu == 28 || nalu == 29 { - // FU-A or FU-B - if len(packet.Payload) < 2 { - return false, false - } - if (packet.Payload[1] & 0x80) == 0 { - // not a starting fragment - return false, true - } - return (packet.Payload[1]&0x1F == 7), true - } - return false, false - } - return false, false -} - -func KeyframeDimensions(codec string, packet *rtp.Packet) (uint32, uint32) { - if strings.EqualFold(codec, "video/vp8") { - var vp8 codecs.VP8Packet - _, err := vp8.Unmarshal(packet.Payload) - if err != nil { - return 0, 0 - } - if len(vp8.Payload) < 10 { - return 0, 0 - } - raw := uint32(vp8.Payload[6]) | uint32(vp8.Payload[7])<<8 | - uint32(vp8.Payload[8])<<16 | uint32(vp8.Payload[9])<<24 - width := raw & 0x3FFF - height := (raw >> 16) & 0x3FFF - return width, height - } else if strings.EqualFold(codec, "video/vp9") { - if packet == nil { - return 0, 0 - } - var vp9 codecs.VP9Packet - _, err := vp9.Unmarshal(packet.Payload) - if err != nil { - return 0, 0 - } - if !vp9.V { - return 0, 0 - } - w := uint32(0) - h := uint32(0) - for i := range vp9.Width { - if i >= len(vp9.Height) { - break - } - if w < uint32(vp9.Width[i]) { - w = uint32(vp9.Width[i]) - } - if h < uint32(vp9.Height[i]) { - h = uint32(vp9.Height[i]) - } - } - return w, h - } else { - return 0, 0 - } -} - +// Flags holds parsed RTP header flags. type Flags struct { - Seqno uint16 - Marker bool - Start bool - End bool - Keyframe bool - Pid uint16 // only returned for VP8 - Tid uint8 - Sid uint8 - TidUpSync bool - SidUpSync bool - SidNonReference bool - Discardable bool + Seqno uint16 + Pid uint16 + Marker bool } +// PacketFlags extracts basic RTP header flags from a raw packet buffer. func PacketFlags(codec string, buf []byte) (Flags, error) { if len(buf) < 4 { return Flags{}, errTruncated } var flags Flags - flags.Seqno = (uint16(buf[2]) << 8) | uint16(buf[3]) flags.Marker = (buf[1] & 0x80) != 0 - - if strings.EqualFold(codec, "video/vp8") { - var packet rtp.Packet - err := packet.Unmarshal(buf) - if err != nil { - return flags, err - } - var vp8 codecs.VP8Packet - _, err = vp8.Unmarshal(packet.Payload) - if err != nil { - return flags, err - } - flags.Start = vp8.S != 0 && vp8.PID == 0 - flags.End = packet.Marker - flags.Keyframe = vp8.S != 0 && vp8.PID == 0 && - len(vp8.Payload) > 0 && (vp8.Payload[0]&0x1) == 0 - flags.Pid = vp8.PictureID - flags.Tid = vp8.TID - flags.TidUpSync = flags.Keyframe || vp8.Y == 1 - flags.SidUpSync = flags.Keyframe - flags.Discardable = vp8.N == 1 - return flags, nil - } else if strings.EqualFold(codec, "video/vp9") { - var packet rtp.Packet - err := packet.Unmarshal(buf) - if err != nil { - return flags, err - } - var vp9 codecs.VP9Packet - _, err = vp9.Unmarshal(packet.Payload) - if err != nil { - return flags, err - } - flags.Start = vp9.B - flags.End = vp9.E - if vp9.B && len(vp9.Payload) > 0 && - (vp9.Payload[0]&0xc0) == 0x80 { - profile := (vp9.Payload[0] >> 4) & 0x3 - if profile != 3 { - flags.Keyframe = (vp9.Payload[0] & 0xC) == 0 - } else { - flags.Keyframe = (vp9.Payload[0] & 0x6) == 0 - } - } - flags.Tid = vp9.TID - flags.Sid = vp9.SID - flags.TidUpSync = flags.Keyframe || vp9.U - flags.SidUpSync = flags.Keyframe || !vp9.P - flags.SidNonReference = (packet.Payload[0] & 0x01) != 0 - return flags, nil - } return flags, nil } +// RewritePacket rewrites the sequence number and marker bit in a raw RTP packet. func RewritePacket(codec string, data []byte, setMarker bool, seqno uint16, delta uint16) error { if len(data) < 12 { return errTruncated @@ -337,39 +55,5 @@ func RewritePacket(codec string, data []byte, setMarker bool, seqno uint16, delt } } - // only rewrite PID for VP8. - if strings.EqualFold(codec, "video/vp8") { - x := (data[offset] & 0x80) != 0 - if !x { - return nil - } - offset++ - if len(data) <= offset { - return errTruncated - } - i := (data[offset] & 0x80) != 0 - if !i { - return nil - } - offset++ - if len(data) <= offset { - return errTruncated - } - m := (data[offset] & 0x80) != 0 - if m { - if len(data) <= offset+1 { - return errTruncated - } - pid := (uint16(data[offset]&0x7F) << 8) | - uint16(data[offset+1]) - pid = (pid + delta) & 0x7FFF - data[offset] = 0x80 | byte((pid>>8)&0x7F) - data[offset+1] = byte(pid & 0xFF) - } else { - data[offset] = (data[offset] + uint8(delta)) & 0x7F - } - return nil - } - return nil } diff --git a/codecs/codecs_test.go b/codecs/codecs_test.go index 68521c8..4a5d413 100644 --- a/codecs/codecs_test.go +++ b/codecs/codecs_test.go @@ -1,249 +1,37 @@ package codecs import ( - "bytes" "testing" - - "github.com/pion/rtp" ) -func TestVP8Keyframe(t *testing.T) { - ps := [][]byte{ - { - - 0x80, 0xe0, 0x71, 0x3e, 0x5d, 0x6f, 0x3c, 0xc5, - 0x75, 0xc, 0x80, 0x96, 0x90, 0x80, 0xb0, 0x4c, - 0x90, 0x2, 0x0, 0x9d, 0x1, 0x2a, 0x10, 0x0, 0x10, - 0x0, 0x39, 0x3, 0x0, 0x0, 0x1c, 0x22, 0x16, 0x16, - 0x22, 0x66, 0x12, 0x20, 0x4, 0x90, 0x40, 0x4e, - 0x9e, 0x8d, 0xe9, 0x40, 0xfe, 0xff, 0xab, 0x59, - 0x72, 0x30, 0xd1, 0xaf, 0xe4, 0x6a, 0x11, 0x3, - 0xfd, 0x15, 0xe9, 0x2, 0x2e, 0xdf, 0xd9, 0xd1, - 0xb8, 0x0, 0x0, - }, - { - 0x80, 0x6f, 0x61, 0x8f, 0xd5, 0x36, 0xdc, 0x15, - 0x1b, 0x4a, 0xb5, 0x29, 0x78, 0x9, 0xa1, 0x93, - 0xa0, 0x5b, 0xd8, 0xf1, 0xde, 0x87, 0x23, 0x5a, - 0xb9, 0x19, 0x97, 0xb7, 0xbd, 0xbf, 0xf7, 0x6e, - 0xad, 0x82, 0xc4, 0x70, 0x1c, 0xc9, 0x3a, 0xb4, - 0x1f, 0x13, 0x45, 0xb5, 0xf1, 0x0, 0xa5, 0xa5, - 0xa9, 0xd0, 0xa5, 0xdf, 0x67, 0x88, 0x26, 0x30, - 0x32, - }, +func TestPacketFlags(t *testing.T) { + buf := []byte{0x80, 0x80, 0x01, 0x02, 0x03, 0x04} + flags, err := PacketFlags("audio/opus", buf) + if err != nil { + t.Fatalf("PacketFlags: %v", err) } - - var packet rtp.Packet - - for i, p := range ps { - err := packet.Unmarshal(p) - if err != nil { - t.Errorf("Unmarshal(p%v): %v", i, err) - } - - kf, kfKnown := Keyframe("video/vp8", &packet) - if kf != (i == 0) || !kfKnown { - t.Errorf("Keyframe(p%v): %v %v", i, kf, kfKnown) - } + if flags.Seqno != 0x0102 { + t.Errorf("seqno %v", flags.Seqno) + } + if !flags.Marker { + t.Errorf("marker") } } -func TestVP9Keyframe(t *testing.T) { - ps := [][]byte{ - { - 0x80, 0xe2, 0x6c, 0xb9, 0xcd, 0xa2, 0x77, 0x5c, - 0xea, 0xf0, 0x14, 0xe9, 0x8f, 0xbd, 0x90, 0x18, - 0x0, 0x10, 0x0, 0x10, 0x1, 0x4, 0x1, 0x82, 0x49, - 0x83, 0x42, 0x0, 0x0, 0xf0, 0x0, 0xf4, 0x2, 0x38, - 0x24, 0x1c, 0x18, 0x10, 0x0, 0x0, 0x20, 0x40, 0x0, - 0x22, 0x9b, 0xff, 0xff, 0xd7, 0xe6, 0xc0, 0xa, - 0xf2, 0x32, 0xd4, 0xdd, 0xa3, 0x69, 0xc6, 0xca, - 0xd1, 0x50, 0xeb, 0x1c, 0x1, 0x50, 0x91, 0xf6, - 0x64, 0xc7, 0x35, 0xe9, 0x0, 0xfe, 0x76, 0xb2, - 0xb, 0x4d, 0xd7, 0x35, 0x23, 0xf3, 0x9f, 0x7f, - 0x86, 0x37, 0xb9, 0x65, 0x3a, 0xf9, 0x66, 0xa0, - 0x6a, 0xb2, 0x9b, 0xb3, 0x36, 0x5b, 0x47, 0xf2, - 0x26, 0x5c, 0xe2, 0x23, 0x4f, 0xff, 0xff, 0xff, - 0xfe, 0xc3, 0x49, 0x6b, 0x14, 0x58, 0x4d, 0xdc, - 0xd8, 0xf5, 0x76, 0x81, 0x2e, 0xb3, 0x7f, 0xff, - 0xfe, 0x18, 0xc8, 0xf8, 0x1b, 0xf6, 0xee, 0xc3, - 0xc, 0x6f, 0x23, 0x34, 0x80, - }, - { - 0x80, 0xe2, 0x4a, 0xb5, 0x1a, 0x33, 0x3f, 0x7b, - 0x9c, 0xda, 0x7b, 0xd0, 0x8d, 0xec, 0x14, 0x86, - 0x0, 0x40, 0x92, 0x88, 0x2c, 0x50, 0x83, 0x30, - 0x10, 0x1c, 0x6, 0x3, 0x0, 0x82, 0x99, 0x15, 0xc8, - 0x0, 0x0, 0x0, 0x0, 0x18, 0x70, 0x0, 0x0, 0x4c, - 0x4, 0xa0, - }, +func TestRewritePacket(t *testing.T) { + buf := []byte{ + 0x80, 0x00, 0x01, 0x02, + 0x03, 0x04, 0x05, 0x06, + 0x07, 0x08, 0x09, 0x0a, } - - var packet rtp.Packet - - for i, p := range ps { - err := packet.Unmarshal(p) - if err != nil { - t.Errorf("Unmarshal(p%v): %v", i, err) - } - - kf, kfKnown := Keyframe("video/vp9", &packet) - if kf != (i == 0) || !kfKnown { - t.Errorf("Keyframe(p%v): %v %v", i, kf, kfKnown) - } + err := RewritePacket("audio/opus", buf, true, 0x0506, 0) + if err != nil { + t.Fatalf("RewritePacket: %v", err) } -} - -func TestH264Keyframe(t *testing.T) { - ps := [][]byte{ - { - 0x80, 0xe6, 0xf, 0xae, 0xfa, 0x86, 0x3b, 0x49, - 0x59, 0xbd, 0x79, 0xe7, 0x78, 0x0, 0xc, 0x67, - 0x42, 0xc0, 0xc, 0x8c, 0x8d, 0x4e, 0x40, 0x3c, - 0x22, 0x11, 0xa8, 0x0, 0x4, 0x68, 0xce, 0x3c, - 0x80, 0x0, 0x1a, 0x65, 0xb8, 0x0, 0x4, 0x0, 0x0, - 0x9, 0xe3, 0x31, 0x40, 0x0, 0x46, 0x76, 0x38, 0x0, - 0x8, 0x2, 0x47, 0x0, 0x2, 0x7f, 0x3f, 0x77, 0x6f, - 0x67, 0x80, - }, - { - - 0x80, 0xe6, 0xf, 0xaf, 0xfa, 0x86, 0x46, 0x89, - 0x59, 0xbd, 0x79, 0xe7, 0x61, 0xe0, 0x0, 0x40, - 0x0, 0xbe, 0x40, 0x9e, 0xa0, - }, + if buf[1] != 0x80 { + t.Errorf("marker not set") } - - var packet rtp.Packet - - for i, p := range ps { - err := packet.Unmarshal(p) - if err != nil { - t.Errorf("Unmarshal(p%v): %v", i, err) - } - - kf, kfKnown := Keyframe("video/h264", &packet) - if kf != (i == 0) || !kfKnown { - t.Errorf("Keyframe(p%v): %v %v", i, kf, kfKnown) - } - } -} - -var vp8 = []byte{ - 0x80, 0, 0, 42, - 0, 0, 0, 0, - 0, 0, 0, 0, - - 0x90, 0x80, 0x80, 57, - - 0, 0, 0, 0, -} - -var emptyVP8 = []byte{ - 0x80, 0, 0, 42, - 0, 0, 0, 0, - 0, 0, 0, 0, - - 0x00, -} - -func TestPacketFlagsVP8(t *testing.T) { - buf := bytes.Clone(vp8) - flags, err := PacketFlags("video/vp8", buf) - if flags.Seqno != 42 || !flags.Start || flags.Pid != 57 || - flags.Sid != 0 || flags.Tid != 0 || - !flags.TidUpSync || flags.Discardable || err != nil { - t.Errorf("Got %v, %v, %v, %v, %v, %v (%v)", - flags.Seqno, flags.Start, flags.Pid, flags.Sid, - flags.TidUpSync, flags.Discardable, err, - ) - } -} - -func TestEmptyPacketFlagsVP8(t *testing.T) { - buf := bytes.Clone(emptyVP8) - flags, err := PacketFlags("video/vp8", buf) - if flags.Seqno != 42 || flags.Start || - flags.Sid != 0 || flags.Tid != 0 || - flags.TidUpSync || flags.Discardable || err != nil { - t.Errorf("Got %v, %v, %v, %v, %v, %v (%v)", - flags.Seqno, flags.Start, flags.Pid, flags.Sid, - flags.TidUpSync, flags.Discardable, err, - ) - } -} - -func TestRewriteVP8(t *testing.T) { - for i := uint16(0); i < 0x7fff; i++ { - buf := bytes.Clone(vp8) - err := RewritePacket("video/vp8", buf, true, i, i) - if err != nil { - t.Errorf("rewrite: %v", err) - continue - } - flags, err := PacketFlags("video/vp8", buf) - if err != nil || flags.Seqno != i || - flags.Pid != (57+i)&0x7FFF || !flags.Marker { - t.Errorf("Expected %v %v, got %v %v (%v)", - i, (57+i)&0x7FFF, - flags.Seqno, flags.Pid, err) - } - } -} - -func TestRewriteEmptyVP8(t *testing.T) { - for i := uint16(0); i < 0x7fff; i++ { - buf := bytes.Clone(emptyVP8) - err := RewritePacket("video/vp8", buf, true, i, i) - if err != nil { - t.Errorf("rewrite: %v", err) - continue - } - flags, err := PacketFlags("video/vp8", buf) - if err != nil || flags.Seqno != i || - !flags.Marker { - t.Errorf("Expected %v %v, got %v %v (%v)", - i, (57+i)&0x7FFF, - flags.Seqno, flags.Pid, err) - } - } -} - -var vp9 = []byte{ - 0x80, 0, 0, 42, - 0, 0, 0, 0, - 0, 0, 0, 0, - - 0x88, 0x80, 57, 0, -} - -func TestPacketFlagsVP9(t *testing.T) { - buf := bytes.Clone(vp9) - flags, err := PacketFlags("video/vp9", buf) - if flags.Seqno != 42 || !flags.Start || flags.Pid != 0 || - flags.Sid != 0 || flags.Tid != 0 || - flags.TidUpSync || flags.Discardable || err != nil { - t.Errorf("Got %v, %v, %v, %v, %v, %v (%v)", - flags.Seqno, flags.Start, flags.Pid, flags.Sid, - flags.TidUpSync, flags.Discardable, err, - ) - } -} - -func TestRewriteVP9(t *testing.T) { - for i := uint16(0); i < 0x7fff; i++ { - buf := bytes.Clone(vp9) - err := RewritePacket("video/vp9", buf, true, i, i) - if err != nil { - t.Errorf("rewrite: %v", err) - continue - } - flags, err := PacketFlags("video/vp9", buf) - if err != nil || flags.Seqno != i || - flags.Pid != 0 || !flags.Marker { - t.Errorf("Expected %v %v, got %v %v (%v)", - i, 0, - flags.Seqno, flags.Pid, err) - } + if buf[2] != 0x05 || buf[3] != 0x06 { + t.Errorf("seqno %v %v", buf[2], buf[3]) } } diff --git a/conn/conn.go b/conn/conn.go index c7ec0d5..f1a122e 100644 --- a/conn/conn.go +++ b/conn/conn.go @@ -28,7 +28,6 @@ type UpTrack interface { // GetPacket fetches a recent packet. Returns 0 if the packet is // not in cache, and, in that case, optionally schedules a NACK. GetPacket(seqno uint16, result []byte, nack bool) uint16 - RequestKeyframe() error } // Type Down represents a connection in the server to client direction. diff --git a/diskwriter/diskwriter.go b/diskwriter/diskwriter.go index 1ecbc5b..91466c4 100644 --- a/diskwriter/diskwriter.go +++ b/diskwriter/diskwriter.go @@ -22,7 +22,6 @@ import ( "github.com/jech/samplebuilder" - gcodecs "git.stormux.org/storm/skald/codecs" "git.stormux.org/storm/skald/conn" "git.stormux.org/storm/skald/hall" "git.stormux.org/storm/skald/rtptime" @@ -30,7 +29,6 @@ import ( const ( audioMaxLate = 32 - videoMaxLate = 256 ) var Directory string @@ -82,7 +80,7 @@ func (client *Client) Data() map[string]interface{} { return nil } -func (client *Client) PushClient(group, kind, id, username string, perms []string, data map[string]interface{}) error { +func (client *Client) PushClient(hall, kind, id, username string, perms []string, data map[string]interface{}) error { return nil } @@ -112,7 +110,7 @@ func (client *Client) Addr() net.Addr { return nil } -func (client *Client) Joined(group, kind string) error { +func (client *Client) Joined(hall, kind string) error { return nil } @@ -173,16 +171,14 @@ type diskConn struct { client *Client directory string username string - hasVideo bool - mu sync.Mutex - file *os.File - remote conn.Up - tracks []*diskTrack - width, height uint32 - lastWarning time.Time - originLocal time.Time - originRemote uint64 + mu sync.Mutex + file *os.File + remote conn.Up + tracks []*diskTrack + lastWarning time.Time + originLocal time.Time + originRemote uint64 } // called locked @@ -315,14 +311,10 @@ type diskTrack struct { remoteNTP uint64 remoteRTP uint32 - - kfRequested time.Time - lastKf time.Time - savedKf *rtp.Packet } func newDiskConn(client *Client, directory string, up conn.Up, remoteTracks []conn.UpTrack) (*diskConn, error) { - var audio, video conn.UpTrack + var audio conn.UpTrack for _, remote := range remoteTracks { codec := remote.Codec().MimeType @@ -332,30 +324,16 @@ func newDiskConn(client *Client, directory string, up conn.Up, remoteTracks []co } else { client.hall.WallOps("Multiple audio tracks, recording just one") } - } else if strings.EqualFold(codec, "video/vp8") || - strings.EqualFold(codec, "video/vp9") || - strings.EqualFold(codec, "video/h264") { - if video == nil || video.Label() == "l" { - video = remote - } else if remote.Label() != "l" { - client.hall.WallOps("Multiple video tracks, recording just one") - } } else { client.hall.WallOps("Unknown codec, " + codec + ", not recording") } } - if video == nil && audio == nil { + if audio == nil { return nil, errors.New("no usable tracks found") } - tracks := make([]conn.UpTrack, 0, 2) - if audio != nil { - tracks = append(tracks, audio) - } - if video != nil { - tracks = append(tracks, video) - } + tracks := []conn.UpTrack{audio} _, username := up.User() conn := diskConn{ @@ -374,24 +352,6 @@ func newDiskConn(client *Client, directory string, up conn.Up, remoteTracks []co audioMaxLate, &codecs.OpusPacket{}, codec.ClockRate, ) - } else if strings.EqualFold(codec.MimeType, "video/vp8") { - builder = samplebuilder.New( - videoMaxLate, - &codecs.VP8Packet{}, codec.ClockRate, - ) - conn.hasVideo = true - } else if strings.EqualFold(codec.MimeType, "video/vp9") { - builder = samplebuilder.New( - videoMaxLate, &codecs.VP9Packet{}, - codec.ClockRate, - ) - conn.hasVideo = true - } else if strings.EqualFold(codec.MimeType, "video/h264") { - builder = samplebuilder.New( - videoMaxLate, &codecs.H264Packet{}, - codec.ClockRate, - ) - conn.hasVideo = true } else { // this shouldn't happen return nil, errors.New( @@ -406,8 +366,6 @@ func newDiskConn(client *Client, directory string, up conn.Up, remoteTracks []co conn.tracks = append(conn.tracks, track) } - // Only do this after all tracks have been added to conn, to avoid - // racing on hasVideo. for _, t := range conn.tracks { err := t.remote.AddLocal(t) if err != nil { @@ -454,7 +412,7 @@ func (t *diskTrack) Write(buf []byte) (int, error) { fetch(t, lastSeqno+i) } } else { - requestKeyframe(t) + } t.lastSeqno = some(uint32(p.SequenceNumber)) } else { @@ -462,7 +420,7 @@ func (t *diskTrack) Write(buf []byte) (int, error) { count := lastSeqno - p.SequenceNumber if count >= 512 { t.lastSeqno = none - requestKeyframe(t) + } } } else { @@ -491,41 +449,14 @@ func fetch(t *diskTrack, seqno uint16) { t.writeRTP(p) } -func requestKeyframe(t *diskTrack) { - now := time.Now() - if now.Sub(t.kfRequested) > 500*time.Millisecond { - t.remote.RequestKeyframe() - t.kfRequested = now - } -} - // writeRTP writes the packet without fetching lost packets // Called locked. func (t *diskTrack) writeRTP(p *rtp.Packet) error { - codec := t.remote.Codec().MimeType - if len(codec) > 6 && strings.EqualFold(codec[:6], "video/") { - kf, _ := gcodecs.Keyframe(codec, p) - if kf { - t.savedKf = p - t.lastKf = time.Now() - if !valid(t.origin) { - t.setOrigin( - p.Timestamp, time.Now(), - t.remote.Codec().ClockRate, - ) - } - } else if time.Since(t.lastKf) > 4*time.Second { - requestKeyframe(t) - } - } - if !valid(t.origin) { - if !t.conn.hasVideo || !t.conn.originLocal.Equal(time.Time{}) { - t.setOrigin( - p.Timestamp, time.Now(), - t.remote.Codec().ClockRate, - ) - } + t.setOrigin( + p.Timestamp, time.Now(), + t.remote.Codec().ClockRate, + ) } t.builder.Push(p) @@ -537,8 +468,6 @@ func (t *diskTrack) writeRTP(p *rtp.Packet) error { // samples will be flushed even if they are preceded by incomplete // samples. func (t *diskTrack) writeBuffered(force bool) error { - codec := t.remote.Codec().MimeType - for { var sample *media.Sample var ts uint32 @@ -561,39 +490,13 @@ func (t *diskTrack) writeBuffered(force bool) error { t.conn.close() } - var keyframe bool - if len(codec) > 6 && strings.EqualFold(codec[:6], "video/") { - if t.savedKf == nil { - keyframe = false - } else { - keyframe = (ts == t.savedKf.Timestamp) - } - - if keyframe { - w, h := gcodecs.KeyframeDimensions( - codec, t.savedKf, + if t.writer == nil { + err := t.conn.initWriter(t, ts) + if err != nil { + t.conn.warn( + "Write to disk " + err.Error(), ) - err := t.conn.initWriter(w, h, t, ts) - if err != nil { - t.conn.warn( - "Write to disk " + err.Error(), - ) - return err - } - } - } else { - keyframe = true - if t.writer == nil { - if !t.conn.hasVideo { - err := t.conn.initWriter(0, 0, t, ts) - if err != nil { - t.conn.warn( - "Write to disk " + - err.Error(), - ) - return err - } - } + return err } } @@ -608,6 +511,7 @@ func (t *diskTrack) writeBuffered(force bool) error { tm := (ts - value(t.origin)) / (t.remote.Codec().ClockRate / 1000) + keyframe := true _, err := t.writer.Write(keyframe, int64(tm), sample.Data) if err != nil { return err @@ -722,67 +626,23 @@ func (t *diskTrack) adjustOrigin(ts uint32) { } // called locked -func (conn *diskConn) initWriter(width, height uint32, track *diskTrack, ts uint32) error { +func (conn *diskConn) initWriter(track *diskTrack, ts uint32) error { if conn.file != nil { - if width == conn.width && height == conn.height { - return nil - } else { - conn.close() - } + conn.close() } - isWebm := true var desc []mkvcore.TrackDescription for i, t := range conn.tracks { - var entry webm.TrackEntry codec := t.remote.Codec() - if strings.EqualFold(codec.MimeType, "audio/opus") { - entry = webm.TrackEntry{ - Name: "Audio", - TrackNumber: uint64(i + 1), - CodecID: "A_OPUS", - TrackType: 2, - Audio: &webm.Audio{ - SamplingFrequency: float64(codec.ClockRate), - Channels: uint64(codec.Channels), - }, - } - } else if strings.EqualFold(codec.MimeType, "video/vp8") { - entry = webm.TrackEntry{ - Name: "Video", - TrackNumber: uint64(i + 1), - CodecID: "V_VP8", - TrackType: 1, - Video: &webm.Video{ - PixelWidth: uint64(width), - PixelHeight: uint64(height), - }, - } - } else if strings.EqualFold(codec.MimeType, "video/vp9") { - entry = webm.TrackEntry{ - Name: "Video", - TrackNumber: uint64(i + 1), - CodecID: "V_VP9", - TrackType: 1, - Video: &webm.Video{ - PixelWidth: uint64(width), - PixelHeight: uint64(height), - }, - } - } else if strings.EqualFold(codec.MimeType, "video/h264") { - entry = webm.TrackEntry{ - Name: "Video", - TrackNumber: uint64(i + 1), - CodecID: "V_MPEG4/ISO/AVC", - TrackType: 1, - Video: &webm.Video{ - PixelWidth: uint64(width), - PixelHeight: uint64(height), - }, - } - isWebm = false - } else { - return errors.New("unknown track type") + entry := webm.TrackEntry{ + Name: "Audio", + TrackNumber: uint64(i + 1), + CodecID: "A_OPUS", + TrackType: 2, + Audio: &webm.Audio{ + SamplingFrequency: float64(codec.ClockRate), + Channels: uint64(codec.Channels), + }, } desc = append(desc, mkvcore.TrackDescription{ @@ -792,27 +652,18 @@ func (conn *diskConn) initWriter(width, height uint32, track *diskTrack, ts uint ) } - extension := "webm" - header := webm.DefaultEBMLHeader - if !isWebm { - extension = "mkv" - h := *header - h.DocType = "matroska" - header = &h - } - if track != nil { track.adjustOrigin(ts) } - err := conn.open(extension) + err := conn.open("webm") if err != nil { return err } interceptor, err := mkvcore.NewMultiTrackBlockSorter( // must be larger than the samplebuilder's MaxLate. - mkvcore.WithMaxDelayedPackets(videoMaxLate+16), + mkvcore.WithMaxDelayedPackets(audioMaxLate+16), mkvcore.WithSortRule(mkvcore.BlockSorterWriteOutdated), ) if err != nil { @@ -823,7 +674,7 @@ func (conn *diskConn) initWriter(width, height uint32, track *diskTrack, ts uint ws, err := mkvcore.NewSimpleBlockWriter( conn.file, desc, - mkvcore.WithEBMLHeader(header), + mkvcore.WithEBMLHeader(webm.DefaultEBMLHeader), mkvcore.WithSegmentInfo(webm.DefaultSegmentInfo), mkvcore.WithBlockInterceptor(interceptor), ) @@ -839,9 +690,6 @@ func (conn *diskConn) initWriter(width, height uint32, track *diskTrack, ts uint return errors.New("unexpected number of writers") } - conn.width = width - conn.height = height - for i, t := range conn.tracks { t.writer = ws[i] } diff --git a/fork-inventory.md b/fork-inventory.md new file mode 100644 index 0000000..3d45245 --- /dev/null +++ b/fork-inventory.md @@ -0,0 +1,91 @@ +# Skald Fork Inventory + +This file records the baseline surfaces found during the early hard-fork +inventory. It is intentionally short; implementation work should update the +main docs and tests, not expand this into a second task list. + +## Static And Generated Assets + +The served static tree is `static/`. Current first-party static files are: + +- `404.css`, `404.html` +- `change-password.css`, `change-password.html`, `change-password.js` +- `common.css` +- `index.html` +- `mainpage.css`, `mainpage.js` +- `management.js` +- `protocol.js` +- `skald.css`, `skald.html`, `skald.js` +- `stats.html`, `stats.js` +- `tsconfig.json` + +The static tree still includes `static/background-blur-worker.js`, which is a +video-era asset to remove during the audio-only frontend/backend work. + +The `static/example/` directory contains `example.html` and `example.js`. + +Third-party static assets are vendored under `static/third-party/`: + +- Font Awesome CSS, fonts, and license files. +- `contextual` CSS/JS and license. +- `toastify` CSS/JS and license. + +No generated static output directory is currently present in the checkout. + +## Public URL Surfaces + +Current active Skald URL surfaces found in the server and browser code: + +- `/hall/name/` +- `/public-halls.json` +- `/recordings/` +- `/skald-api/v0/` +- `/skald-api/v0/.halls/` +- `/skald-api/v0/.stats` + +Old Galene URL surfaces from the planning checklist are not active routes in +the current server code: + +- `/group/` +- `/public-groups.json` +- `/galene-api/` +- `.groups` + +The old `/group/name/` spelling remains in historical `CHANGES` text only. + +## Config And Data Paths + +Current runtime defaults and config paths: + +- `-static ./static/` +- `-data ./data/` +- `-halls ./halls/` +- `-recordings ./recordings/` +- `data/config.json` +- `data/ice-servers.json` +- `data/var/tokens.jsonl` +- `halls/hallname.json` +- `skaldctl.json` + +The old `groups/` and `galenectl.json` paths are not current runtime defaults. +Existing Galene installations should be treated as incompatible unless a later +task explicitly adds a one-shot manual migration tool or documented manual +conversion procedure. + +## Binary, Service, And Install Names + +Current binary and service surfaces found in active docs/code: + +- Server binary: `skald` +- Control tool binary: `skaldctl` +- Service example: `skald.service` +- Service user and group examples: `skald` + +Remaining `galene`, `Galene`, and `galenectl` references are historical +changelog entries, fork attribution, or the master todo's record of completed +rename work. + +## License And Attribution + +The upstream `LICENCE` file remains in place. Skald docs also keep explicit +fork attribution to Galene where appropriate. diff --git a/hall/description.go b/hall/description.go index d87238a..ce9772b 100644 --- a/hall/description.go +++ b/hall/description.go @@ -151,7 +151,7 @@ func (u UserDescription) MarshalJSON() ([]byte, error) { // about the JSON file it was deserialised from. type Description struct { // The file this was deserialised from. This is not necessarily - // the name of the hall, for example in case of a subgroup. + // the name of the hall, for example in case of a subhall. FileName string `json:"-"` // The modtime and size of the file. These are used to detect @@ -159,8 +159,8 @@ type Description struct { modTime time.Time `json:"-"` fileSize int64 `json:"-"` - // Whether this is an automatically generated subgroup - isSubgroup bool `json:"-"` + // Whether this is an automatically generated subhall + isSubhall bool `json:"-"` // The user-friendly hall name DisplayName string `json:"displayName,omitempty"` @@ -199,8 +199,8 @@ type Description struct { // Whether creating tokens is allowed UnrestrictedTokens bool `json:"unrestricted-tokens,omitempty"` - // Whether subgroups are created on the fly. - AutoSubgroups bool `json:"auto-subgroups,omitempty"` + // Whether subhalls are created on the fly. + AutoSubhalls bool `json:"auto-subhalls,omitempty"` // Whether to lock the hall when the last op logs out. Autolock bool `json:"autolock,omitempty"` @@ -231,7 +231,7 @@ type Description struct { Op []ClientPattern `json:"op,omitempty"` Presenter []ClientPattern `json:"presenter,omitempty"` Other []ClientPattern `json:"other,omitempty"` - AllowSubgroups bool `json:"allow-subgroups,omitempty"` + AllowSubhalls bool `json:"allow-subhalls,omitempty"` AllowAnonymous bool `json:"allow-anonymous,omitempty"` } @@ -244,20 +244,20 @@ func maxHistoryAge(desc *Description) time.Duration { return DefaultMaxHistoryAge } -func getDescriptionFile[T any](name string, allowSubgroups bool, get func(string) (T, error)) (T, string, bool, error) { - isSubgroup := false +func getDescriptionFile[T any](name string, allowSubhalls bool, get func(string) (T, error)) (T, string, bool, error) { + isSubhall := false for name != "" { fileName := filepath.Join( Directory, path.Clean("/"+name)+".json", ) r, err := get(fileName) if !errors.Is(err, os.ErrNotExist) { - return r, fileName, isSubgroup, err + return r, fileName, isSubhall, err } - if !allowSubgroups { + if !allowSubhalls { break } - isSubgroup = true + isSubhall = true name, _ = path.Split(name) name = strings.TrimRight(name, "/") } @@ -311,7 +311,7 @@ func GetSanitisedDescription(name string) (*Description, string, error) { if err != nil { return nil, "", err } - if d.isSubgroup { + if d.isSubhall { return nil, "", os.ErrNotExist } @@ -395,7 +395,7 @@ func rewriteDescriptionFile(filename string, desc *Description) error { if err != nil { return err } - if !conf.WritableGroups { + if !conf.WritableHalls { return ErrDescriptionsNotWritable } @@ -439,9 +439,9 @@ func rewriteDescriptionFile(filename string, desc *Description) error { } // readDescription reads a hall's description from disk -func readDescription(name string, allowSubgroups bool) (*Description, error) { - r, fileName, isSubgroup, err := - getDescriptionFile(name, allowSubgroups, os.Open) +func readDescription(name string, allowSubhalls bool) (*Description, error) { + r, fileName, isSubhall, err := + getDescriptionFile(name, allowSubhalls, os.Open) if err != nil { return nil, err } @@ -469,11 +469,11 @@ func readDescription(name string, allowSubgroups bool) (*Description, error) { return nil, err } - if isSubgroup { - if !desc.AutoSubgroups { + if isSubhall { + if !desc.AutoSubhalls { return nil, os.ErrNotExist } - desc.isSubgroup = true + desc.isSubhall = true desc.Public = false desc.Description = "" } @@ -490,9 +490,9 @@ func upgradeDescription(desc *Description) error { desc.AllowAnonymous = false } - if desc.AllowSubgroups { - desc.AutoSubgroups = true - desc.AllowSubgroups = false + if desc.AllowSubhalls { + desc.AutoSubhalls = true + desc.AllowSubhalls = false } upgradePassword := func(pw *Password) Password { diff --git a/hall/description_test.go b/hall/description_test.go index 24ec663..e625f75 100644 --- a/hall/description_test.go +++ b/hall/description_test.go @@ -59,12 +59,12 @@ func TestEmptyJSON(t *testing.T) { var descJSON = ` { "max-history-age": 10, - "auto-subgroups": true, + "auto-subhalls": true, "users": { - "jch": {"password": "topsecret", "permissions": "op"}, - "john": {"password": "secret", "permissions": "present"}, - "james": {"password": "secret2", "permissions": "message"}, - "peter": {"password": "secret4"} + "user1": {"password": "topsecret", "permissions": "op"}, + "user2": {"password": "secret", "permissions": "present"}, + "user3": {"password": "secret2", "permissions": "message"}, + "user4": {"password": "secret4"} }, "wildcard-user": {"permissions": "message", "password": {"type":"wildcard"}} @@ -95,15 +95,15 @@ func TestDescriptionJSON(t *testing.T) { var obsoleteJSON = ` { - "op": [{"username": "jch","password": "topsecret"}], + "op": [{"username": "user1","password": "topsecret"}], "max-history-age": 10, - "allow-subgroups": true, + "allow-subhalls": true, "presenter": [ - {"username": "john", "password": "secret"} + {"username": "user2", "password": "secret"} ], "other": [ - {"username": "james", "password": "secret2"}, - {"username": "peter", "password": "secret4"}, + {"username": "user3", "password": "secret2"}, + {"username": "user4", "password": "secret4"}, {} ] }` @@ -124,9 +124,9 @@ func TestUpgradeDescription(t *testing.T) { t.Fatalf("upgradeDescription: %v", err) } - if d1.AutoSubgroups != d2.AutoSubgroups || - d1.AllowSubgroups != d2.AllowSubgroups { - t.Errorf("AllowSubgroups not upgraded correctly") + if d1.AutoSubhalls != d2.AutoSubhalls || + d1.AllowSubhalls != d2.AllowSubhalls { + t.Errorf("AllowSubhalls not upgraded correctly") } if d2.Op != nil || d2.Presenter != nil || d2.Other != nil { @@ -139,7 +139,7 @@ func TestUpgradeDescription(t *testing.T) { } for k, v1 := range d1.Users { - if k == "peter" { + if k == "user4" { // not representable in the old format continue } @@ -176,13 +176,13 @@ func setupTest(dir, datadir string, writable bool) error { defer f.Close() conf := `{}` if writable { - conf = `{"writableGroups": true}` + conf = `{"writableHalls": true}` } _, err = f.WriteString(conf) return err } -func TestNonWritableGroups(t *testing.T) { +func TestNonWritableHalls(t *testing.T) { err := setupTest(t.TempDir(), t.TempDir(), false) if err != nil { t.Fatalf("setupTest: %v", err) @@ -200,7 +200,7 @@ func TestNonWritableGroups(t *testing.T) { } } -func TestWritableGroups(t *testing.T) { +func TestWritableHalls(t *testing.T) { err := setupTest(t.TempDir(), t.TempDir(), true) if err != nil { t.Fatalf("setupTest: %v", err) @@ -259,7 +259,7 @@ func TestWritableGroups(t *testing.T) { nil, "Test", err, desc.AllowAnonymous, ) } - testUser(t, "jch", false) + testUser(t, "user1", false) testUser(t, "", true) } @@ -308,7 +308,7 @@ func testUser(t *testing.T, username string, wildcard bool) { } } -func TestSubGroup(t *testing.T) { +func TestSubHall(t *testing.T) { err := setupTest(t.TempDir(), t.TempDir(), true) if err != nil { t.Fatalf("setupTest: %v", err) diff --git a/hall/hall.go b/hall/hall.go index 9c0f8d0..4a124cc 100644 --- a/hall/hall.go +++ b/hall/hall.go @@ -216,39 +216,6 @@ func fmtpValue(fmtp, key string) string { func CodecPayloadType(codec webrtc.RTPCodecCapability) (webrtc.PayloadType, error) { switch strings.ToLower(codec.MimeType) { - case "video/vp8": - return 96, nil - case "video/vp9": - profile := fmtpValue(codec.SDPFmtpLine, "profile-id") - switch profile { - case "", "0": - return 98, nil - case "2": - return 100, nil - default: - return 0, fmt.Errorf("unknown VP9 profile %v", profile) - - } - case "video/av1": - return 35, nil - case "video/h264": - profile := fmtpValue(codec.SDPFmtpLine, "profile-level-id") - if profile == "" { - return 102, nil - } - if len(profile) < 4 { - return 0, errors.New("malforned H.264 profile") - } - switch strings.ToLower(profile[:4]) { - case "4200": - return 102, nil - case "42e0": - return 108, nil - default: - return 0, fmt.Errorf( - "unknown H.264 profile %v", profile, - ) - } case "audio/opus": return 111, nil case "audio/g722": @@ -262,89 +229,48 @@ func CodecPayloadType(codec webrtc.RTPCodecCapability) (webrtc.PayloadType, erro } } -// VideoRTCPFeedback are the RTCP feedback types that we expect for video -// tracks. -var VideoRTCPFeedback = []webrtc.RTCPFeedback{ - {"goog-remb", ""}, - {"nack", ""}, - {"nack", "pli"}, - {"ccm", "fir"}, -} - -// AudioRTCPFeedback is like VideoRTCPFeedback but for audio tracks. +// AudioRTCPFeedback is the RTCP feedback for audio tracks. var AudioRTCPFeedback = []webrtc.RTCPFeedback(nil) func codecsFromName(name string) ([]webrtc.RTPCodecParameters, error) { var codecs []webrtc.RTPCodecCapability switch name { - case "vp8": - codecs = []webrtc.RTPCodecCapability{ - { - "video/VP8", 90000, 0, - "", - VideoRTCPFeedback, - }, - } - case "vp9": - codecs = []webrtc.RTPCodecCapability{ - { - "video/VP9", 90000, 0, - "profile-id=0", - VideoRTCPFeedback, - }, - { - "video/VP9", 90000, 0, - "profile-id=2", - VideoRTCPFeedback, - }, - } - case "av1": - codecs = []webrtc.RTPCodecCapability{ - { - "video/AV1", 90000, 0, - "", - VideoRTCPFeedback, - }, - } - case "h264": - codecs = []webrtc.RTPCodecCapability{ - { - "video/H264", 90000, 0, - "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f", - VideoRTCPFeedback, - }, - } case "opus": codecs = []webrtc.RTPCodecCapability{ { - "audio/opus", 48000, 2, - "minptime=10;useinbandfec=1;stereo=1;sprop-stereo=1", - AudioRTCPFeedback, + MimeType: "audio/opus", + ClockRate: 48000, + Channels: 2, + SDPFmtpLine: "minptime=10;useinbandfec=1;stereo=1;sprop-stereo=1", + RTCPFeedback: AudioRTCPFeedback, }, } case "g722": codecs = []webrtc.RTPCodecCapability{ { - "audio/G722", 8000, 1, - "", - AudioRTCPFeedback, + MimeType: "audio/G722", + ClockRate: 8000, + Channels: 1, + RTCPFeedback: AudioRTCPFeedback, }, } case "pcmu": codecs = []webrtc.RTPCodecCapability{ { - "audio/PCMU", 8000, 1, - "", - AudioRTCPFeedback, + MimeType: "audio/PCMU", + ClockRate: 8000, + Channels: 1, + RTCPFeedback: AudioRTCPFeedback, }, } case "pcma": codecs = []webrtc.RTPCodecCapability{ { - "audio/PCMA", 8000, 1, - "", - AudioRTCPFeedback, + MimeType: "audio/PCMA", + ClockRate: 8000, + Channels: 1, + RTCPFeedback: AudioRTCPFeedback, }, } default: @@ -384,11 +310,7 @@ func APIFromCodecs(codecs []webrtc.RTPCodecParameters) (*webrtc.API, error) { m := webrtc.MediaEngine{} for _, codec := range codecs { - tpe := webrtc.RTPCodecTypeVideo - if strings.HasPrefix(strings.ToLower(codec.MimeType), "audio/") { - tpe = webrtc.RTPCodecTypeAudio - } - err := m.RegisterCodec(codec, tpe) + err := m.RegisterCodec(codec, webrtc.RTPCodecTypeAudio) if err != nil { log.Printf("%v", err) continue @@ -401,11 +323,6 @@ func APIFromCodecs(codecs []webrtc.RTPCodecParameters) (*webrtc.API, error) { s.SetEphemeralUDPPortRange(UDPMin, UDPMax) } - err := webrtc.ConfigureSimulcastExtensionHeaders(&m) - if err != nil { - return nil, err - } - ir := interceptor.Registry{} return webrtc.NewAPI( @@ -417,7 +334,7 @@ func APIFromCodecs(codecs []webrtc.RTPCodecParameters) (*webrtc.API, error) { func APIFromNames(names []string) (*webrtc.API, error) { if len(names) == 0 { - names = []string{"vp8", "opus"} + names = []string{"opus"} } var codecs []webrtc.RTPCodecParameters for _, n := range names { @@ -543,14 +460,14 @@ func GetNames() []string { return names } -type SubGroup struct { +type SubHall struct { Name string Clients int } -func GetSubGroups(parent string) []SubGroup { +func GetSubHalls(parent string) []SubHall { prefix := parent + "/" - subgroups := make([]SubGroup, 0) + subhalls := make([]SubHall, 0) Range(func(g *Hall) bool { if strings.HasPrefix(g.name, prefix) { @@ -558,13 +475,13 @@ func GetSubGroups(parent string) []SubGroup { count := len(g.clients) g.mu.Unlock() if count > 0 { - subgroups = append(subgroups, - SubGroup{g.name, count}) + subhalls = append(subhalls, + SubHall{g.name, count}) } } return true }) - return subgroups + return subhalls } func Get(name string) *Hall { @@ -929,7 +846,7 @@ type Configuration struct { AllowOrigin []string `json:"allowOrigin,omitempty"` AllowAdminOrigin []string `json:"allowAdminOrigin,omitempty"` ProxyURL string `json:"proxyURL,omitempty"` - WritableGroups bool `json:"writableGroups,omitempty"` + WritableHalls bool `json:"writableHalls,omitempty"` Users map[string]UserDescription `json:"users,omitempty"` // obsolete fields @@ -1173,7 +1090,7 @@ func (g *Hall) Status(authentified bool, base *url.URL) Status { if authentified { conf, err := GetConfiguration() if err == nil { - d.CanChangePassword = conf.WritableGroups + d.CanChangePassword = conf.WritableHalls } } return d diff --git a/hall/hall_test.go b/hall/hall_test.go index 59ffd45..041df3a 100644 --- a/hall/hall_test.go +++ b/hall/hall_test.go @@ -34,23 +34,23 @@ func TestConstantTimeCompare(t *testing.T) { } } -func TestGroup(t *testing.T) { +func TestHall(t *testing.T) { halls.halls = nil Add("hall", &Description{}) - Add("hall/subgroup", &Description{Public: true}) + Add("hall/subhall", &Description{Public: true}) if len(halls.halls) != 2 { t.Errorf("Expected 2, got %v", len(halls.halls)) } g := Get("hall") - g2 := Get("hall/subgroup") + g2 := Get("hall/subhall") if g == nil { t.Fatalf("Couldn't get hall") } if g2 == nil { - t.Fatalf("Couldn't get hall/subgroup") + t.Fatalf("Couldn't get hall/subhall") } if name := g.Name(); name != "hall" { - t.Errorf("Name: expected group1, got %v", name) + t.Errorf("Name: expected hall, got %v", name) } if locked, _ := g.Locked(); locked { t.Errorf("Locked: expected false, got %v", locked) @@ -64,12 +64,12 @@ func TestGroup(t *testing.T) { t.Errorf("Expected 2, got %v", names) } - if subs := GetSubGroups("hall"); len(subs) != 0 { + if subs := GetSubHalls("hall"); len(subs) != 0 { t.Errorf("Expected [], got %v", subs) } - if public := GetPublic(nil); len(public) != 1 || public[0].Name != "hall/subgroup" { - t.Errorf("Expected hall/subgroup, got %v", public) + if public := GetPublic(nil); len(public) != 1 || public[0].Name != "hall/subhall" { + t.Errorf("Expected hall/subhall, got %v", public) } } @@ -167,7 +167,7 @@ type credPerm struct { var desc2JSON = ` { "max-history-age": 10, - "auto-subgroups": true, + "auto-subhalls": true, "users": { "jch": {"password": "topsecret", "permissions": "op"}, "john": {"password": "secret", "permissions": "present"}, @@ -377,7 +377,6 @@ func TestValidHallName(t *testing.T) { func TestPayloadTypeDistinct(t *testing.T) { names := []string{ - "vp8", "vp9", "av1", "h264", "opus", "g722", "pcmu", "pcma", } diff --git a/packetcache/packetcache_test.go b/packetcache/packetcache_test.go index 5dbc035..f5ee203 100644 --- a/packetcache/packetcache_test.go +++ b/packetcache/packetcache_test.go @@ -270,7 +270,10 @@ func TestBitmapPacket(t *testing.T) { t.Fatalf("Didn't find any 0 bits") } - p := rtcp.NackPair{first, rtcp.PacketBitmap(bitmap)} + p := rtcp.NackPair{ + PacketID: first, + LostPackets: rtcp.PacketBitmap(bitmap), + } p.Range(func(s uint16) bool { if s < 42 || s >= 42+64 { if (value & (1 << (s - 42))) != 0 { @@ -403,7 +406,10 @@ func TestToBitmapNack(t *testing.T) { for len(m) > 0 { var f, b uint16 f, b, m = ToBitmap(m) - nacks = append(nacks, rtcp.NackPair{f, rtcp.PacketBitmap(b)}) + nacks = append(nacks, rtcp.NackPair{ + PacketID: f, + LostPackets: rtcp.PacketBitmap(b), + }) } var n []uint16 for len(nacks) > 0 { diff --git a/rtpconn/rtpconn.go b/rtpconn/rtpconn.go index 54db4ee..92ea956 100644 --- a/rtpconn/rtpconn.go +++ b/rtpconn/rtpconn.go @@ -230,68 +230,12 @@ func (down *rtpDownTrack) Write(buf []byte) (int, error) { return 0, err } - layer := down.getLayerInfo() - - if flags.Tid > layer.maxTid || flags.Sid > layer.maxSid { - if flags.Tid > layer.maxTid { - // increase eagerly if this is the first time we - // see a given layer - if layer.tid == layer.maxTid { - layer.wantedTid = flags.Tid - layer.tid = flags.Tid - } - layer.maxTid = flags.Tid - } - if flags.Sid > layer.maxSid { - if layer.sid == layer.maxSid && !layer.limitSid { - layer.wantedSid = flags.Sid - layer.sid = flags.Sid - } - layer.maxSid = flags.Sid - } - down.setLayerInfo(layer) - down.adjustLayer() - layer = down.getLayerInfo() - } - - if flags.Start && (layer.tid != layer.wantedTid) { - if flags.Keyframe { - layer.tid = layer.wantedTid - down.setLayerInfo(layer) - } else if layer.wantedTid < layer.tid { - layer.tid = layer.wantedTid - down.setLayerInfo(layer) - } else if flags.TidUpSync && flags.Tid <= layer.wantedTid { - layer.tid = flags.Tid - down.setLayerInfo(layer) - } - } - - if flags.Start && (layer.sid != layer.wantedSid) { - if flags.Keyframe { - layer.sid = layer.wantedSid - down.setLayerInfo(layer) - } else { - down.remote.RequestKeyframe() - } - } - - if flags.Tid > layer.tid || flags.Sid > layer.sid || - (flags.Sid < layer.sid && flags.SidNonReference) { - ok := down.packetmap.Drop(flags.Seqno, flags.Pid) - if ok { - return 0, nil - } - } - ok, newseqno, piddelta := down.packetmap.Map(flags.Seqno, flags.Pid) if !ok { return 0, nil } - setMarker := flags.Sid == layer.sid && flags.End && !flags.Marker - - if !setMarker && newseqno == flags.Seqno && piddelta == 0 { + if newseqno == flags.Seqno && piddelta == 0 { return down.write(buf) } @@ -300,7 +244,7 @@ func (down *rtpDownTrack) Write(buf []byte) (int, error) { buf2 := ibuf2.([]byte) n := copy(buf2, buf) - err = codecs.RewritePacket(codec, buf2[:n], setMarker, newseqno, piddelta) + err = codecs.RewritePacket(codec, buf2[:n], false, newseqno, piddelta) if err != nil { return 0, err } @@ -420,7 +364,6 @@ type trackActionKind int const ( trackActionAdd trackActionKind = iota trackActionDel - trackActionKeyframe ) type trackAction struct { @@ -454,11 +397,6 @@ func (up *rtpUpTrack) AddLocal(local conn.DownTrack) error { return nil } -func (up *rtpUpTrack) RequestKeyframe() error { - up.action(trackActionKeyframe, nil) - return nil -} - func (up *rtpUpTrack) DelLocal(local conn.DownTrack) bool { up.mu.Lock() for i, l := range up.local { @@ -598,7 +536,7 @@ func (up *rtpUpConnection) flushICECandidates() error { return err } -// pushConnNow pushes a connection to all of the clients in a group +// pushConnNow pushes a connection to all of the clients in a hall func pushConnNow(up *rtpUpConnection, g *hall.Hall, cs []hall.Client) { up.mu.Lock() up.pushed = true @@ -650,8 +588,13 @@ func newUpConn(c hall.Client, id string, label string, offer string) (*rtpUpConn } for _, m := range o.MediaDescriptions { + tpe := webrtc.NewRTPCodecType(m.MediaName.Media) + if tpe == webrtc.RTPCodecTypeVideo { + log.Printf("Rejecting incoming video media section") + continue + } _, err = pc.AddTransceiverFromKind( - webrtc.NewRTPCodecType(m.MediaName.Media), + tpe, webrtc.RTPTransceiverInit{ Direction: webrtc.RTPTransceiverDirectionRecvonly, }, @@ -665,6 +608,10 @@ func newUpConn(c hall.Client, id string, label string, offer string) (*rtpUpConn up := &rtpUpConnection{id: id, client: c, label: label, pc: pc} pc.OnTrack(func(remote *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { + if remote.Kind() == webrtc.RTPCodecTypeVideo { + log.Printf("Rejecting incoming video track") + return + } up.mu.Lock() track := &rtpUpTrack{ @@ -698,26 +645,16 @@ func newUpConn(c hall.Client, id string, label string, offer string) (*rtpUpConn var ErrUnsupportedFeedback = errors.New("unsupported feedback type") var ErrRateLimited = errors.New("rate limited") -func (track *rtpUpTrack) sendPLI() error { - if !track.hasRtcpFb("nack", "pli") { - return ErrUnsupportedFeedback - } - return sendPLI(track.conn.pc, track.track.SSRC()) -} - -func sendPLI(pc *webrtc.PeerConnection, ssrc webrtc.SSRC) error { - return pc.WriteRTCP([]rtcp.Packet{ - &rtcp.PictureLossIndication{MediaSSRC: uint32(ssrc)}, - }) -} - func (track *rtpUpTrack) sendNACK(first uint16, bitmap uint16) error { if !track.hasRtcpFb("nack", "") { return ErrUnsupportedFeedback } err := sendNACKs(track.conn.pc, track.track.SSRC(), - []rtcp.NackPair{{first, rtcp.PacketBitmap(bitmap)}}, + []rtcp.NackPair{{ + PacketID: first, + LostPackets: rtcp.PacketBitmap(bitmap), + }}, ) if err == nil { track.cache.Expect(1 + bits.OnesCount16(bitmap)) @@ -744,7 +681,10 @@ func (track *rtpUpTrack) sendNACKs(seqnos []uint16) error { } var f, b uint16 f, b, seqnos = packetcache.ToBitmap(seqnos) - nacks = append(nacks, rtcp.NackPair{f, rtcp.PacketBitmap(b)}) + nacks = append(nacks, rtcp.NackPair{ + PacketID: f, + LostPackets: rtcp.PacketBitmap(b), + }) } err := sendNACKs(track.conn.pc, track.track.SSRC(), nacks) if err == nil { @@ -814,7 +754,7 @@ func rtcpUpListener(track *rtpUpTrack) { for { firstSR := false - n, _, err := track.receiver.ReadSimulcast(buf, track.track.RID()) + n, _, err := track.receiver.Read(buf) if err != nil { if err != io.EOF && err != io.ErrClosedPipe { log.Printf("Read RTCP: %v", err) @@ -1147,7 +1087,6 @@ func (track *rtpDownTrack) updateRate(loss uint8, now uint64) { } func rtcpDownListener(track *rtpDownTrack) { - lastFirSeqno := uint8(0) buf := make([]byte, 1500) @@ -1165,40 +1104,18 @@ func rtcpDownListener(track *rtpDownTrack) { continue } - adjust := false jiffies := rtptime.Jiffies() for _, p := range ps { switch p := p.(type) { - case *rtcp.PictureLossIndication: - track.remote.RequestKeyframe() - case *rtcp.FullIntraRequest: - found := false - var seqno uint8 - for _, entry := range p.FIR { - if entry.SSRC == uint32(track.ssrc) { - found = true - seqno = entry.SequenceNumber - break - } - } - if !found { - log.Printf("Misdirected FIR") - continue - } - if seqno != lastFirSeqno { - track.remote.RequestKeyframe() - } case *rtcp.ReceiverEstimatedMaximumBitrate: rate := uint64(p.Bitrate + 0.5) track.maxREMBBitrate.Set(rate, jiffies) - adjust = true case *rtcp.ReceiverReport: for _, r := range p.Reports { if r.SSRC == uint32(track.ssrc) { handleReport(track, r, jiffies) - adjust = true } } case *rtcp.SenderReport: @@ -1211,9 +1128,7 @@ func rtcpDownListener(track *rtpDownTrack) { gotNACK(track, p) } } - if adjust { - track.adjustLayer() - } + } } @@ -1245,9 +1160,6 @@ func handleReport(track *rtpDownTrack, report rtcp.ReceptionReport, jiffies uint } func minPacketCache(track *webrtc.TrackRemote) int { - if track.Kind() == webrtc.RTPCodecTypeVideo { - return 128 - } return 24 } diff --git a/rtpconn/rtpreader.go b/rtpconn/rtpreader.go index ecf14ac..2dd3f37 100644 --- a/rtpconn/rtpreader.go +++ b/rtpconn/rtpreader.go @@ -3,12 +3,9 @@ package rtpconn import ( "io" "log" - "time" "github.com/pion/rtp" - "github.com/pion/webrtc/v4" - "git.stormux.org/storm/skald/codecs" "git.stormux.org/storm/skald/packetcache" "git.stormux.org/storm/skald/rtptime" ) @@ -20,12 +17,7 @@ func readLoop(track *rtpUpTrack) { close(track.readerDone) }() - isvideo := track.track.Kind() == webrtc.RTPCodecTypeVideo - codec := track.track.Codec() sendNACK := track.hasRtcpFb("nack", "") - sendPLI := track.hasRtcpFb("nack", "pli") - var kfNeeded bool - var kfRequested time.Time buf := make([]byte, packetcache.BufSize) var packet rtp.Packet for { @@ -46,8 +38,6 @@ func readLoop(track *rtpUpTrack) { err, ) } - case trackActionKeyframe: - kfNeeded = true default: log.Printf("Unknown action") } @@ -72,10 +62,6 @@ func readLoop(track *rtpUpTrack) { track.jitter.Accumulate(packet.Timestamp) - kf, kfKnown := codecs.Keyframe(codec.MimeType, &packet) - if kf || !kfKnown { - kfNeeded = false - } if packet.Extension { packet.Extension = false packet.Extensions = nil @@ -88,7 +74,7 @@ func readLoop(track *rtpUpTrack) { first, index := track.cache.Store( packet.SequenceNumber, packet.Timestamp, - kf, packet.Marker, buf[:bytes], + false, packet.Marker, buf[:bytes], ) _, rate := track.rate.Estimate() @@ -131,20 +117,6 @@ func readLoop(track *rtpUpTrack) { } writers.write(packet.SequenceNumber, index, delay, - isvideo, packet.Marker) - - now := time.Now() - if kfNeeded && now.Sub(kfRequested) > time.Second/2 { - if sendPLI { - err := track.sendPLI() - if err != nil { - log.Printf("sendPLI: %v", err) - kfNeeded = false - } - } else { - kfNeeded = false - } - kfRequested = now - } + packet.Marker) } } diff --git a/rtpconn/rtpwriter.go b/rtpconn/rtpwriter.go index 7f4f524..56484c9 100644 --- a/rtpconn/rtpwriter.go +++ b/rtpconn/rtpwriter.go @@ -105,7 +105,7 @@ func (wp *rtpWriterPool) close() { } // write writes a packet stored in the packet cache to all local tracks -func (wp *rtpWriterPool) write(seqno uint16, index uint16, delay uint32, isvideo bool, marker bool) { +func (wp *rtpWriterPool) write(seqno uint16, index uint16, delay uint32, marker bool) { pi := packetIndex{seqno, index} var dead []*rtpWriter @@ -127,15 +127,7 @@ func (wp *rtpWriterPool) write(seqno uint16, index uint16, delay uint32, isvideo // the writer is dead. dead = append(dead, w) default: - // the writer is congested - if isvideo { - // drop until the end of the frame - if !marker { - w.drop = 7 - } - continue - } - // audio, try again with a delay + // the writer is congested, try again with a delay d := delay / uint32(2*len(wp.writers)) timer := time.NewTimer(rtptime.ToDuration( int64(d), rtptime.JiffiesPerSec, @@ -256,20 +248,12 @@ func rtpWriterLoop(writer *rtpWriter, track *rtpUpTrack) { } last, foundLast := track.cache.Last() - kf, foundKf := track.cache.Keyframe() - if foundLast && foundKf { - if last-kf < 40 { // modulo 2^16 - go sendSequence( - kf, last, - action.track, - track.cache, - ) - } else { - track.RequestKeyframe() - } - } else { - // no keyframe yet, one should - // arrive soon. Do nothing. + if foundLast { + go sendSequence( + last, last, + action.track, + track.cache, + ) } } else { found := false diff --git a/rtpconn/webclient.go b/rtpconn/webclient.go index 377164d..9e026c1 100644 --- a/rtpconn/webclient.go +++ b/rtpconn/webclient.go @@ -10,7 +10,6 @@ import ( "maps" "net" "os" - "strings" "sync" "time" @@ -109,9 +108,9 @@ func (c *webClient) SetPermissions(perms []string) { c.permissions = perms } -func (c *webClient) PushClient(group, kind, id string, username string, perms []string, data map[string]interface{}) error { +func (c *webClient) PushClient(hall, kind, id string, username string, perms []string, data map[string]interface{}) error { c.action(pushClientAction{ - group, kind, id, username, perms, data, + hall, kind, id, username, perms, data, }) return nil } @@ -132,7 +131,7 @@ type clientMessage struct { Permissions []string `json:"permissions,omitempty"` Status *hall.Status `json:"status,omitempty"` Data map[string]interface{} `json:"data,omitempty"` - Group string `json:"group,omitempty"` + Hall string `json:"hall,omitempty"` Value interface{} `json:"value,omitempty"` NoEcho bool `json:"noecho,omitempty"` Time string `json:"time,omitempty"` @@ -385,11 +384,7 @@ func addDownTrackUnlocked(conn *rtpDownConnection, remoteTrack *rtpUpTrack) erro // replace the RTCP feedback types with the ones we understand remoteCodec := remoteTrack.Codec() - if strings.HasPrefix(strings.ToLower(remoteCodec.MimeType), "video/") { - remoteCodec.RTCPFeedback = hall.VideoRTCPFeedback - } else { - remoteCodec.RTCPFeedback = hall.AudioRTCPFeedback - } + remoteCodec.RTCPFeedback = hall.AudioRTCPFeedback local, err := webrtc.NewTrackLocalStaticRTP( remoteCodec, id, msid, @@ -727,7 +722,7 @@ func parseRequested(r interface{}) (map[string][]string, error) { func (c *webClient) setRequested(requested map[string][]string) error { if c.hall == nil { - return errors.New("attempted to request with no group joined") + return errors.New("attempted to request with no hall joined") } c.requested = requested @@ -761,60 +756,27 @@ func requestedTracks(c *webClient, requested []string, tracks []conn.UpTrack) ([ if len(requested) == 0 { return nil, false } - var audio, video, videoLow bool + var audio bool for _, s := range requested { switch s { case "audio": audio = true - case "video": - video = true - case "video-low": - videoLow = true default: log.Printf("client requested unknown value %v", s) } } - find := func(kind webrtc.RTPCodecType, last bool) (conn.UpTrack, int) { - var track conn.UpTrack - count := 0 + var ts []conn.UpTrack + if audio { for _, t := range tracks { - if t.Kind() != kind { - continue - } - track = t - count++ - if !last { + if t.Kind() == webrtc.RTPCodecTypeAudio { + ts = append(ts, t) break } } - return track, count } - var ts []conn.UpTrack - limitSid := false - if audio { - t, _ := find(webrtc.RTPCodecTypeAudio, false) - if t != nil { - ts = append(ts, t) - } - } - if video { - t, _ := find(webrtc.RTPCodecTypeVideo, false) - if t != nil { - ts = append(ts, t) - } - } else if videoLow { - t, count := find(webrtc.RTPCodecTypeVideo, true) - if t != nil { - ts = append(ts, t) - } - if count < 2 { - limitSid = true - } - } - - return ts, limitSid + return ts, false } func (c *webClient) PushConn(g *hall.Hall, id string, up conn.Up, tracks []conn.UpTrack, replace string) error { @@ -914,7 +876,7 @@ type connectionFailedAction struct { } type pushClientAction struct { - group string + hall string kind string id string username string @@ -929,8 +891,8 @@ type changePermissionsAction struct { type permissionsChangedAction struct{} type joinedAction struct { - group string - kind string + hall string + kind string } type kickAction struct { @@ -972,7 +934,7 @@ func clientLoop(c *webClient, ws *websocket.Conn, versionError bool) error { read := make(chan interface{}, 1) go clientReader(ws, read, c.done) - defer leaveGroup(c) + defer leaveHall(c) readTime := time.Now() @@ -1109,7 +1071,7 @@ func handleAction(c *webClient, a any) error { switch a := a.(type) { case pushConnAction: if c.hall == nil || c.hall != a.hall { - log.Printf("Got connectsions for wrong group") + log.Printf("Got connectsions for wrong hall") return nil } return pushDownConn(c, a.id, a.conn, a.tracks, a.replace) @@ -1163,7 +1125,7 @@ func handleAction(c *webClient, a any) error { } case pushClientAction: - if a.group != c.hall.Name() { + if a.hall != c.hall.Name() { log.Printf("got client for wrong hall") return nil } @@ -1181,8 +1143,8 @@ func handleAction(c *webClient, a any) error { var status *hall.Status var data map[string]interface{} var g *hall.Hall - if a.group != "" { - g = hall.Get(a.group) + if a.hall != "" { + g = hall.Get(a.hall) if g != nil { s := g.Status(true, nil) status = &s @@ -1194,7 +1156,7 @@ func handleAction(c *webClient, a any) error { err := c.write(clientMessage{ Type: "joined", Kind: a.kind, - Group: a.group, + Hall: a.hall, Username: &username, Permissions: perms, Status: status, @@ -1252,7 +1214,7 @@ func handleAction(c *webClient, a any) error { case permissionsChangedAction: g := c.Hall() if g == nil { - return errors.New("Permissions changed in no group") + return errors.New("Permissions changed in no hall") } perms := append([]string(nil), c.permissions...) status := g.Status(true, nil) @@ -1260,7 +1222,7 @@ func handleAction(c *webClient, a any) error { c.write(clientMessage{ Type: "joined", Kind: "change", - Group: g.Name(), + Hall: g.Name(), Username: &username, Permissions: perms, Status: &status, @@ -1293,7 +1255,9 @@ func handleAction(c *webClient, a any) error { }(clients) case kickAction: return hall.KickError{ - a.id, a.username, a.message, + Id: a.id, + Username: a.username, + Message: a.message, } default: log.Printf("unexpected action %T", a) @@ -1321,7 +1285,7 @@ func failUpConnection(c *webClient, id string, message string) error { return nil } -func leaveGroup(c *webClient) { +func leaveHall(c *webClient) { if c.hall == nil { return } @@ -1370,8 +1334,8 @@ func (c *webClient) Kick(id string, user *string, message string) error { return nil } -func (c *webClient) Joined(group, kind string) error { - c.action(joinedAction{group, kind}) +func (c *webClient) Joined(hall, kind string) error { + c.action(joinedAction{hall, kind}) return nil } @@ -1402,10 +1366,10 @@ func handleClientMessage(c *webClient, m clientMessage) error { switch m.Type { case "join": if m.Kind == "leave" { - if c.hall == nil || c.hall.Name() != m.Group { + if c.hall == nil || c.hall.Name() != m.Hall { return hall.UserError("you are not joined") } - leaveGroup(c) + leaveHall(c) return nil } @@ -1415,11 +1379,11 @@ func handleClientMessage(c *webClient, m clientMessage) error { if c.hall != nil { return hall.ProtocolError( - "cannot join multiple groups", + "cannot join multiple halls", ) } c.data = m.Data - g, err := hall.AddClient(m.Group, c, + g, err := hall.AddClient(m.Hall, c, hall.ClientCredentials{ Username: m.Username, Password: m.Password, @@ -1438,33 +1402,33 @@ func handleClientMessage(c *webClient, m clientMessage) error { } else if errors.As(err, &autherr) { s = "not authorised" time.Sleep(200 * time.Millisecond) - log.Printf("Join group: %v", err) + log.Printf("Join hall: %v", err) } else if errors.Is(err, os.ErrNotExist) { - s = "group does not exist" + s = "hall does not exist" } else if _, ok := err.(hall.UserError); ok { s = err.Error() } else { s = "internal server error" - log.Printf("Join group: %v", err) + log.Printf("Join hall: %v", err) } username := c.username return c.write(clientMessage{ Type: "joined", Kind: "fail", Error: e, - Group: m.Group, + Hall: m.Hall, Username: &username, Value: s, }) } if redirect := g.Description().Redirect; redirect != "" { - // We normally redirect at the HTTP level, but the group + // We normally redirect at the HTTP level, but the hall // description could have been edited in the meantime. username := c.username return c.write(clientMessage{ Type: "joined", Kind: "redirect", - Group: m.Group, + Hall: m.Hall, Username: &username, Value: redirect, }) @@ -1574,7 +1538,7 @@ func handleClientMessage(c *webClient, m clientMessage) error { case "chat", "usermessage": g := c.hall if g == nil { - return c.error(hall.UserError("join a group first")) + return c.error(hall.UserError("join a hall first")) } required := "message" @@ -1635,10 +1599,10 @@ func handleClientMessage(c *webClient, m clientMessage) error { } ccc.write(mm) } - case "groupaction": + case "hallaction": g := c.hall if g == nil { - return c.error(hall.UserError("join a group first")) + return c.error(hall.UserError("join a hall first")) } switch m.Kind { case "clearchat": @@ -1714,12 +1678,12 @@ func handleClientMessage(c *webClient, m clientMessage) error { hall.DelClient(disk) } } - case "subgroups": + case "subhalls": if !member("op", c.permissions) { return c.error(hall.UserError("not authorised")) } s := "" - for _, sg := range hall.GetSubGroups(g.Name()) { + for _, sg := range hall.GetSubHalls(g.Name()) { plural := "" if sg.Clients > 1 { plural = "s" @@ -1773,10 +1737,10 @@ func handleClientMessage(c *webClient, m clientMessage) error { return terror("error", "client specified token") } - if tok.Group != c.hall.Name() { - return terror("error", "wrong group in token") + if tok.Hall != c.hall.Name() { + return terror("error", "wrong hall in token") } - if tok.IncludeSubgroups { + if tok.IncludeSubhalls { return terror("error", "hierarchical token not allowed", ) @@ -1835,8 +1799,8 @@ func handleClientMessage(c *webClient, m clientMessage) error { if err != nil { return terror("error", err.Error()) } - if tok.Group != "" || tok.Username != nil || - tok.IncludeSubgroups || + if tok.Hall != "" || tok.Username != nil || + tok.IncludeSubhalls || tok.Permissions != nil || tok.IssuedBy != nil || tok.IssuedAt != nil { @@ -1892,12 +1856,12 @@ func handleClientMessage(c *webClient, m clientMessage) error { Value: tokens, }) default: - return hall.UserError("unknown group action") + return hall.UserError("unknown hall action") } case "useraction": g := c.hall if g == nil { - return c.error(hall.UserError("join a group first")) + return c.error(hall.UserError("join a hall first")) } switch m.Kind { case "op", "unop", "present", "unpresent", "shutup", "unshutup": @@ -2076,7 +2040,7 @@ func parseStatefulToken(value interface{}) (*token.Stateful, error) { if err != nil { return nil, err } - g, err := parseString("group") + g, err := parseString("hall") if err != nil { return nil, err } @@ -2098,7 +2062,7 @@ func parseStatefulToken(value interface{}) (*token.Stateful, error) { } return &token.Stateful{ Token: tt, - Group: gg, + Hall: gg, Username: u, Permissions: p, Expires: e, diff --git a/rtpconn/webclient_test.go b/rtpconn/webclient_test.go index 7939579..12b10d6 100644 --- a/rtpconn/webclient_test.go +++ b/rtpconn/webclient_test.go @@ -11,18 +11,18 @@ import ( var tokens = []string{ `{ "token": "a", - "group": "g", + "hall": "h", "username": "u", "permissions":["present"], "expires": "2023-05-03T20:24:47.616624532+02:00" }`, `{ "token": "a", - "group": "g" + "hall": "h" }`, `{ "token": "a", - "group": "g", + "hall": "h", "username":"" }`, } diff --git a/rtpconn/whipclient.go b/rtpconn/whipclient.go index 9c3d84a..46ea2a1 100644 --- a/rtpconn/whipclient.go +++ b/rtpconn/whipclient.go @@ -107,11 +107,11 @@ func (c *WhipClient) RequestConns(target hall.Client, g *hall.Hall, id string) e return nil } -func (c *WhipClient) Joined(group, kind string) error { +func (c *WhipClient) Joined(hall, kind string) error { return nil } -func (c *WhipClient) PushClient(group, kind, id, username string, permissions []string, status map[string]interface{}) error { +func (c *WhipClient) PushClient(hall, kind, id, username string, permissions []string, status map[string]interface{}) error { return nil } diff --git a/skald-api.md b/skald-api.md index 41d4825..eefbb69 100644 --- a/skald-api.md +++ b/skald-api.md @@ -7,19 +7,19 @@ users. For example, in order to create a hall, a client may do Content-Type: application/json If-None-Match: * -The `If-None-Match` header avoids overwriting an existing group. +The `If-None-Match` header avoids overwriting an existing hall. In order to edit a hall definition, a client first does GET /skald-api/v0/.halls/hallname/ This yields the hall definition and an entity tag (in the ETag header). -The client then modifies the group defintion, and does +The client then modifies the hall definition, and does PUT /skald-api/v0/.halls/hallname/ If-Match: "abcd" -where "abcd" is the entity tag returned by the GET request. If the group +where "abcd" is the entity tag returned by the GET request. If the hall definition has changed in the meantime, the entity tag will no longer be valid, and the server will fail the update, which avoids losing an update in the case of a concurrent modification. @@ -46,7 +46,7 @@ allowed methods are HEAD and GET. Returns a list of halls, as a JSON array. The only allowed methods are HEAD and GET. -### Group definition +### Hall definition /skald-api/v0/.halls/hallname diff --git a/skald-client.md b/skald-client.md index d2f33bc..d284321 100644 --- a/skald-client.md +++ b/skald-client.md @@ -19,15 +19,14 @@ well as all the associated streams. Unless your frontend communicates with multiple servers, it will probably create just a single instance of this class. -The class `Stream` encapsulates a set of related audio and video tracks -(for example, an audio track from a microphone and a video track from -a webcam). A stream is said to go *up* when it carries data from the -client to the server, and *down* otherwise. Streams going up are created -by the client (your frontend), streams going down are created by the server. +The class `Stream` encapsulates a set of related audio tracks. A stream +is said to go *up* when it carries data from the client to the server, and +*down* otherwise. Streams going up are created by the client (your +frontend), streams going down are created by the server. ## Connecting to the server -First, fetch the `.status` JSON at the group URL: +First, fetch the `.status` JSON at the hall URL: ```javascript let r = await fetch(url + ".status"); @@ -57,11 +56,11 @@ have been closed by the time it is called. The `onusermessage` callback indicates an application-specific message, either from another user or from the server; the field `kind` indicates the kind of message. -Once you have joined a group (see below), the remaining callbacks may +Once you have joined a hall (see below), the remaining callbacks may trigger. The `onuser` callback is used to indicate that a user has joined -or left the current group, or that their attributes have changed; the +or left the current hall, or that their attributes have changed; the user's state can be found in the `users` dictionary. The `onchat` -callback indicates that a chat message has been posted to the group, and +callback indicates that a chat message has been posted to the hall, and `onclearchat` indicates that the chat history has been cleared. Finally, `ondownstream` is called when the server pushes a stream to the client; see the section below about streams. @@ -72,11 +71,11 @@ You may now connect to the server: serverConnection.connect(status.endpoint); ``` -You typically join a group in the `onconnected` callback: +You typically join a hall in the `onconnected` callback: ```javascript serverConnection.onconnected = function() { - this.join(group, 'join', username, password); + this.join(hall, 'join', username, password); } ``` @@ -85,10 +84,10 @@ will trigger. There, you update your user interface and request incoming streams: ```javascript -serverConnection.onjoined = function(kind, group, perms, status, data, error, message) { +serverConnection.onjoined = function(kind, hall, perms, status, data, error, message) { switch(kind) { case 'join': - this.request({'':['audio','video']}); + this.request({'':['audio']}); // then update the UI, possibly taking perms.present into account break; case 'change': @@ -113,7 +112,7 @@ serverConnection.onjoined = function(kind, group, perms, status, data, error, me ## Sending and receiving chat messages -Once you have joined a group, you send chat messages with the `chat` +Once you have joined a hall, you send chat messages with the `chat` method of the `ServerConnection` class. No permission is needed to do that. ```javascript @@ -133,11 +132,11 @@ chat messages, application-specific messages are not interpreted by the server; unlike chat messages, they are not kept in the chat history. The `useraction` method is used to ask the server to act on a remote user -(kick it, change its permissions, etc.); similarly, the `groupaction` -class requests an action to be performed on the current group. Most +(kick it, change its permissions, etc.); similarly, the `hallaction` +class requests an action to be performed on the current hall. Most actions require either the `Op` or the `Record` permission. -## Accepting incoming video streams +## Accepting incoming audio streams When the server pushes a stream to the client, the `ondownstream` callback will trigger; you should set up the stream's callbacks here. @@ -150,7 +149,7 @@ serverConnection.ondownstream = function(stream) { } ``` -The `stream.label` field is one of `camera`, `screenshare` or `video`. +The `stream.label` field is normally `audio`. After a new stream is created, `ondowntrack` will be called whenever a track is added. @@ -166,11 +165,11 @@ parameter is true when the stream is being replaced by a new stream; in that case, the call to `onclose` will be followed with a call to `onstream` with the same `localId` value. -## Pushing outgoing video streams +## Pushing outgoing audio streams If you have the `present` permission, you may use the `newUpStream` method to push a stream to the server. Given a `MediaStream` called `localStream` -(as obtained from `getUserMedia` or `getDisplayMedia`). +(as obtained from `getUserMedia`). ```javascript let stream = serverConnection.newUpStream(); diff --git a/skald-install.md b/skald-install.md index b47a625..8fb563d 100644 --- a/skald-install.md +++ b/skald-install.md @@ -26,30 +26,6 @@ Olimex board, for example), you would say: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags='-s -w' ``` -### Optional: install libraries for background blur - -Skald's client uses Google's MediaPipe library to implement background -blur. This library is optional, and if it is absent, Skald will -disable the menu entries for background blur. - -Optionally install Google's MediaPipe library: - -```sh -mkdir mediapipe -cd mediapipe -wget https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.21.tgz -tar xzf tasks-vision-*.tgz -rm -f ../static/third-party/tasks-vision -mv package ../static/third-party/tasks-vision -cd ../static/third-party/tasks-vision -mkdir models -cd models -wget https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite -cd ../../../../ -``` - -If you don't have `wget` on your system, try using `curl -O` instead. - ### Deploy to your server The following instructions assume that your server is called @@ -154,9 +130,9 @@ server's `data/` directory: rsync config.json skald@skald.example.org:data/ ``` -### Group setup +### Hall setup -Create a group: +Create a hall: ```sh skaldctl create-hall -hall city-watch @@ -169,7 +145,7 @@ If you didn't install a TLS certificate above, you will need to run skaldctl -insecure create-hall -hall city-watch ``` -Create an "op", a user with group moderation privileges: +Create an "op", a user with hall moderation privileges: ```sh skaldctl create-user -hall city-watch -user vimes -permissions op diff --git a/skald-protocol.md b/skald-protocol.md index 5d141fb..333bba9 100644 --- a/skald-protocol.md +++ b/skald-protocol.md @@ -2,9 +2,9 @@ ## Data structures -### Group +### Hall -A group is a set of clients. It is identified by a human-readable name +A hall is a set of clients. It is identified by a human-readable name that must not start or end with a slash "`/`", must not start with a period "`.`", and must not contain the substrings "`/../`" or "`/./`". @@ -28,24 +28,24 @@ server to client direction. ## Before connecting -The client needs to know the location of the group, the (user-visible) URL -at which the group is found. This may be obtained either by explicit +The client needs to know the location of the hall, the (user-visible) URL +at which the hall is found. This may be obtained either by explicit configuration by the user, or by parsing the `/public-halls.json` file -which may contain an array of group statuses (see below). +which may contain an array of hall statuses (see below). A client then performs an HTTP GET request on the file `.status` at -the group's location. This yields a single JSON object, which contains +the hall's location. This yields a single JSON object, which contains the following fields: - - `name`: the group's name - - `location`: the group's location + - `name`: the hall's name + - `location`: the hall's location - `endpoint`: the URL of the server's WebSocket endpoint - `displayName`: a longer version of the name used for display; - `description`: a user-readable description; - `authServer`: the URL of the authentication server, if any; - `authPortal`: the uRL of the authentication portal, if any; - - `locked`: true if the group is locked; - - `clientCount`: the number of clients currently in the group. + - `locked`: true if the hall is locked; + - `clientCount`: the number of clients currently in the hall. All fields are optional except `name`, `location` and `endpoint`. @@ -119,13 +119,13 @@ The receiving peer must reply with a `pong` message within 30s. ## Joining and leaving -The `join` message requests that the sender join or leave a group: +The `join` message requests that the sender join or leave a hall: ```javascript { type: 'join', kind: 'join' or 'leave', - group: group, + hall: hall, username: username, password: password, data: data @@ -135,7 +135,7 @@ The `join` message requests that the sender join or leave a group: If token-based authorisation is beling used, then the `username` and `password` fields are omitted, and a `token` field is included instead. -When the sender has effectively joined the group, the peer will send +When the sender has effectively joined the hall, the peer will send a 'joined' message of kind 'join'; it may then send a 'joined' message of kind 'change' at any time, in order to inform the client of a change in its permissions or in the recommended RTC configuration. @@ -145,7 +145,7 @@ its permissions or in the recommended RTC configuration. type: 'joined', kind: 'join' or 'fail' or 'change' or 'leave', error: may be set if kind is 'fail', - group: group, + hall: hall, username: username, permissions: permissions, status: status, @@ -157,12 +157,12 @@ its permissions or in the recommended RTC configuration. The `username` field is the username that the server assigned to this user. The `permissions` field is an array of strings that may contain the values `present`, `op` and `record`. The `status` field is a dictionary -that contains status information about the group, and updates the data +that contains status information about the hall, and updates the data obtained from the `.status` URL described above. -## Maintaining group membership +## Maintaining hall membership -Whenever a user joins or leaves a group, the server will send all other +Whenever a user joins or leaves a hall, the server will send all other users a `user` message: ```javascript @@ -188,15 +188,14 @@ A peer must explicitly request the streams that it wants to receive. ``` The field `request` is a dictionary that maps the labels of requested -streams to a list containing either 'audio', or one of 'video' or -'video-low'. The empty key `''` serves as default. For example: +streams to a list containing `audio`. The empty key `''` serves as +default. For example: ```javascript { type: 'request', request: { - camera: ['audio', 'video-low'], - '': ['audio', 'video'] + '': ['audio'] } } ``` @@ -220,23 +219,16 @@ A stream is created by the sender with the `offer` message: If a stream with the same id exists, then this is a renegotiation; otherwise this message creates a new stream. If the field `replace` is not empty, then this request additionally requests that an existing stream -with the given id should be closed, and the new stream should replace it; -this is used most notably when changing the simulcast envelope. +with the given id should be closed, and the new stream should replace it. -The field `label` is one of `camera`, `screenshare` or `video`, and will -be matched against the keys sent by the receiver in their `request` message. +The field `label` is normally `audio`, and will be matched against the +keys sent by the receiver in their `request` message. The field `sdp` contains the raw SDP string (i.e. the `sdp` field of -a JSEP session description). Skald will interpret the `nack`, -`nack pli`, `ccm fir` and `goog-remb` RTCP feedback types, and act -accordingly. +a JSEP session description). Skald is audio-only; video media sections +are not accepted. -The sender may either send a single stream per media section in the SDP, -or use rid-based simulcasting with the streams ordered in decreasing order -of throughput. In that case, it should send two video streams, the -first one with high throughput, and the second one with throughput limited -to roughly 100kbit/s. If more than two streams are sent, then only the -first and the last one will be considered. +The sender sends a single audio stream per media section in the SDP. The receiver may either abort the stream immediately (see below), or send an answer. @@ -275,7 +267,7 @@ being offered by sending a 'requestStream' request: { type: 'requestStream' id: id, - request: [audio, video] + request: [audio] } ``` @@ -329,7 +321,7 @@ The field `kind` can have one of the following values: the `caption` permission). If `dest` is empty, the message is a broadcast message, destined to all of -the clients in the group. If `source` is empty, then the message was +the clients in the hall. If `source` is empty, then the message was originated by the server. The message is forwarded by the server without interpretation, the server only validates that the `source` and `username` fields are authentic. The field `privileged` is set to true by the server @@ -359,7 +351,7 @@ chat history, and is not expected to contain user-visible content. ``` Currently defined kinds include `error`, `warning`, `info`, `kicked`, -`clearchat` (not to be confused with the `clearchat` group action), and +`clearchat` (not to be confused with the `clearchat` hall action), and `mute`. A user action requests that the server act upon a user. @@ -377,11 +369,11 @@ A user action requests that the server act upon a user. Currently defined kinds include `op`, `unop`, `present`, `unpresent`, `kick` and `setdata`. -Finally, a group action requests that the server act on the current group. +Finally, a hall action requests that the server act on the current hall. ```javascript { - type: 'groupaction', + type: 'hallaction', kind: kind, source: source-id, username: username, @@ -391,7 +383,7 @@ Finally, a group action requests that the server act on the current group. Currently defined kinds include `clearchat` (not to be confused with the `clearchat` user message), `lock`, `unlock`, `record`, `unrecord`, -`subgroups` and `setdata`. +`subhalls` and `setdata`. # Peer-to-peer file transfer protocol @@ -552,8 +544,8 @@ Javascript code. ## Authentication server -If a group's status dictionary has a non-empty `authServer` field, then -the group uses an authentication server. Before joining, the client sends +If a hall's status dictionary has a non-empty `authServer` field, then +the hall uses an authentication server. Before joining, the client sends a POST request to the authorisation server URL containing in its body a JSON dictionary of the following form: ```javascript @@ -564,7 +556,7 @@ a JSON dictionary of the following form: } ``` -If the user is not allowed to join the group, then the authorisation +If the user is not allowed to join the hall, then the authorisation server replies with a code of 403 ("not authorised"), and Skald will reject the user. If the authentication server has no opinion about whether the user is allowed to join, it replies with a code of 204 ("no @@ -587,16 +579,16 @@ the same format as in the `joined` message. Since the client will only use the token once, at the very beginning of the session, the tokens issued may have a short lifetime (on the order of 30s). -A boolean `include-subgroups` claim may be included in the JWT payload. It -has the same effect as the `include-subgroups` parameter used when generating +A boolean `include-subhalls` claim may be included in the JWT payload. It +has the same effect as the `include-subhalls` parameter used when generating a stateful token. ## Authentication portal -If a group's status dictionary has a non-empty `authPortal` field, Skald +If a hall's status dictionary has a non-empty `authPortal` field, Skald redirects the user agent to the URL indicated by `authPortal`. The authentication portal performs authorisation, generates a token as above, -then redirects back to the group's URL with the token stores in a URL +then redirects back to the hall's URL with the token stores in a URL query parameter named `token`: https://skald.example.org/hall/hallname/?token=eyJhbG... diff --git a/skald.md b/skald.md index bc180fc..e9995bc 100644 --- a/skald.md +++ b/skald.md @@ -14,56 +14,49 @@ Skald is therefore accessed on URLs such as: ### The landing page There is a landing page at the root of the server. The landing page -contains a list of all groups marked "public" in their configuration, as -well as a form that allows joining an arbitrary group. +contains a list of all halls marked "public" in their configuration, as +well as a form that allows joining an arbitrary hall. Going through the landing page is not required: you are welcome to point -your browser directly at the desired group URL. +your browser directly at the desired hall URL. -### The group pages +### The hall pages -A group named e.g. *city-watch* has an associated page +A hall named e.g. *city-watch* has an associated page `/hall/city-watch/`. After login, it presents an interface consisting -of three panes: +of a user list, chat, and connection controls: - the left side pane contains the list of users who have joined the - group; every username doubles as a menu of group and user actions; - - the middle pane contains the chat messages published to the group; - - the main pane, on the right, contains the videos being streamed by - users in the group. + hall; every username doubles as a menu of hall and user actions; + - the chat pane contains the messages published to the hall; + - the controls area contains login, connection, microphone, and settings + controls. -On mobile, only the latter pane is shown by default. An icon at the top -left opens the user list, and an icon in the main pane switches to the -chat. +On mobile, the interface is collapsed to fit the smaller screen. The top +controls expose the user list, chat, and settings. ### Buttons There are up to three buttons at the top. The most important is the -*Enable*/*Disable* button, which switches on or off both the camera and -the microphone. +*Enable*/*Disable* button, which switches the microphone on or off. The *Mute* button mutes or unmutes the microphone; the microphone can be -muted remotely by the group moderator, but it cannot be unmuted remotely. - -The *Share screen* button streams the contents of the screen or an -individual window. +muted remotely by the hall moderator, but it cannot be unmuted remotely. ### Side menu -There is a menu on the right of the user interface. It allows switching -off the camera (for audio-only sessions), choosing the camera and -microphone, and setting the video throughput. The *Blackboard mode* -checkbox increases resolution and sacrifices frame rate in favour of image -quality. The *Play local file* dialogue streams a video from a local file. +There is a menu on the right of the user interface. It allows choosing +the microphone, selecting whether to receive audio, and adjusting audio +processing options. ### User list There is a user list on the left, starting with the current user and -followed by all the users that have joined the current group. +followed by all the users that have joined the current hall. The user list doubles as a set of menus. Clicking on the current user (the -first entry in the user list) opens the *group menu*, a menu with actions -that apply to the group as a whole. Clicking on a different user opens +first entry in the user list) opens the *hall menu*, a menu with actions +that apply to the hall as a whole. Clicking on a different user opens a *user menu*, a menu that applies to that specific user. ### Chat pane @@ -85,16 +78,16 @@ commands. ### Inviting users In order to generate an invitation link, choose the entry *Invite user* in -the group menu. This generates a link of the form +the hall menu. This generates a link of the form https://skald.example.org:8443/hall/city-watch/?token=XXX where the *XXX* part, known as the *token*, is a shared secret. Such -a link allows password-less login to the group, and may therefore be +a link allows password-less login to the hall, and may therefore be shared e.g. over e-mail or instant messaging. The invitation functionality is usually restricted to the moderator; -however, groups may be configured with the `"unrestricted-tokens"` option, +however, halls may be configured with the `"unrestricted-tokens"` option, which allows all users to generate tokens. Tokens can be created, modified, and expired using the `/invite`, @@ -106,20 +99,20 @@ Skald includes a peer-to-peer, end-to-end encrypted file transfer protocol. In order to transfer a file, click on the receiver's entry in the user list and choose *Send file*. -### Group moderation +### Hall moderation If a user has the *op* permission (short for *operator*), then they have access to a number of moderation tools. The contextual menu that opens when clicking on an entry in the user list is expanded with commands for muting a user, sending them a warning, retrieving their IP address, or -kicking them out from a group. +kicking them out from a hall. -The group menu (opened by clicking on one's own entry in the user's list) -is extended with options to lock or to unlock a group (a locked group is +The hall menu (opened by clicking on one's own entry in the user's list) +is extended with options to lock or to unlock a hall (a locked hall is one that non-operator users cannot join). All of the moderation commands are also available as command-line commands -(see above), which is helpful when moderating large groups. +(see above), which is helpful when moderating large halls. # Server administration @@ -132,7 +125,7 @@ file may look as follows: { "users":{"vetinari": {"password":"lagniappe", "permissions": "admin"}}, "canonicalHost": "skald.example.org", - "writableGroups": true + "writableHalls": true } ``` @@ -147,7 +140,7 @@ or, better, with a hashed password: } }, "canonicalHost": "skald.example.org", - "writableGroups": true + "writableHalls": true } ``` @@ -156,10 +149,10 @@ manually edited at any time (there is no need to restart the server). The fields are as follows: - `users` defines the users allowed to administer the server, and has the - same syntax as user definitions in groups (see below), except that the + same syntax as user definitions in halls (see below), except that the only meaningful permission is `"admin"`; - - `writableGroups`: if true, then the API used by `skaldctl` can be used + - `writableHalls`: if true, then the API used by `skaldctl` can be used to modify hall definitions; if unset or false, then only read-only access is allowed; @@ -241,8 +234,8 @@ skaldctl create-user -hall city-watch -user vimes -permissions op ``` If the `-permissions` flag is not specified, it defaults to `present`, -meaning that the user can participate in the chat and present videos to -the group. Other useful values are `message`, which allows a user +meaning that the user can participate in the chat and present audio to +the hall. Other useful values are `message`, which allows a user to participate in the chat only, and `observe`, which doesn't allow any active participation. @@ -276,7 +269,7 @@ skaldctl set-password -hall city-watch -wildcard -type wildcard See the section *Client authorisation* below for more information about password types. -#### Automatic subgroups +#### Automatic subhalls It is sometimes necessary to create a large number of identical halls. For example, the author has been using Skald to supervise computer @@ -285,20 +278,20 @@ science practicals, where up to 40 students are working in pairs. While it is possible to automate the creation of halls by accessing Skald's API, by scripting calls to skaldctl, or by generating files directly under `halls/`, Skald provides a facility called *automatic -subgroups* that can be used to generate halls on demand. +subhalls* that can be used to generate halls on demand. -Automatic subgroups are enabled by setting the `"auto-subgroups"` +Automatic subhalls are enabled by setting the `"auto-subhalls"` field in the hall description: ```sh -skaldctl create-hall unseen-university -auto-subgroups +skaldctl create-hall unseen-university -auto-subhalls ``` -Whenever a user attempts to access a subgroup of `unseen-university`, for -example `unseen-university/hex`, the group is created in memory and +Whenever a user attempts to access a subhall of `unseen-university`, for +example `unseen-university/hex`, the hall is created in memory and persists until it is empty and its chat history has expired. The main -hall's operator can view the list of populated subgroups with the command -`/subgroups`. +hall's operator can view the list of populated subhalls with the command +`/subhalls`. #### Managing tokens @@ -312,19 +305,19 @@ skaldctl create-token -hall city-watch skaldctl list-tokens -l -hall city-watch ``` -A token that is generated with the `-include-subgroups` flag applies to +A token that is generated with the `-include-subhalls` flag applies to the whole hierarchy rooted at the given hall, including both ordinary -halls and automatically generated subgroups. +halls and automatically generated subhalls. ```sh -skaldctl create-token -hall city-watch -include-subgroups +skaldctl create-token -hall city-watch -include-subhalls ``` Such a token can be attached to the root of the hall hierarchy, and therefore be valid for any hall on the server: ```sh -skaldctl create-token -hall '' -include-subgroups +skaldctl create-token -hall '' -include-subhalls ``` ### Hall description reference @@ -346,50 +339,50 @@ are optional. The following fields are allowed: - `authKeys`, `authServer` and `authPortal`: see *Authorisation* below; - - `public`: if true, then the group is listed on the landing page; + - `public`: if true, then the hall is listed on the landing page; - - `displayName`: a human-friendly version of the group name; this is - displayed at the top of the group page; + - `displayName`: a human-friendly version of the hall name; this is + displayed at the top of the hall page; - - `description`: a human-readable description of the group; this is + - `description`: a human-readable description of the hall; this is displayed on the landing page for public halls; - - `contact`: a human-readable contact for this group, such as an e-mail + - `contact`: a human-readable contact for this hall, such as an e-mail address, ignored by the server; - `comment`: a human-readable string, ignored by the server; - - `max-clients`: the maximum number of clients that may join the group at + - `max-clients`: the maximum number of clients that may join the hall at one time; - `max-history-age`: the time, in seconds, during which chat history is kept (default 14400, i.e. 4 hours); - `not-before` and `expires`: the times (in ISO 8601 or RFC 3339 format) - between which joining the group is allowed; + between which joining the hall is allowed; - - `allow-recording`: if true, then recording is allowed in this group; + - `allow-recording`: if true, then recording is allowed in this hall; - `unrestricted-tokens`: if true, then ordinary users (without the "op" privilege) are allowed to create tokens; - `allow-anonymous`: if true, then users may connect with an empty username; - - `auto-subgroups`: if true, then subgroups of the form `group/subgroup` + - `auto-subhalls`: if true, then subhalls of the form `hall/subhall` are automatically created when first accessed; - - `autolock`: if true, the group will start locked and become locked + - `autolock`: if true, the hall will start locked and become locked whenever there are no clients with operator privileges; - `autokick`: if true, all clients will be kicked out whenever there are no clients with operator privileges; this is not recommended, prefer the `autolock` option instead; - - `redirect`: if set, then attempts to join the group will be redirected + - `redirect`: if set, then attempts to join the hall will be redirected to the given URL; most other fields are ignored in this case; - - `codecs`: a list of codecs allowed in this group, see below for - possible values. The default is `["vp8", "opus"]`. + - `codecs`: a list of codecs allowed in this hall, see below for + possible values. The default is `["opus"]`. A user definition is a dictionary with entries `password` and `permission`. The value of the `password` field is either a plaintext @@ -398,30 +391,19 @@ hash-password` command. The value of the `permissions` field can either be an array of individual permissions (not recommended), or one of the following strings: - - `op`: a group operator, with all rights except administering the group; - - `present`, an ordinary user with the right to publish audio and video - streams and send chat messages; + - `op`: a hall operator, with all rights except administering the hall; + - `present`, an ordinary user with the right to publish audio streams + and send chat messages; - `message`: a user with the right to send chat messages; - `observe`: a user that receives media streams and chat messages, but is not allowed to send them; - `caption`: a user with the right to display captions (only); - - `admin`: a user with the right to administer the group (only). + - `admin`: a user with the right to administer the hall (only). The value of the `codecs` field is an array of codecs allowed in the -group. Supported video codecs include: - - - `"vp8"` (compatible with all supported browsers, full functionality); - - `"vp9"` (better video quality, but incompatible with Safari; somewhat - buggy in Firefox; full functionality); - - `"av1"` (even better video quality, only supported by some browsers, - limited functionality: no recording, no SVC); - - `"h264"` (well supported by Apple devices, but incompatible with Debian - Linux and with some older Android devices, SVC is not supported; might - be covered by patents in some countries). - -Supported audio codecs include `"opus"`, `"g722"`, `"pcmu"` and `"pcma"`. -Only Opus can be recorded to disk. There is no good reason to use -anything except Opus. +hall. Supported audio codecs include `"opus"`, `"g722"`, `"pcmu"` and +`"pcma"`. Opus is the default and is the only codec planned for the +single-file recording path. ## Client Authorisation @@ -435,7 +417,7 @@ infrastructure (such as LDAP, OAuth2, or even Unix passwords). ### Password authorisation When password authorisation is used, authorised usernames and passwords are -defined directly in the group configuration file, in the `users` and +defined directly in the hall configuration file, in the `users` and `wildcard-user` entries. The `users` entry is a dictionary that maps user names to user descriptions; the `wildcard-user` is a user description that is used with usernames that don't appear in `users`. These two @@ -521,7 +503,7 @@ authorisation decisions using cryptographic tokens generated by a third party known as an *authorisation server*. Two authorisation servers are available: an [LDAP client][2], and a [sample server written in Python][3]. -When an authorisation server is used, the `"authKeys"` entry of the group +When an authorisation server is used, the `"authKeys"` entry of the hall configuration file specifies one or more public keys in JWK format (with the restriction that the "alg" key must be specified explicitly): @@ -545,7 +527,7 @@ If multiple keys are provided, then they will all be tried in turn, unless the token includes the "kid" header field, in which case only the specified key will be used. -The group file should also specify either an authorisation server or an +The hall file should also specify either an authorisation server or an authorisation portal. An authorisation server is specified using the `"authServer"` key: @@ -557,10 +539,10 @@ authorisation portal. An authorisation server is specified using the If an authorisation server is specified, then the client, after it prompts for a password, will request a token from the authorisation server and -join the group using token authentication. The password is never +join the hall using token authentication. The password is never communicated to the server. -Alternatively, the group file may specify an authorisation portal using +Alternatively, the hall file may specify an authorisation portal using the `"authPortal"` key. If an authorisation portal is specified, then the default client will redirect initial client connections to the authorisation portal. The authorisation portal is expected to authorise diff --git a/skaldctl/skaldctl.go b/skaldctl/skaldctl.go index 4f32a30..4ae1faa 100644 --- a/skaldctl/skaldctl.go +++ b/skaldctl/skaldctl.go @@ -66,23 +66,23 @@ var commands = map[string]command{ description: "delete a user's password", }, "list-halls": { - command: listGroupsCmd, + command: listHallsCmd, description: "list halls", }, "show-hall": { - command: showGroupCmd, + command: showHallCmd, description: "show hall definition", }, "create-hall": { - command: createGroupCmd, + command: createHallCmd, description: "create a hall", }, "update-hall": { - command: updateGroupCmd, + command: updateHallCmd, description: "change a hall's definition", }, "delete-hall": { - command: deleteGroupCmd, + command: deleteHallCmd, description: "delete a hall", }, "list-users": { @@ -412,8 +412,8 @@ func initialSetupCmd(cmdname string, args []string) { } config := hall.Configuration{ - WritableGroups: true, - Users: users, + WritableHalls: true, + Users: users, } encoder := json.NewEncoder(skaldConfig) @@ -795,9 +795,9 @@ func stdinJSON(doit bool) (map[string]any, error) { return data, nil } -func createGroupCmd(cmdname string, args []string) { +func createHallCmd(cmdname string, args []string) { var hallname string - var unrestrictedTokens, autoSubgroups boolOption + var unrestrictedTokens, autoSubhalls boolOption var doJSON bool cmd := flag.NewFlagSet(cmdname, flag.ExitOnError) setUsage(cmd, cmdname, @@ -808,8 +808,8 @@ func createGroupCmd(cmdname string, args []string) { cmd.Var(&unrestrictedTokens, "unrestricted-tokens", "allow ordinary users to create tokens", ) - cmd.Var(&autoSubgroups, "auto-subgroups", - "create subgroups automatically", + cmd.Var(&autoSubhalls, "auto-subhalls", + "create subhalls automatically", ) cmd.BoolVar(&doJSON, "json", false, "read JSON template from standard input", @@ -842,8 +842,8 @@ func createGroupCmd(cmdname string, args []string) { if unrestrictedTokens.set { data["unrestricted-tokens"] = unrestrictedTokens.value } - if autoSubgroups.set { - data["auto-subgroups"] = autoSubgroups.value + if autoSubhalls.set { + data["auto-subhalls"] = autoSubhalls.value } err = putJSON(u, data, false) @@ -852,7 +852,7 @@ func createGroupCmd(cmdname string, args []string) { } } -func deleteGroupCmd(cmdname string, args []string) { +func deleteHallCmd(cmdname string, args []string) { var hallname string cmd := flag.NewFlagSet(cmdname, flag.ExitOnError) setUsage(cmd, cmdname, @@ -886,9 +886,9 @@ func deleteGroupCmd(cmdname string, args []string) { } } -func updateGroupCmd(cmdname string, args []string) { +func updateHallCmd(cmdname string, args []string) { var hallname string - var unrestrictedTokens, autoSubgroups boolOption + var unrestrictedTokens, autoSubhalls boolOption var doJSON bool cmd := flag.NewFlagSet(cmdname, flag.ExitOnError) setUsage(cmd, cmdname, @@ -899,8 +899,8 @@ func updateGroupCmd(cmdname string, args []string) { cmd.Var(&unrestrictedTokens, "unrestricted-tokens", "allow ordinary users to create tokens", ) - cmd.Var(&autoSubgroups, "auto-subgroups", - "create subgroups automatically", + cmd.Var(&autoSubhalls, "auto-subhalls", + "create subhalls automatically", ) cmd.BoolVar(&doJSON, "json", false, "read JSON template from standard input", @@ -936,14 +936,14 @@ func updateGroupCmd(cmdname string, args []string) { if unrestrictedTokens.set { m["unrestricted-tokens"] = unrestrictedTokens.value } - if autoSubgroups.set { - m["auto-subgroups"] = autoSubgroups.value + if autoSubhalls.set { + m["auto-subhalls"] = autoSubhalls.value } return m }) if err != nil { - log.Fatalf("Update group: %v", err) + log.Fatalf("Update hall: %v", err) } } @@ -1269,7 +1269,7 @@ func deleteUserCmd(cmdname string, args []string) { } } -func showGroupCmd(cmdname string, args []string) { +func showHallCmd(cmdname string, args []string) { var hallname string cmd := flag.NewFlagSet(cmdname, flag.ExitOnError) setUsage(cmd, cmdname, @@ -1306,7 +1306,7 @@ func showGroupCmd(cmdname string, args []string) { } } -func listGroupsCmd(cmdname string, args []string) { +func listHallsCmd(cmdname string, args []string) { cmd := flag.NewFlagSet(cmdname, flag.ExitOnError) setUsage(cmd, cmdname, "%v [option...] %v [pattern...]\n", @@ -1320,17 +1320,17 @@ func listGroupsCmd(cmdname string, args []string) { log.Fatalf("Build URL: %v", err) } - var groups []string - _, err = getJSON(u, &groups) + var halls []string + _, err = getJSON(u, &halls) if err != nil { log.Fatalf("Get halls: %v", err) } - sort.Slice(groups, func(i, j int) bool { - return groups[i] < groups[j] + sort.Slice(halls, func(i, j int) bool { + return halls[i] < halls[j] }) - for _, g := range groups { + for _, h := range halls { if len(patterns) > 0 { - found, err := match(patterns, g) + found, err := match(patterns, h) if err != nil { log.Fatalf("Match: %v", err) } @@ -1338,7 +1338,7 @@ func listGroupsCmd(cmdname string, args []string) { continue } } - fmt.Println(g) + fmt.Println(h) } } @@ -1409,7 +1409,7 @@ func listTokensCmd(cmdname string, args []string) { exp = tt.Expires.Format(time.DateTime) } var perms []byte - if tt.IncludeSubgroups { + if tt.IncludeSubhalls { perms = append(perms, 'H') } for _, p := range tt.Permissions { @@ -1432,13 +1432,13 @@ func listTokensCmd(cmdname string, args []string) { func createTokenCmd(cmdname string, args []string) { var hallname stringOption var username, permissions string - var includeSubgroups boolOption + var includeSubhalls boolOption cmd := flag.NewFlagSet(cmdname, flag.ExitOnError) setUsage(cmd, cmdname, "%v [option...] %v [option...]\n", os.Args[0], cmdname, ) cmd.Var(&hallname, "hall", "hall `name`") - cmd.Var(&includeSubgroups, "include-subgroups", "include subgroups") + cmd.Var(&includeSubhalls, "include-subhalls", "include subhalls") cmd.StringVar(&username, "user", "", "encode user `name` in token") cmd.StringVar(&permissions, "permissions", "present", "permissions") cmd.Parse(args) @@ -1464,8 +1464,8 @@ func createTokenCmd(cmdname string, args []string) { if username != "" { t["username"] = username } - if includeSubgroups.set { - t["includeSubgroups"] = includeSubgroups.value + if includeSubhalls.set { + t["includeSubhalls"] = includeSubhalls.value } u, err := url.JoinPath( diff --git a/static/background-blur-worker.js b/static/background-blur-worker.js deleted file mode 100644 index 789d690..0000000 --- a/static/background-blur-worker.js +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) 2024 by Juliusz Chroboczek. - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -'use strict'; - -let imageSegmenter; - -async function loadImageSegmenter(model) { - let module = await import('/third-party/tasks-vision/vision_bundle.mjs'); - let vision = await module.FilesetResolver.forVisionTasks( - "/third-party/tasks-vision/wasm" - ); - - return await module.ImageSegmenter.createFromOptions(vision, { - baseOptions: { - modelAssetPath: model, - }, - outputCategoryMask: true, - outputConfidenceMasks: false, - runningMode: 'VIDEO', - }); -} - -async function foregroundMask(bitmap, timestamp) { - if(!(bitmap instanceof ImageBitmap)) - throw new Error('Bad type for worker data'); - - try { - let width = bitmap.width; - let height = bitmap.height; - let p = new Promise((resolve, reject) => - imageSegmenter.segmentForVideo( - bitmap, timestamp, - result => resolve(result), - )); - let result = await p; - /** @type{Uint8Array} */ - let mask = result.categoryMask.getAsUint8Array(); - let id = new ImageData(width, height); - for(let i = 0; i < mask.length; i++) - id.data[4 * i + 3] = mask[i]; - result.close(); - - let ib = await createImageBitmap(id); - return { - bitmap: bitmap, - mask: ib, - }; - } catch(e) { - bitmap.close(); - throw(e); - } -} - -onmessage = async e => { - try { - let data = e.data; - if(data.model) { - if(imageSegmenter) - throw new Error("image segmenter already initialised"); - imageSegmenter = await loadImageSegmenter(data.model); - if(!imageSegmenter) - throw new Error("loadImageSegmenter returned null"); - postMessage(null); - } else if(data.bitmap) { - if(imageSegmenter == null) - throw new Error("image segmenter not initialised"); - let mask = await foregroundMask(data.bitmap, data.timestamp); - postMessage(mask, [mask.bitmap, mask.mask]); - } else { - throw new Error("unexpected message type"); - } - } catch(e) { - postMessage(e); - } -} diff --git a/static/change-password.js b/static/change-password.js index 419d0c1..ff03131 100644 --- a/static/change-password.js +++ b/static/change-password.js @@ -24,9 +24,9 @@ document.getElementById('passwordform').onsubmit = async function(e) { e.preventDefault(); let parms = new URLSearchParams(window.location.search); - let group = parms.get('group'); - if(!group) { - displayError("Couldn't determine group"); + let hall = parms.get('hall'); + if(!hall) { + displayError("Couldn't determine hall"); return; } let user = parms.get('username'); @@ -44,7 +44,7 @@ document.getElementById('passwordform').onsubmit = async function(e) { } try { - await setPassword(group, user, false, new1, old); + await setPassword(hall, user, false, new1, old); document.getElementById('old').value = ''; document.getElementById('new1').value = ''; document.getElementById('new2').value = ''; diff --git a/static/example/example.html b/static/example/example.html index 9ab1717..35499c2 100644 --- a/static/example/example.html +++ b/static/example/example.html @@ -7,15 +7,10 @@ -

+

- -
- -
-
diff --git a/static/example/example.js b/static/example/example.js index 5205b2c..b2be75a 100644 --- a/static/example/example.js +++ b/static/example/example.js @@ -1,4 +1,4 @@ -/* Skald client example. */ +/* Skald client example - audio-only. */ /** * The main function. @@ -6,7 +6,7 @@ * @param {string} url */ async function start(url) { - // fetch the group information + // fetch the hall information let r = await fetch(url + ".status"); if(!r.ok) { throw new Error(`${r.status} ${r.statusText}`); @@ -23,7 +23,7 @@ async function start(url) { if(token) { serverConnect(status, token); } else if(status.authPortal) { - window.location.href = groupStatus.authPortal + window.location.href = status.authPortal return; } else { serverConnect(status, null); @@ -33,7 +33,7 @@ async function start(url) { /** * Display the connection status. * - * @parm {string} status + * @param {string} status */ function displayStatus(status) { let c = document.getElementById('status'); @@ -43,38 +43,29 @@ function displayStatus(status) { /** * Connect to the server. * - * @parm {Object} status - * @parm {string} token + * @param {Object} status + * @param {string} token */ function serverConnect(status, token) { - // create the connection to the server let conn = new ServerConnection(); conn.onconnected = async function() { displayStatus('Connected'); let creds = token ? {type: 'token', token: token} : {type: 'password', password: ''}; - // join the group and wait for the onjoined callback await this.join("public", "example-user", creds); }; conn.onchat = onChat; conn.onusermessage = onUserMessage; - conn.ondownstream = onDownStream; conn.onclose = function() { displayStatus('Disconnected'); } conn.onjoined = onJoined; - - // connect and wait for the onconnected callback conn.connect(status.endpoint); } /** * Called whenever we receive a chat message. - * - * @this {ServerConnection} - * @parm {string} username - * @parm {string} message */ function onChat(id, dest, username, time, privileged, history, kind, message) { let p = document.createElement('p'); @@ -85,11 +76,6 @@ function onChat(id, dest, username, time, privileged, history, kind, message) { /** * Called whenever we receive a user message. - * - * @this {ServerConnection} - * @parm {string} username - * @parm {string} message - * @parm {string} kind */ function onUserMessage(id, dest, username, time, privileged, kind, error, message) { switch(kind) { @@ -113,57 +99,13 @@ function onUserMessage(id, dest, username, time, privileged, kind, error, messag } } - /** - * Find the camera stream, if any. - * - * @parm {string} conn - * @returns {Stream} + * Called when we join or leave a hall. */ -function cameraStream(conn) { - for(let id in conn.up) { - let s = conn.up[id]; - if(s.label === 'camera') - return s; - } - return null; -} - -/** - * Enable or disable the show/hide button. - * - * @parm{ServerConnection} conn - * @parm{boolean} enable - */ -function enableShow(conn, enable) { - let b = /** @type{HTMLButtonElement} */(document.getElementById('show')); - if(enable) { - b.onclick = function() { - let s = cameraStream(conn); - if(!s) - showCamera(conn); - else - hide(conn, s); - } - b.disabled = false; - } else { - b.disabled = true; - b.onclick = null; - } -} - -/** - * Called when we join or leave a group. - * - * @this {ServerConnection} - * @parm {string} kind - * @parm {string} message} - */ -async function onJoined(kind, group, perms, status, data, error, message) { +async function onJoined(kind, hall, perms, status, data, error, message) { switch(kind) { case 'fail': displayError(message); - enableShow(this, false); this.close(); break; case 'redirect': @@ -172,15 +114,13 @@ async function onJoined(kind, group, perms, status, data, error, message) { return; case 'leave': displayStatus('Connected'); - enableShow(this, false); this.close(); break; case 'join': case 'change': - displayStatus(`Connected as ${this.username} in group ${this.group}.`); - enableShow(this, true); - // request videos from the server - this.request({'': ['audio', 'video']}); + displayStatus(`Connected as ${this.username} in hall ${this.hall}.`); + // request audio from the server + this.request({'': ['audio']}); break; default: displayError(`Unexpected state ${kind}.`); @@ -189,115 +129,10 @@ async function onJoined(kind, group, perms, status, data, error, message) { } } -/** - * Create a video element. We encode the stream's id in the element's id - * in order to avoid having a global hash table that maps ids to video - * elements. - * - * @parm {string} id - * @returns {HTMLVideoElement} - */ -function makeVideoElement(id) { - let v = document.createElement('video'); - v.id = 'video-' + id; - let container = document.getElementById('videos'); - container.appendChild(v); - return v; -} - -/** - * Find the video element that shows a given id. - * - * @parm {string} id - * @returns {HTMLVideoElement} - */ -function getVideoElement(id) { - let v = document.getElementById('video-' + id); - return /** @type{HTMLVideoElement} */(v); -} - -/** - * Enable the camera and broadcast yourself to the group. - * - * @parm {ServerConnection} conn - */ -async function showCamera(conn) { - let ms = await navigator.mediaDevices.getUserMedia({audio: true, video: true}); - - /* Send the new stream to the server */ - let s = conn.newUpStream(); - s.label = 'camera'; - s.setStream(ms); - let v = makeVideoElement(s.localId); - s.onclose = function(replace) { - s.stream.getTracks().forEach(t => t.stop()); - v.srcObject = null; - v.parentNode.removeChild(v); - } - - function addTrack(t) { - t.oneneded = function(e) { - ms.onaddtrack = null; - s.onremovetrack = null; - s.close(); - } - s.pc.addTransceiver(t, { - direction: 'sendonly', - streams: [ms], - }); - } - - // Make sure all future tracks are added. - s.onaddtrack = function(e) { - addTrack(e.track); - } - // Add any existing tracks. - ms.getTracks().forEach(addTrack); - - // Connect the MediaStream to the video element and start playing. - v.srcObject = ms; - v.muted = true; - v.play(); -} - -/** - * Stop broadcasting. - * - * @parm {ServerConnection} conn - * @parm {Stream} s - */ -async function hide(conn, s) { - s.stream.getTracks().forEach(t => t.stop()); - s.close(); -} - -/** - * Called when the server pushes a stream. - * - * @this {ServerConnection} - * @parm {Stream} c - */ -function onDownStream(s) { - s.onclose = function(replace) { - let v = getVideoElement(s.localId); - v.srcObject = null; - v.parentNode.removeChild(v); - } - s.ondowntrack = function(track, transceiver, stream) { - let v = getVideoElement(s.localId); - if(v.srcObject !== stream) - v.srcObject = stream; - } - - let v = makeVideoElement(s.localId); - v.srcObject = s.stream; - v.play(); -} - /** * Display an error message. * - * @parm {string} message + * @param {string} message */ function displayError(message) { document.getElementById('error').textContent = message; diff --git a/static/index.html b/static/index.html index dd43049..0737517 100644 --- a/static/index.html +++ b/static/index.html @@ -23,7 +23,7 @@

-
+

Public halls

diff --git a/static/mainpage.css b/static/mainpage.css index 94dfe31..a7bf064 100644 --- a/static/mainpage.css +++ b/static/mainpage.css @@ -10,10 +10,10 @@ body { height: 12px; } -.groups { +.halls { } -.nogroups { +.nohalls { display: none; } diff --git a/static/mainpage.js b/static/mainpage.js index 428e38d..a55dc81 100644 --- a/static/mainpage.js +++ b/static/mainpage.js @@ -23,17 +23,17 @@ document.getElementById('hallform').onsubmit = async function(e) { e.preventDefault(); clearError(); - let groupinput = document.getElementById('hall') + let hallInput = document.getElementById('hall') let button = document.getElementById('submitbutton'); - let group = groupinput.value.trim(); - if(group === '') + let hall = hallInput.value.trim(); + if(hall === '') return; - let url = '/hall/' + group + '/'; + let url = '/hall/' + hall + '/'; let statusUrl = url + '.status.json'; try { - groupinput.disabled = true; + hallInput.disabled = true; button.disabled = true; try { let resp = await fetch(statusUrl, { @@ -41,7 +41,7 @@ document.getElementById('hallform').onsubmit = async function(e) { }); if(!resp.ok) { if(resp.status === 404) - displayError('No such group'); + displayError('No such hall'); else displayError(`The server said: ${resp.status} ${resp.statusText}`); return; @@ -51,7 +51,7 @@ document.getElementById('hallform').onsubmit = async function(e) { return; } } finally { - groupinput.disabled = false; + hallInput.disabled = false; button.disabled = false; } @@ -90,38 +90,38 @@ async function listPublicHalls() { l = await r.json(); } catch(e) { table.textContent = `Couldn't fetch halls: ${e}`; - div.classList.remove('nogroups'); - div.classList.add('groups'); + div.classList.remove('nohalls'); + div.classList.add('halls'); return; } if (l.length === 0) { table.textContent = '(No halls found.)'; - div.classList.remove('groups'); - div.classList.add('nogroups'); + div.classList.remove('halls'); + div.classList.add('nohalls'); return; } - div.classList.remove('nogroups'); - div.classList.add('groups'); + div.classList.remove('nohalls'); + div.classList.add('halls'); for(let i = 0; i < l.length; i++) { - let group = l[i]; + let hall = l[i]; let tr = document.createElement('tr'); let td = document.createElement('td'); let a = document.createElement('a'); - a.textContent = group.displayName || group.name; - a.href = group.location; + a.textContent = hall.displayName || hall.name; + a.href = hall.location; td.appendChild(a); tr.appendChild(td); let td2 = document.createElement('td'); - if(group.description) - td2.textContent = group.description; + if(hall.description) + td2.textContent = hall.description; tr.appendChild(td2); let td3 = document.createElement('td'); - if(!group.redirect) { - let locked = group.locked ? ', locked' : ''; - td3.textContent = `(${group.clientCount} clients${locked})`; + if(!hall.redirect) { + let locked = hall.locked ? ', locked' : ''; + td3.textContent = `(${hall.clientCount} clients${locked})`; } else { td3.textContent = '(remote)'; } diff --git a/static/management.js b/static/management.js index d753f25..a7053ba 100644 --- a/static/management.js +++ b/static/management.js @@ -154,133 +154,133 @@ async function updateObject(url, values, etag) { } /** - * listGroups returns the list of groups. + * listHalls returns the list of halls. * * @returns {Promise>} */ -async function listGroups() { +async function listHalls() { return await listObjects('/skald-api/v0/.halls/'); } /** - * getGroup returns the sanitised description of the given group. + * getHall returns the sanitised description of the given hall. * - * @param {string} group + * @param {string} hall * @param {string} [etag] * @returns {Promise} */ -async function getGroup(group, etag) { - return await getObject(`/skald-api/v0/.halls/${group}`, etag); +async function getHall(hall, etag) { + return await getObject(`/skald-api/v0/.halls/${hall}`, etag); } /** - * createGroup creates a group. It fails if the group already exists. + * createHall creates a hall. It fails if the hall already exists. * - * @param {string} group + * @param {string} hall * @param {Object} [values] */ -async function createGroup(group, values) { - return await createObject(`/skald-api/v0/.halls/${group}`, values); +async function createHall(hall, values) { + return await createObject(`/skald-api/v0/.halls/${hall}`, values); } /** - * deleteGroup deletes a group. + * deleteHall deletes a hall. * - * @param {string} group + * @param {string} hall * @param {string} [etag] */ -async function deleteGroup(group, etag) { - return await deleteObject(`/skald-api/v0/.halls/${group}`, etag); +async function deleteHall(hall, etag) { + return await deleteObject(`/skald-api/v0/.halls/${hall}`, etag); } /** - * updateGroup modifies a hall definition. + * updateHall modifies a hall definition. * Any fields present in values are overriden, any fields absent in values * are left unchanged. * - * @param {string} group + * @param {string} hall * @param {Object} values * @param {string} [etag] */ -async function updateGroup(group, values, etag) { - return await updateObject(`/skald-api/v0/.halls/${group}`, values); +async function updateHall(hall, values, etag) { + return await updateObject(`/skald-api/v0/.halls/${hall}`, values); } /** - * listUsers lists the users in a given group. + * listUsers lists the users in a given hall. * - * @param {string} group + * @param {string} hall * @returns {Promise>} */ -async function listUsers(group) { - return await listObjects(`/skald-api/v0/.halls/${group}/.users/`); +async function listUsers(hall) { + return await listObjects(`/skald-api/v0/.halls/${hall}/.users/`); } /** * userURL returns the URL for a user entry * - * @param {string} group + * @param {string} hall * @param {string} user * @param {boolean} wildcard */ -function userURL(group, user, wildcard) { +function userURL(hall, user, wildcard) { if(wildcard) - return `/skald-api/v0/.halls/${group}/.wildcard-user`; + return `/skald-api/v0/.halls/${hall}/.wildcard-user`; else if(user === "") - return `/skald-api/v0/.halls/${group}/.empty-user`; + return `/skald-api/v0/.halls/${hall}/.empty-user`; else - return `/skald-api/v0/.halls/${group}/.users/${user}` + return `/skald-api/v0/.halls/${hall}/.users/${user}` } /** * getUser returns a given user entry. * - * @param {string} group + * @param {string} hall * @param {string} user * @param {boolean} wildcard * @param {string} [etag] * @returns {Promise} */ -async function getUser(group, user, wildcard, etag) { - return await getObject(userURL(group, user, wildcard), etag); +async function getUser(hall, user, wildcard, etag) { + return await getObject(userURL(hall, user, wildcard), etag); } /** * createUser creates a new user entry. It fails if the user already * exists. * - * @param {string} group + * @param {string} hall * @param {string} user * @param {boolean} wildcard * @param {Object} values */ -async function createUser(group, user, wildcard, values) { - return await createObject(userURL(group, user, wildcard), values); +async function createUser(hall, user, wildcard, values) { + return await createObject(userURL(hall, user, wildcard), values); } /** * deleteUser deletes a user. * - * @param {string} group + * @param {string} hall * @param {string} user * @param {boolean} wildcard * @param {string} [etag] */ -async function deleteUser(group, user, wildcard, etag) { - return await deleteObject(userURL(group, user, wildcard), etag); +async function deleteUser(hall, user, wildcard, etag) { + return await deleteObject(userURL(hall, user, wildcard), etag); } /** * updateUser modifies a given user entry. * - * @param {string} group + * @param {string} hall * @param {string} user * @param {Object} values * @param {boolean} wildcard * @param {string} [etag] */ -async function updateUser(group, user, wildcard, values, etag) { - return await updateObject(userURL(group, user, wildcard), values, etag); +async function updateUser(hall, user, wildcard, values, etag) { + return await updateObject(userURL(hall, user, wildcard), values, etag); } /** @@ -288,13 +288,13 @@ async function updateUser(group, user, wildcard, values, etag) { * If oldpassword is provided, then it is used for authentication instead * of the browser's normal mechanism. * - * @param {string} group + * @param {string} hall * @param {string} user * @param {boolean} wildcard * @param {string} password * @param {string} [oldpassword] */ -async function setPassword(group, user, wildcard, password, oldpassword) { +async function setPassword(hall, user, wildcard, password, oldpassword) { let options = { method: 'POST', headers: { @@ -308,42 +308,42 @@ async function setPassword(group, user, wildcard, password, oldpassword) { `Basic ${btoa(user + ':' + oldpassword)}` } - let r = await fetch(userURL(group, user, wildcard) + '/.password', options); + let r = await fetch(userURL(hall, user, wildcard) + '/.password', options); if(!r.ok) throw httpError(r); } /** - * listTokens lists the tokens for a given group. + * listTokens lists the tokens for a given hall. * - * @param {string} group + * @param {string} hall * @returns {Promise>} */ -async function listTokens(group) { - return await listObjects(`/skald-api/v0/.halls/${group}/.tokens/`); +async function listTokens(hall) { + return await listObjects(`/skald-api/v0/.halls/${hall}/.tokens/`); } /** * getToken returns a given token. * - * @param {string} group + * @param {string} hall * @param {string} token * @param {string} [etag] * @returns {Promise} */ -async function getToken(group, token, etag) { - return await getObject(`/skald-api/v0/.halls/${group}/.tokens/${token}`, +async function getToken(hall, token, etag) { + return await getObject(`/skald-api/v0/.halls/${hall}/.tokens/${token}`, etag); } /** * createToken creates a new token and returns its name * - * @param {string} group + * @param {string} hall * @param {Object} template * @returns {Promise} */ -async function createToken(group, template) { +async function createToken(hall, template) { let options = { method: 'POST', headers: { @@ -353,7 +353,7 @@ async function createToken(group, template) { } let r = await fetch( - `/skald-api/v0/.halls/${group}/.tokens/`, + `/skald-api/v0/.halls/${hall}/.tokens/`, options); if(!r.ok) throw httpError(r); @@ -366,13 +366,13 @@ async function createToken(group, template) { /** * updateToken modifies a token. * - * @param {string} group + * @param {string} hall * @param {Object} token */ -async function updateToken(group, token, etag) { +async function updateToken(hall, token, etag) { if(!token.token) throw new Error("Unnamed token"); return await updateObject( - `/skald-api/v0/.halls/${group}/.tokens/${token.token}`, + `/skald-api/v0/.halls/${hall}/.tokens/${token.token}`, token, etag); } diff --git a/static/protocol.js b/static/protocol.js index 250c033..20a7964 100644 --- a/static/protocol.js +++ b/static/protocol.js @@ -83,11 +83,11 @@ function ServerConnection() { */ this.id = newRandomId(); /** - * The group that we have joined, or null if we haven't joined yet. + * The hall that we have joined, or null if we haven't joined yet. * * @type {string} */ - this.group = null; + this.hall = null; /** * The username we joined as. * @@ -95,7 +95,7 @@ function ServerConnection() { */ this.username = null; /** - * The set of users in this group, including ourself. + * The set of users in this hall, including ourself. * * @type {Object} */ @@ -186,19 +186,19 @@ function ServerConnection() { */ this.onpeerconnection = null; /** - * onuser is called whenever a user in the group changes. The users + * onuser is called whenever a user in the hall changes. The users * array has already been updated. * * @type{(this: ServerConnection, id: string, kind: string) => void} */ this.onuser = null; /** - * onjoined is called whenever we join or leave a group or whenever the - * permissions we have in a group change. + * onjoined is called whenever we join or leave a hall or whenever the + * permissions we have in a hall change. * * kind is one of 'join', 'fail', 'change' or 'leave'. * - * @type{(this: ServerConnection, kind: string, group: string, permissions: Array, status: Object, data: Object, error: string, message: string) => void} + * @type{(this: ServerConnection, kind: string, hall: string, permissions: Array, status: Object, data: Object, error: string, message: string) => void} */ this.onjoined = null; /** @@ -262,7 +262,7 @@ function ServerConnection() { * @property {Array} [permissions] * @property {Object} [status] * @property {Object} [data] - * @property {string} [group] + * @property {string} [hall] * @property {unknown} [value] * @property {boolean} [noecho] * @property {string|number} [time] @@ -365,9 +365,9 @@ ServerConnection.prototype.connect = function(url) { if(sc.onuser) sc.onuser.call(sc, id, 'delete'); } - if(sc.group && sc.onjoined) - sc.onjoined.call(sc, 'leave', sc.group, [], {}, {}, '', ''); - sc.group = null; + if(sc.hall && sc.onjoined) + sc.onjoined.call(sc, 'leave', sc.hall, [], {}, {}, '', ''); + sc.hall = null; sc.username = null; if(sc.pingHandler) { clearInterval(sc.pingHandler); @@ -432,19 +432,19 @@ ServerConnection.prototype.connect = function(url) { sc.permissions = []; sc.rtcConfiguration = null; } else if(m.kind === 'join' || m.kind == 'change') { - if(m.kind === 'join' && sc.group) { - throw new Error('Joined multiple groups'); - } else if(m.kind === 'change' && m.group != sc.group) { - console.warn('join(change) for inconsistent group'); + if(m.kind === 'join' && sc.hall) { + throw new Error('Joined multiple halls'); + } else if(m.kind === 'change' && m.hall != sc.hall) { + console.warn('join(change) for inconsistent hall'); break; } - sc.group = m.group; + sc.hall = m.hall; sc.username = m.username; sc.permissions = m.permissions || []; sc.rtcConfiguration = m.rtcConfiguration || null; } if(sc.onjoined) - sc.onjoined.call(sc, m.kind, m.group, + sc.onjoined.call(sc, m.kind, m.hall, m.permissions || [], m.status, m.data, m.error || null, m.value || null); @@ -546,19 +546,19 @@ function parseTime(value) { } /** - * join requests to join a group. The onjoined callback will be called + * join requests to join a hall. The onjoined callback will be called * when we've effectively joined. * - * @param {string} group - The name of the group to join. + * @param {string} hall - The name of the hall to join. * @param {string} username - the username to join as. * @param {string|Object} credentials - password or authServer. * @param {Object} [data] - the initial associated data. */ -ServerConnection.prototype.join = async function(group, username, credentials, data) { +ServerConnection.prototype.join = async function(hall, username, credentials, data) { let m = { type: 'join', kind: 'join', - group: group, + hall: hall, }; if(typeof username !== 'undefined' && username !== null) m.username = username; @@ -629,16 +629,16 @@ ServerConnection.prototype.join = async function(group, username, credentials, d }; /** - * leave leaves a group. The onjoined callback will be called when we've + * leave leaves a hall. The onjoined callback will be called when we've * effectively left. * - * @param {string} group - The name of the group to join. + * @param {string} hall - The name of the hall to join. */ -ServerConnection.prototype.leave = function(group) { +ServerConnection.prototype.leave = function(hall) { this.send({ type: 'join', kind: 'leave', - group: group, + hall: hall, }); }; @@ -646,8 +646,8 @@ ServerConnection.prototype.leave = function(group) { * request sets the list of requested tracks * * @param {Object>} what - * - A dictionary that maps labels to a sequence of 'audio', 'video' - * or 'video-low. An entry with an empty label '' provides the default. + * - A dictionary that maps labels to a sequence of 'audio' + * . An entry with an empty label '' provides the default. */ ServerConnection.prototype.request = function(what) { this.send({ @@ -809,14 +809,14 @@ ServerConnection.prototype.userMessage = function(kind, dest, value, noecho) { }; /** - * groupAction sends a request to act on the current group. + * hallAction sends a request to act on the current hall. * * @param {string} kind * @param {any} [data] */ -ServerConnection.prototype.groupAction = function(kind, data) { +ServerConnection.prototype.hallAction = function(kind, data) { this.send({ - type: 'groupaction', + type: 'hallaction', source: this.id, kind: kind, username: this.username, @@ -1459,7 +1459,7 @@ Stream.prototype.restartIce = function () { * request sets the list of tracks. If this is not called, or called with * a null argument, then the default is provided by ServerConnection.request. * - * @param {Array} what - a sequence of 'audio', 'video' or 'video-low'. + * @param {Array} what - a sequence of 'audio' . */ Stream.prototype.request = function(what) { let c = this; @@ -1501,7 +1501,7 @@ Stream.prototype.updateStats = async function() { if(stid && r.type === 'outbound-rtp') { let id = stid; // Firefox doesn't implement rid, use ssrc - // to discriminate simulcast tracks. + // to discriminate RTP tracks. id = id + '-' + r.ssrc; if(!('bytesSent' in r)) continue; diff --git a/static/skald.css b/static/skald.css index d8bccf6..24e9dfe 100644 --- a/static/skald.css +++ b/static/skald.css @@ -466,17 +466,9 @@ textarea.form-reply { white-space: pre-wrap; } -.video-container { - height: calc(var(--vh, 1vh) * 100 - 56px); - position: relative; - background: rgba(0, 0, 0, 0.91); - /* Display only when showing video */ - display: block; -} .chat-btn { display: block; - /*on top of video peers*/ z-index: 1002; font-size: 1.8em; position: absolute; @@ -492,37 +484,9 @@ textarea.form-reply { text-shadow: 0px 0px 1px #b3adad; } -.collapse-video { - left: inherit; - right: 30px; -} -.video-controls, .top-video-controls { - position: absolute; - width: 100%; - left: 0; - bottom: 25px; - text-align: center; - color: #eaeaea; - font-size: 1.1em; - opacity: 0; - height: 32px; -} -.video-controls:after, .top-video-controls:after { - clear: both; - display: table; - content: " "; -} -.top-video-controls { - text-align: right; - bottom: inherit; - top: 0; - line-height: 1.1; - font-size: 1.3em; - text-shadow: 1px 1px 2px rgb(90 86 86); -} .controls-button { padding: 3px 10px; @@ -544,54 +508,14 @@ textarea.form-reply { background: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 50%) 0%, rgb(0 0 0 / 70%) 100%); } -.peer:hover > .video-controls, .peer:hover > .top-video-controls { - opacity: 1; -} -.video-controls span, .top-video-controls span { - margin-right: 20px; - transition: opacity .7s ease-out; - opacity: 1; - cursor: pointer; -} -.video-controls span:last-child, .top-video-controls span:last-child { - margin-right: 0; -} -.video-controls span:hover, .top-video-controls span:hover { - opacity: .8; - transition: opacity .5s ease-out; -} -.top-video-controls .video-stop{ - display: flex; - width: 1.5em; - height: 1.5em; - background: rgba(0,0,0,0.5); - border-radius: 50%; - justify-content: center; - align-items: center; - color: #eaeaea; -} -.video-controls .volume { - display: inline-block; - text-align: center; -} -.video-controls .video-play { - font-size: 0.85em; -} -.video-controls .video-stop { - color: #d03e3e; -} -.video-controls span.disabled, .video-controls span.disabled:hover, .top-video-controls span.disabled:hover{ - opacity: .2; - color: #c8c8c8 -} .volume-mute { vertical-align: middle; @@ -609,10 +533,6 @@ textarea.form-reply { transition: opacity .5s ease-out; } -.video-controls .volume:hover { - --ov: 1; - --dv: inline; -} .mobile-container { display: block !important; @@ -794,38 +714,16 @@ h1 { width: 5.8em; } -#videoselect { - text-align-last: center; - margin-right: 0.4em; -} #audioselect { text-align-last: center; } -#sharebutton, #unsharebutton { - white-space: nowrap; -} - -#unsharebutton { - margin-right: 0.4em; -} - -#filterselect { - text-align-last: center; - margin-right: 0.4em; -} - #sendselect { text-align-last: center; margin-right: 0.4em; } -#simulcastselect { - text-align-last: center; - margin-right: 0.4em; -} - #requestselect { text-align-last: center; } @@ -910,58 +808,6 @@ h1 { cursor: ew-resize; } -#peers { - padding: 10px; - display: grid; - grid-template-columns: repeat(1, 1fr); - grid-template-rows: repeat(1, auto); - row-gap: 5px; - column-gap: 10px; - position: absolute; - top: 0; - right: 0; - bottom: 0; - min-width: 100%; - min-height: 100%; - width: auto; - height: auto; - z-index: 1000; - background-size: cover; - overflow: hidden; - vertical-align: top!important; -} - -.peer { - margin-top: auto; - margin-bottom: auto; - position: relative; - border: 2px solid rgba(0,0,0,0); - background: #80808014; -} - -.peer-active { - border: 2px solid #610a86; -} - -.peer-hidden { - display: none; -} - -.media { - width: 100%; - max-height: calc(var(--vh, 1vh) * 100 - 76px); - padding-bottom: 20px; - object-fit: contain; -} - -.media-failed { - filter: grayscale(0.5) contrast(0.5); -} - -.mirror { - transform: scaleX(-1); -} - #inputform { width: 100%; } @@ -1066,20 +912,6 @@ legend { list-style: none; } -.show-video { - position: absolute; - right: 30px; - bottom: 120px; - color: white; - width: 50px; - height: 50px; - text-align: center; - line-height: 50px; - font-size: 150%; - border-radius: 30px; - background: #600aa0; - box-shadow: 4px 4px 7px 1px rgba(0,0,0,0.16); -} .blink { -ms-animation: blink 1.0s linear infinite; @@ -1210,9 +1042,6 @@ header .collapse:hover { content: "\f130"; } -#users > div.user-status-camera::after { - content: "\f030"; -} .close-icon { font: normal 1em/1 Arial, sans-serif; @@ -1223,11 +1052,6 @@ header .collapse:hover { /* END Sidebar Left */ -@media only screen and (min-width: 1025px) { - .coln-right .collapse-video, .coln-right .show-video { - display: none; - } -} @media only screen and (max-width: 1024px) { #presentbutton, #unpresentbutton { @@ -1259,18 +1083,7 @@ header .collapse:hover { display: none; } - .video-container { - position: fixed; - height: calc(var(--vh, 1vh) * 100 - 56px); - top: 56px; - right: 0; - left: 0; - margin-bottom: 60px; - } - .top-video-controls { - opacity: 1; - } .login-container { position: fixed; @@ -1304,7 +1117,6 @@ header .collapse:hover { #left-sidebar.active { min-width: 200px; max-width: 200px; - /* on top of video peers */ z-index: 1002; } @@ -1336,10 +1148,6 @@ header .collapse:hover { line-height: 36px; } - #peers { - padding: 3px; - } - #resizer { display: none; } diff --git a/static/skald.html b/static/skald.html index d75fdf7..e15a751 100644 --- a/static/skald.html +++ b/static/skald.html @@ -37,12 +37,12 @@